From 73ccab53d5194282de4b983354c9afa5a6d678fb Mon Sep 17 00:00:00 2001 From: Qstick Date: Wed, 24 May 2023 23:13:27 -0500 Subject: [PATCH] New: Bulk Manage Import Lists, Indexers, Clients --- frontend/src/App/State/SettingsAppState.ts | 22 ++ .../src/Components/Form/FormInputGroup.js | 2 + frontend/src/Helpers/Props/icons.js | 2 + .../DownloadClients/DownloadClientSettings.js | 26 +- .../Edit/ManageDownloadClientsEditModal.tsx | 28 ++ .../ManageDownloadClientsEditModalContent.css | 16 + ...geDownloadClientsEditModalContent.css.d.ts | 8 + .../ManageDownloadClientsEditModalContent.tsx | 180 +++++++++++ .../Manage/ManageDownloadClientsModal.tsx | 20 ++ .../ManageDownloadClientsModalContent.css | 16 + ...ManageDownloadClientsModalContent.css.d.ts | 9 + .../ManageDownloadClientsModalContent.tsx | 241 +++++++++++++++ .../Manage/ManageDownloadClientsModalRow.css | 11 + .../ManageDownloadClientsModalRow.css.d.ts | 13 + .../Manage/ManageDownloadClientsModalRow.tsx | 87 ++++++ .../ImportLists/ImportListSettings.js | 25 +- .../Edit/ManageImportListsEditModal.tsx | 26 ++ .../ManageImportListsEditModalContent.css | 16 + ...ManageImportListsEditModalContent.css.d.ts | 8 + .../ManageImportListsEditModalContent.tsx | 158 ++++++++++ .../Manage/ManageImportListsModal.tsx | 20 ++ .../Manage/ManageImportListsModalContent.css | 16 + .../ManageImportListsModalContent.css.d.ts | 9 + .../Manage/ManageImportListsModalContent.tsx | 283 +++++++++++++++++ .../Manage/ManageImportListsModalRow.css | 10 + .../Manage/ManageImportListsModalRow.css.d.ts | 12 + .../Manage/ManageImportListsModalRow.tsx | 92 ++++++ .../ImportLists/Manage/Tags/TagsModal.tsx | 22 ++ .../Manage/Tags/TagsModalContent.css | 12 + .../Manage/Tags/TagsModalContent.css.d.ts | 9 + .../Manage/Tags/TagsModalContent.tsx | 178 +++++++++++ .../src/Settings/Indexers/IndexerSettings.js | 26 +- .../Manage/Edit/ManageIndexersEditModal.tsx | 26 ++ .../Edit/ManageIndexersEditModalContent.css | 16 + .../ManageIndexersEditModalContent.css.d.ts | 8 + .../Edit/ManageIndexersEditModalContent.tsx | 178 +++++++++++ .../Indexers/Manage/ManageIndexersModal.tsx | 20 ++ .../Manage/ManageIndexersModalContent.css | 16 + .../ManageIndexersModalContent.css.d.ts | 9 + .../Manage/ManageIndexersModalContent.tsx | 287 ++++++++++++++++++ .../Manage/ManageIndexersModalRow.css | 11 + .../Manage/ManageIndexersModalRow.css.d.ts | 13 + .../Manage/ManageIndexersModalRow.tsx | 92 ++++++ .../Indexers/Manage/Tags/TagsModal.tsx | 22 ++ .../Indexers/Manage/Tags/TagsModalContent.css | 12 + .../Manage/Tags/TagsModalContent.css.d.ts | 9 + .../Indexers/Manage/Tags/TagsModalContent.tsx | 178 +++++++++++ .../Creators/createBulkEditItemHandler.js | 54 ++++ .../Creators/createBulkRemoveItemHandler.js | 48 +++ .../Store/Actions/Settings/downloadClients.js | 12 +- .../src/Store/Actions/Settings/importLists.js | 12 +- .../src/Store/Actions/Settings/indexers.js | 13 +- .../Selectors/createQualityProfileSelector.js | 11 + frontend/src/typings/ImportList.ts | 27 ++ frontend/src/typings/Indexer.ts | 28 ++ frontend/src/typings/Notification.ts | 24 ++ src/NzbDrone.Core/Localization/Core/en.json | 14 + .../ThingiProvider/IProviderFactory.cs | 5 +- .../ThingiProvider/ProviderFactory.cs | 27 ++ .../DownloadClientBulkResource.cs | 34 +++ .../DownloadClientController.cs | 5 +- .../ImportLists/ImportListBulkResource.cs | 32 ++ .../ImportLists/ImportListController.cs | 5 +- .../Indexers/IndexerBulkResource.cs | 34 +++ .../Indexers/IndexerController.cs | 5 +- .../Metadata/MetadataBulkResource.cs | 12 + .../Metadata/MetadataController.cs | 5 +- .../Notifications/NotificationBulkResource.cs | 12 + .../Notifications/NotificationController.cs | 5 +- src/Sonarr.Api.V3/ProviderBulkResource.cs | 28 ++ src/Sonarr.Api.V3/ProviderControllerBase.cs | 54 +++- 71 files changed, 2984 insertions(+), 22 deletions(-) create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModal.tsx create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts create mode 100644 frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModal.tsx create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css.d.ts create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModal.tsx create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css.d.ts create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css.d.ts create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.tsx create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModal.tsx create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css.d.ts create mode 100644 frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModal.tsx create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css.d.ts create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModal.tsx create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css.d.ts create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModal.tsx create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css.d.ts create mode 100644 frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx create mode 100644 frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js create mode 100644 frontend/src/typings/ImportList.ts create mode 100644 frontend/src/typings/Indexer.ts create mode 100644 frontend/src/typings/Notification.ts create mode 100644 src/Sonarr.Api.V3/DownloadClient/DownloadClientBulkResource.cs create mode 100644 src/Sonarr.Api.V3/ImportLists/ImportListBulkResource.cs create mode 100644 src/Sonarr.Api.V3/Indexers/IndexerBulkResource.cs create mode 100644 src/Sonarr.Api.V3/Metadata/MetadataBulkResource.cs create mode 100644 src/Sonarr.Api.V3/Notifications/NotificationBulkResource.cs create mode 100644 src/Sonarr.Api.V3/ProviderBulkResource.cs diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 20f8a6ad6..6960db2f9 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,14 +1,33 @@ import AppSectionState, { AppSectionDeleteState, + AppSectionSaveState, AppSectionSchemaState, } from 'App/State/AppSectionState'; import Language from 'Language/Language'; import DownloadClient from 'typings/DownloadClient'; +import ImportList from 'typings/ImportList'; +import Indexer from 'typings/Indexer'; +import Notification from 'typings/Notification'; import QualityProfile from 'typings/QualityProfile'; import { UiSettings } from 'typings/UiSettings'; export interface DownloadClientAppState extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface ImportListAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface IndexerAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface NotificationAppState + extends AppSectionState, AppSectionDeleteState {} export interface QualityProfilesAppState @@ -20,6 +39,9 @@ export type UiSettingsAppState = AppSectionState; interface SettingsAppState { downloadClients: DownloadClientAppState; + importLists: ImportListAppState; + indexers: IndexerAppState; + notifications: NotificationAppState; language: LanguageSettingsAppState; uiSettings: UiSettingsAppState; qualityProfiles: QualityProfilesAppState; diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index c3dbd0165..805b97217 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -261,6 +261,8 @@ FormInputGroup.propTypes = { values: PropTypes.arrayOf(PropTypes.any), type: PropTypes.string.isRequired, kind: PropTypes.oneOf(kinds.all), + min: PropTypes.number, + max: PropTypes.number, unit: PropTypes.string, buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), helpText: PropTypes.string, diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 026bb6e9f..b4921d3b0 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -65,6 +65,7 @@ import { faInfoCircle as fasInfoCircle, faLaptop as fasLaptop, faLevelUpAlt as fasLevelUpAlt, + faListCheck as fasListCheck, faMedkit as fasMedkit, faMinus as fasMinus, faPause as fasPause, @@ -158,6 +159,7 @@ export const INFO = fasInfoCircle; export const INTERACTIVE = fasUser; export const KEYBOARD = farKeyboard; export const LOGOUT = fasSignOutAlt; +export const MANAGE = fasListCheck; export const MEDIA_INFO = farFileInvoice; export const MISSING = fasExclamationTriangle; export const MONITORED = fasBookmark; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js index 14e4e65f2..de955a326 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js @@ -7,6 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import { icons } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; +import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal'; import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; @@ -22,7 +23,8 @@ class DownloadClientSettings extends Component { this.state = { isSaving: false, - hasPendingChanges: false + hasPendingChanges: false, + isManageDownloadClientsOpen: false }; } @@ -37,6 +39,14 @@ class DownloadClientSettings extends Component { this.setState(payload); }; + onManageDownloadClientsPress = () => { + this.setState({ isManageDownloadClientsOpen: true }); + }; + + onManageDownloadClientsModalClose = () => { + this.setState({ isManageDownloadClientsOpen: false }); + }; + onSavePress = () => { if (this._saveCallback) { this._saveCallback(); @@ -54,7 +64,8 @@ class DownloadClientSettings extends Component { const { isSaving, - hasPendingChanges + hasPendingChanges, + isManageDownloadClientsOpen } = this.state; return ( @@ -72,6 +83,12 @@ class DownloadClientSettings extends Component { isSpinning={isTestingAll} onPress={dispatchTestAllDownloadClients} /> + + } onSavePress={this.onSavePress} @@ -86,6 +103,11 @@ class DownloadClientSettings extends Component { /> + + ); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModal.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModal.tsx new file mode 100644 index 000000000..549a091ff --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModal.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageDownloadClientsEditModalContent from './ManageDownloadClientsEditModalContent'; + +interface ManageDownloadClientsEditModalProps { + isOpen: boolean; + downloadClientIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageDownloadClientsEditModal( + props: ManageDownloadClientsEditModalProps +) { + const { isOpen, downloadClientIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageDownloadClientsEditModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx new file mode 100644 index 000000000..369a12b1a --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx @@ -0,0 +1,180 @@ +import React, { useCallback, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageDownloadClientsEditModalContent.css'; + +interface SavePayload { + enable?: boolean; + removeCompletedDownloads?: boolean; + removeFailedDownloads?: boolean; + priority?: number; +} + +interface ManageDownloadClientsEditModalContentProps { + downloadClientIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const enableOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'enabled', value: 'Enabled' }, + { key: 'disabled', value: 'Disabled' }, +]; + +function ManageDownloadClientsEditModalContent( + props: ManageDownloadClientsEditModalContentProps +) { + const { downloadClientIds, onSavePress, onModalClose } = props; + + const [enable, setEnable] = useState(NO_CHANGE); + const [removeCompletedDownloads, setRemoveCompletedDownloads] = + useState(NO_CHANGE); + const [removeFailedDownloads, setRemoveFailedDownloads] = useState(NO_CHANGE); + const [priority, setPriority] = useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (enable !== NO_CHANGE) { + hasChanges = true; + payload.enable = enable === 'enabled'; + } + + if (removeCompletedDownloads !== NO_CHANGE) { + hasChanges = true; + payload.removeCompletedDownloads = removeCompletedDownloads === 'enabled'; + } + + if (removeFailedDownloads !== NO_CHANGE) { + hasChanges = true; + payload.removeFailedDownloads = removeFailedDownloads === 'enabled'; + } + + if (priority !== NO_CHANGE) { + hasChanges = true; + payload.priority = priority as number; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [ + enable, + priority, + removeCompletedDownloads, + removeFailedDownloads, + onSavePress, + onModalClose, + ]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'enable': + setEnable(value); + break; + case 'priority': + setPriority(value); + break; + case 'removeCompletedDownloads': + setRemoveCompletedDownloads(value); + break; + case 'removeFailedDownloads': + setRemoveFailedDownloads(value); + break; + default: + console.warn('EditDownloadClientsModalContent Unknown Input'); + } + }, + [] + ); + + const selectedCount = downloadClientIds.length; + + return ( + + {translate('EditSelectedDownloadClients')} + + + + {translate('Enabled')} + + + + + + {translate('Priority')} + + + + + + {translate('RemoveCompletedDownloads')} + + + + + + {translate('RemoveFailedDownloads')} + + + + + + +
+ {translate('{count} download clients selected', { + count: selectedCount, + })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageDownloadClientsEditModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx new file mode 100644 index 000000000..0302f3544 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageDownloadClientsModalContent from './ManageDownloadClientsModalContent'; + +interface ManageDownloadClientsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageDownloadClientsModal(props: ManageDownloadClientsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageDownloadClientsModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css new file mode 100644 index 000000000..c106388ab --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} \ No newline at end of file diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx new file mode 100644 index 000000000..da9a81574 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -0,0 +1,241 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { DownloadClientAppState } from 'App/State/SettingsAppState'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds } from 'Helpers/Props'; +import { + bulkDeleteDownloadClients, + bulkEditDownloadClients, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageDownloadClientsEditModal from './Edit/ManageDownloadClientsEditModal'; +import ManageDownloadClientsModalRow from './ManageDownloadClientsModalRow'; +import styles from './ManageDownloadClientsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageDownloadClientsModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: 'Name', + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: 'Implementation', + isSortable: true, + isVisible: true, + }, + { + name: 'enable', + label: 'Enabled', + isSortable: true, + isVisible: true, + }, + { + name: 'priority', + label: 'Priority', + isSortable: true, + isVisible: true, + }, + { + name: 'removeCompletedDownloads', + label: 'Remove Completed', + isSortable: true, + isVisible: true, + }, + { + name: 'removeFailedDownloads', + label: 'Remove Failed', + isSortable: true, + isVisible: true, + }, +]; + +interface ManageDownloadClientsModalContentProps { + onModalClose(): void; +} + +function ManageDownloadClientsModalContent( + props: ManageDownloadClientsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + }: DownloadClientAppState = useSelector( + createClientSideCollectionSelector('settings.downloadClients') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteDownloadClients({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditDownloadClients({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); + const anySelected = selectedCount > 0; + + return ( + + Manage Import Lists + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + Delete + + + + Edit + +
+ + +
+ + + + +
+ ); +} + +export default ManageDownloadClientsModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css new file mode 100644 index 000000000..242e0c84e --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css @@ -0,0 +1,11 @@ +.name, +.enable, +.tags, +.priority, +.removeCompletedDownloads, +.removeFailedDownloads, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} \ No newline at end of file diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts new file mode 100644 index 000000000..74553b4f9 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'enable': string; + 'implementation': string; + 'name': string; + 'priority': string; + 'removeCompletedDownloads': string; + 'removeFailedDownloads': string; + 'tags': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx new file mode 100644 index 000000000..ad291b1ed --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx @@ -0,0 +1,87 @@ +import React, { useCallback } from 'react'; +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 { SelectStateInputProps } from 'typings/props'; +import styles from './ManageDownloadClientsModalRow.css'; + +interface ManageDownloadClientsModalRowProps { + id: number; + name: string; + enable: boolean; + priority: number; + removeCompletedDownloads: boolean; + removeFailedDownloads: boolean; + implementation: string; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageDownloadClientsModalRow( + props: ManageDownloadClientsModalRowProps +) { + const { + id, + isSelected, + name, + enable, + priority, + removeCompletedDownloads, + removeFailedDownloads, + implementation, + onSelectedChange, + } = props; + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + + {name} + + + + {implementation} + + + + {enable ? 'Yes' : 'No'} + + + + {priority} + + + + {removeCompletedDownloads ? 'Yes' : 'No'} + + + + {removeFailedDownloads ? 'Yes' : 'No'} + + + ); +} + +export default ManageDownloadClientsModalRow; diff --git a/frontend/src/Settings/ImportLists/ImportListSettings.js b/frontend/src/Settings/ImportLists/ImportListSettings.js index 9f23aed6d..98a86bcea 100644 --- a/frontend/src/Settings/ImportLists/ImportListSettings.js +++ b/frontend/src/Settings/ImportLists/ImportListSettings.js @@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector'; import ImportListsConnector from './ImportLists/ImportListsConnector'; +import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal'; class ImportListSettings extends Component { @@ -18,7 +19,8 @@ class ImportListSettings extends Component { super(props, context); this.state = { - hasPendingChanges: false + hasPendingChanges: false, + isManageImportListsOpen: false }; } @@ -29,6 +31,14 @@ class ImportListSettings extends Component { this._listOptions = ref; }; + onManageImportListsPress = () => { + this.setState({ isManageImportListsOpen: true }); + }; + + onManageImportListsModalClose = () => { + this.setState({ isManageImportListsOpen: false }); + }; + onHasPendingChange = (hasPendingChanges) => { this.setState({ hasPendingChanges @@ -50,7 +60,8 @@ class ImportListSettings extends Component { const { isSaving, - hasPendingChanges + hasPendingChanges, + isManageImportListsOpen } = this.state; return ( @@ -68,6 +79,12 @@ class ImportListSettings extends Component { isSpinning={isTestingAll} onPress={dispatchTestAllImportLists} /> + + } onSavePress={this.onSavePress} @@ -76,6 +93,10 @@ class ImportListSettings extends Component { + ); diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModal.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModal.tsx new file mode 100644 index 000000000..0fed1c4b8 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageImportListsEditModalContent from './ManageImportListsEditModalContent'; + +interface ManageImportListsEditModalProps { + isOpen: boolean; + importListIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageImportListsEditModal(props: ManageImportListsEditModalProps) { + const { isOpen, importListIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageImportListsEditModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css.d.ts b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx new file mode 100644 index 000000000..937b15eb8 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageImportListsEditModalContent.css'; + +interface SavePayload { + enableAutomaticAdd?: boolean; + qualityProfileId?: number; + rootFolderPath?: string; +} + +interface ManageImportListsEditModalContentProps { + importListIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const autoAddOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'enabled', value: 'Enabled' }, + { key: 'disabled', value: 'Disabled' }, +]; + +function ManageImportListsEditModalContent( + props: ManageImportListsEditModalContentProps +) { + const { importListIds, onSavePress, onModalClose } = props; + + const [enableAutomaticAdd, setEnableAutomaticAdd] = useState(NO_CHANGE); + const [qualityProfileId, setQualityProfileId] = useState( + NO_CHANGE + ); + const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (enableAutomaticAdd !== NO_CHANGE) { + hasChanges = true; + payload.enableAutomaticAdd = enableAutomaticAdd === 'enabled'; + } + + if (qualityProfileId !== NO_CHANGE) { + hasChanges = true; + payload.qualityProfileId = qualityProfileId as number; + } + + if (rootFolderPath !== NO_CHANGE) { + hasChanges = true; + payload.rootFolderPath = rootFolderPath; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [ + enableAutomaticAdd, + qualityProfileId, + rootFolderPath, + onSavePress, + onModalClose, + ]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'enableAutomaticAdd': + setEnableAutomaticAdd(value); + break; + case 'qualityProfileId': + setQualityProfileId(value); + break; + case 'rootFolderPath': + setRootFolderPath(value); + break; + default: + console.warn('EditImportListModalContent Unknown Input'); + } + }, + [] + ); + + const selectedCount = importListIds.length; + + return ( + + {translate('EditSelectedImportLists')} + + + + {translate('AutomaticAdd')} + + + + + + {translate('QualityProfile')} + + + + + + {translate('RootFolder')} + + + + + + +
+ {translate('{count} import lists selected', { count: selectedCount })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageImportListsEditModalContent; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModal.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModal.tsx new file mode 100644 index 000000000..67a029d85 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageImportListsModalContent from './ManageImportListsModalContent'; + +interface ManageImportListsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageImportListsModal(props: ManageImportListsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageImportListsModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css new file mode 100644 index 000000000..c106388ab --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} \ No newline at end of file diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css.d.ts b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx new file mode 100644 index 000000000..51b799bdf --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx @@ -0,0 +1,283 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { ImportListAppState } from 'App/State/SettingsAppState'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds } from 'Helpers/Props'; +import { + bulkDeleteImportLists, + bulkEditImportLists, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageImportListsEditModal from './Edit/ManageImportListsEditModal'; +import ManageImportListsModalRow from './ManageImportListsModalRow'; +import TagsModal from './Tags/TagsModal'; +import styles from './ManageImportListsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageImportListsModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: 'Name', + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: 'Implementation', + isSortable: true, + isVisible: true, + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true, + }, + { + name: 'rootFolderPath', + label: 'Root Folder', + isSortable: true, + isVisible: true, + }, + { + name: 'enableAutomaticAdd', + label: 'Auto Add', + isSortable: true, + isVisible: true, + }, + { + name: 'tags', + label: 'Tags', + isSortable: true, + isVisible: true, + }, +]; + +interface ManageImportListsModalContentProps { + onModalClose(): void; +} + +function ManageImportListsModalContent( + props: ManageImportListsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + }: ImportListAppState = useSelector( + createClientSideCollectionSelector('settings.importLists') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); + const [isSavingTags, setIsSavingTags] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteImportLists({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditImportLists({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onTagsPress = useCallback(() => { + setIsTagsModalOpen(true); + }, [setIsTagsModalOpen]); + + const onTagsModalClose = useCallback(() => { + setIsTagsModalOpen(false); + }, [setIsTagsModalOpen]); + + const onApplyTagsPress = useCallback( + (tags: number[], applyTags: string) => { + setIsSavingTags(true); + setIsTagsModalOpen(false); + + dispatch( + bulkEditImportLists({ + ids: selectedIds, + tags, + applyTags, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); + const anySelected = selectedCount > 0; + + return ( + + Manage Import Lists + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + Delete + + + + Edit + + + + Set Tags + +
+ + +
+ + + + + + +
+ ); +} + +export default ManageImportListsModalContent; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css new file mode 100644 index 000000000..e620e1279 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css @@ -0,0 +1,10 @@ +.name, +.tags, +.enableAutomaticAdd, +.qualityProfileId, +.rootFolderPath, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} \ No newline at end of file diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css.d.ts b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css.d.ts new file mode 100644 index 000000000..5286b4d98 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'enableAutomaticAdd': string; + 'implementation': string; + 'name': string; + 'qualityProfileId': string; + 'rootFolderPath': string; + 'tags': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.tsx new file mode 100644 index 000000000..25592b969 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.tsx @@ -0,0 +1,92 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +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 TagListConnector from 'Components/TagListConnector'; +import { createQualityProfileSelectorForHook } from 'Store/Selectors/createQualityProfileSelector'; +import { SelectStateInputProps } from 'typings/props'; +import styles from './ManageImportListsModalRow.css'; + +interface ManageImportListsModalRowProps { + id: number; + name: string; + rootFolderPath: string; + qualityProfileId: number; + implementation: string; + tags: number[]; + enableAutomaticAdd: boolean; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageImportListsModalRow(props: ManageImportListsModalRowProps) { + const { + id, + isSelected, + name, + rootFolderPath, + qualityProfileId, + implementation, + enableAutomaticAdd, + tags, + onSelectedChange, + } = props; + + const qualityProfile = useSelector( + createQualityProfileSelectorForHook(qualityProfileId) + ); + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + + {name} + + + + {implementation} + + + + {qualityProfile?.name ?? 'None'} + + + + {rootFolderPath} + + + + {enableAutomaticAdd ? 'Yes' : 'No'} + + + + + + + ); +} + +export default ManageImportListsModalRow; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModal.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModal.tsx new file mode 100644 index 000000000..2e24d60e8 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css.d.ts b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css.d.ts new file mode 100644 index 000000000..9b4321dcc --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'renameIcon': string; + 'result': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..ad9ae4652 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx @@ -0,0 +1,178 @@ +import { uniq } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { ImportListAppState } from 'App/State/SettingsAppState'; +import { Tag } from 'App/State/TagsAppState'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import ImportList from 'typings/ImportList'; +import styles from './TagsModalContent.css'; + +interface TagsModalContentProps { + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModalContent(props: TagsModalContentProps) { + const { ids, onModalClose, onApplyTagsPress } = props; + + const allImportLists: ImportListAppState = useSelector( + (state: AppState) => state.settings.importLists + ); + const tagList: Tag[] = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const seriesTags = useMemo(() => { + const tags = ids.reduce((acc: number[], id) => { + const s = allImportLists.items.find((s: ImportList) => s.id === id); + + if (s) { + acc.push(...s.tags); + } + + return acc; + }, []); + + return uniq(tags); + }, [ids, allImportLists]); + + const onTagsChange = useCallback( + ({ value }: { value: number[] }) => { + setTags(value); + }, + [setTags] + ); + + const onApplyTagsChange = useCallback( + ({ value }: { value: string }) => { + setApplyTags(value); + }, + [setApplyTags] + ); + + const onApplyPress = useCallback(() => { + onApplyTagsPress(tags, applyTags); + }, [tags, applyTags, onApplyTagsPress]); + + const applyTagsOptions = [ + { key: 'add', value: 'Add' }, + { key: 'remove', value: 'Remove' }, + { key: 'replace', value: 'Replace' }, + ]; + + return ( + + Tags + + +
+ + Tags + + + + + + Apply Tags + + + + + + Result + +
+ {seriesTags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + const removeTag = + (applyTags === 'remove' && tags.indexOf(id) > -1) || + (applyTags === 'replace' && tags.indexOf(id) === -1); + + return ( + + ); + })} + + {(applyTags === 'add' || applyTags === 'replace') && + tags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + if (seriesTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js index 669b0fcc8..7ab09b36b 100644 --- a/frontend/src/Settings/Indexers/IndexerSettings.js +++ b/frontend/src/Settings/Indexers/IndexerSettings.js @@ -7,6 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import { icons } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import IndexersConnector from './Indexers/IndexersConnector'; +import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal'; import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; class IndexerSettings extends Component { @@ -21,7 +22,8 @@ class IndexerSettings extends Component { this.state = { isSaving: false, - hasPendingChanges: false + hasPendingChanges: false, + isManageIndexersOpen: false }; } @@ -36,6 +38,14 @@ class IndexerSettings extends Component { this.setState(payload); }; + onManageIndexersPress = () => { + this.setState({ isManageIndexersOpen: true }); + }; + + onManageIndexersModalClose = () => { + this.setState({ isManageIndexersOpen: false }); + }; + onSavePress = () => { if (this._saveCallback) { this._saveCallback(); @@ -53,7 +63,8 @@ class IndexerSettings extends Component { const { isSaving, - hasPendingChanges + hasPendingChanges, + isManageIndexersOpen } = this.state; return ( @@ -71,6 +82,12 @@ class IndexerSettings extends Component { isSpinning={isTestingAll} onPress={dispatchTestAllIndexers} /> + + } onSavePress={this.onSavePress} @@ -83,6 +100,11 @@ class IndexerSettings extends Component { onChildMounted={this.onChildMounted} onChildStateChange={this.onChildStateChange} /> + + ); diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModal.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModal.tsx new file mode 100644 index 000000000..15c16b980 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageIndexersEditModalContent from './ManageIndexersEditModalContent'; + +interface ManageIndexersEditModalProps { + isOpen: boolean; + indexerIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageIndexersEditModal(props: ManageIndexersEditModalProps) { + const { isOpen, indexerIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageIndexersEditModal; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css.d.ts b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx new file mode 100644 index 000000000..b6613f7da --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx @@ -0,0 +1,178 @@ +import React, { useCallback, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageIndexersEditModalContent.css'; + +interface SavePayload { + enableRss?: boolean; + enableAutomaticSearch?: boolean; + enableInteractiveSearch?: boolean; + priority?: number; +} + +interface ManageIndexersEditModalContentProps { + indexerIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const enableOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'enabled', value: 'Enabled' }, + { key: 'disabled', value: 'Disabled' }, +]; + +function ManageIndexersEditModalContent( + props: ManageIndexersEditModalContentProps +) { + const { indexerIds, onSavePress, onModalClose } = props; + + const [enableRss, setEnableRss] = useState(NO_CHANGE); + const [enableAutomaticSearch, setEnableAutomaticSearch] = useState(NO_CHANGE); + const [enableInteractiveSearch, setEnableInteractiveSearch] = + useState(NO_CHANGE); + const [priority, setPriority] = useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (enableRss !== NO_CHANGE) { + hasChanges = true; + payload.enableRss = enableRss === 'enabled'; + } + + if (enableAutomaticSearch !== NO_CHANGE) { + hasChanges = true; + payload.enableAutomaticSearch = enableAutomaticSearch === 'enabled'; + } + + if (enableInteractiveSearch !== NO_CHANGE) { + hasChanges = true; + payload.enableInteractiveSearch = enableInteractiveSearch === 'enabled'; + } + + if (priority !== NO_CHANGE) { + hasChanges = true; + payload.priority = priority as number; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [ + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + priority, + onSavePress, + onModalClose, + ]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'enableRss': + setEnableRss(value); + break; + case 'enableAutomaticSearch': + setEnableAutomaticSearch(value); + break; + case 'enableInteractiveSearch': + setEnableInteractiveSearch(value); + break; + case 'priority': + setPriority(value); + break; + default: + console.warn('EditIndexersModalContent Unknown Input'); + } + }, + [] + ); + + const selectedCount = indexerIds.length; + + return ( + + {translate('EditSelectedIndexers')} + + + + {translate('EnableRss')} + + + + + + {translate('EnableAutomaticSearch')} + + + + + + {translate('EnableInteractiveSearch')} + + + + + + {translate('Priority')} + + + + + + +
+ {translate('{count} indexers selected', { count: selectedCount })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageIndexersEditModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModal.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModal.tsx new file mode 100644 index 000000000..afb90adab --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageIndexersModalContent from './ManageIndexersModalContent'; + +interface ManageIndexersModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageIndexersModal(props: ManageIndexersModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageIndexersModal; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css new file mode 100644 index 000000000..c106388ab --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} \ No newline at end of file diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css.d.ts b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx new file mode 100644 index 000000000..e721a8193 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx @@ -0,0 +1,287 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { IndexerAppState } from 'App/State/SettingsAppState'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds } from 'Helpers/Props'; +import { + bulkDeleteIndexers, + bulkEditIndexers, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageIndexersEditModal from './Edit/ManageIndexersEditModal'; +import ManageIndexersModalRow from './ManageIndexersModalRow'; +import TagsModal from './Tags/TagsModal'; +import styles from './ManageIndexersModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageIndexersModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: 'Name', + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: 'Implementation', + isSortable: true, + isVisible: true, + }, + { + name: 'enableRss', + label: 'Enable RSS', + isSortable: true, + isVisible: true, + }, + { + name: 'enableAutomaticSearch', + label: 'Enable Automatic Search', + isSortable: true, + isVisible: true, + }, + { + name: 'enableInteractiveSearch', + label: 'Enable Interactive Search', + isSortable: true, + isVisible: true, + }, + { + name: 'priority', + label: 'Priority', + isSortable: true, + isVisible: true, + }, + { + name: 'tags', + label: 'Tags', + isSortable: true, + isVisible: true, + }, +]; + +interface ManageIndexersModalContentProps { + onModalClose(): void; +} + +function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + }: IndexerAppState = useSelector( + createClientSideCollectionSelector('settings.indexers') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); + const [isSavingTags, setIsSavingTags] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteIndexers({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditIndexers({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onTagsPress = useCallback(() => { + setIsTagsModalOpen(true); + }, [setIsTagsModalOpen]); + + const onTagsModalClose = useCallback(() => { + setIsTagsModalOpen(false); + }, [setIsTagsModalOpen]); + + const onApplyTagsPress = useCallback( + (tags: number[], applyTags: string) => { + setIsSavingTags(true); + setIsTagsModalOpen(false); + + dispatch( + bulkEditIndexers({ + ids: selectedIds, + tags, + applyTags, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); + const anySelected = selectedCount > 0; + + return ( + + Manage Import Lists + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + Delete + + + + Edit + + + + Set Tags + +
+ + +
+ + + + + + +
+ ); +} + +export default ManageIndexersModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css new file mode 100644 index 000000000..982495344 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css @@ -0,0 +1,11 @@ +.name, +.tags, +.enableRss, +.enableAutomaticSearch, +.enableInteractiveSearch, +.priority, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} \ No newline at end of file diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts new file mode 100644 index 000000000..7991c19fd --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'enableAutomaticSearch': string; + 'enableInteractiveSearch': string; + 'enableRss': string; + 'implementation': string; + 'name': string; + 'priority': string; + 'tags': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx new file mode 100644 index 000000000..a122d70f3 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx @@ -0,0 +1,92 @@ +import React, { useCallback } from 'react'; +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 TagListConnector from 'Components/TagListConnector'; +import { SelectStateInputProps } from 'typings/props'; +import styles from './ManageIndexersModalRow.css'; + +interface ManageIndexersModalRowProps { + id: number; + name: string; + enableRss: boolean; + enableAutomaticSearch: boolean; + enableInteractiveSearch: boolean; + priority: number; + implementation: string; + tags: number[]; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageIndexersModalRow(props: ManageIndexersModalRowProps) { + const { + id, + isSelected, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + priority, + implementation, + tags, + onSelectedChange, + } = props; + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + + {name} + + + + {implementation} + + + + {enableRss ? 'Yes' : 'No'} + + + + {enableAutomaticSearch ? 'Yes' : 'No'} + + + + {enableInteractiveSearch ? 'Yes' : 'No'} + + + + {priority} + + + + + + + ); +} + +export default ManageIndexersModalRow; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModal.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModal.tsx new file mode 100644 index 000000000..2e24d60e8 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css.d.ts b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css.d.ts new file mode 100644 index 000000000..9b4321dcc --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'renameIcon': string; + 'result': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..1f681707c --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx @@ -0,0 +1,178 @@ +import { uniq } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { IndexerAppState } from 'App/State/SettingsAppState'; +import { Tag } from 'App/State/TagsAppState'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import Indexer from 'typings/Indexer'; +import styles from './TagsModalContent.css'; + +interface TagsModalContentProps { + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModalContent(props: TagsModalContentProps) { + const { ids, onModalClose, onApplyTagsPress } = props; + + const allIndexers: IndexerAppState = useSelector( + (state: AppState) => state.settings.indexers + ); + const tagList: Tag[] = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const seriesTags = useMemo(() => { + const tags = ids.reduce((acc: number[], id) => { + const s = allIndexers.items.find((s: Indexer) => s.id === id); + + if (s) { + acc.push(...s.tags); + } + + return acc; + }, []); + + return uniq(tags); + }, [ids, allIndexers]); + + const onTagsChange = useCallback( + ({ value }: { value: number[] }) => { + setTags(value); + }, + [setTags] + ); + + const onApplyTagsChange = useCallback( + ({ value }: { value: string }) => { + setApplyTags(value); + }, + [setApplyTags] + ); + + const onApplyPress = useCallback(() => { + onApplyTagsPress(tags, applyTags); + }, [tags, applyTags, onApplyTagsPress]); + + const applyTagsOptions = [ + { key: 'add', value: 'Add' }, + { key: 'remove', value: 'Remove' }, + { key: 'replace', value: 'Replace' }, + ]; + + return ( + + Tags + + +
+ + Tags + + + + + + Apply Tags + + + + + + Result + +
+ {seriesTags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + const removeTag = + (applyTags === 'remove' && tags.indexOf(id) > -1) || + (applyTags === 'replace' && tags.indexOf(id) === -1); + + return ( + + ); + })} + + {(applyTags === 'add' || applyTags === 'replace') && + tags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + if (seriesTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js new file mode 100644 index 000000000..f174dae54 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js @@ -0,0 +1,54 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, updateItem } from '../baseActions'; + +function createBulkEditItemHandler(section, url) { + return function(getState, payload, dispatch) { + + dispatch(set({ section, isSaving: true })); + + const ajaxOptions = { + url: `${url}`, + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }; + + const promise = createAjaxRequest(ajaxOptions).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isSaving: false, + saveError: null + }), + + ...data.map((provider) => { + + const { + ...propsToUpdate + } = provider; + + return updateItem({ + id: provider.id, + section, + ...propsToUpdate + }); + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + + return promise; + }; +} + +export default createBulkEditItemHandler; diff --git a/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js new file mode 100644 index 000000000..3293ff1b5 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js @@ -0,0 +1,48 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { removeItem, set } from '../baseActions'; + +function createBulkRemoveItemHandler(section, url) { + return function(getState, payload, dispatch) { + const { + ids + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const ajaxOptions = { + url: `${url}`, + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }; + + const promise = createAjaxRequest(ajaxOptions).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isDeleting: false, + deleteError: null + }), + + ...ids.map((id) => { + return removeItem({ section, id }); + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + + return promise; + }; +} + +export default createBulkRemoveItemHandler; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index c18b4db76..f5624ee2a 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -1,4 +1,6 @@ import { createAction } from 'redux-actions'; +import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; +import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; @@ -30,6 +32,9 @@ export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient'; export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; +export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients'; +export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients'; + // // Action Creators @@ -44,6 +49,9 @@ export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); +export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS); +export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS); + export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { return { section, @@ -95,7 +103,9 @@ export default { [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), - [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') + [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'), + [BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk'), + [BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk') }, // diff --git a/frontend/src/Store/Actions/Settings/importLists.js b/frontend/src/Store/Actions/Settings/importLists.js index 5cb0307a3..732ea23ad 100644 --- a/frontend/src/Store/Actions/Settings/importLists.js +++ b/frontend/src/Store/Actions/Settings/importLists.js @@ -1,4 +1,6 @@ import { createAction } from 'redux-actions'; +import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; +import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; @@ -30,6 +32,9 @@ export const TEST_IMPORT_LIST = 'settings/importlists/testImportList'; export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList'; export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists'; +export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists'; +export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists'; + // // Action Creators @@ -44,6 +49,9 @@ export const testImportList = createThunk(TEST_IMPORT_LIST); export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST); export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS); +export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS); +export const bulkEditImportLists = createThunk(BULK_EDIT_IMPORT_LISTS); + export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => { return { section, @@ -94,7 +102,9 @@ export default { [DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'), [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'), [CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section), - [TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist') + [TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist'), + [BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk'), + [BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk') }, // diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js index e2e85cbb5..17fc6e0ce 100644 --- a/frontend/src/Store/Actions/Settings/indexers.js +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -11,6 +11,8 @@ import { createThunk } from 'Store/thunks'; import getSectionState from 'Utilities/State/getSectionState'; import selectProviderSchema from 'Utilities/State/selectProviderSchema'; import updateSectionState from 'Utilities/State/updateSectionState'; +import createBulkEditItemHandler from '../Creators/createBulkEditItemHandler'; +import createBulkRemoveItemHandler from '../Creators/createBulkRemoveItemHandler'; // // Variables @@ -33,6 +35,9 @@ export const TEST_INDEXER = 'settings/indexers/testIndexer'; export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; +export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers'; +export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers'; + // // Action Creators @@ -48,6 +53,9 @@ export const testIndexer = createThunk(TEST_INDEXER); export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); +export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); +export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS); + export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { return { section, @@ -99,7 +107,10 @@ export default { [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), - [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') + [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'), + + [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk'), + [BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk') }, // diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js index 451aacfd4..611dfc903 100644 --- a/frontend/src/Store/Selectors/createQualityProfileSelector.js +++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js @@ -1,5 +1,16 @@ import { createSelector } from 'reselect'; +export function createQualityProfileSelectorForHook(qualityProfileId) { + return createSelector( + (state) => state.settings.qualityProfiles.items, + (qualityProfiles) => { + return qualityProfiles.find((profile) => { + return profile.id === qualityProfileId; + }); + } + ); +} + function createQualityProfileSelector() { return createSelector( (state, { qualityProfileId }) => qualityProfileId, diff --git a/frontend/src/typings/ImportList.ts b/frontend/src/typings/ImportList.ts new file mode 100644 index 000000000..7af4c2bbc --- /dev/null +++ b/frontend/src/typings/ImportList.ts @@ -0,0 +1,27 @@ +import ModelBase from 'App/ModelBase'; + +export interface Field { + order: number; + name: string; + label: string; + value: boolean | number | string; + type: string; + advanced: boolean; + privacy: string; +} + +interface ImportList extends ModelBase { + enable: boolean; + enableAutomaticAdd: boolean; + qualityProfileId: number; + rootFolderPath: string; + name: string; + fields: Field[]; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default ImportList; diff --git a/frontend/src/typings/Indexer.ts b/frontend/src/typings/Indexer.ts new file mode 100644 index 000000000..e6c23eda2 --- /dev/null +++ b/frontend/src/typings/Indexer.ts @@ -0,0 +1,28 @@ +import ModelBase from 'App/ModelBase'; + +export interface Field { + order: number; + name: string; + label: string; + value: boolean | number | string; + type: string; + advanced: boolean; + privacy: string; +} + +interface Indexer extends ModelBase { + enableRss: boolean; + enableAutomaticSearch: boolean; + enableInteractiveSearch: boolean; + protocol: string; + priority: number; + name: string; + fields: Field[]; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default Indexer; diff --git a/frontend/src/typings/Notification.ts b/frontend/src/typings/Notification.ts new file mode 100644 index 000000000..e2b5ad7eb --- /dev/null +++ b/frontend/src/typings/Notification.ts @@ -0,0 +1,24 @@ +import ModelBase from 'App/ModelBase'; + +export interface Field { + order: number; + name: string; + label: string; + value: boolean | number | string; + type: string; + advanced: boolean; + privacy: string; +} + +interface Notification extends ModelBase { + enable: boolean; + name: string; + fields: Field[]; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default Notification; diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index d9af60071..d71fc38a3 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1,9 +1,23 @@ { + "ApplyChanges": "Apply Changes", + "AutomaticAdd": "Automatic Add", "Browser Reload Required": "Browser Reload Required", + "EditSelectedDownloadClients": "Edit Selected Download Clients", + "EditSelectedImportLists": "Edit Selected Import Lists", + "EditSelectedIndexers": "Edit Selected Indexers", + "EnableAutomaticSearch": "Enable Automatic Search", + "Enabled": "Enabled", + "EnableInteractiveSearch": "Enable Interactive Search", + "EnableRss": "Enable Rss", "HiddenClickToShow": "Hidden, click to show", "HideAdvanced": "Hide Advanced", "Language": "Language", "Language that Sonarr will use for UI": "Language that Sonarr will use for UI", + "Priority": "Priority", + "QualityProfile": "Quality Profile", + "RemoveCompletedDownloads": "Remove Completed Downloads", + "RemoveFailedDownloads": "Remove Failed Downloads", + "RootFolder": "Root Folder", "ShowAdvanced": "Show Advanced", "ShownClickToHide": "Shown, click to hide", "UI Language": "UI Language" diff --git a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs index 0196d1858..0cfeed9a8 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FluentValidation.Results; namespace NzbDrone.Core.ThingiProvider @@ -12,9 +12,12 @@ namespace NzbDrone.Core.ThingiProvider bool Exists(int id); TProviderDefinition Find(int id); TProviderDefinition Get(int id); + IEnumerable Get(IEnumerable ids); TProviderDefinition Create(TProviderDefinition definition); void Update(TProviderDefinition definition); + IEnumerable Update(IEnumerable definitions); void Delete(int id); + void Delete(IEnumerable ids); IEnumerable GetDefaultDefinitions(); IEnumerable GetPresetDefinitions(TProviderDefinition providerDefinition); void SetProviderCharacteristics(TProviderDefinition definition); diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index b81b64446..782141963 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -101,6 +101,11 @@ namespace NzbDrone.Core.ThingiProvider return _providerRepository.Get(id); } + public IEnumerable Get(IEnumerable ids) + { + return _providerRepository.Get(ids); + } + public TProviderDefinition Find(int id) { return _providerRepository.Find(id); @@ -120,12 +125,34 @@ namespace NzbDrone.Core.ThingiProvider _eventAggregator.PublishEvent(new ProviderUpdatedEvent(definition)); } + public virtual IEnumerable Update(IEnumerable definitions) + { + _providerRepository.UpdateMany(definitions.ToList()); + + foreach (var definition in definitions) + { + _eventAggregator.PublishEvent(new ProviderUpdatedEvent(definition)); + } + + return definitions; + } + public void Delete(int id) { _providerRepository.Delete(id); _eventAggregator.PublishEvent(new ProviderDeletedEvent(id)); } + public void Delete(IEnumerable ids) + { + _providerRepository.DeleteMany(ids); + + foreach (var id in ids) + { + _eventAggregator.PublishEvent(new ProviderDeletedEvent(id)); + } + } + public TProvider GetInstance(TProviderDefinition definition) { var type = GetImplementation(definition); diff --git a/src/Sonarr.Api.V3/DownloadClient/DownloadClientBulkResource.cs b/src/Sonarr.Api.V3/DownloadClient/DownloadClientBulkResource.cs new file mode 100644 index 000000000..8f2fd34c8 --- /dev/null +++ b/src/Sonarr.Api.V3/DownloadClient/DownloadClientBulkResource.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using NzbDrone.Core.Download; + +namespace Sonarr.Api.V3.DownloadClient +{ + public class DownloadClientBulkResource : ProviderBulkResource + { + public bool? Enable { get; set; } + public int? Priority { get; set; } + public bool? RemoveCompletedDownloads { get; set; } + public bool? RemoveFailedDownloads { get; set; } + } + + public class DownloadClientBulkResourceMapper : ProviderBulkResourceMapper + { + public override List UpdateModel(DownloadClientBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + existingDefinitions.ForEach(existing => + { + existing.Enable = resource.Enable ?? existing.Enable; + existing.Priority = resource.Priority ?? existing.Priority; + existing.RemoveCompletedDownloads = resource.RemoveCompletedDownloads ?? existing.RemoveCompletedDownloads; + existing.RemoveFailedDownloads = resource.RemoveFailedDownloads ?? existing.RemoveFailedDownloads; + }); + + return existingDefinitions; + } + } +} diff --git a/src/Sonarr.Api.V3/DownloadClient/DownloadClientController.cs b/src/Sonarr.Api.V3/DownloadClient/DownloadClientController.cs index b50db0d78..48795c542 100644 --- a/src/Sonarr.Api.V3/DownloadClient/DownloadClientController.cs +++ b/src/Sonarr.Api.V3/DownloadClient/DownloadClientController.cs @@ -4,12 +4,13 @@ using Sonarr.Http; namespace Sonarr.Api.V3.DownloadClient { [V3ApiController] - public class DownloadClientController : ProviderControllerBase + public class DownloadClientController : ProviderControllerBase { public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper(); + public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new DownloadClientBulkResourceMapper(); public DownloadClientController(IDownloadClientFactory downloadClientFactory) - : base(downloadClientFactory, "downloadclient", ResourceMapper) + : base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Sonarr.Api.V3/ImportLists/ImportListBulkResource.cs b/src/Sonarr.Api.V3/ImportLists/ImportListBulkResource.cs new file mode 100644 index 000000000..b426d0d28 --- /dev/null +++ b/src/Sonarr.Api.V3/ImportLists/ImportListBulkResource.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using NzbDrone.Core.ImportLists; + +namespace Sonarr.Api.V3.ImportLists +{ + public class ImportListBulkResource : ProviderBulkResource + { + public bool? EnableAutomaticAdd { get; set; } + public string RootFolderPath { get; set; } + public int? QualityProfileId { get; set; } + } + + public class ImportListBulkResourceMapper : ProviderBulkResourceMapper + { + public override List UpdateModel(ImportListBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + existingDefinitions.ForEach(existing => + { + existing.EnableAutomaticAdd = resource.EnableAutomaticAdd ?? existing.EnableAutomaticAdd; + existing.RootFolderPath = resource.RootFolderPath ?? existing.RootFolderPath; + existing.QualityProfileId = resource.QualityProfileId ?? existing.QualityProfileId; + }); + + return existingDefinitions; + } + } +} diff --git a/src/Sonarr.Api.V3/ImportLists/ImportListController.cs b/src/Sonarr.Api.V3/ImportLists/ImportListController.cs index 3f564ec22..659d54d9f 100644 --- a/src/Sonarr.Api.V3/ImportLists/ImportListController.cs +++ b/src/Sonarr.Api.V3/ImportLists/ImportListController.cs @@ -6,12 +6,13 @@ using Sonarr.Http; namespace Sonarr.Api.V3.ImportLists { [V3ApiController] - public class ImportListController : ProviderControllerBase + public class ImportListController : ProviderControllerBase { public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper(); + public static readonly ImportListBulkResourceMapper BulkResourceMapper = new ImportListBulkResourceMapper(); public ImportListController(IImportListFactory importListFactory, ProfileExistsValidator profileExistsValidator) - : base(importListFactory, "importlist", ResourceMapper) + : base(importListFactory, "importlist", ResourceMapper, BulkResourceMapper) { Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.QualityProfileId)); diff --git a/src/Sonarr.Api.V3/Indexers/IndexerBulkResource.cs b/src/Sonarr.Api.V3/Indexers/IndexerBulkResource.cs new file mode 100644 index 000000000..7308e2ab7 --- /dev/null +++ b/src/Sonarr.Api.V3/Indexers/IndexerBulkResource.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using NzbDrone.Core.Indexers; + +namespace Sonarr.Api.V3.Indexers +{ + public class IndexerBulkResource : ProviderBulkResource + { + public bool? EnableRss { get; set; } + public bool? EnableAutomaticSearch { get; set; } + public bool? EnableInteractiveSearch { get; set; } + public int? Priority { get; set; } + } + + public class IndexerBulkResourceMapper : ProviderBulkResourceMapper + { + public override List UpdateModel(IndexerBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + existingDefinitions.ForEach(existing => + { + existing.EnableRss = resource.EnableRss ?? existing.EnableRss; + existing.EnableAutomaticSearch = resource.EnableAutomaticSearch ?? existing.EnableAutomaticSearch; + existing.EnableInteractiveSearch = resource.EnableInteractiveSearch ?? existing.EnableInteractiveSearch; + existing.Priority = resource.Priority ?? existing.Priority; + }); + + return existingDefinitions; + } + } +} diff --git a/src/Sonarr.Api.V3/Indexers/IndexerController.cs b/src/Sonarr.Api.V3/Indexers/IndexerController.cs index e7cf33a49..0f79678d1 100644 --- a/src/Sonarr.Api.V3/Indexers/IndexerController.cs +++ b/src/Sonarr.Api.V3/Indexers/IndexerController.cs @@ -4,12 +4,13 @@ using Sonarr.Http; namespace Sonarr.Api.V3.Indexers { [V3ApiController] - public class IndexerController : ProviderControllerBase + public class IndexerController : ProviderControllerBase { public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper(); + public static readonly IndexerBulkResourceMapper BulkResourceMapper = new IndexerBulkResourceMapper(); public IndexerController(IndexerFactory indexerFactory) - : base(indexerFactory, "indexer", ResourceMapper) + : base(indexerFactory, "indexer", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Sonarr.Api.V3/Metadata/MetadataBulkResource.cs b/src/Sonarr.Api.V3/Metadata/MetadataBulkResource.cs new file mode 100644 index 000000000..861f9d588 --- /dev/null +++ b/src/Sonarr.Api.V3/Metadata/MetadataBulkResource.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Extras.Metadata; + +namespace Sonarr.Api.V3.Metadata +{ + public class MetadataBulkResource : ProviderBulkResource + { + } + + public class MetadataBulkResourceMapper : ProviderBulkResourceMapper + { + } +} diff --git a/src/Sonarr.Api.V3/Metadata/MetadataController.cs b/src/Sonarr.Api.V3/Metadata/MetadataController.cs index b906465b0..1f27d1e31 100644 --- a/src/Sonarr.Api.V3/Metadata/MetadataController.cs +++ b/src/Sonarr.Api.V3/Metadata/MetadataController.cs @@ -4,12 +4,13 @@ using Sonarr.Http; namespace Sonarr.Api.V3.Metadata { [V3ApiController] - public class MetadataController : ProviderControllerBase + public class MetadataController : ProviderControllerBase { public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper(); + public static readonly MetadataBulkResourceMapper BulkResourceMapper = new MetadataBulkResourceMapper(); public MetadataController(IMetadataFactory metadataFactory) - : base(metadataFactory, "metadata", ResourceMapper) + : base(metadataFactory, "metadata", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Sonarr.Api.V3/Notifications/NotificationBulkResource.cs b/src/Sonarr.Api.V3/Notifications/NotificationBulkResource.cs new file mode 100644 index 000000000..a0a04ab97 --- /dev/null +++ b/src/Sonarr.Api.V3/Notifications/NotificationBulkResource.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Notifications; + +namespace Sonarr.Api.V3.Notifications +{ + public class NotificationBulkResource : ProviderBulkResource + { + } + + public class NotificationBulkResourceMapper : ProviderBulkResourceMapper + { + } +} diff --git a/src/Sonarr.Api.V3/Notifications/NotificationController.cs b/src/Sonarr.Api.V3/Notifications/NotificationController.cs index 53d507ab0..7e3566b01 100644 --- a/src/Sonarr.Api.V3/Notifications/NotificationController.cs +++ b/src/Sonarr.Api.V3/Notifications/NotificationController.cs @@ -4,12 +4,13 @@ using Sonarr.Http; namespace Sonarr.Api.V3.Notifications { [V3ApiController] - public class NotificationController : ProviderControllerBase + public class NotificationController : ProviderControllerBase { public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); + public static readonly NotificationBulkResourceMapper BulkResourceMapper = new NotificationBulkResourceMapper(); public NotificationController(NotificationFactory notificationFactory) - : base(notificationFactory, "notification", ResourceMapper) + : base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Sonarr.Api.V3/ProviderBulkResource.cs b/src/Sonarr.Api.V3/ProviderBulkResource.cs new file mode 100644 index 000000000..57379db68 --- /dev/null +++ b/src/Sonarr.Api.V3/ProviderBulkResource.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using NzbDrone.Core.ThingiProvider; +using Sonarr.Api.V3.Series; + +namespace Sonarr.Api.V3 +{ + public class ProviderBulkResource + { + public List Ids { get; set; } + public List Tags { get; set; } + public ApplyTags ApplyTags { get; set; } + } + + public class ProviderBulkResourceMapper + where TProviderBulkResource : ProviderBulkResource, new() + where TProviderDefinition : ProviderDefinition, new() + { + public virtual List UpdateModel(TProviderBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + return existingDefinitions; + } + } +} diff --git a/src/Sonarr.Api.V3/ProviderControllerBase.cs b/src/Sonarr.Api.V3/ProviderControllerBase.cs index f91ccccaa..25cef26ea 100644 --- a/src/Sonarr.Api.V3/ProviderControllerBase.cs +++ b/src/Sonarr.Api.V3/ProviderControllerBase.cs @@ -6,23 +6,31 @@ using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Serializer; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; +using Sonarr.Api.V3.Series; using Sonarr.Http.REST; using Sonarr.Http.REST.Attributes; namespace Sonarr.Api.V3 { - public abstract class ProviderControllerBase : RestController + public abstract class ProviderControllerBase : RestController where TProviderDefinition : ProviderDefinition, new() where TProvider : IProvider where TProviderResource : ProviderResource, new() + where TBulkProviderResource : ProviderBulkResource, new() { private readonly IProviderFactory _providerFactory; private readonly ProviderResourceMapper _resourceMapper; + private readonly ProviderBulkResourceMapper _bulkResourceMapper; - protected ProviderControllerBase(IProviderFactory providerFactory, string resource, ProviderResourceMapper resourceMapper) + protected ProviderControllerBase(IProviderFactory providerFactory, + string resource, + ProviderResourceMapper resourceMapper, + ProviderBulkResourceMapper bulkResourceMapper) { _providerFactory = providerFactory; _resourceMapper = resourceMapper; + _bulkResourceMapper = bulkResourceMapper; SharedValidator.RuleFor(c => c.Name).NotEmpty(); SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique"); @@ -91,6 +99,39 @@ namespace Sonarr.Api.V3 return Accepted(providerResource.Id); } + [HttpPut("bulk")] + [Consumes("application/json")] + public ActionResult UpdateProvider([FromBody] TBulkProviderResource providerResource) + { + var definitionsToUpdate = _providerFactory.Get(providerResource.Ids).ToList(); + + foreach (var definition in definitionsToUpdate) + { + if (providerResource.Tags != null) + { + var newTags = providerResource.Tags; + var applyTags = providerResource.ApplyTags; + + switch (applyTags) + { + case ApplyTags.Add: + newTags.ForEach(t => definition.Tags.Add(t)); + break; + case ApplyTags.Remove: + newTags.ForEach(t => definition.Tags.Remove(t)); + break; + case ApplyTags.Replace: + definition.Tags = new HashSet(newTags); + break; + } + } + } + + _bulkResourceMapper.UpdateModel(providerResource, definitionsToUpdate); + + return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x))); + } + private TProviderDefinition GetDefinition(TProviderResource providerResource, bool validate, bool includeWarnings, bool forceValidate) { var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; @@ -112,6 +153,15 @@ namespace Sonarr.Api.V3 return new { }; } + [HttpDelete("bulk")] + [Consumes("application/json")] + public object DeleteProviders([FromBody] TBulkProviderResource resource) + { + _providerFactory.Delete(resource.Ids); + + return new { }; + } + [HttpGet("schema")] [Produces("application/json")] public List GetTemplates()