From 17067282308c73cf22d2e40126da9742eb540c45 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 9 Jul 2023 20:43:36 +0300 Subject: [PATCH] New: Bulk Manage Applications, Download Clients Co-authored-by: Qstick --- frontend/src/App/State/AppSectionState.ts | 48 +++ frontend/src/App/State/AppState.ts | 43 +++ frontend/src/App/State/IndexerAppState.ts | 12 + frontend/src/App/State/SettingsAppState.ts | 33 ++ frontend/src/App/State/TagsAppState.ts | 12 + .../IndexerFilterBuilderRowValueConnector.js | 4 +- frontend/src/Components/Form/NumberInput.js | 2 +- .../Table/Cells/TableSelectCell.css.d.ts | 8 + frontend/src/Components/Table/Column.ts | 8 +- frontend/src/Components/Table/Table.js | 1 + frontend/src/Helpers/Props/icons.js | 2 + frontend/src/Indexer/Index/IndexerIndex.tsx | 2 +- .../Delete/DeleteIndexerModalContent.tsx | 4 +- .../Select/Edit/EditIndexerModalContent.tsx | 118 +++++++- .../Index/Select/IndexerIndexSelectFooter.tsx | 13 +- .../Indexer/Index/Table/IndexerIndexRow.css | 6 +- .../Index/Table/IndexerIndexRow.css.d.ts | 4 + .../Indexer/Index/Table/IndexerIndexRow.tsx | 49 +++ .../Index/Table/IndexerIndexTableHeader.css | 6 +- .../Table/IndexerIndexTableHeader.css.d.ts | 4 + .../Index/Table/IndexerIndexTableHeader.tsx | 3 +- .../Applications/ApplicationSettings.js | 40 +++ .../Edit/ManageApplicationsEditModal.tsx | 26 ++ .../ManageApplicationsEditModalContent.css | 16 + ...anageApplicationsEditModalContent.css.d.ts | 8 + .../ManageApplicationsEditModalContent.tsx | 108 +++++++ .../Manage/ManageApplicationsModal.tsx | 20 ++ .../Manage/ManageApplicationsModalContent.css | 16 + .../ManageApplicationsModalContent.css.d.ts | 9 + .../Manage/ManageApplicationsModalContent.tsx | 282 ++++++++++++++++++ .../Manage/ManageApplicationsModalRow.css | 8 + .../ManageApplicationsModalRow.css.d.ts | 10 + .../Manage/ManageApplicationsModalRow.tsx | 82 +++++ .../Applications/Manage/Tags/TagsModal.tsx | 22 ++ .../Manage/Tags/TagsModalContent.css | 12 + .../Manage/Tags/TagsModalContent.css.d.ts | 9 + .../Manage/Tags/TagsModalContent.tsx | 183 ++++++++++++ .../DownloadClients/DownloadClientSettings.js | 26 +- .../DownloadClients/DownloadClient.js | 2 +- .../EditDownloadClientModalContent.js | 2 +- .../Edit/ManageDownloadClientsEditModal.tsx | 28 ++ .../ManageDownloadClientsEditModalContent.css | 16 + ...geDownloadClientsEditModalContent.css.d.ts | 8 + .../ManageDownloadClientsEditModalContent.tsx | 129 ++++++++ .../Manage/ManageDownloadClientsModal.tsx | 20 ++ .../ManageDownloadClientsModalContent.css | 16 + ...ManageDownloadClientsModalContent.css.d.ts | 9 + .../ManageDownloadClientsModalContent.tsx | 240 +++++++++++++++ .../Manage/ManageDownloadClientsModalRow.css | 8 + .../ManageDownloadClientsModalRow.css.d.ts | 10 + .../Manage/ManageDownloadClientsModalRow.tsx | 70 +++++ .../Creators/createBulkEditItemHandler.js | 54 ++++ .../Creators/createBulkRemoveItemHandler.js | 48 +++ .../src/Store/Actions/Settings/appProfiles.js | 4 +- .../Store/Actions/Settings/applications.js | 12 +- .../Settings/downloadClientCategories.js | 2 + .../Store/Actions/Settings/downloadClients.js | 12 +- .../Store/Actions/Settings/indexerProxies.js | 2 + .../Store/Actions/Settings/notifications.js | 2 + frontend/src/Store/Actions/indexerActions.js | 10 +- .../src/Store/Actions/indexerIndexActions.js | 113 ++----- frontend/src/typings/Application.ts | 19 ++ frontend/src/typings/DownloadClient.ts | 26 ++ frontend/src/typings/Indexer.ts | 27 ++ frontend/src/typings/Notification.ts | 24 ++ frontend/src/typings/UiSettings.ts | 6 + frontend/src/typings/props.ts | 5 + src/NzbDrone.Core/Localization/Core/en.json | 24 +- .../ThingiProvider/IProviderFactory.cs | 3 +- .../ThingiProvider/ProviderFactory.cs | 9 +- .../Applications/ApplicationBulkResource.cs | 28 ++ .../Applications/ApplicationController.cs | 9 +- .../Applications/ApplicationResource.cs | 2 +- .../DownloadClientBulkResource.cs | 30 ++ .../DownloadClientController.cs | 7 +- .../IndexerProxyBulkResource.cs | 12 + .../IndexerProxies/IndexerProxyController.cs | 21 +- .../Indexers/IndexerBulkResource.cs | 44 +++ .../Indexers/IndexerController.cs | 6 +- .../Indexers/IndexerEditorController.cs | 88 ------ .../Indexers/IndexerEditorResource.cs | 21 -- .../Notifications/NotificationBulkResource.cs | 12 + .../Notifications/NotificationController.cs | 21 +- src/Prowlarr.Api.V1/ProviderBulkResource.cs | 39 +++ src/Prowlarr.Api.V1/ProviderControllerBase.cs | 62 +++- 85 files changed, 2366 insertions(+), 255 deletions(-) create mode 100644 frontend/src/App/State/AppSectionState.ts create mode 100644 frontend/src/App/State/AppState.ts create mode 100644 frontend/src/App/State/IndexerAppState.ts create mode 100644 frontend/src/App/State/SettingsAppState.ts create mode 100644 frontend/src/App/State/TagsAppState.ts create mode 100644 frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts create mode 100644 frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx create mode 100644 frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css create mode 100644 frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css.d.ts create mode 100644 frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx create mode 100644 frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx create mode 100644 frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css create mode 100644 frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css.d.ts create mode 100644 frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx create mode 100644 frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css create mode 100644 frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts create mode 100644 frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx create mode 100644 frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModal.tsx create mode 100644 frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css create mode 100644 frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts create mode 100644 frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.tsx 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/Store/Actions/Creators/createBulkEditItemHandler.js create mode 100644 frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js create mode 100644 frontend/src/typings/Application.ts create mode 100644 frontend/src/typings/DownloadClient.ts create mode 100644 frontend/src/typings/Indexer.ts create mode 100644 frontend/src/typings/Notification.ts create mode 100644 frontend/src/typings/UiSettings.ts create mode 100644 frontend/src/typings/props.ts create mode 100644 src/Prowlarr.Api.V1/Applications/ApplicationBulkResource.cs create mode 100644 src/Prowlarr.Api.V1/DownloadClient/DownloadClientBulkResource.cs create mode 100644 src/Prowlarr.Api.V1/IndexerProxies/IndexerProxyBulkResource.cs create mode 100644 src/Prowlarr.Api.V1/Indexers/IndexerBulkResource.cs delete mode 100644 src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs delete mode 100644 src/Prowlarr.Api.V1/Indexers/IndexerEditorResource.cs create mode 100644 src/Prowlarr.Api.V1/Notifications/NotificationBulkResource.cs create mode 100644 src/Prowlarr.Api.V1/ProviderBulkResource.cs diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts new file mode 100644 index 000000000..d511963fc --- /dev/null +++ b/frontend/src/App/State/AppSectionState.ts @@ -0,0 +1,48 @@ +import SortDirection from 'Helpers/Props/SortDirection'; + +export interface Error { + responseJSON: { + message: string; + }; +} + +export interface AppSectionDeleteState { + isDeleting: boolean; + deleteError: Error; +} + +export interface AppSectionSaveState { + isSaving: boolean; + saveError: Error; +} + +export interface PagedAppSectionState { + pageSize: number; +} + +export interface AppSectionSchemaState { + isSchemaFetching: boolean; + isSchemaPopulated: boolean; + schemaError: Error; + schema: { + items: T[]; + }; +} + +export interface AppSectionItemState { + isFetching: boolean; + isPopulated: boolean; + error: Error; + item: T; +} + +interface AppSectionState { + isFetching: boolean; + isPopulated: boolean; + error: Error; + items: T[]; + sortKey: string; + sortDirection: SortDirection; +} + +export default AppSectionState; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts new file mode 100644 index 000000000..6e868cce4 --- /dev/null +++ b/frontend/src/App/State/AppState.ts @@ -0,0 +1,43 @@ +import IndexerAppState from './IndexerAppState'; +import SettingsAppState from './SettingsAppState'; +import TagsAppState from './TagsAppState'; + +interface FilterBuilderPropOption { + id: string; + name: string; +} + +export interface FilterBuilderProp { + name: string; + label: string; + type: string; + valueType?: string; + optionsSelector?: (items: T[]) => FilterBuilderPropOption[]; +} + +export interface PropertyFilter { + key: string; + value: boolean | string | number | string[] | number[]; + type: string; +} + +export interface Filter { + key: string; + label: string; + filers: PropertyFilter[]; +} + +export interface CustomFilter { + id: number; + type: string; + label: string; + filers: PropertyFilter[]; +} + +interface AppState { + indexers: IndexerAppState; + settings: SettingsAppState; + tags: TagsAppState; +} + +export default AppState; diff --git a/frontend/src/App/State/IndexerAppState.ts b/frontend/src/App/State/IndexerAppState.ts new file mode 100644 index 000000000..ad7e778e6 --- /dev/null +++ b/frontend/src/App/State/IndexerAppState.ts @@ -0,0 +1,12 @@ +import Indexer from 'typings/Indexer'; +import AppSectionState, { + AppSectionDeleteState, + AppSectionSaveState, +} from './AppSectionState'; + +interface IndexerAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export default IndexerAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts new file mode 100644 index 000000000..bded59754 --- /dev/null +++ b/frontend/src/App/State/SettingsAppState.ts @@ -0,0 +1,33 @@ +import AppSectionState, { + AppSectionDeleteState, + AppSectionSaveState, +} from 'App/State/AppSectionState'; +import Application from 'typings/Application'; +import DownloadClient from 'typings/DownloadClient'; +import Notification from 'typings/Notification'; +import { UiSettings } from 'typings/UiSettings'; + +export interface ApplicationAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface DownloadClientAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface NotificationAppState + extends AppSectionState, + AppSectionDeleteState {} + +export type UiSettingsAppState = AppSectionState; + +interface SettingsAppState { + applications: ApplicationAppState; + downloadClients: DownloadClientAppState; + notifications: NotificationAppState; + uiSettings: UiSettingsAppState; +} + +export default SettingsAppState; diff --git a/frontend/src/App/State/TagsAppState.ts b/frontend/src/App/State/TagsAppState.ts new file mode 100644 index 000000000..d1f1d5a2f --- /dev/null +++ b/frontend/src/App/State/TagsAppState.ts @@ -0,0 +1,12 @@ +import ModelBase from 'App/ModelBase'; +import AppSectionState, { + AppSectionDeleteState, +} from 'App/State/AppSectionState'; + +export interface Tag extends ModelBase { + label: string; +} + +interface TagsAppState extends AppSectionState, AppSectionDeleteState {} + +export default TagsAppState; diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js index bb4e594cc..fc211caec 100644 --- a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js @@ -9,13 +9,13 @@ import FilterBuilderRowValue from './FilterBuilderRowValue'; function createMapStateToProps() { return createSelector( (state) => state.indexers, - (qualityProfiles) => { + (indexers) => { const { isFetching, isPopulated, error, items - } = qualityProfiles; + } = indexers; const tagList = items.map((item) => { return { diff --git a/frontend/src/Components/Form/NumberInput.js b/frontend/src/Components/Form/NumberInput.js index 8db1cd7b6..454aad997 100644 --- a/frontend/src/Components/Form/NumberInput.js +++ b/frontend/src/Components/Form/NumberInput.js @@ -10,7 +10,7 @@ function parseValue(props, value) { } = props; if (value == null || value === '') { - return min; + return null; } let newValue = isFloat ? parseFloat(value) : parseInt(value); diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts b/frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts new file mode 100644 index 000000000..b6aee3c85 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableSelectCell.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'input': string; + 'selectCell': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Table/Column.ts b/frontend/src/Components/Table/Column.ts index f9ff7287c..8c2122c65 100644 --- a/frontend/src/Components/Table/Column.ts +++ b/frontend/src/Components/Table/Column.ts @@ -1,8 +1,10 @@ +import React from 'react'; + interface Column { name: string; - label: string; - columnLabel: string; - isSortable: boolean; + label: string | React.ReactNode; + columnLabel?: string; + isSortable?: boolean; isVisible: boolean; isModifiable?: boolean; } diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js index c41fc982a..befc8219a 100644 --- a/frontend/src/Components/Table/Table.js +++ b/frontend/src/Components/Table/Table.js @@ -121,6 +121,7 @@ function Table(props) { } Table.propTypes = { + ...TableHeaderCell.props, className: PropTypes.string, horizontalScroll: PropTypes.bool.isRequired, selectAll: PropTypes.bool.isRequired, diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 00e2c1aa0..589add5a8 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -72,6 +72,7 @@ import { faLanguage as fasLanguage, faLaptop as fasLaptop, faLevelUpAlt as fasLevelUpAlt, + faListCheck as fasListCheck, faLocationArrow as fasLocationArrow, faLock as fasLock, faMedkit as fasMedkit, @@ -180,6 +181,7 @@ export const INTERACTIVE = fasUser; export const KEYBOARD = farKeyboard; export const LOCK = fasLock; 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/Indexer/Index/IndexerIndex.tsx b/frontend/src/Indexer/Index/IndexerIndex.tsx index 587206066..dcb7b8c9e 100644 --- a/frontend/src/Indexer/Index/IndexerIndex.tsx +++ b/frontend/src/Indexer/Index/IndexerIndex.tsx @@ -190,7 +190,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { return ( - + { dispatch( bulkDeleteIndexers({ - indexerIds, + ids: indexerIds, }) ); diff --git a/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx b/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx index 7de97a964..f3bb9cca7 100644 --- a/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx +++ b/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx @@ -7,7 +7,7 @@ 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 { inputTypes, sizes } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './EditIndexerModalContent.css'; @@ -15,6 +15,10 @@ interface SavePayload { enable?: boolean; appProfileId?: number; priority?: number; + minimumSeeders?: number; + seedRatio?: number; + seedTime?: number; + packSeedTime?: number; } interface EditIndexerModalContentProps { @@ -37,6 +41,14 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { const [enable, setEnable] = useState(NO_CHANGE); const [appProfileId, setAppProfileId] = useState(NO_CHANGE); const [priority, setPriority] = useState(null); + const [minimumSeeders, setMinimumSeeders] = useState( + null + ); + const [seedRatio, setSeedRatio] = useState(null); + const [seedTime, setSeedTime] = useState(null); + const [packSeedTime, setPackSeedTime] = useState( + null + ); const save = useCallback(() => { let hasChanges = false; @@ -57,12 +69,42 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { payload.priority = priority as number; } + if (minimumSeeders !== null) { + hasChanges = true; + payload.minimumSeeders = minimumSeeders as number; + } + + if (seedRatio !== null) { + hasChanges = true; + payload.seedRatio = seedRatio as number; + } + + if (seedTime !== null) { + hasChanges = true; + payload.seedTime = seedTime as number; + } + + if (packSeedTime !== null) { + hasChanges = true; + payload.packSeedTime = packSeedTime as number; + } + if (hasChanges) { onSavePress(payload); } onModalClose(); - }, [enable, appProfileId, priority, onSavePress, onModalClose]); + }, [ + enable, + appProfileId, + priority, + minimumSeeders, + seedRatio, + seedTime, + packSeedTime, + onSavePress, + onModalClose, + ]); const onInputChange = useCallback( ({ name, value }) => { @@ -76,6 +118,18 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { case 'priority': setPriority(value); break; + case 'minimumSeeders': + setMinimumSeeders(value); + break; + case 'seedRatio': + setSeedRatio(value); + break; + case 'seedTime': + setSeedTime(value); + break; + case 'packSeedTime': + setPackSeedTime(value); + break; default: console.warn(`EditIndexersModalContent Unknown Input: '${name}'`); } @@ -94,7 +148,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { {translate('EditSelectedIndexers')} - + {translate('Enable')} - + {translate('SyncProfile')} - - {translate('Priority')} + + {translate('IndexerPriority')} + + + + {translate('AppsMinimumSeeders')} + + + + + + {translate('SeedRatio')} + + + + + + {translate('SeedTime')} + + + + + + {translate('PackSeedTime')} + + diff --git a/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx b/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx index 7cd6adca7..953d0daf9 100644 --- a/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx +++ b/frontend/src/Indexer/Index/Select/IndexerIndexSelectFooter.tsx @@ -2,11 +2,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import { useSelect } from 'App/SelectContext'; +import AppState from 'App/State/AppState'; import SpinnerButton from 'Components/Link/SpinnerButton'; import PageContentFooter from 'Components/Page/PageContentFooter'; import usePrevious from 'Helpers/Hooks/usePrevious'; import { kinds } from 'Helpers/Props'; -import { saveIndexerEditor } from 'Store/Actions/indexerIndexActions'; +import { bulkEditIndexers } from 'Store/Actions/indexerActions'; import translate from 'Utilities/String/translate'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; import DeleteIndexerModal from './Delete/DeleteIndexerModal'; @@ -15,7 +16,7 @@ import TagsModal from './Tags/TagsModal'; import styles from './IndexerIndexSelectFooter.css'; const indexersEditorSelector = createSelector( - (state) => state.indexers, + (state: AppState) => state.indexers, (indexers) => { const { isSaving, isDeleting, deleteError } = indexers; @@ -64,9 +65,9 @@ function IndexerIndexSelectFooter() { setIsEditModalOpen(false); dispatch( - saveIndexerEditor({ + bulkEditIndexers({ ...payload, - indexerIds, + ids: indexerIds, }) ); }, @@ -87,8 +88,8 @@ function IndexerIndexSelectFooter() { setIsTagsModalOpen(false); dispatch( - saveIndexerEditor({ - indexerIds, + bulkEditIndexers({ + ids: indexerIds, tags, applyTags, }) diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css index 2dd31f668..a0a0daee4 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css @@ -19,7 +19,11 @@ .priority, .protocol, -.privacy { +.privacy, +.minimumSeeders, +.seedRatio, +.seedTime, +.packSeedTime { composes: cell; flex: 0 0 90px; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts index 89c648d39..c5d22cf6d 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts @@ -8,9 +8,13 @@ interface CssExports { 'cell': string; 'checkInput': string; 'externalLink': string; + 'minimumSeeders': string; + 'packSeedTime': string; 'priority': string; 'privacy': string; 'protocol': string; + 'seedRatio': string; + 'seedTime': string; 'sortName': string; 'status': string; 'tags': string; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx b/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx index fbb5a88ca..5325028e9 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx @@ -55,6 +55,23 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { const vipExpiration = fields.find((field) => field.name === 'vipExpiration')?.value ?? ''; + const minimumSeeders = + fields.find( + (field) => field.name === 'torrentBaseSettings.appMinimumSeeders' + )?.value ?? undefined; + + const seedRatio = + fields.find((field) => field.name === 'torrentBaseSettings.seedRatio') + ?.value ?? undefined; + + const seedTime = + fields.find((field) => field.name === 'torrentBaseSettings.seedTime') + ?.value ?? undefined; + + const packSeedTime = + fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime') + ?.value ?? undefined; + const rssUrl = `${window.location.origin}${ window.Prowlarr.urlBase }/${id}/api?apikey=${encodeURIComponent( @@ -213,6 +230,38 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { ); } + if (name === 'minimumSeeders') { + return ( + + {minimumSeeders} + + ); + } + + if (name === 'seedRatio') { + return ( + + {seedRatio} + + ); + } + + if (name === 'seedTime') { + return ( + + {seedTime} + + ); + } + + if (name === 'packSeedTime') { + return ( + + {packSeedTime} + + ); + } + if (name === 'actions') { return ( { + ({ value }: SelectStateInputProps) => { selectDispatch({ type: value ? 'selectAll' : 'unselectAll', }); diff --git a/frontend/src/Settings/Applications/ApplicationSettings.js b/frontend/src/Settings/Applications/ApplicationSettings.js index eed6a3a52..d06b7fb65 100644 --- a/frontend/src/Settings/Applications/ApplicationSettings.js +++ b/frontend/src/Settings/Applications/ApplicationSettings.js @@ -9,8 +9,35 @@ import AppProfilesConnector from 'Settings/Profiles/App/AppProfilesConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; import ApplicationsConnector from './Applications/ApplicationsConnector'; +import ManageApplicationsModal from './Applications/Manage/ManageApplicationsModal'; class ApplicationSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isManageApplicationsOpen: false + }; + } + + // + // Listeners + + onManageApplicationsPress = () => { + this.setState({ isManageApplicationsOpen: true }); + }; + + onManageApplicationsModalClose = () => { + this.setState({ isManageApplicationsOpen: false }); + }; + + // + // Render + render() { const { isTestingAll, @@ -19,6 +46,8 @@ class ApplicationSettings extends Component { onAppIndexerSyncPress } = this.props; + const { isManageApplicationsOpen } = this.state; + return ( + + } /> @@ -47,6 +82,11 @@ class ApplicationSettings extends Component { + + ); diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx new file mode 100644 index 000000000..1b99f543a --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageApplicationsEditModalContent from './ManageApplicationsEditModalContent'; + +interface ManageApplicationsEditModalProps { + isOpen: boolean; + applicationIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageApplicationsEditModal(props: ManageApplicationsEditModalProps) { + const { isOpen, applicationIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageApplicationsEditModal; diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.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/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.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/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx new file mode 100644 index 000000000..dfdd1639b --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx @@ -0,0 +1,108 @@ +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 { ApplicationSyncLevel } from 'typings/Application'; +import translate from 'Utilities/String/translate'; +import styles from './ManageApplicationsEditModalContent.css'; + +interface SavePayload { + syncLevel?: ApplicationSyncLevel; +} + +interface ManageApplicationsEditModalContentProps { + applicationIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const syncLevelOptions = [ + { key: NO_CHANGE, value: translate('NoChange'), disabled: true }, + { key: ApplicationSyncLevel.Disabled, value: translate('Disabled') }, + { key: ApplicationSyncLevel.AddOnly, value: translate('AddOnly') }, + { key: ApplicationSyncLevel.FullSync, value: translate('FullSync') }, +]; + +function ManageApplicationsEditModalContent( + props: ManageApplicationsEditModalContentProps +) { + const { applicationIds, onSavePress, onModalClose } = props; + + const [syncLevel, setSyncLevel] = useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (syncLevel !== NO_CHANGE) { + hasChanges = true; + payload.syncLevel = syncLevel as ApplicationSyncLevel; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [syncLevel, onSavePress, onModalClose]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'syncLevel': + setSyncLevel(value); + break; + default: + console.warn(`EditApplicationsModalContent Unknown Input: '${name}'`); + } + }, + [] + ); + + const selectedCount = applicationIds.length; + + return ( + + {translate('EditSelectedApplications')} + + + + {translate('SyncLevel')} + + ${translate( + 'SyncLevelFull' + )}`} + onChange={onInputChange} + /> + + + + +
+ {translate('CountApplicationsSelected', [selectedCount])} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageApplicationsEditModalContent; diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx new file mode 100644 index 000000000..e0bce2138 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageApplicationsModalContent from './ManageApplicationsModalContent'; + +interface ManageApplicationsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageApplicationsModal(props: ManageApplicationsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageApplicationsModal; diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css new file mode 100644 index 000000000..c106388ab --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.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/Applications/Applications/Manage/ManageApplicationsModalContent.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.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/Applications/Applications/Manage/ManageApplicationsModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx new file mode 100644 index 000000000..b6c636fbe --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx @@ -0,0 +1,282 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { ApplicationAppState } from 'App/State/SettingsAppState'; +import Alert from 'Components/Alert'; +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 { + bulkDeleteApplications, + bulkEditApplications, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageApplicationsEditModal from './Edit/ManageApplicationsEditModal'; +import ManageApplicationsModalRow from './ManageApplicationsModalRow'; +import TagsModal from './Tags/TagsModal'; +import styles from './ManageApplicationsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageApplicationsModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: translate('Name'), + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: translate('Implementation'), + isSortable: true, + isVisible: true, + }, + { + name: 'syncLevel', + label: translate('SyncLevel'), + isSortable: true, + isVisible: true, + }, + { + name: 'tags', + label: translate('Tags'), + isSortable: true, + isVisible: true, + }, +]; + +interface ManageApplicationsModalContentProps { + onModalClose(): void; +} + +function ManageApplicationsModalContent( + props: ManageApplicationsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + }: ApplicationAppState = useSelector( + createClientSideCollectionSelector('settings.applications') + ); + 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(bulkDeleteApplications({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditApplications({ + 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( + bulkEditApplications({ + 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 download clients.' + ); + const anySelected = selectedCount > 0; + + return ( + + {translate('ManageApplications')} + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !error && !items.length && ( + {translate('NoApplicationsFound')} + )} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + {translate('Delete')} + + + + {translate('Edit')} + + + + {translate('SetTags')} + +
+ + +
+ + + + + + +
+ ); +} + +export default ManageApplicationsModalContent; diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css new file mode 100644 index 000000000..8c126288c --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css @@ -0,0 +1,8 @@ +.name, +.syncLevel, +.tags, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts new file mode 100644 index 000000000..cd3e47aae --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'implementation': string; + 'name': string; + 'syncLevel': string; + 'tags': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx new file mode 100644 index 000000000..f41997f54 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalRow.tsx @@ -0,0 +1,82 @@ +import React, { useCallback } from 'react'; +import Label from 'Components/Label'; +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 { kinds } from 'Helpers/Props'; +import { ApplicationSyncLevel } from 'typings/Application'; +import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageApplicationsModalRow.css'; + +interface ManageApplicationsModalRowProps { + id: number; + name: string; + syncLevel: string; + implementation: string; + tags: number[]; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageApplicationsModalRow(props: ManageApplicationsModalRowProps) { + const { + id, + isSelected, + name, + syncLevel, + implementation, + tags, + onSelectedChange, + } = props; + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + {name} + + + {implementation} + + + + {syncLevel === ApplicationSyncLevel.AddOnly && ( + + )} + + {syncLevel === ApplicationSyncLevel.FullSync && ( + + )} + + {syncLevel === ApplicationSyncLevel.Disabled && ( + + )} + + + + + + + ); +} + +export default ManageApplicationsModalRow; diff --git a/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModal.tsx b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModal.tsx new file mode 100644 index 000000000..2e24d60e8 --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/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/Applications/Applications/Manage/Tags/TagsModalContent.css b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/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/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.css.d.ts new file mode 100644 index 000000000..9b4321dcc --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/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/Applications/Applications/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..bbc43aced --- /dev/null +++ b/frontend/src/Settings/Applications/Applications/Manage/Tags/TagsModalContent.tsx @@ -0,0 +1,183 @@ +import { uniq } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { ApplicationAppState } 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 Application from 'typings/Application'; +import translate from 'Utilities/String/translate'; +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 allApplications: ApplicationAppState = useSelector( + (state: AppState) => state.settings.applications + ); + const tagList: Tag[] = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const applicationsTags = useMemo(() => { + const tags = ids.reduce((acc: number[], id) => { + const s = allApplications.items.find((s: Application) => s.id === id); + + if (s) { + acc.push(...s.tags); + } + + return acc; + }, []); + + return uniq(tags); + }, [ids, allApplications]); + + 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: translate('Add') }, + { key: 'remove', value: translate('Remove') }, + { key: 'replace', value: translate('Replace') }, + ]; + + return ( + + {translate('Tags')} + + +
+ + {translate('Tags')} + + + + + + {translate('ApplyTags')} + + + + + + {translate('Result')} + +
+ {applicationsTags.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 (applicationsTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js index 3e060aa5d..98cf2ef99 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js @@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; +import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal'; class DownloadClientSettings extends Component { @@ -21,7 +22,8 @@ class DownloadClientSettings extends Component { this.state = { isSaving: false, - hasPendingChanges: false + hasPendingChanges: false, + isManageDownloadClientsOpen: false }; } @@ -36,6 +38,14 @@ class DownloadClientSettings extends Component { this.setState(payload); }; + onManageDownloadClientsPress = () => { + this.setState({ isManageDownloadClientsOpen: true }); + }; + + onManageDownloadClientsModalClose = () => { + this.setState({ isManageDownloadClientsOpen: false }); + }; + onSavePress = () => { if (this._saveCallback) { this._saveCallback(); @@ -53,7 +63,8 @@ class DownloadClientSettings extends Component { const { isSaving, - hasPendingChanges + hasPendingChanges, + isManageDownloadClientsOpen } = this.state; return ( @@ -71,6 +82,12 @@ class DownloadClientSettings extends Component { isSpinning={isTestingAll} onPress={dispatchTestAllDownloadClients} /> + + } onSavePress={this.onSavePress} @@ -78,6 +95,11 @@ class DownloadClientSettings extends Component { + +
); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js index 8cea557a9..7136f531e 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js @@ -89,7 +89,7 @@ class DownloadClient extends Component { kind={kinds.DISABLED} outline={true} > - {translate('PrioritySettings', [priority])} + {translate('Priority')}: {priority} } diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js index 28554a31c..b9cb2cc9c 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -159,7 +159,7 @@ class EditDownloadClientModalContent extends Component { + + + ); +} + +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..c9279de3b --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx @@ -0,0 +1,129 @@ +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; + priority?: number; +} + +interface ManageDownloadClientsEditModalContentProps { + downloadClientIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const enableOptions = [ + { key: NO_CHANGE, value: translate('NoChange'), disabled: true }, + { key: 'enabled', value: translate('Enabled') }, + { key: 'disabled', value: translate('Disabled') }, +]; + +function ManageDownloadClientsEditModalContent( + props: ManageDownloadClientsEditModalContentProps +) { + const { downloadClientIds, onSavePress, onModalClose } = props; + + const [enable, setEnable] = useState(NO_CHANGE); + const [priority, setPriority] = useState(null); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (enable !== NO_CHANGE) { + hasChanges = true; + payload.enable = enable === 'enabled'; + } + + if (priority !== null) { + hasChanges = true; + payload.priority = priority as number; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [enable, priority, onSavePress, onModalClose]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'enable': + setEnable(value); + break; + case 'priority': + setPriority(value); + break; + default: + console.warn( + `EditDownloadClientsModalContent Unknown Input: '${name}'` + ); + } + }, + [] + ); + + const selectedCount = downloadClientIds.length; + + return ( + + {translate('EditSelectedDownloadClients')} + + + + {translate('Enabled')} + + + + + + {translate('ClientPriority')} + + + + + + +
+ {translate('CountDownloadClientsSelected', [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..c7291b012 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -0,0 +1,240 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { DownloadClientAppState } from 'App/State/SettingsAppState'; +import Alert from 'Components/Alert'; +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 translate from 'Utilities/String/translate'; +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: translate('Name'), + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: translate('Implementation'), + isSortable: true, + isVisible: true, + }, + { + name: 'enable', + label: translate('Enabled'), + isSortable: true, + isVisible: true, + }, + { + name: 'priority', + label: translate('ClientPriority'), + 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 download clients.' + ); + const anySelected = selectedCount > 0; + + return ( + + {translate('ManageDownloadClients')} + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !error && !items.length && ( + {translate('NoDownloadClientsFound')} + )} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + {translate('Delete')} + + + + {translate('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..444f376cc --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css @@ -0,0 +1,8 @@ +.name, +.enable, +.priority, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} 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..6c8cd9c29 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'enable': string; + 'implementation': string; + 'name': string; + 'priority': 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..001bced52 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx @@ -0,0 +1,70 @@ +import React, { useCallback } from 'react'; +import Label from 'Components/Label'; +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 { kinds } from 'Helpers/Props'; +import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageDownloadClientsModalRow.css'; + +interface ManageDownloadClientsModalRowProps { + id: number; + name: string; + enable: boolean; + priority: number; + implementation: string; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageDownloadClientsModalRow( + props: ManageDownloadClientsModalRowProps +) { + const { + id, + isSelected, + name, + enable, + priority, + implementation, + onSelectedChange, + } = props; + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + {name} + + + {implementation} + + + + + + + {priority} + + ); +} + +export default ManageDownloadClientsModalRow; 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/appProfiles.js b/frontend/src/Store/Actions/Settings/appProfiles.js index 70f8a8961..357588b75 100644 --- a/frontend/src/Store/Actions/Settings/appProfiles.js +++ b/frontend/src/Store/Actions/Settings/appProfiles.js @@ -52,14 +52,14 @@ export default { isFetching: false, isPopulated: false, error: null, - isDeleting: false, - deleteError: null, isSchemaFetching: false, isSchemaPopulated: false, schemaError: null, schema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, items: [], pendingChanges: {} }, diff --git a/frontend/src/Store/Actions/Settings/applications.js b/frontend/src/Store/Actions/Settings/applications.js index a670732e0..9f7111960 100644 --- a/frontend/src/Store/Actions/Settings/applications.js +++ b/frontend/src/Store/Actions/Settings/applications.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'; @@ -29,6 +31,8 @@ export const DELETE_APPLICATION = 'settings/applications/deleteApplication'; export const TEST_APPLICATION = 'settings/applications/testApplication'; export const CANCEL_TEST_APPLICATION = 'settings/applications/cancelTestApplication'; export const TEST_ALL_APPLICATIONS = 'indexers/testAllApplications'; +export const BULK_EDIT_APPLICATIONS = 'settings/applications/bulkEditApplications'; +export const BULK_DELETE_APPLICATIONS = 'settings/applications/bulkDeleteApplications'; // // Action Creators @@ -43,6 +47,8 @@ export const deleteApplication = createThunk(DELETE_APPLICATION); export const testApplication = createThunk(TEST_APPLICATION); export const cancelTestApplication = createThunk(CANCEL_TEST_APPLICATION); export const testAllApplications = createThunk(TEST_ALL_APPLICATIONS); +export const bulkEditApplications = createThunk(BULK_EDIT_APPLICATIONS); +export const bulkDeleteApplications = createThunk(BULK_DELETE_APPLICATIONS); export const setApplicationValue = createAction(SET_APPLICATION_VALUE, (payload) => { return { @@ -77,6 +83,8 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, isTesting: false, isTestingAll: false, items: [], @@ -95,7 +103,9 @@ export default { [DELETE_APPLICATION]: createRemoveItemHandler(section, '/applications'), [TEST_APPLICATION]: createTestProviderHandler(section, '/applications'), [CANCEL_TEST_APPLICATION]: createCancelTestProviderHandler(section), - [TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications') + [TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications'), + [BULK_EDIT_APPLICATIONS]: createBulkEditItemHandler(section, '/applications/bulk'), + [BULK_DELETE_APPLICATIONS]: createBulkRemoveItemHandler(section, '/applications/bulk') }, // diff --git a/frontend/src/Store/Actions/Settings/downloadClientCategories.js b/frontend/src/Store/Actions/Settings/downloadClientCategories.js index b9fb04404..38cce33c5 100644 --- a/frontend/src/Store/Actions/Settings/downloadClientCategories.js +++ b/frontend/src/Store/Actions/Settings/downloadClientCategories.js @@ -75,6 +75,8 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, items: [], pendingChanges: {} }, diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index 7e9292f24..b4513e7c1 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,8 @@ export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadCl 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_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients'; +export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients'; // // Action Creators @@ -44,6 +48,8 @@ export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT); 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 bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS); +export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS); export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { return { @@ -78,6 +84,8 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, isTesting: false, isTestingAll: false, items: [], @@ -120,7 +128,9 @@ export default { }, [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), - [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') + [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'), + [BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk'), + [BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk') }, // diff --git a/frontend/src/Store/Actions/Settings/indexerProxies.js b/frontend/src/Store/Actions/Settings/indexerProxies.js index 6ba5c731b..20edb2099 100644 --- a/frontend/src/Store/Actions/Settings/indexerProxies.js +++ b/frontend/src/Store/Actions/Settings/indexerProxies.js @@ -74,6 +74,8 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, isTesting: false, items: [], pendingChanges: {} diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js index 3242cef4b..de927ba4f 100644 --- a/frontend/src/Store/Actions/Settings/notifications.js +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -74,6 +74,8 @@ export default { selectedSchema: {}, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, isTesting: false, items: [], pendingChanges: {} diff --git a/frontend/src/Store/Actions/indexerActions.js b/frontend/src/Store/Actions/indexerActions.js index 508140a22..3a2e7d5ce 100644 --- a/frontend/src/Store/Actions/indexerActions.js +++ b/frontend/src/Store/Actions/indexerActions.js @@ -13,6 +13,8 @@ import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate'; import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; import translate from 'Utilities/String/translate'; +import createBulkEditItemHandler from './Creators/createBulkEditItemHandler'; +import createBulkRemoveItemHandler from './Creators/createBulkRemoveItemHandler'; import createHandleActions from './Creators/createHandleActions'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; @@ -95,6 +97,8 @@ export const DELETE_INDEXER = 'indexers/deleteIndexer'; export const TEST_INDEXER = 'indexers/testIndexer'; export const CANCEL_TEST_INDEXER = 'indexers/cancelTestIndexer'; export const TEST_ALL_INDEXERS = 'indexers/testAllIndexers'; +export const BULK_EDIT_INDEXERS = 'indexers/bulkEditIndexers'; +export const BULK_DELETE_INDEXERS = 'indexers/bulkDeleteIndexers'; // // Action Creators @@ -111,6 +115,8 @@ export const deleteIndexer = createThunk(DELETE_INDEXER); export const testIndexer = createThunk(TEST_INDEXER); export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); +export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS); +export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { return { @@ -163,7 +169,9 @@ export const actionHandlers = handleThunks({ [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_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'), + [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk') }); // diff --git a/frontend/src/Store/Actions/indexerIndexActions.js b/frontend/src/Store/Actions/indexerIndexActions.js index 91a4be981..a42897996 100644 --- a/frontend/src/Store/Actions/indexerIndexActions.js +++ b/frontend/src/Store/Actions/indexerIndexActions.js @@ -1,10 +1,6 @@ import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; import translate from 'Utilities/String/translate'; -import { removeItem, set, updateItem } from './baseActions'; import createHandleActions from './Creators/createHandleActions'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; @@ -90,6 +86,30 @@ export const defaultState = { isSortable: false, isVisible: true }, + { + name: 'minimumSeeders', + label: translate('MinimumSeeders'), + isSortable: true, + isVisible: false + }, + { + name: 'seedRatio', + label: translate('SeedRatio'), + isSortable: true, + isVisible: false + }, + { + name: 'seedTime', + label: translate('SeedTime'), + isSortable: true, + isVisible: false + }, + { + name: 'packSeedTime', + label: translate('PackSeedTime'), + isSortable: true, + isVisible: false + }, { name: 'tags', label: translate('Tags'), @@ -186,8 +206,6 @@ export const SET_INDEXER_SORT = 'indexerIndex/setIndexerSort'; export const SET_INDEXER_FILTER = 'indexerIndex/setIndexerFilter'; export const SET_INDEXER_VIEW = 'indexerIndex/setIndexerView'; export const SET_INDEXER_TABLE_OPTION = 'indexerIndex/setIndexerTableOption'; -export const SAVE_INDEXER_EDITOR = 'indexerIndex/saveIndexerEditor'; -export const BULK_DELETE_INDEXERS = 'indexerIndex/bulkDeleteIndexers'; // // Action Creators @@ -196,89 +214,6 @@ export const setIndexerSort = createAction(SET_INDEXER_SORT); export const setIndexerFilter = createAction(SET_INDEXER_FILTER); export const setIndexerView = createAction(SET_INDEXER_VIEW); export const setIndexerTableOption = createAction(SET_INDEXER_TABLE_OPTION); -export const saveIndexerEditor = createThunk(SAVE_INDEXER_EDITOR); -export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - [SAVE_INDEXER_EDITOR]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isSaving: true - })); - - const promise = createAjaxRequest({ - url: '/indexer/editor', - method: 'PUT', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done((data) => { - dispatch(batchActions([ - ...data.map((indexer) => { - return updateItem({ - id: indexer.id, - section: 'indexers', - ...indexer - }); - }), - - set({ - section, - isSaving: false, - saveError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isSaving: false, - saveError: xhr - })); - }); - }, - - [BULK_DELETE_INDEXERS]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isDeleting: true - })); - - const promise = createAjaxRequest({ - url: '/indexer/editor', - method: 'DELETE', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done(() => { - dispatch(batchActions([ - ...payload.indexerIds.map((id) => { - return removeItem({ section: 'indexers', id }); - }), - - set({ - section, - isDeleting: false, - deleteError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isDeleting: false, - deleteError: xhr - })); - }); - } -}); // // Reducers diff --git a/frontend/src/typings/Application.ts b/frontend/src/typings/Application.ts new file mode 100644 index 000000000..650429475 --- /dev/null +++ b/frontend/src/typings/Application.ts @@ -0,0 +1,19 @@ +import ModelBase from 'App/ModelBase'; + +export enum ApplicationSyncLevel { + Disabled = 'disabled', + AddOnly = 'addOnly', + FullSync = 'fullSync', +} + +interface Application extends ModelBase { + name: string; + syncLevel: ApplicationSyncLevel; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default Application; diff --git a/frontend/src/typings/DownloadClient.ts b/frontend/src/typings/DownloadClient.ts new file mode 100644 index 000000000..45af9eb32 --- /dev/null +++ b/frontend/src/typings/DownloadClient.ts @@ -0,0 +1,26 @@ +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 DownloadClient extends ModelBase { + enable: boolean; + protocol: string; + priority: number; + name: string; + fields: Field[]; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default DownloadClient; diff --git a/frontend/src/typings/Indexer.ts b/frontend/src/typings/Indexer.ts new file mode 100644 index 000000000..6e31aa0c6 --- /dev/null +++ b/frontend/src/typings/Indexer.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 Indexer extends ModelBase { + enable: boolean; + appProfileId: number; + 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/frontend/src/typings/UiSettings.ts b/frontend/src/typings/UiSettings.ts new file mode 100644 index 000000000..79cb0f333 --- /dev/null +++ b/frontend/src/typings/UiSettings.ts @@ -0,0 +1,6 @@ +export interface UiSettings { + showRelativeDates: boolean; + shortDateFormat: string; + longDateFormat: string; + timeFormat: string; +} diff --git a/frontend/src/typings/props.ts b/frontend/src/typings/props.ts new file mode 100644 index 000000000..5b87e36b3 --- /dev/null +++ b/frontend/src/typings/props.ts @@ -0,0 +1,5 @@ +export interface SelectStateInputProps { + id: number; + value: boolean; + shiftKey: boolean; +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 2d3e7bdd8..d2f0b0189 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -48,6 +48,8 @@ "ApplyTagsHelpTexts3": "Remove: Remove the entered tags", "ApplyTagsHelpTexts4": "Replace: Replace the tags with the entered tags (enter no tags to clear all tags)", "Apps": "Apps", + "AppsMinimumSeeders": "Apps Minimum Seeders", + "AppsMinimumSeedersHelpText": "Minimum seeders required by the Applications for the indexer to grab, empty is Sync profile's default", "AreYouSureYouWantToDeleteCategory": "Are you sure you want to delete mapped category?", "AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?", "Artist": "Artist", @@ -102,7 +104,9 @@ "ConnectionLostMessage": "Prowlarr has lost its connection to the backend and will need to be reloaded to restore functionality.", "Connections": "Connections", "CouldNotConnectSignalR": "Could not connect to SignalR, UI won't update", - "CountIndexersSelected": "{0} indexers selected", + "CountApplicationsSelected": "{0} application(s) selected", + "CountDownloadClientsSelected": "{0} download client(s) selected", + "CountIndexersSelected": "{0} indexer(s) selected", "Custom": "Custom", "CustomFilters": "Custom Filters", "DBMigration": "DB Migration", @@ -122,6 +126,10 @@ "DeleteIndexerProxyMessageText": "Are you sure you want to delete the proxy '{0}'?", "DeleteNotification": "Delete Notification", "DeleteNotificationMessageText": "Are you sure you want to delete the notification '{0}'?", + "DeleteSelectedApplications": "Delete Selected Applications", + "DeleteSelectedApplicationsMessageText": "Are you sure you want to delete {0} selected application(s)?", + "DeleteSelectedDownloadClients": "Delete Download Client(s)", + "DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {0} selected download client(s)?", "DeleteSelectedIndexer": "Delete Selected Indexer", "DeleteSelectedIndexers": "Delete Selected Indexers", "DeleteSelectedIndexersMessageText": "Are you sure you want to delete {0} selected indexer(s)?", @@ -137,6 +145,7 @@ "Donations": "Donations", "DownloadClient": "Download Client", "DownloadClientCategory": "Download Client Category", + "DownloadClientPriorityHelpText": "Prioritize multiple Download Clients. Round-Robin is used for clients with the same priority.", "DownloadClientSettings": "Download Client Settings", "DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures", "DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}", @@ -145,6 +154,7 @@ "Duration": "Duration", "Edit": "Edit", "EditIndexer": "Edit Indexer", + "EditSelectedDownloadClients": "Edit Selected Download Clients", "EditSelectedIndexers": "Edit Selected Indexers", "EditSyncProfile": "Edit Sync Profile", "ElapsedTime": "Elapsed Time", @@ -204,6 +214,7 @@ "Id": "Id", "IgnoredAddresses": "Ignored Addresses", "IllRestartLater": "I'll restart later", + "Implementation": "Implementation", "IncludeHealthWarningsHelpText": "Include Health Warnings", "IncludeManualGrabsHelpText": "Include Manual Grabs made within Prowlarr", "Indexer": "Indexer", @@ -263,6 +274,8 @@ "Logs": "Logs", "MIA": "MIA", "MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details", + "ManageApplications": "Manage Applications", + "ManageDownloadClients": "Manage Download Clients", "Manual": "Manual", "MappedCategories": "Mapped Categories", "MappedDrivesRunningAsService": "Mapped network drives are not available when running as a Windows Service. Please see the FAQ for more information", @@ -287,6 +300,7 @@ "NoBackupsAreAvailable": "No backups are available", "NoChange": "No Change", "NoChanges": "No Changes", + "NoDownloadClientsFound": "No download clients found", "NoHistoryFound": "No history found", "NoIndexersFound": "No indexers found", "NoLeaveIt": "No, Leave It", @@ -313,6 +327,8 @@ "OpenBrowserOnStart": "Open browser on start", "OpenThisModal": "Open This Modal", "Options": "Options", + "PackSeedTime": "Pack Seed Time", + "PackSeedTimeHelpText": "The time a pack (season or discography) torrent should be seeded before stopping, empty is app's default", "PackageVersion": "Package Version", "PageSize": "Page Size", "PageSizeHelpText": "Number of items to show on each page", @@ -326,8 +342,6 @@ "PortNumber": "Port Number", "Presets": "Presets", "Priority": "Priority", - "PriorityHelpText": "Prioritize multiple Download Clients. Round-Robin is used for clients with the same priority.", - "PrioritySettings": "Priority", "Privacy": "Privacy", "Private": "Private", "Protocol": "Protocol", @@ -398,6 +412,10 @@ "SearchTypes": "Search Types", "Season": "Season", "Security": "Security", + "SeedRatio": "Seed Ratio", + "SeedRatioHelpText": "The ratio a torrent should reach before stopping, empty is app's default", + "SeedTime": "Seed Time", + "SeedTimeHelpText": "The time a torrent should be seeded before stopping, empty is app's default", "Seeders": "Seeders", "SelectAll": "Select All", "SelectIndexers": "Select Indexers", diff --git a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs index 303a03d55..0cfeed9a8 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs @@ -12,9 +12,10 @@ 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); - void Update(IEnumerable definitions); + IEnumerable Update(IEnumerable definitions); void Delete(int id); void Delete(IEnumerable ids); IEnumerable GetDefaultDefinitions(); diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index 4bb3da6b7..fad090940 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,10 +125,12 @@ namespace NzbDrone.Core.ThingiProvider _eventAggregator.PublishEvent(new ProviderUpdatedEvent(updatedDef)); } - public virtual void Update(IEnumerable definitions) + public virtual IEnumerable Update(IEnumerable definitions) { _providerRepository.UpdateMany(definitions.ToList()); _eventAggregator.PublishEvent(new ProviderBulkUpdatedEvent(definitions)); + + return definitions; } public void Delete(int id) diff --git a/src/Prowlarr.Api.V1/Applications/ApplicationBulkResource.cs b/src/Prowlarr.Api.V1/Applications/ApplicationBulkResource.cs new file mode 100644 index 000000000..200c35021 --- /dev/null +++ b/src/Prowlarr.Api.V1/Applications/ApplicationBulkResource.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using NzbDrone.Core.Applications; + +namespace Prowlarr.Api.V1.Applications +{ + public class ApplicationBulkResource : ProviderBulkResource + { + public ApplicationSyncLevel? SyncLevel { get; set; } + } + + public class ApplicationBulkResourceMapper : ProviderBulkResourceMapper + { + public override List UpdateModel(ApplicationBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + existingDefinitions.ForEach(existing => + { + existing.SyncLevel = resource.SyncLevel ?? existing.SyncLevel; + }); + + return existingDefinitions; + } + } +} diff --git a/src/Prowlarr.Api.V1/Applications/ApplicationController.cs b/src/Prowlarr.Api.V1/Applications/ApplicationController.cs index b7b63b46e..c3819d8f9 100644 --- a/src/Prowlarr.Api.V1/Applications/ApplicationController.cs +++ b/src/Prowlarr.Api.V1/Applications/ApplicationController.cs @@ -1,15 +1,16 @@ using NzbDrone.Core.Applications; using Prowlarr.Http; -namespace Prowlarr.Api.V1.Application +namespace Prowlarr.Api.V1.Applications { [V1ApiController("applications")] - public class ApplicationController : ProviderControllerBase + public class ApplicationController : ProviderControllerBase { - public static readonly ApplicationResourceMapper ResourceMapper = new ApplicationResourceMapper(); + public static readonly ApplicationResourceMapper ResourceMapper = new (); + public static readonly ApplicationBulkResourceMapper BulkResourceMapper = new (); public ApplicationController(ApplicationFactory applicationsFactory) - : base(applicationsFactory, "applications", ResourceMapper) + : base(applicationsFactory, "applications", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Prowlarr.Api.V1/Applications/ApplicationResource.cs b/src/Prowlarr.Api.V1/Applications/ApplicationResource.cs index 69b142ef0..5c1374ea6 100644 --- a/src/Prowlarr.Api.V1/Applications/ApplicationResource.cs +++ b/src/Prowlarr.Api.V1/Applications/ApplicationResource.cs @@ -1,6 +1,6 @@ using NzbDrone.Core.Applications; -namespace Prowlarr.Api.V1.Application +namespace Prowlarr.Api.V1.Applications { public class ApplicationResource : ProviderResource { diff --git a/src/Prowlarr.Api.V1/DownloadClient/DownloadClientBulkResource.cs b/src/Prowlarr.Api.V1/DownloadClient/DownloadClientBulkResource.cs new file mode 100644 index 000000000..9664382e1 --- /dev/null +++ b/src/Prowlarr.Api.V1/DownloadClient/DownloadClientBulkResource.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using NzbDrone.Core.Download; + +namespace Prowlarr.Api.V1.DownloadClient +{ + public class DownloadClientBulkResource : ProviderBulkResource + { + public bool? Enable { get; set; } + public int? Priority { 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; + }); + + return existingDefinitions; + } + } +} diff --git a/src/Prowlarr.Api.V1/DownloadClient/DownloadClientController.cs b/src/Prowlarr.Api.V1/DownloadClient/DownloadClientController.cs index 994db6c69..5dd43ea7d 100644 --- a/src/Prowlarr.Api.V1/DownloadClient/DownloadClientController.cs +++ b/src/Prowlarr.Api.V1/DownloadClient/DownloadClientController.cs @@ -4,12 +4,13 @@ using Prowlarr.Http; namespace Prowlarr.Api.V1.DownloadClient { [V1ApiController] - public class DownloadClientController : ProviderControllerBase + public class DownloadClientController : ProviderControllerBase { - public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper(); + public static readonly DownloadClientResourceMapper ResourceMapper = new (); + public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new (); public DownloadClientController(IDownloadClientFactory downloadClientFactory) - : base(downloadClientFactory, "downloadclient", ResourceMapper) + : base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Prowlarr.Api.V1/IndexerProxies/IndexerProxyBulkResource.cs b/src/Prowlarr.Api.V1/IndexerProxies/IndexerProxyBulkResource.cs new file mode 100644 index 000000000..4b65ac44b --- /dev/null +++ b/src/Prowlarr.Api.V1/IndexerProxies/IndexerProxyBulkResource.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.IndexerProxies; + +namespace Prowlarr.Api.V1.IndexerProxies +{ + public class IndexerProxyBulkResource : ProviderBulkResource + { + } + + public class IndexerProxyBulkResourceMapper : ProviderBulkResourceMapper + { + } +} diff --git a/src/Prowlarr.Api.V1/IndexerProxies/IndexerProxyController.cs b/src/Prowlarr.Api.V1/IndexerProxies/IndexerProxyController.cs index f63ee4a3e..ba6cbfbe7 100644 --- a/src/Prowlarr.Api.V1/IndexerProxies/IndexerProxyController.cs +++ b/src/Prowlarr.Api.V1/IndexerProxies/IndexerProxyController.cs @@ -1,16 +1,31 @@ +using System; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.IndexerProxies; using Prowlarr.Http; namespace Prowlarr.Api.V1.IndexerProxies { [V1ApiController] - public class IndexerProxyController : ProviderControllerBase + public class IndexerProxyController : ProviderControllerBase { - public static readonly IndexerProxyResourceMapper ResourceMapper = new IndexerProxyResourceMapper(); + public static readonly IndexerProxyResourceMapper ResourceMapper = new (); + public static readonly IndexerProxyBulkResourceMapper BulkResourceMapper = new (); public IndexerProxyController(IndexerProxyFactory notificationFactory) - : base(notificationFactory, "indexerProxy", ResourceMapper) + : base(notificationFactory, "indexerProxy", ResourceMapper, BulkResourceMapper) { } + + [NonAction] + public override ActionResult UpdateProvider([FromBody] IndexerProxyBulkResource providerResource) + { + throw new NotImplementedException(); + } + + [NonAction] + public override object DeleteProviders([FromBody] IndexerProxyBulkResource resource) + { + throw new NotImplementedException(); + } } } diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerBulkResource.cs b/src/Prowlarr.Api.V1/Indexers/IndexerBulkResource.cs new file mode 100644 index 000000000..7f3d281f0 --- /dev/null +++ b/src/Prowlarr.Api.V1/Indexers/IndexerBulkResource.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using NzbDrone.Core.Indexers; + +namespace Prowlarr.Api.V1.Indexers +{ + public class IndexerBulkResource : ProviderBulkResource + { + public bool? Enable { get; set; } + public int? AppProfileId { get; set; } + public int? Priority { get; set; } + public int? MinimumSeeders { get; set; } + public double? SeedRatio { get; set; } + public int? SeedTime { get; set; } + public int? PackSeedTime { get; set; } + } + + public class IndexerBulkResourceMapper : ProviderBulkResourceMapper + { + public override List UpdateModel(IndexerBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + existingDefinitions.ForEach(existing => + { + existing.Enable = resource.Enable ?? existing.Enable; + existing.AppProfileId = resource.AppProfileId ?? existing.AppProfileId; + existing.Priority = resource.Priority ?? existing.Priority; + + if (existing.Protocol == DownloadProtocol.Torrent) + { + ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.AppMinimumSeeders = resource.MinimumSeeders ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.AppMinimumSeeders; + ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedRatio = resource.SeedRatio ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedRatio; + ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedTime = resource.SeedTime ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedTime; + ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.PackSeedTime = resource.PackSeedTime ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.PackSeedTime; + } + }); + + return existingDefinitions; + } + } +} diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerController.cs b/src/Prowlarr.Api.V1/Indexers/IndexerController.cs index c184e2436..0e38f0ccb 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerController.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerController.cs @@ -4,10 +4,10 @@ using Prowlarr.Http; namespace Prowlarr.Api.V1.Indexers { [V1ApiController] - public class IndexerController : ProviderControllerBase + public class IndexerController : ProviderControllerBase { - public IndexerController(IndexerFactory indexerFactory, IndexerResourceMapper resourceMapper) - : base(indexerFactory, "indexer", resourceMapper) + public IndexerController(IndexerFactory indexerFactory, IndexerResourceMapper resourceMapper, IndexerBulkResourceMapper bulkResourceMapper) + : base(indexerFactory, "indexer", resourceMapper, bulkResourceMapper) { } } diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs b/src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs deleted file mode 100644 index 9f7fda2ae..000000000 --- a/src/Prowlarr.Api.V1/Indexers/IndexerEditorController.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Microsoft.AspNetCore.Mvc; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Messaging.Commands; -using Prowlarr.Http; - -namespace Prowlarr.Api.V1.Indexers -{ - [V1ApiController("indexer/editor")] - public class IndexerEditorController : Controller - { - private readonly IIndexerFactory _indexerFactory; - private readonly IManageCommandQueue _commandQueueManager; - private readonly IndexerResourceMapper _resourceMapper; - - public IndexerEditorController(IIndexerFactory indexerFactory, IManageCommandQueue commandQueueManager, IndexerResourceMapper resourceMapper) - { - _indexerFactory = indexerFactory; - _commandQueueManager = commandQueueManager; - _resourceMapper = resourceMapper; - } - - [HttpPut] - [Consumes("application/json")] - public IActionResult SaveAll(IndexerEditorResource resource) - { - var indexersToUpdate = _indexerFactory.AllProviders(false).Select(x => (IndexerDefinition)x.Definition).Where(d => resource.IndexerIds.Contains(d.Id)); - - foreach (var indexer in indexersToUpdate) - { - if (resource.Enable.HasValue) - { - indexer.Enable = resource.Enable.Value; - } - - if (resource.AppProfileId.HasValue) - { - indexer.AppProfileId = resource.AppProfileId.Value; - } - - if (resource.Priority.HasValue) - { - indexer.Priority = resource.Priority.Value; - } - - if (resource.Tags != null) - { - var newTags = resource.Tags; - var applyTags = resource.ApplyTags; - - switch (applyTags) - { - case ApplyTags.Add: - newTags.ForEach(t => indexer.Tags.Add(t)); - break; - case ApplyTags.Remove: - newTags.ForEach(t => indexer.Tags.Remove(t)); - break; - case ApplyTags.Replace: - indexer.Tags = new HashSet(newTags); - break; - } - } - } - - _indexerFactory.Update(indexersToUpdate); - - var indexers = _indexerFactory.All(); - - foreach (var definition in indexers) - { - _indexerFactory.SetProviderCharacteristics(definition); - } - - return Accepted(_resourceMapper.ToResource(indexers)); - } - - [HttpDelete] - [Consumes("application/json")] - public object DeleteIndexers([FromBody] IndexerEditorResource resource) - { - _indexerFactory.Delete(resource.IndexerIds); - - return new { }; - } - } -} diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerEditorResource.cs b/src/Prowlarr.Api.V1/Indexers/IndexerEditorResource.cs deleted file mode 100644 index 2e70686ee..000000000 --- a/src/Prowlarr.Api.V1/Indexers/IndexerEditorResource.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; - -namespace Prowlarr.Api.V1.Indexers -{ - public class IndexerEditorResource - { - public List IndexerIds { get; set; } - public bool? Enable { get; set; } - public int? AppProfileId { get; set; } - public int? Priority { get; set; } - public List Tags { get; set; } - public ApplyTags ApplyTags { get; set; } - } - - public enum ApplyTags - { - Add, - Remove, - Replace - } -} diff --git a/src/Prowlarr.Api.V1/Notifications/NotificationBulkResource.cs b/src/Prowlarr.Api.V1/Notifications/NotificationBulkResource.cs new file mode 100644 index 000000000..033e45f3e --- /dev/null +++ b/src/Prowlarr.Api.V1/Notifications/NotificationBulkResource.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Notifications; + +namespace Prowlarr.Api.V1.Notifications +{ + public class NotificationBulkResource : ProviderBulkResource + { + } + + public class NotificationBulkResourceMapper : ProviderBulkResourceMapper + { + } +} diff --git a/src/Prowlarr.Api.V1/Notifications/NotificationController.cs b/src/Prowlarr.Api.V1/Notifications/NotificationController.cs index 310d2dd0b..b6aa8d99e 100644 --- a/src/Prowlarr.Api.V1/Notifications/NotificationController.cs +++ b/src/Prowlarr.Api.V1/Notifications/NotificationController.cs @@ -1,16 +1,31 @@ +using System; +using Microsoft.AspNetCore.Mvc; using NzbDrone.Core.Notifications; using Prowlarr.Http; namespace Prowlarr.Api.V1.Notifications { [V1ApiController] - public class NotificationController : ProviderControllerBase + public class NotificationController : ProviderControllerBase { - public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); + public static readonly NotificationResourceMapper ResourceMapper = new (); + public static readonly NotificationBulkResourceMapper BulkResourceMapper = new (); public NotificationController(NotificationFactory notificationFactory) - : base(notificationFactory, "notification", ResourceMapper) + : base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper) { } + + [NonAction] + public override ActionResult UpdateProvider([FromBody] NotificationBulkResource providerResource) + { + throw new NotImplementedException(); + } + + [NonAction] + public override object DeleteProviders([FromBody] NotificationBulkResource resource) + { + throw new NotImplementedException(); + } } } diff --git a/src/Prowlarr.Api.V1/ProviderBulkResource.cs b/src/Prowlarr.Api.V1/ProviderBulkResource.cs new file mode 100644 index 000000000..d9726cb82 --- /dev/null +++ b/src/Prowlarr.Api.V1/ProviderBulkResource.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using NzbDrone.Core.ThingiProvider; + +namespace Prowlarr.Api.V1 +{ + public class ProviderBulkResource + { + public List Ids { get; set; } + public List Tags { get; set; } + public ApplyTags ApplyTags { get; set; } + + public ProviderBulkResource() + { + Ids = new List(); + } + } + + public enum ApplyTags + { + Add, + Remove, + Replace + } + + 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/Prowlarr.Api.V1/ProviderControllerBase.cs b/src/Prowlarr.Api.V1/ProviderControllerBase.cs index 9edfe1642..40bbe205f 100644 --- a/src/Prowlarr.Api.V1/ProviderControllerBase.cs +++ b/src/Prowlarr.Api.V1/ProviderControllerBase.cs @@ -10,18 +10,25 @@ using Prowlarr.Http.REST; namespace Prowlarr.Api.V1 { - public abstract class ProviderControllerBase : RestController + public abstract class ProviderControllerBase : RestController where TProviderDefinition : ProviderDefinition, new() + where TBulkProviderResource : ProviderBulkResource, new() where TProvider : IProvider where TProviderResource : ProviderResource, new() { protected readonly IProviderFactory _providerFactory; protected 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"); @@ -92,6 +99,47 @@ namespace Prowlarr.Api.V1 return Accepted(providerResource.Id); } + [HttpPut("bulk")] + [Consumes("application/json")] + [Produces("application/json")] + public virtual ActionResult UpdateProvider([FromBody] TBulkProviderResource providerResource) + { + if (!providerResource.Ids.Any()) + { + throw new BadRequestException("ids must be provided"); + } + + var definitionsToUpdate = _providerFactory.Get(providerResource.Ids).ToList(); + + foreach (var definition in definitionsToUpdate) + { + _providerFactory.SetProviderCharacteristics(definition); + + 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 definition = _resourceMapper.ToModel(providerResource); @@ -112,6 +160,16 @@ namespace Prowlarr.Api.V1 return new { }; } + [HttpDelete("bulk")] + [Consumes("application/json")] + [Produces("application/json")] + public virtual object DeleteProviders([FromBody] TBulkProviderResource resource) + { + _providerFactory.Delete(resource.Ids); + + return new { }; + } + [HttpGet("schema")] [Produces("application/json")] public virtual List GetTemplates()