You've already forked Sonarr
							
							
				mirror of
				https://github.com/Sonarr/Sonarr.git
				synced 2025-10-31 00:07:55 +02:00 
			
		
		
		
	New: Bulk Manage Import Lists, Indexers, Clients
This commit is contained in:
		| @@ -1,14 +1,33 @@ | ||||
| import AppSectionState, { | ||||
|   AppSectionDeleteState, | ||||
|   AppSectionSaveState, | ||||
|   AppSectionSchemaState, | ||||
| } from 'App/State/AppSectionState'; | ||||
| import Language from 'Language/Language'; | ||||
| import DownloadClient from 'typings/DownloadClient'; | ||||
| import ImportList from 'typings/ImportList'; | ||||
| import Indexer from 'typings/Indexer'; | ||||
| import Notification from 'typings/Notification'; | ||||
| import QualityProfile from 'typings/QualityProfile'; | ||||
| import { UiSettings } from 'typings/UiSettings'; | ||||
|  | ||||
| export interface DownloadClientAppState | ||||
|   extends AppSectionState<DownloadClient>, | ||||
|     AppSectionDeleteState, | ||||
|     AppSectionSaveState {} | ||||
|  | ||||
| export interface ImportListAppState | ||||
|   extends AppSectionState<ImportList>, | ||||
|     AppSectionDeleteState, | ||||
|     AppSectionSaveState {} | ||||
|  | ||||
| export interface IndexerAppState | ||||
|   extends AppSectionState<Indexer>, | ||||
|     AppSectionDeleteState, | ||||
|     AppSectionSaveState {} | ||||
|  | ||||
| export interface NotificationAppState | ||||
|   extends AppSectionState<Notification>, | ||||
|     AppSectionDeleteState {} | ||||
|  | ||||
| export interface QualityProfilesAppState | ||||
| @@ -20,6 +39,9 @@ export type UiSettingsAppState = AppSectionState<UiSettings>; | ||||
|  | ||||
| interface SettingsAppState { | ||||
|   downloadClients: DownloadClientAppState; | ||||
|   importLists: ImportListAppState; | ||||
|   indexers: IndexerAppState; | ||||
|   notifications: NotificationAppState; | ||||
|   language: LanguageSettingsAppState; | ||||
|   uiSettings: UiSettingsAppState; | ||||
|   qualityProfiles: QualityProfilesAppState; | ||||
|   | ||||
| @@ -261,6 +261,8 @@ FormInputGroup.propTypes = { | ||||
|   values: PropTypes.arrayOf(PropTypes.any), | ||||
|   type: PropTypes.string.isRequired, | ||||
|   kind: PropTypes.oneOf(kinds.all), | ||||
|   min: PropTypes.number, | ||||
|   max: PropTypes.number, | ||||
|   unit: PropTypes.string, | ||||
|   buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), | ||||
|   helpText: PropTypes.string, | ||||
|   | ||||
| @@ -65,6 +65,7 @@ import { | ||||
|   faInfoCircle as fasInfoCircle, | ||||
|   faLaptop as fasLaptop, | ||||
|   faLevelUpAlt as fasLevelUpAlt, | ||||
|   faListCheck as fasListCheck, | ||||
|   faMedkit as fasMedkit, | ||||
|   faMinus as fasMinus, | ||||
|   faPause as fasPause, | ||||
| @@ -158,6 +159,7 @@ export const INFO = fasInfoCircle; | ||||
| export const INTERACTIVE = fasUser; | ||||
| export const KEYBOARD = farKeyboard; | ||||
| export const LOGOUT = fasSignOutAlt; | ||||
| export const MANAGE = fasListCheck; | ||||
| export const MEDIA_INFO = farFileInvoice; | ||||
| export const MISSING = fasExclamationTriangle; | ||||
| export const MONITORED = fasBookmark; | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; | ||||
| import { icons } from 'Helpers/Props'; | ||||
| import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; | ||||
| import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; | ||||
| import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal'; | ||||
| import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; | ||||
| import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; | ||||
|  | ||||
| @@ -22,7 +23,8 @@ class DownloadClientSettings extends Component { | ||||
|  | ||||
|     this.state = { | ||||
|       isSaving: false, | ||||
|       hasPendingChanges: false | ||||
|       hasPendingChanges: false, | ||||
|       isManageDownloadClientsOpen: false | ||||
|     }; | ||||
|   } | ||||
|  | ||||
| @@ -37,6 +39,14 @@ class DownloadClientSettings extends Component { | ||||
|     this.setState(payload); | ||||
|   }; | ||||
|  | ||||
|   onManageDownloadClientsPress = () => { | ||||
|     this.setState({ isManageDownloadClientsOpen: true }); | ||||
|   }; | ||||
|  | ||||
|   onManageDownloadClientsModalClose = () => { | ||||
|     this.setState({ isManageDownloadClientsOpen: false }); | ||||
|   }; | ||||
|  | ||||
|   onSavePress = () => { | ||||
|     if (this._saveCallback) { | ||||
|       this._saveCallback(); | ||||
| @@ -54,7 +64,8 @@ class DownloadClientSettings extends Component { | ||||
|  | ||||
|     const { | ||||
|       isSaving, | ||||
|       hasPendingChanges | ||||
|       hasPendingChanges, | ||||
|       isManageDownloadClientsOpen | ||||
|     } = this.state; | ||||
|  | ||||
|     return ( | ||||
| @@ -72,6 +83,12 @@ class DownloadClientSettings extends Component { | ||||
|                 isSpinning={isTestingAll} | ||||
|                 onPress={dispatchTestAllDownloadClients} | ||||
|               /> | ||||
|  | ||||
|               <PageToolbarButton | ||||
|                 label="Manage Clients" | ||||
|                 iconName={icons.MANAGE} | ||||
|                 onPress={this.onManageDownloadClientsPress} | ||||
|               /> | ||||
|             </Fragment> | ||||
|           } | ||||
|           onSavePress={this.onSavePress} | ||||
| @@ -86,6 +103,11 @@ class DownloadClientSettings extends Component { | ||||
|           /> | ||||
|  | ||||
|           <RemotePathMappingsConnector /> | ||||
|  | ||||
|           <ManageDownloadClientsModal | ||||
|             isOpen={isManageDownloadClientsOpen} | ||||
|             onModalClose={this.onManageDownloadClientsModalClose} | ||||
|           /> | ||||
|         </PageContentBody> | ||||
|       </PageContent> | ||||
|     ); | ||||
|   | ||||
| @@ -0,0 +1,28 @@ | ||||
| import React from 'react'; | ||||
| import Modal from 'Components/Modal/Modal'; | ||||
| import ManageDownloadClientsEditModalContent from './ManageDownloadClientsEditModalContent'; | ||||
|  | ||||
| interface ManageDownloadClientsEditModalProps { | ||||
|   isOpen: boolean; | ||||
|   downloadClientIds: number[]; | ||||
|   onSavePress(payload: object): void; | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| function ManageDownloadClientsEditModal( | ||||
|   props: ManageDownloadClientsEditModalProps | ||||
| ) { | ||||
|   const { isOpen, downloadClientIds, onSavePress, onModalClose } = props; | ||||
|  | ||||
|   return ( | ||||
|     <Modal isOpen={isOpen} onModalClose={onModalClose}> | ||||
|       <ManageDownloadClientsEditModalContent | ||||
|         downloadClientIds={downloadClientIds} | ||||
|         onSavePress={onSavePress} | ||||
|         onModalClose={onModalClose} | ||||
|       /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageDownloadClientsEditModal; | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
| @@ -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; | ||||
| @@ -0,0 +1,180 @@ | ||||
| import React, { useCallback, useState } from 'react'; | ||||
| import FormGroup from 'Components/Form/FormGroup'; | ||||
| import FormInputGroup from 'Components/Form/FormInputGroup'; | ||||
| import FormLabel from 'Components/Form/FormLabel'; | ||||
| import Button from 'Components/Link/Button'; | ||||
| import ModalBody from 'Components/Modal/ModalBody'; | ||||
| import ModalContent from 'Components/Modal/ModalContent'; | ||||
| import ModalFooter from 'Components/Modal/ModalFooter'; | ||||
| import ModalHeader from 'Components/Modal/ModalHeader'; | ||||
| import { inputTypes } from 'Helpers/Props'; | ||||
| import translate from 'Utilities/String/translate'; | ||||
| import styles from './ManageDownloadClientsEditModalContent.css'; | ||||
|  | ||||
| interface SavePayload { | ||||
|   enable?: boolean; | ||||
|   removeCompletedDownloads?: boolean; | ||||
|   removeFailedDownloads?: boolean; | ||||
|   priority?: number; | ||||
| } | ||||
|  | ||||
| interface ManageDownloadClientsEditModalContentProps { | ||||
|   downloadClientIds: number[]; | ||||
|   onSavePress(payload: object): void; | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| const NO_CHANGE = 'noChange'; | ||||
|  | ||||
| const enableOptions = [ | ||||
|   { key: NO_CHANGE, value: 'No Change', disabled: true }, | ||||
|   { key: 'enabled', value: 'Enabled' }, | ||||
|   { key: 'disabled', value: 'Disabled' }, | ||||
| ]; | ||||
|  | ||||
| function ManageDownloadClientsEditModalContent( | ||||
|   props: ManageDownloadClientsEditModalContentProps | ||||
| ) { | ||||
|   const { downloadClientIds, onSavePress, onModalClose } = props; | ||||
|  | ||||
|   const [enable, setEnable] = useState(NO_CHANGE); | ||||
|   const [removeCompletedDownloads, setRemoveCompletedDownloads] = | ||||
|     useState(NO_CHANGE); | ||||
|   const [removeFailedDownloads, setRemoveFailedDownloads] = useState(NO_CHANGE); | ||||
|   const [priority, setPriority] = useState<string | number>(NO_CHANGE); | ||||
|  | ||||
|   const save = useCallback(() => { | ||||
|     let hasChanges = false; | ||||
|     const payload: SavePayload = {}; | ||||
|  | ||||
|     if (enable !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.enable = enable === 'enabled'; | ||||
|     } | ||||
|  | ||||
|     if (removeCompletedDownloads !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.removeCompletedDownloads = removeCompletedDownloads === 'enabled'; | ||||
|     } | ||||
|  | ||||
|     if (removeFailedDownloads !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.removeFailedDownloads = removeFailedDownloads === 'enabled'; | ||||
|     } | ||||
|  | ||||
|     if (priority !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.priority = priority as number; | ||||
|     } | ||||
|  | ||||
|     if (hasChanges) { | ||||
|       onSavePress(payload); | ||||
|     } | ||||
|  | ||||
|     onModalClose(); | ||||
|   }, [ | ||||
|     enable, | ||||
|     priority, | ||||
|     removeCompletedDownloads, | ||||
|     removeFailedDownloads, | ||||
|     onSavePress, | ||||
|     onModalClose, | ||||
|   ]); | ||||
|  | ||||
|   const onInputChange = useCallback( | ||||
|     ({ name, value }: { name: string; value: string }) => { | ||||
|       switch (name) { | ||||
|         case 'enable': | ||||
|           setEnable(value); | ||||
|           break; | ||||
|         case 'priority': | ||||
|           setPriority(value); | ||||
|           break; | ||||
|         case 'removeCompletedDownloads': | ||||
|           setRemoveCompletedDownloads(value); | ||||
|           break; | ||||
|         case 'removeFailedDownloads': | ||||
|           setRemoveFailedDownloads(value); | ||||
|           break; | ||||
|         default: | ||||
|           console.warn('EditDownloadClientsModalContent Unknown Input'); | ||||
|       } | ||||
|     }, | ||||
|     [] | ||||
|   ); | ||||
|  | ||||
|   const selectedCount = downloadClientIds.length; | ||||
|  | ||||
|   return ( | ||||
|     <ModalContent onModalClose={onModalClose}> | ||||
|       <ModalHeader>{translate('EditSelectedDownloadClients')}</ModalHeader> | ||||
|  | ||||
|       <ModalBody> | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('Enabled')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.SELECT} | ||||
|             name="enable" | ||||
|             value={enable} | ||||
|             values={enableOptions} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|  | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('Priority')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.NUMBER} | ||||
|             name="priority" | ||||
|             value={priority} | ||||
|             min={1} | ||||
|             max={50} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|  | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('RemoveCompletedDownloads')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.SELECT} | ||||
|             name="removeCompletedDownloads" | ||||
|             value={removeCompletedDownloads} | ||||
|             values={enableOptions} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|  | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('RemoveFailedDownloads')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.SELECT} | ||||
|             name="removeFailedDownloads" | ||||
|             value={removeFailedDownloads} | ||||
|             values={enableOptions} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|       </ModalBody> | ||||
|  | ||||
|       <ModalFooter className={styles.modalFooter}> | ||||
|         <div className={styles.selected}> | ||||
|           {translate('{count} download clients selected', { | ||||
|             count: selectedCount, | ||||
|           })} | ||||
|         </div> | ||||
|  | ||||
|         <div> | ||||
|           <Button onPress={onModalClose}>{translate('Cancel')}</Button> | ||||
|  | ||||
|           <Button onPress={save}>{translate('Apply Changes')}</Button> | ||||
|         </div> | ||||
|       </ModalFooter> | ||||
|     </ModalContent> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageDownloadClientsEditModalContent; | ||||
| @@ -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 ( | ||||
|     <Modal isOpen={isOpen} onModalClose={onModalClose}> | ||||
|       <ManageDownloadClientsModalContent onModalClose={onModalClose} /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageDownloadClientsModal; | ||||
| @@ -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; | ||||
| } | ||||
| @@ -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; | ||||
| @@ -0,0 +1,241 @@ | ||||
| import React, { useCallback, useMemo, useState } from 'react'; | ||||
| import { useDispatch, useSelector } from 'react-redux'; | ||||
| import { DownloadClientAppState } from 'App/State/SettingsAppState'; | ||||
| import Button from 'Components/Link/Button'; | ||||
| import SpinnerButton from 'Components/Link/SpinnerButton'; | ||||
| import LoadingIndicator from 'Components/Loading/LoadingIndicator'; | ||||
| import ConfirmModal from 'Components/Modal/ConfirmModal'; | ||||
| import ModalBody from 'Components/Modal/ModalBody'; | ||||
| import ModalContent from 'Components/Modal/ModalContent'; | ||||
| import ModalFooter from 'Components/Modal/ModalFooter'; | ||||
| import ModalHeader from 'Components/Modal/ModalHeader'; | ||||
| import Table from 'Components/Table/Table'; | ||||
| import TableBody from 'Components/Table/TableBody'; | ||||
| import useSelectState from 'Helpers/Hooks/useSelectState'; | ||||
| import { kinds } from 'Helpers/Props'; | ||||
| import { | ||||
|   bulkDeleteDownloadClients, | ||||
|   bulkEditDownloadClients, | ||||
| } from 'Store/Actions/settingsActions'; | ||||
| import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; | ||||
| import { SelectStateInputProps } from 'typings/props'; | ||||
| import getErrorMessage from 'Utilities/Object/getErrorMessage'; | ||||
| import getSelectedIds from 'Utilities/Table/getSelectedIds'; | ||||
| import ManageDownloadClientsEditModal from './Edit/ManageDownloadClientsEditModal'; | ||||
| import ManageDownloadClientsModalRow from './ManageDownloadClientsModalRow'; | ||||
| import styles from './ManageDownloadClientsModalContent.css'; | ||||
|  | ||||
| // TODO: This feels janky to do, but not sure of a better way currently | ||||
| type OnSelectedChangeCallback = React.ComponentProps< | ||||
|   typeof ManageDownloadClientsModalRow | ||||
| >['onSelectedChange']; | ||||
|  | ||||
| const COLUMNS = [ | ||||
|   { | ||||
|     name: 'name', | ||||
|     label: 'Name', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'implementation', | ||||
|     label: 'Implementation', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'enable', | ||||
|     label: 'Enabled', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'priority', | ||||
|     label: 'Priority', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'removeCompletedDownloads', | ||||
|     label: 'Remove Completed', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'removeFailedDownloads', | ||||
|     label: 'Remove Failed', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| interface ManageDownloadClientsModalContentProps { | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| function ManageDownloadClientsModalContent( | ||||
|   props: ManageDownloadClientsModalContentProps | ||||
| ) { | ||||
|   const { onModalClose } = props; | ||||
|  | ||||
|   const { | ||||
|     isFetching, | ||||
|     isPopulated, | ||||
|     isDeleting, | ||||
|     isSaving, | ||||
|     error, | ||||
|     items, | ||||
|   }: DownloadClientAppState = useSelector( | ||||
|     createClientSideCollectionSelector('settings.downloadClients') | ||||
|   ); | ||||
|   const dispatch = useDispatch(); | ||||
|  | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [isEditModalOpen, setIsEditModalOpen] = useState(false); | ||||
|  | ||||
|   const [selectState, setSelectState] = useSelectState(); | ||||
|  | ||||
|   const { allSelected, allUnselected, selectedState } = selectState; | ||||
|  | ||||
|   const selectedIds: number[] = useMemo(() => { | ||||
|     return getSelectedIds(selectedState); | ||||
|   }, [selectedState]); | ||||
|  | ||||
|   const selectedCount = selectedIds.length; | ||||
|  | ||||
|   const onDeletePress = useCallback(() => { | ||||
|     setIsDeleteModalOpen(true); | ||||
|   }, [setIsDeleteModalOpen]); | ||||
|  | ||||
|   const onDeleteModalClose = useCallback(() => { | ||||
|     setIsDeleteModalOpen(false); | ||||
|   }, [setIsDeleteModalOpen]); | ||||
|  | ||||
|   const onEditPress = useCallback(() => { | ||||
|     setIsEditModalOpen(true); | ||||
|   }, [setIsEditModalOpen]); | ||||
|  | ||||
|   const onEditModalClose = useCallback(() => { | ||||
|     setIsEditModalOpen(false); | ||||
|   }, [setIsEditModalOpen]); | ||||
|  | ||||
|   const onConfirmDelete = useCallback(() => { | ||||
|     dispatch(bulkDeleteDownloadClients({ ids: selectedIds })); | ||||
|     setIsDeleteModalOpen(false); | ||||
|   }, [selectedIds, dispatch]); | ||||
|  | ||||
|   const onSavePress = useCallback( | ||||
|     (payload: object) => { | ||||
|       setIsEditModalOpen(false); | ||||
|  | ||||
|       dispatch( | ||||
|         bulkEditDownloadClients({ | ||||
|           ids: selectedIds, | ||||
|           ...payload, | ||||
|         }) | ||||
|       ); | ||||
|     }, | ||||
|     [selectedIds, dispatch] | ||||
|   ); | ||||
|  | ||||
|   const onSelectAllChange = useCallback( | ||||
|     ({ value }: SelectStateInputProps) => { | ||||
|       setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); | ||||
|     }, | ||||
|     [items, setSelectState] | ||||
|   ); | ||||
|  | ||||
|   const onSelectedChange = useCallback<OnSelectedChangeCallback>( | ||||
|     ({ id, value, shiftKey = false }) => { | ||||
|       setSelectState({ | ||||
|         type: 'toggleSelected', | ||||
|         items, | ||||
|         id, | ||||
|         isSelected: value, | ||||
|         shiftKey, | ||||
|       }); | ||||
|     }, | ||||
|     [items, setSelectState] | ||||
|   ); | ||||
|  | ||||
|   const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); | ||||
|   const anySelected = selectedCount > 0; | ||||
|  | ||||
|   return ( | ||||
|     <ModalContent onModalClose={onModalClose}> | ||||
|       <ModalHeader>Manage Import Lists</ModalHeader> | ||||
|       <ModalBody> | ||||
|         {isFetching ? <LoadingIndicator /> : null} | ||||
|  | ||||
|         {error ? <div>{errorMessage}</div> : null} | ||||
|  | ||||
|         {isPopulated && !!items.length && !isFetching && !isFetching ? ( | ||||
|           <Table | ||||
|             columns={COLUMNS} | ||||
|             horizontalScroll={true} | ||||
|             selectAll={true} | ||||
|             allSelected={allSelected} | ||||
|             allUnselected={allUnselected} | ||||
|             onSelectAllChange={onSelectAllChange} | ||||
|           > | ||||
|             <TableBody> | ||||
|               {items.map((item) => { | ||||
|                 return ( | ||||
|                   <ManageDownloadClientsModalRow | ||||
|                     key={item.id} | ||||
|                     isSelected={selectedState[item.id]} | ||||
|                     {...item} | ||||
|                     columns={COLUMNS} | ||||
|                     onSelectedChange={onSelectedChange} | ||||
|                   /> | ||||
|                 ); | ||||
|               })} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         ) : null} | ||||
|       </ModalBody> | ||||
|  | ||||
|       <ModalFooter> | ||||
|         <div className={styles.leftButtons}> | ||||
|           <SpinnerButton | ||||
|             kind={kinds.DANGER} | ||||
|             isSpinning={isDeleting} | ||||
|             isDisabled={!anySelected} | ||||
|             onPress={onDeletePress} | ||||
|           > | ||||
|             Delete | ||||
|           </SpinnerButton> | ||||
|  | ||||
|           <SpinnerButton | ||||
|             isSpinning={isSaving} | ||||
|             isDisabled={!anySelected} | ||||
|             onPress={onEditPress} | ||||
|           > | ||||
|             Edit | ||||
|           </SpinnerButton> | ||||
|         </div> | ||||
|  | ||||
|         <Button onPress={onModalClose}>Close</Button> | ||||
|       </ModalFooter> | ||||
|  | ||||
|       <ManageDownloadClientsEditModal | ||||
|         isOpen={isEditModalOpen} | ||||
|         onModalClose={onEditModalClose} | ||||
|         onSavePress={onSavePress} | ||||
|         downloadClientIds={selectedIds} | ||||
|       /> | ||||
|  | ||||
|       <ConfirmModal | ||||
|         isOpen={isDeleteModalOpen} | ||||
|         kind={kinds.DANGER} | ||||
|         title="Delete Download Clients(s)" | ||||
|         message={`Are you sure you want to delete ${selectedIds.length} download clients(s)?`} | ||||
|         confirmLabel="Delete" | ||||
|         onConfirm={onConfirmDelete} | ||||
|         onCancel={onDeleteModalClose} | ||||
|       /> | ||||
|     </ModalContent> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageDownloadClientsModalContent; | ||||
| @@ -0,0 +1,11 @@ | ||||
| .name, | ||||
| .enable, | ||||
| .tags, | ||||
| .priority, | ||||
| .removeCompletedDownloads, | ||||
| .removeFailedDownloads, | ||||
| .implementation { | ||||
|   composes: cell from '~Components/Table/Cells/TableRowCell.css'; | ||||
|  | ||||
|   word-break: break-all; | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| // This file is automatically generated. | ||||
| // Please do not change this file! | ||||
| interface CssExports { | ||||
|   'enable': string; | ||||
|   'implementation': string; | ||||
|   'name': string; | ||||
|   'priority': string; | ||||
|   'removeCompletedDownloads': string; | ||||
|   'removeFailedDownloads': string; | ||||
|   'tags': string; | ||||
| } | ||||
| export const cssExports: CssExports; | ||||
| export default cssExports; | ||||
| @@ -0,0 +1,87 @@ | ||||
| import React, { useCallback } from 'react'; | ||||
| import TableRowCell from 'Components/Table/Cells/TableRowCell'; | ||||
| import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; | ||||
| import Column from 'Components/Table/Column'; | ||||
| import TableRow from 'Components/Table/TableRow'; | ||||
| import { SelectStateInputProps } from 'typings/props'; | ||||
| import styles from './ManageDownloadClientsModalRow.css'; | ||||
|  | ||||
| interface ManageDownloadClientsModalRowProps { | ||||
|   id: number; | ||||
|   name: string; | ||||
|   enable: boolean; | ||||
|   priority: number; | ||||
|   removeCompletedDownloads: boolean; | ||||
|   removeFailedDownloads: boolean; | ||||
|   implementation: string; | ||||
|   columns: Column[]; | ||||
|   isSelected?: boolean; | ||||
|   onSelectedChange(result: SelectStateInputProps): void; | ||||
| } | ||||
|  | ||||
| function ManageDownloadClientsModalRow( | ||||
|   props: ManageDownloadClientsModalRowProps | ||||
| ) { | ||||
|   const { | ||||
|     id, | ||||
|     isSelected, | ||||
|     name, | ||||
|     enable, | ||||
|     priority, | ||||
|     removeCompletedDownloads, | ||||
|     removeFailedDownloads, | ||||
|     implementation, | ||||
|     onSelectedChange, | ||||
|   } = props; | ||||
|  | ||||
|   const onSelectedChangeWrapper = useCallback( | ||||
|     (result: SelectStateInputProps) => { | ||||
|       onSelectedChange({ | ||||
|         ...result, | ||||
|       }); | ||||
|     }, | ||||
|     [onSelectedChange] | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <TableRow> | ||||
|       <TableSelectCell | ||||
|         id={id} | ||||
|         isSelected={isSelected} | ||||
|         onSelectedChange={onSelectedChangeWrapper} | ||||
|       /> | ||||
|  | ||||
|       <TableRowCell className={styles.name} title={name}> | ||||
|         {name} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.implementation} title={implementation}> | ||||
|         {implementation} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.enable} title={enable}> | ||||
|         {enable ? 'Yes' : 'No'} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.priority} title={priority}> | ||||
|         {priority} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell | ||||
|         className={styles.removeCompletedDownloads} | ||||
|         title={removeCompletedDownloads} | ||||
|       > | ||||
|         {removeCompletedDownloads ? 'Yes' : 'No'} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell | ||||
|         className={styles.removeFailedDownloads} | ||||
|         title={removeFailedDownloads} | ||||
|       > | ||||
|         {removeFailedDownloads ? 'Yes' : 'No'} | ||||
|       </TableRowCell> | ||||
|     </TableRow> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageDownloadClientsModalRow; | ||||
| @@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props'; | ||||
| import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; | ||||
| import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector'; | ||||
| import ImportListsConnector from './ImportLists/ImportListsConnector'; | ||||
| import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal'; | ||||
|  | ||||
| class ImportListSettings extends Component { | ||||
|  | ||||
| @@ -18,7 +19,8 @@ class ImportListSettings extends Component { | ||||
|     super(props, context); | ||||
|  | ||||
|     this.state = { | ||||
|       hasPendingChanges: false | ||||
|       hasPendingChanges: false, | ||||
|       isManageImportListsOpen: false | ||||
|     }; | ||||
|   } | ||||
|  | ||||
| @@ -29,6 +31,14 @@ class ImportListSettings extends Component { | ||||
|     this._listOptions = ref; | ||||
|   }; | ||||
|  | ||||
|   onManageImportListsPress = () => { | ||||
|     this.setState({ isManageImportListsOpen: true }); | ||||
|   }; | ||||
|  | ||||
|   onManageImportListsModalClose = () => { | ||||
|     this.setState({ isManageImportListsOpen: false }); | ||||
|   }; | ||||
|  | ||||
|   onHasPendingChange = (hasPendingChanges) => { | ||||
|     this.setState({ | ||||
|       hasPendingChanges | ||||
| @@ -50,7 +60,8 @@ class ImportListSettings extends Component { | ||||
|  | ||||
|     const { | ||||
|       isSaving, | ||||
|       hasPendingChanges | ||||
|       hasPendingChanges, | ||||
|       isManageImportListsOpen | ||||
|     } = this.state; | ||||
|  | ||||
|     return ( | ||||
| @@ -68,6 +79,12 @@ class ImportListSettings extends Component { | ||||
|                 isSpinning={isTestingAll} | ||||
|                 onPress={dispatchTestAllImportLists} | ||||
|               /> | ||||
|  | ||||
|               <PageToolbarButton | ||||
|                 label="Manage Lists" | ||||
|                 iconName={icons.MANAGE} | ||||
|                 onPress={this.onManageImportListsPress} | ||||
|               /> | ||||
|             </Fragment> | ||||
|           } | ||||
|           onSavePress={this.onSavePress} | ||||
| @@ -76,6 +93,10 @@ class ImportListSettings extends Component { | ||||
|         <PageContentBody> | ||||
|           <ImportListsConnector /> | ||||
|           <ImportListsExclusionsConnector /> | ||||
|           <ManageImportListsModal | ||||
|             isOpen={isManageImportListsOpen} | ||||
|             onModalClose={this.onManageImportListsModalClose} | ||||
|           /> | ||||
|         </PageContentBody> | ||||
|       </PageContent> | ||||
|     ); | ||||
|   | ||||
| @@ -0,0 +1,26 @@ | ||||
| import React from 'react'; | ||||
| import Modal from 'Components/Modal/Modal'; | ||||
| import ManageImportListsEditModalContent from './ManageImportListsEditModalContent'; | ||||
|  | ||||
| interface ManageImportListsEditModalProps { | ||||
|   isOpen: boolean; | ||||
|   importListIds: number[]; | ||||
|   onSavePress(payload: object): void; | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| function ManageImportListsEditModal(props: ManageImportListsEditModalProps) { | ||||
|   const { isOpen, importListIds, onSavePress, onModalClose } = props; | ||||
|  | ||||
|   return ( | ||||
|     <Modal isOpen={isOpen} onModalClose={onModalClose}> | ||||
|       <ManageImportListsEditModalContent | ||||
|         importListIds={importListIds} | ||||
|         onSavePress={onSavePress} | ||||
|         onModalClose={onModalClose} | ||||
|       /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageImportListsEditModal; | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
| @@ -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; | ||||
| @@ -0,0 +1,158 @@ | ||||
| import React, { useCallback, useState } from 'react'; | ||||
| import FormGroup from 'Components/Form/FormGroup'; | ||||
| import FormInputGroup from 'Components/Form/FormInputGroup'; | ||||
| import FormLabel from 'Components/Form/FormLabel'; | ||||
| import Button from 'Components/Link/Button'; | ||||
| import ModalBody from 'Components/Modal/ModalBody'; | ||||
| import ModalContent from 'Components/Modal/ModalContent'; | ||||
| import ModalFooter from 'Components/Modal/ModalFooter'; | ||||
| import ModalHeader from 'Components/Modal/ModalHeader'; | ||||
| import { inputTypes } from 'Helpers/Props'; | ||||
| import translate from 'Utilities/String/translate'; | ||||
| import styles from './ManageImportListsEditModalContent.css'; | ||||
|  | ||||
| interface SavePayload { | ||||
|   enableAutomaticAdd?: boolean; | ||||
|   qualityProfileId?: number; | ||||
|   rootFolderPath?: string; | ||||
| } | ||||
|  | ||||
| interface ManageImportListsEditModalContentProps { | ||||
|   importListIds: number[]; | ||||
|   onSavePress(payload: object): void; | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| const NO_CHANGE = 'noChange'; | ||||
|  | ||||
| const autoAddOptions = [ | ||||
|   { key: NO_CHANGE, value: 'No Change', disabled: true }, | ||||
|   { key: 'enabled', value: 'Enabled' }, | ||||
|   { key: 'disabled', value: 'Disabled' }, | ||||
| ]; | ||||
|  | ||||
| function ManageImportListsEditModalContent( | ||||
|   props: ManageImportListsEditModalContentProps | ||||
| ) { | ||||
|   const { importListIds, onSavePress, onModalClose } = props; | ||||
|  | ||||
|   const [enableAutomaticAdd, setEnableAutomaticAdd] = useState(NO_CHANGE); | ||||
|   const [qualityProfileId, setQualityProfileId] = useState<string | number>( | ||||
|     NO_CHANGE | ||||
|   ); | ||||
|   const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE); | ||||
|  | ||||
|   const save = useCallback(() => { | ||||
|     let hasChanges = false; | ||||
|     const payload: SavePayload = {}; | ||||
|  | ||||
|     if (enableAutomaticAdd !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.enableAutomaticAdd = enableAutomaticAdd === 'enabled'; | ||||
|     } | ||||
|  | ||||
|     if (qualityProfileId !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.qualityProfileId = qualityProfileId as number; | ||||
|     } | ||||
|  | ||||
|     if (rootFolderPath !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.rootFolderPath = rootFolderPath; | ||||
|     } | ||||
|  | ||||
|     if (hasChanges) { | ||||
|       onSavePress(payload); | ||||
|     } | ||||
|  | ||||
|     onModalClose(); | ||||
|   }, [ | ||||
|     enableAutomaticAdd, | ||||
|     qualityProfileId, | ||||
|     rootFolderPath, | ||||
|     onSavePress, | ||||
|     onModalClose, | ||||
|   ]); | ||||
|  | ||||
|   const onInputChange = useCallback( | ||||
|     ({ name, value }: { name: string; value: string }) => { | ||||
|       switch (name) { | ||||
|         case 'enableAutomaticAdd': | ||||
|           setEnableAutomaticAdd(value); | ||||
|           break; | ||||
|         case 'qualityProfileId': | ||||
|           setQualityProfileId(value); | ||||
|           break; | ||||
|         case 'rootFolderPath': | ||||
|           setRootFolderPath(value); | ||||
|           break; | ||||
|         default: | ||||
|           console.warn('EditImportListModalContent Unknown Input'); | ||||
|       } | ||||
|     }, | ||||
|     [] | ||||
|   ); | ||||
|  | ||||
|   const selectedCount = importListIds.length; | ||||
|  | ||||
|   return ( | ||||
|     <ModalContent onModalClose={onModalClose}> | ||||
|       <ModalHeader>{translate('EditSelectedImportLists')}</ModalHeader> | ||||
|  | ||||
|       <ModalBody> | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('AutomaticAdd')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.SELECT} | ||||
|             name="enableAutomaticAdd" | ||||
|             value={enableAutomaticAdd} | ||||
|             values={autoAddOptions} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|  | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('QualityProfile')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.QUALITY_PROFILE_SELECT} | ||||
|             name="qualityProfileId" | ||||
|             value={qualityProfileId} | ||||
|             includeNoChange={true} | ||||
|             includeNoChangeDisabled={false} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|  | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('RootFolder')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.ROOT_FOLDER_SELECT} | ||||
|             name="rootFolderPath" | ||||
|             value={rootFolderPath} | ||||
|             includeNoChange={true} | ||||
|             includeNoChangeDisabled={false} | ||||
|             selectedValueOptions={{ includeFreeSpace: false }} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|       </ModalBody> | ||||
|  | ||||
|       <ModalFooter className={styles.modalFooter}> | ||||
|         <div className={styles.selected}> | ||||
|           {translate('{count} import lists selected', { count: selectedCount })} | ||||
|         </div> | ||||
|  | ||||
|         <div> | ||||
|           <Button onPress={onModalClose}>{translate('Cancel')}</Button> | ||||
|  | ||||
|           <Button onPress={save}>{translate('ApplyChanges')}</Button> | ||||
|         </div> | ||||
|       </ModalFooter> | ||||
|     </ModalContent> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageImportListsEditModalContent; | ||||
| @@ -0,0 +1,20 @@ | ||||
| import React from 'react'; | ||||
| import Modal from 'Components/Modal/Modal'; | ||||
| import ManageImportListsModalContent from './ManageImportListsModalContent'; | ||||
|  | ||||
| interface ManageImportListsModalProps { | ||||
|   isOpen: boolean; | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| function ManageImportListsModal(props: ManageImportListsModalProps) { | ||||
|   const { isOpen, onModalClose } = props; | ||||
|  | ||||
|   return ( | ||||
|     <Modal isOpen={isOpen} onModalClose={onModalClose}> | ||||
|       <ManageImportListsModalContent onModalClose={onModalClose} /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageImportListsModal; | ||||
| @@ -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; | ||||
| } | ||||
							
								
								
									
										9
									
								
								frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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; | ||||
| @@ -0,0 +1,283 @@ | ||||
| import React, { useCallback, useMemo, useState } from 'react'; | ||||
| import { useDispatch, useSelector } from 'react-redux'; | ||||
| import { ImportListAppState } from 'App/State/SettingsAppState'; | ||||
| import Button from 'Components/Link/Button'; | ||||
| import SpinnerButton from 'Components/Link/SpinnerButton'; | ||||
| import LoadingIndicator from 'Components/Loading/LoadingIndicator'; | ||||
| import ConfirmModal from 'Components/Modal/ConfirmModal'; | ||||
| import ModalBody from 'Components/Modal/ModalBody'; | ||||
| import ModalContent from 'Components/Modal/ModalContent'; | ||||
| import ModalFooter from 'Components/Modal/ModalFooter'; | ||||
| import ModalHeader from 'Components/Modal/ModalHeader'; | ||||
| import Table from 'Components/Table/Table'; | ||||
| import TableBody from 'Components/Table/TableBody'; | ||||
| import useSelectState from 'Helpers/Hooks/useSelectState'; | ||||
| import { kinds } from 'Helpers/Props'; | ||||
| import { | ||||
|   bulkDeleteImportLists, | ||||
|   bulkEditImportLists, | ||||
| } from 'Store/Actions/settingsActions'; | ||||
| import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; | ||||
| import { SelectStateInputProps } from 'typings/props'; | ||||
| import getErrorMessage from 'Utilities/Object/getErrorMessage'; | ||||
| import getSelectedIds from 'Utilities/Table/getSelectedIds'; | ||||
| import ManageImportListsEditModal from './Edit/ManageImportListsEditModal'; | ||||
| import ManageImportListsModalRow from './ManageImportListsModalRow'; | ||||
| import TagsModal from './Tags/TagsModal'; | ||||
| import styles from './ManageImportListsModalContent.css'; | ||||
|  | ||||
| // TODO: This feels janky to do, but not sure of a better way currently | ||||
| type OnSelectedChangeCallback = React.ComponentProps< | ||||
|   typeof ManageImportListsModalRow | ||||
| >['onSelectedChange']; | ||||
|  | ||||
| const COLUMNS = [ | ||||
|   { | ||||
|     name: 'name', | ||||
|     label: 'Name', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'implementation', | ||||
|     label: 'Implementation', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'qualityProfileId', | ||||
|     label: 'Quality Profile', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'rootFolderPath', | ||||
|     label: 'Root Folder', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'enableAutomaticAdd', | ||||
|     label: 'Auto Add', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'tags', | ||||
|     label: 'Tags', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| interface ManageImportListsModalContentProps { | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| function ManageImportListsModalContent( | ||||
|   props: ManageImportListsModalContentProps | ||||
| ) { | ||||
|   const { onModalClose } = props; | ||||
|  | ||||
|   const { | ||||
|     isFetching, | ||||
|     isPopulated, | ||||
|     isDeleting, | ||||
|     isSaving, | ||||
|     error, | ||||
|     items, | ||||
|   }: ImportListAppState = useSelector( | ||||
|     createClientSideCollectionSelector('settings.importLists') | ||||
|   ); | ||||
|   const dispatch = useDispatch(); | ||||
|  | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [isEditModalOpen, setIsEditModalOpen] = useState(false); | ||||
|   const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); | ||||
|   const [isSavingTags, setIsSavingTags] = useState(false); | ||||
|  | ||||
|   const [selectState, setSelectState] = useSelectState(); | ||||
|  | ||||
|   const { allSelected, allUnselected, selectedState } = selectState; | ||||
|  | ||||
|   const selectedIds: number[] = useMemo(() => { | ||||
|     return getSelectedIds(selectedState); | ||||
|   }, [selectedState]); | ||||
|  | ||||
|   const selectedCount = selectedIds.length; | ||||
|  | ||||
|   const onDeletePress = useCallback(() => { | ||||
|     setIsDeleteModalOpen(true); | ||||
|   }, [setIsDeleteModalOpen]); | ||||
|  | ||||
|   const onDeleteModalClose = useCallback(() => { | ||||
|     setIsDeleteModalOpen(false); | ||||
|   }, [setIsDeleteModalOpen]); | ||||
|  | ||||
|   const onEditPress = useCallback(() => { | ||||
|     setIsEditModalOpen(true); | ||||
|   }, [setIsEditModalOpen]); | ||||
|  | ||||
|   const onEditModalClose = useCallback(() => { | ||||
|     setIsEditModalOpen(false); | ||||
|   }, [setIsEditModalOpen]); | ||||
|  | ||||
|   const onConfirmDelete = useCallback(() => { | ||||
|     dispatch(bulkDeleteImportLists({ ids: selectedIds })); | ||||
|     setIsDeleteModalOpen(false); | ||||
|   }, [selectedIds, dispatch]); | ||||
|  | ||||
|   const onSavePress = useCallback( | ||||
|     (payload: object) => { | ||||
|       setIsEditModalOpen(false); | ||||
|  | ||||
|       dispatch( | ||||
|         bulkEditImportLists({ | ||||
|           ids: selectedIds, | ||||
|           ...payload, | ||||
|         }) | ||||
|       ); | ||||
|     }, | ||||
|     [selectedIds, dispatch] | ||||
|   ); | ||||
|  | ||||
|   const onTagsPress = useCallback(() => { | ||||
|     setIsTagsModalOpen(true); | ||||
|   }, [setIsTagsModalOpen]); | ||||
|  | ||||
|   const onTagsModalClose = useCallback(() => { | ||||
|     setIsTagsModalOpen(false); | ||||
|   }, [setIsTagsModalOpen]); | ||||
|  | ||||
|   const onApplyTagsPress = useCallback( | ||||
|     (tags: number[], applyTags: string) => { | ||||
|       setIsSavingTags(true); | ||||
|       setIsTagsModalOpen(false); | ||||
|  | ||||
|       dispatch( | ||||
|         bulkEditImportLists({ | ||||
|           ids: selectedIds, | ||||
|           tags, | ||||
|           applyTags, | ||||
|         }) | ||||
|       ); | ||||
|     }, | ||||
|     [selectedIds, dispatch] | ||||
|   ); | ||||
|  | ||||
|   const onSelectAllChange = useCallback( | ||||
|     ({ value }: SelectStateInputProps) => { | ||||
|       setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); | ||||
|     }, | ||||
|     [items, setSelectState] | ||||
|   ); | ||||
|  | ||||
|   const onSelectedChange = useCallback<OnSelectedChangeCallback>( | ||||
|     ({ id, value, shiftKey = false }) => { | ||||
|       setSelectState({ | ||||
|         type: 'toggleSelected', | ||||
|         items, | ||||
|         id, | ||||
|         isSelected: value, | ||||
|         shiftKey, | ||||
|       }); | ||||
|     }, | ||||
|     [items, setSelectState] | ||||
|   ); | ||||
|  | ||||
|   const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); | ||||
|   const anySelected = selectedCount > 0; | ||||
|  | ||||
|   return ( | ||||
|     <ModalContent onModalClose={onModalClose}> | ||||
|       <ModalHeader>Manage Import Lists</ModalHeader> | ||||
|       <ModalBody> | ||||
|         {isFetching ? <LoadingIndicator /> : null} | ||||
|  | ||||
|         {error ? <div>{errorMessage}</div> : null} | ||||
|  | ||||
|         {isPopulated && !!items.length && !isFetching && !isFetching ? ( | ||||
|           <Table | ||||
|             columns={COLUMNS} | ||||
|             horizontalScroll={true} | ||||
|             selectAll={true} | ||||
|             allSelected={allSelected} | ||||
|             allUnselected={allUnselected} | ||||
|             onSelectAllChange={onSelectAllChange} | ||||
|           > | ||||
|             <TableBody> | ||||
|               {items.map((item) => { | ||||
|                 return ( | ||||
|                   <ManageImportListsModalRow | ||||
|                     key={item.id} | ||||
|                     isSelected={selectedState[item.id]} | ||||
|                     {...item} | ||||
|                     columns={COLUMNS} | ||||
|                     onSelectedChange={onSelectedChange} | ||||
|                   /> | ||||
|                 ); | ||||
|               })} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         ) : null} | ||||
|       </ModalBody> | ||||
|  | ||||
|       <ModalFooter> | ||||
|         <div className={styles.leftButtons}> | ||||
|           <SpinnerButton | ||||
|             kind={kinds.DANGER} | ||||
|             isSpinning={isDeleting} | ||||
|             isDisabled={!anySelected} | ||||
|             onPress={onDeletePress} | ||||
|           > | ||||
|             Delete | ||||
|           </SpinnerButton> | ||||
|  | ||||
|           <SpinnerButton | ||||
|             isSpinning={isSaving} | ||||
|             isDisabled={!anySelected} | ||||
|             onPress={onEditPress} | ||||
|           > | ||||
|             Edit | ||||
|           </SpinnerButton> | ||||
|  | ||||
|           <SpinnerButton | ||||
|             isSpinning={isSaving && isSavingTags} | ||||
|             isDisabled={!anySelected} | ||||
|             onPress={onTagsPress} | ||||
|           > | ||||
|             Set Tags | ||||
|           </SpinnerButton> | ||||
|         </div> | ||||
|  | ||||
|         <Button onPress={onModalClose}>Close</Button> | ||||
|       </ModalFooter> | ||||
|  | ||||
|       <ManageImportListsEditModal | ||||
|         isOpen={isEditModalOpen} | ||||
|         onModalClose={onEditModalClose} | ||||
|         onSavePress={onSavePress} | ||||
|         importListIds={selectedIds} | ||||
|       /> | ||||
|  | ||||
|       <TagsModal | ||||
|         isOpen={isTagsModalOpen} | ||||
|         ids={selectedIds} | ||||
|         onApplyTagsPress={onApplyTagsPress} | ||||
|         onModalClose={onTagsModalClose} | ||||
|       /> | ||||
|  | ||||
|       <ConfirmModal | ||||
|         isOpen={isDeleteModalOpen} | ||||
|         kind={kinds.DANGER} | ||||
|         title="Delete Import List(s)" | ||||
|         message={`Are you sure you want to delete ${selectedIds.length} import list(s)?`} | ||||
|         confirmLabel="Delete" | ||||
|         onConfirm={onConfirmDelete} | ||||
|         onCancel={onDeleteModalClose} | ||||
|       /> | ||||
|     </ModalContent> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageImportListsModalContent; | ||||
| @@ -0,0 +1,10 @@ | ||||
| .name, | ||||
| .tags, | ||||
| .enableAutomaticAdd, | ||||
| .qualityProfileId, | ||||
| .rootFolderPath, | ||||
| .implementation { | ||||
|   composes: cell from '~Components/Table/Cells/TableRowCell.css'; | ||||
|  | ||||
|   word-break: break-all; | ||||
| } | ||||
							
								
								
									
										12
									
								
								frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| // This file is automatically generated. | ||||
| // Please do not change this file! | ||||
| interface CssExports { | ||||
|   'enableAutomaticAdd': string; | ||||
|   'implementation': string; | ||||
|   'name': string; | ||||
|   'qualityProfileId': string; | ||||
|   'rootFolderPath': string; | ||||
|   'tags': string; | ||||
| } | ||||
| export const cssExports: CssExports; | ||||
| export default cssExports; | ||||
| @@ -0,0 +1,92 @@ | ||||
| import React, { useCallback } from 'react'; | ||||
| import { useSelector } from 'react-redux'; | ||||
| import TableRowCell from 'Components/Table/Cells/TableRowCell'; | ||||
| import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; | ||||
| import Column from 'Components/Table/Column'; | ||||
| import TableRow from 'Components/Table/TableRow'; | ||||
| import TagListConnector from 'Components/TagListConnector'; | ||||
| import { createQualityProfileSelectorForHook } from 'Store/Selectors/createQualityProfileSelector'; | ||||
| import { SelectStateInputProps } from 'typings/props'; | ||||
| import styles from './ManageImportListsModalRow.css'; | ||||
|  | ||||
| interface ManageImportListsModalRowProps { | ||||
|   id: number; | ||||
|   name: string; | ||||
|   rootFolderPath: string; | ||||
|   qualityProfileId: number; | ||||
|   implementation: string; | ||||
|   tags: number[]; | ||||
|   enableAutomaticAdd: boolean; | ||||
|   columns: Column[]; | ||||
|   isSelected?: boolean; | ||||
|   onSelectedChange(result: SelectStateInputProps): void; | ||||
| } | ||||
|  | ||||
| function ManageImportListsModalRow(props: ManageImportListsModalRowProps) { | ||||
|   const { | ||||
|     id, | ||||
|     isSelected, | ||||
|     name, | ||||
|     rootFolderPath, | ||||
|     qualityProfileId, | ||||
|     implementation, | ||||
|     enableAutomaticAdd, | ||||
|     tags, | ||||
|     onSelectedChange, | ||||
|   } = props; | ||||
|  | ||||
|   const qualityProfile = useSelector( | ||||
|     createQualityProfileSelectorForHook(qualityProfileId) | ||||
|   ); | ||||
|  | ||||
|   const onSelectedChangeWrapper = useCallback( | ||||
|     (result: SelectStateInputProps) => { | ||||
|       onSelectedChange({ | ||||
|         ...result, | ||||
|       }); | ||||
|     }, | ||||
|     [onSelectedChange] | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <TableRow> | ||||
|       <TableSelectCell | ||||
|         id={id} | ||||
|         isSelected={isSelected} | ||||
|         onSelectedChange={onSelectedChangeWrapper} | ||||
|       /> | ||||
|  | ||||
|       <TableRowCell className={styles.name} title={name}> | ||||
|         {name} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.implementation} title={implementation}> | ||||
|         {implementation} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell | ||||
|         className={styles.qualityProfileId} | ||||
|         title={qualityProfileId} | ||||
|       > | ||||
|         {qualityProfile?.name ?? 'None'} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.rootFolderPath} title={rootFolderPath}> | ||||
|         {rootFolderPath} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell | ||||
|         className={styles.enableAutomaticAdd} | ||||
|         title={enableAutomaticAdd} | ||||
|       > | ||||
|         {enableAutomaticAdd ? 'Yes' : 'No'} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.tags} title={tags}> | ||||
|         <TagListConnector tags={tags} /> | ||||
|       </TableRowCell> | ||||
|     </TableRow> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageImportListsModalRow; | ||||
| @@ -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 ( | ||||
|     <Modal isOpen={isOpen} onModalClose={onModalClose}> | ||||
|       <TagsModalContent {...otherProps} onModalClose={onModalClose} /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default TagsModal; | ||||
| @@ -0,0 +1,12 @@ | ||||
| .renameIcon { | ||||
|   margin-left: 5px; | ||||
| } | ||||
|  | ||||
| .message { | ||||
|   margin-top: 20px; | ||||
|   margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .result { | ||||
|   padding-top: 4px; | ||||
| } | ||||
							
								
								
									
										9
									
								
								frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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; | ||||
| @@ -0,0 +1,178 @@ | ||||
| import { uniq } from 'lodash'; | ||||
| import React, { useCallback, useMemo, useState } from 'react'; | ||||
| import { useSelector } from 'react-redux'; | ||||
| import AppState from 'App/State/AppState'; | ||||
| import { ImportListAppState } from 'App/State/SettingsAppState'; | ||||
| import { Tag } from 'App/State/TagsAppState'; | ||||
| import Form from 'Components/Form/Form'; | ||||
| import FormGroup from 'Components/Form/FormGroup'; | ||||
| import FormInputGroup from 'Components/Form/FormInputGroup'; | ||||
| import FormLabel from 'Components/Form/FormLabel'; | ||||
| import Label from 'Components/Label'; | ||||
| import Button from 'Components/Link/Button'; | ||||
| import ModalBody from 'Components/Modal/ModalBody'; | ||||
| import ModalContent from 'Components/Modal/ModalContent'; | ||||
| import ModalFooter from 'Components/Modal/ModalFooter'; | ||||
| import ModalHeader from 'Components/Modal/ModalHeader'; | ||||
| import { inputTypes, kinds, sizes } from 'Helpers/Props'; | ||||
| import createTagsSelector from 'Store/Selectors/createTagsSelector'; | ||||
| import ImportList from 'typings/ImportList'; | ||||
| import styles from './TagsModalContent.css'; | ||||
|  | ||||
| interface TagsModalContentProps { | ||||
|   ids: number[]; | ||||
|   onApplyTagsPress: (tags: number[], applyTags: string) => void; | ||||
|   onModalClose: () => void; | ||||
| } | ||||
|  | ||||
| function TagsModalContent(props: TagsModalContentProps) { | ||||
|   const { ids, onModalClose, onApplyTagsPress } = props; | ||||
|  | ||||
|   const allImportLists: ImportListAppState = useSelector( | ||||
|     (state: AppState) => state.settings.importLists | ||||
|   ); | ||||
|   const tagList: Tag[] = useSelector(createTagsSelector()); | ||||
|  | ||||
|   const [tags, setTags] = useState<number[]>([]); | ||||
|   const [applyTags, setApplyTags] = useState('add'); | ||||
|  | ||||
|   const seriesTags = useMemo(() => { | ||||
|     const tags = ids.reduce((acc: number[], id) => { | ||||
|       const s = allImportLists.items.find((s: ImportList) => s.id === id); | ||||
|  | ||||
|       if (s) { | ||||
|         acc.push(...s.tags); | ||||
|       } | ||||
|  | ||||
|       return acc; | ||||
|     }, []); | ||||
|  | ||||
|     return uniq(tags); | ||||
|   }, [ids, allImportLists]); | ||||
|  | ||||
|   const onTagsChange = useCallback( | ||||
|     ({ value }: { value: number[] }) => { | ||||
|       setTags(value); | ||||
|     }, | ||||
|     [setTags] | ||||
|   ); | ||||
|  | ||||
|   const onApplyTagsChange = useCallback( | ||||
|     ({ value }: { value: string }) => { | ||||
|       setApplyTags(value); | ||||
|     }, | ||||
|     [setApplyTags] | ||||
|   ); | ||||
|  | ||||
|   const onApplyPress = useCallback(() => { | ||||
|     onApplyTagsPress(tags, applyTags); | ||||
|   }, [tags, applyTags, onApplyTagsPress]); | ||||
|  | ||||
|   const applyTagsOptions = [ | ||||
|     { key: 'add', value: 'Add' }, | ||||
|     { key: 'remove', value: 'Remove' }, | ||||
|     { key: 'replace', value: 'Replace' }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <ModalContent onModalClose={onModalClose}> | ||||
|       <ModalHeader>Tags</ModalHeader> | ||||
|  | ||||
|       <ModalBody> | ||||
|         <Form> | ||||
|           <FormGroup> | ||||
|             <FormLabel>Tags</FormLabel> | ||||
|  | ||||
|             <FormInputGroup | ||||
|               type={inputTypes.TAG} | ||||
|               name="tags" | ||||
|               value={tags} | ||||
|               onChange={onTagsChange} | ||||
|             /> | ||||
|           </FormGroup> | ||||
|  | ||||
|           <FormGroup> | ||||
|             <FormLabel>Apply Tags</FormLabel> | ||||
|  | ||||
|             <FormInputGroup | ||||
|               type={inputTypes.SELECT} | ||||
|               name="applyTags" | ||||
|               value={applyTags} | ||||
|               values={applyTagsOptions} | ||||
|               helpTexts={[ | ||||
|                 'How to apply tags to the selected list', | ||||
|                 'Add: Add the tags the existing list of tags', | ||||
|                 'Remove: Remove the entered tags', | ||||
|                 'Replace: Replace the tags with the entered tags (enter no tags to clear all tags)', | ||||
|               ]} | ||||
|               onChange={onApplyTagsChange} | ||||
|             /> | ||||
|           </FormGroup> | ||||
|  | ||||
|           <FormGroup> | ||||
|             <FormLabel>Result</FormLabel> | ||||
|  | ||||
|             <div className={styles.result}> | ||||
|               {seriesTags.map((id) => { | ||||
|                 const tag = tagList.find((t) => t.id === id); | ||||
|  | ||||
|                 if (!tag) { | ||||
|                   return null; | ||||
|                 } | ||||
|  | ||||
|                 const removeTag = | ||||
|                   (applyTags === 'remove' && tags.indexOf(id) > -1) || | ||||
|                   (applyTags === 'replace' && tags.indexOf(id) === -1); | ||||
|  | ||||
|                 return ( | ||||
|                   <Label | ||||
|                     key={tag.id} | ||||
|                     title={removeTag ? 'Removing tag' : 'Existing tag'} | ||||
|                     kind={removeTag ? kinds.INVERSE : kinds.INFO} | ||||
|                     size={sizes.LARGE} | ||||
|                   > | ||||
|                     {tag.label} | ||||
|                   </Label> | ||||
|                 ); | ||||
|               })} | ||||
|  | ||||
|               {(applyTags === 'add' || applyTags === 'replace') && | ||||
|                 tags.map((id) => { | ||||
|                   const tag = tagList.find((t) => t.id === id); | ||||
|  | ||||
|                   if (!tag) { | ||||
|                     return null; | ||||
|                   } | ||||
|  | ||||
|                   if (seriesTags.indexOf(id) > -1) { | ||||
|                     return null; | ||||
|                   } | ||||
|  | ||||
|                   return ( | ||||
|                     <Label | ||||
|                       key={tag.id} | ||||
|                       title={'Adding tag'} | ||||
|                       kind={kinds.SUCCESS} | ||||
|                       size={sizes.LARGE} | ||||
|                     > | ||||
|                       {tag.label} | ||||
|                     </Label> | ||||
|                   ); | ||||
|                 })} | ||||
|             </div> | ||||
|           </FormGroup> | ||||
|         </Form> | ||||
|       </ModalBody> | ||||
|  | ||||
|       <ModalFooter> | ||||
|         <Button onPress={onModalClose}>Cancel</Button> | ||||
|  | ||||
|         <Button kind={kinds.PRIMARY} onPress={onApplyPress}> | ||||
|           Apply | ||||
|         </Button> | ||||
|       </ModalFooter> | ||||
|     </ModalContent> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default TagsModalContent; | ||||
| @@ -7,6 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; | ||||
| import { icons } from 'Helpers/Props'; | ||||
| import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; | ||||
| import IndexersConnector from './Indexers/IndexersConnector'; | ||||
| import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal'; | ||||
| import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; | ||||
|  | ||||
| class IndexerSettings extends Component { | ||||
| @@ -21,7 +22,8 @@ class IndexerSettings extends Component { | ||||
|  | ||||
|     this.state = { | ||||
|       isSaving: false, | ||||
|       hasPendingChanges: false | ||||
|       hasPendingChanges: false, | ||||
|       isManageIndexersOpen: false | ||||
|     }; | ||||
|   } | ||||
|  | ||||
| @@ -36,6 +38,14 @@ class IndexerSettings extends Component { | ||||
|     this.setState(payload); | ||||
|   }; | ||||
|  | ||||
|   onManageIndexersPress = () => { | ||||
|     this.setState({ isManageIndexersOpen: true }); | ||||
|   }; | ||||
|  | ||||
|   onManageIndexersModalClose = () => { | ||||
|     this.setState({ isManageIndexersOpen: false }); | ||||
|   }; | ||||
|  | ||||
|   onSavePress = () => { | ||||
|     if (this._saveCallback) { | ||||
|       this._saveCallback(); | ||||
| @@ -53,7 +63,8 @@ class IndexerSettings extends Component { | ||||
|  | ||||
|     const { | ||||
|       isSaving, | ||||
|       hasPendingChanges | ||||
|       hasPendingChanges, | ||||
|       isManageIndexersOpen | ||||
|     } = this.state; | ||||
|  | ||||
|     return ( | ||||
| @@ -71,6 +82,12 @@ class IndexerSettings extends Component { | ||||
|                 isSpinning={isTestingAll} | ||||
|                 onPress={dispatchTestAllIndexers} | ||||
|               /> | ||||
|  | ||||
|               <PageToolbarButton | ||||
|                 label="Manage Indexers" | ||||
|                 iconName={icons.MANAGE} | ||||
|                 onPress={this.onManageIndexersPress} | ||||
|               /> | ||||
|             </Fragment> | ||||
|           } | ||||
|           onSavePress={this.onSavePress} | ||||
| @@ -83,6 +100,11 @@ class IndexerSettings extends Component { | ||||
|             onChildMounted={this.onChildMounted} | ||||
|             onChildStateChange={this.onChildStateChange} | ||||
|           /> | ||||
|  | ||||
|           <ManageIndexersModal | ||||
|             isOpen={isManageIndexersOpen} | ||||
|             onModalClose={this.onManageIndexersModalClose} | ||||
|           /> | ||||
|         </PageContentBody> | ||||
|       </PageContent> | ||||
|     ); | ||||
|   | ||||
| @@ -0,0 +1,26 @@ | ||||
| import React from 'react'; | ||||
| import Modal from 'Components/Modal/Modal'; | ||||
| import ManageIndexersEditModalContent from './ManageIndexersEditModalContent'; | ||||
|  | ||||
| interface ManageIndexersEditModalProps { | ||||
|   isOpen: boolean; | ||||
|   indexerIds: number[]; | ||||
|   onSavePress(payload: object): void; | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| function ManageIndexersEditModal(props: ManageIndexersEditModalProps) { | ||||
|   const { isOpen, indexerIds, onSavePress, onModalClose } = props; | ||||
|  | ||||
|   return ( | ||||
|     <Modal isOpen={isOpen} onModalClose={onModalClose}> | ||||
|       <ManageIndexersEditModalContent | ||||
|         indexerIds={indexerIds} | ||||
|         onSavePress={onSavePress} | ||||
|         onModalClose={onModalClose} | ||||
|       /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageIndexersEditModal; | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										8
									
								
								frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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; | ||||
| @@ -0,0 +1,178 @@ | ||||
| import React, { useCallback, useState } from 'react'; | ||||
| import FormGroup from 'Components/Form/FormGroup'; | ||||
| import FormInputGroup from 'Components/Form/FormInputGroup'; | ||||
| import FormLabel from 'Components/Form/FormLabel'; | ||||
| import Button from 'Components/Link/Button'; | ||||
| import ModalBody from 'Components/Modal/ModalBody'; | ||||
| import ModalContent from 'Components/Modal/ModalContent'; | ||||
| import ModalFooter from 'Components/Modal/ModalFooter'; | ||||
| import ModalHeader from 'Components/Modal/ModalHeader'; | ||||
| import { inputTypes } from 'Helpers/Props'; | ||||
| import translate from 'Utilities/String/translate'; | ||||
| import styles from './ManageIndexersEditModalContent.css'; | ||||
|  | ||||
| interface SavePayload { | ||||
|   enableRss?: boolean; | ||||
|   enableAutomaticSearch?: boolean; | ||||
|   enableInteractiveSearch?: boolean; | ||||
|   priority?: number; | ||||
| } | ||||
|  | ||||
| interface ManageIndexersEditModalContentProps { | ||||
|   indexerIds: number[]; | ||||
|   onSavePress(payload: object): void; | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| const NO_CHANGE = 'noChange'; | ||||
|  | ||||
| const enableOptions = [ | ||||
|   { key: NO_CHANGE, value: 'No Change', disabled: true }, | ||||
|   { key: 'enabled', value: 'Enabled' }, | ||||
|   { key: 'disabled', value: 'Disabled' }, | ||||
| ]; | ||||
|  | ||||
| function ManageIndexersEditModalContent( | ||||
|   props: ManageIndexersEditModalContentProps | ||||
| ) { | ||||
|   const { indexerIds, onSavePress, onModalClose } = props; | ||||
|  | ||||
|   const [enableRss, setEnableRss] = useState(NO_CHANGE); | ||||
|   const [enableAutomaticSearch, setEnableAutomaticSearch] = useState(NO_CHANGE); | ||||
|   const [enableInteractiveSearch, setEnableInteractiveSearch] = | ||||
|     useState(NO_CHANGE); | ||||
|   const [priority, setPriority] = useState<string | number>(NO_CHANGE); | ||||
|  | ||||
|   const save = useCallback(() => { | ||||
|     let hasChanges = false; | ||||
|     const payload: SavePayload = {}; | ||||
|  | ||||
|     if (enableRss !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.enableRss = enableRss === 'enabled'; | ||||
|     } | ||||
|  | ||||
|     if (enableAutomaticSearch !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.enableAutomaticSearch = enableAutomaticSearch === 'enabled'; | ||||
|     } | ||||
|  | ||||
|     if (enableInteractiveSearch !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.enableInteractiveSearch = enableInteractiveSearch === 'enabled'; | ||||
|     } | ||||
|  | ||||
|     if (priority !== NO_CHANGE) { | ||||
|       hasChanges = true; | ||||
|       payload.priority = priority as number; | ||||
|     } | ||||
|  | ||||
|     if (hasChanges) { | ||||
|       onSavePress(payload); | ||||
|     } | ||||
|  | ||||
|     onModalClose(); | ||||
|   }, [ | ||||
|     enableRss, | ||||
|     enableAutomaticSearch, | ||||
|     enableInteractiveSearch, | ||||
|     priority, | ||||
|     onSavePress, | ||||
|     onModalClose, | ||||
|   ]); | ||||
|  | ||||
|   const onInputChange = useCallback( | ||||
|     ({ name, value }: { name: string; value: string }) => { | ||||
|       switch (name) { | ||||
|         case 'enableRss': | ||||
|           setEnableRss(value); | ||||
|           break; | ||||
|         case 'enableAutomaticSearch': | ||||
|           setEnableAutomaticSearch(value); | ||||
|           break; | ||||
|         case 'enableInteractiveSearch': | ||||
|           setEnableInteractiveSearch(value); | ||||
|           break; | ||||
|         case 'priority': | ||||
|           setPriority(value); | ||||
|           break; | ||||
|         default: | ||||
|           console.warn('EditIndexersModalContent Unknown Input'); | ||||
|       } | ||||
|     }, | ||||
|     [] | ||||
|   ); | ||||
|  | ||||
|   const selectedCount = indexerIds.length; | ||||
|  | ||||
|   return ( | ||||
|     <ModalContent onModalClose={onModalClose}> | ||||
|       <ModalHeader>{translate('EditSelectedIndexers')}</ModalHeader> | ||||
|  | ||||
|       <ModalBody> | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('EnableRss')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.SELECT} | ||||
|             name="enableRss" | ||||
|             value={enableRss} | ||||
|             values={enableOptions} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|  | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('EnableAutomaticSearch')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.SELECT} | ||||
|             name="enableAutomaticSearch" | ||||
|             value={enableAutomaticSearch} | ||||
|             values={enableOptions} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|  | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('EnableInteractiveSearch')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.SELECT} | ||||
|             name="enableInteractiveSearch" | ||||
|             value={enableInteractiveSearch} | ||||
|             values={enableOptions} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|  | ||||
|         <FormGroup> | ||||
|           <FormLabel>{translate('Priority')}</FormLabel> | ||||
|  | ||||
|           <FormInputGroup | ||||
|             type={inputTypes.NUMBER} | ||||
|             name="priority" | ||||
|             value={priority} | ||||
|             min={1} | ||||
|             max={50} | ||||
|             onChange={onInputChange} | ||||
|           /> | ||||
|         </FormGroup> | ||||
|       </ModalBody> | ||||
|  | ||||
|       <ModalFooter className={styles.modalFooter}> | ||||
|         <div className={styles.selected}> | ||||
|           {translate('{count} indexers selected', { count: selectedCount })} | ||||
|         </div> | ||||
|  | ||||
|         <div> | ||||
|           <Button onPress={onModalClose}>{translate('Cancel')}</Button> | ||||
|  | ||||
|           <Button onPress={save}>{translate('Apply Changes')}</Button> | ||||
|         </div> | ||||
|       </ModalFooter> | ||||
|     </ModalContent> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageIndexersEditModalContent; | ||||
| @@ -0,0 +1,20 @@ | ||||
| import React from 'react'; | ||||
| import Modal from 'Components/Modal/Modal'; | ||||
| import ManageIndexersModalContent from './ManageIndexersModalContent'; | ||||
|  | ||||
| interface ManageIndexersModalProps { | ||||
|   isOpen: boolean; | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| function ManageIndexersModal(props: ManageIndexersModalProps) { | ||||
|   const { isOpen, onModalClose } = props; | ||||
|  | ||||
|   return ( | ||||
|     <Modal isOpen={isOpen} onModalClose={onModalClose}> | ||||
|       <ManageIndexersModalContent onModalClose={onModalClose} /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageIndexersModal; | ||||
| @@ -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; | ||||
| } | ||||
							
								
								
									
										9
									
								
								frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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; | ||||
| @@ -0,0 +1,287 @@ | ||||
| import React, { useCallback, useMemo, useState } from 'react'; | ||||
| import { useDispatch, useSelector } from 'react-redux'; | ||||
| import { IndexerAppState } from 'App/State/SettingsAppState'; | ||||
| import Button from 'Components/Link/Button'; | ||||
| import SpinnerButton from 'Components/Link/SpinnerButton'; | ||||
| import LoadingIndicator from 'Components/Loading/LoadingIndicator'; | ||||
| import ConfirmModal from 'Components/Modal/ConfirmModal'; | ||||
| import ModalBody from 'Components/Modal/ModalBody'; | ||||
| import ModalContent from 'Components/Modal/ModalContent'; | ||||
| import ModalFooter from 'Components/Modal/ModalFooter'; | ||||
| import ModalHeader from 'Components/Modal/ModalHeader'; | ||||
| import Table from 'Components/Table/Table'; | ||||
| import TableBody from 'Components/Table/TableBody'; | ||||
| import useSelectState from 'Helpers/Hooks/useSelectState'; | ||||
| import { kinds } from 'Helpers/Props'; | ||||
| import { | ||||
|   bulkDeleteIndexers, | ||||
|   bulkEditIndexers, | ||||
| } from 'Store/Actions/settingsActions'; | ||||
| import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; | ||||
| import { SelectStateInputProps } from 'typings/props'; | ||||
| import getErrorMessage from 'Utilities/Object/getErrorMessage'; | ||||
| import getSelectedIds from 'Utilities/Table/getSelectedIds'; | ||||
| import ManageIndexersEditModal from './Edit/ManageIndexersEditModal'; | ||||
| import ManageIndexersModalRow from './ManageIndexersModalRow'; | ||||
| import TagsModal from './Tags/TagsModal'; | ||||
| import styles from './ManageIndexersModalContent.css'; | ||||
|  | ||||
| // TODO: This feels janky to do, but not sure of a better way currently | ||||
| type OnSelectedChangeCallback = React.ComponentProps< | ||||
|   typeof ManageIndexersModalRow | ||||
| >['onSelectedChange']; | ||||
|  | ||||
| const COLUMNS = [ | ||||
|   { | ||||
|     name: 'name', | ||||
|     label: 'Name', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'implementation', | ||||
|     label: 'Implementation', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'enableRss', | ||||
|     label: 'Enable RSS', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'enableAutomaticSearch', | ||||
|     label: 'Enable Automatic Search', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'enableInteractiveSearch', | ||||
|     label: 'Enable Interactive Search', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'priority', | ||||
|     label: 'Priority', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
|   { | ||||
|     name: 'tags', | ||||
|     label: 'Tags', | ||||
|     isSortable: true, | ||||
|     isVisible: true, | ||||
|   }, | ||||
| ]; | ||||
|  | ||||
| interface ManageIndexersModalContentProps { | ||||
|   onModalClose(): void; | ||||
| } | ||||
|  | ||||
| function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { | ||||
|   const { onModalClose } = props; | ||||
|  | ||||
|   const { | ||||
|     isFetching, | ||||
|     isPopulated, | ||||
|     isDeleting, | ||||
|     isSaving, | ||||
|     error, | ||||
|     items, | ||||
|   }: IndexerAppState = useSelector( | ||||
|     createClientSideCollectionSelector('settings.indexers') | ||||
|   ); | ||||
|   const dispatch = useDispatch(); | ||||
|  | ||||
|   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); | ||||
|   const [isEditModalOpen, setIsEditModalOpen] = useState(false); | ||||
|   const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); | ||||
|   const [isSavingTags, setIsSavingTags] = useState(false); | ||||
|  | ||||
|   const [selectState, setSelectState] = useSelectState(); | ||||
|  | ||||
|   const { allSelected, allUnselected, selectedState } = selectState; | ||||
|  | ||||
|   const selectedIds: number[] = useMemo(() => { | ||||
|     return getSelectedIds(selectedState); | ||||
|   }, [selectedState]); | ||||
|  | ||||
|   const selectedCount = selectedIds.length; | ||||
|  | ||||
|   const onDeletePress = useCallback(() => { | ||||
|     setIsDeleteModalOpen(true); | ||||
|   }, [setIsDeleteModalOpen]); | ||||
|  | ||||
|   const onDeleteModalClose = useCallback(() => { | ||||
|     setIsDeleteModalOpen(false); | ||||
|   }, [setIsDeleteModalOpen]); | ||||
|  | ||||
|   const onEditPress = useCallback(() => { | ||||
|     setIsEditModalOpen(true); | ||||
|   }, [setIsEditModalOpen]); | ||||
|  | ||||
|   const onEditModalClose = useCallback(() => { | ||||
|     setIsEditModalOpen(false); | ||||
|   }, [setIsEditModalOpen]); | ||||
|  | ||||
|   const onConfirmDelete = useCallback(() => { | ||||
|     dispatch(bulkDeleteIndexers({ ids: selectedIds })); | ||||
|     setIsDeleteModalOpen(false); | ||||
|   }, [selectedIds, dispatch]); | ||||
|  | ||||
|   const onSavePress = useCallback( | ||||
|     (payload: object) => { | ||||
|       setIsEditModalOpen(false); | ||||
|  | ||||
|       dispatch( | ||||
|         bulkEditIndexers({ | ||||
|           ids: selectedIds, | ||||
|           ...payload, | ||||
|         }) | ||||
|       ); | ||||
|     }, | ||||
|     [selectedIds, dispatch] | ||||
|   ); | ||||
|  | ||||
|   const onTagsPress = useCallback(() => { | ||||
|     setIsTagsModalOpen(true); | ||||
|   }, [setIsTagsModalOpen]); | ||||
|  | ||||
|   const onTagsModalClose = useCallback(() => { | ||||
|     setIsTagsModalOpen(false); | ||||
|   }, [setIsTagsModalOpen]); | ||||
|  | ||||
|   const onApplyTagsPress = useCallback( | ||||
|     (tags: number[], applyTags: string) => { | ||||
|       setIsSavingTags(true); | ||||
|       setIsTagsModalOpen(false); | ||||
|  | ||||
|       dispatch( | ||||
|         bulkEditIndexers({ | ||||
|           ids: selectedIds, | ||||
|           tags, | ||||
|           applyTags, | ||||
|         }) | ||||
|       ); | ||||
|     }, | ||||
|     [selectedIds, dispatch] | ||||
|   ); | ||||
|  | ||||
|   const onSelectAllChange = useCallback( | ||||
|     ({ value }: SelectStateInputProps) => { | ||||
|       setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); | ||||
|     }, | ||||
|     [items, setSelectState] | ||||
|   ); | ||||
|  | ||||
|   const onSelectedChange = useCallback<OnSelectedChangeCallback>( | ||||
|     ({ id, value, shiftKey = false }) => { | ||||
|       setSelectState({ | ||||
|         type: 'toggleSelected', | ||||
|         items, | ||||
|         id, | ||||
|         isSelected: value, | ||||
|         shiftKey, | ||||
|       }); | ||||
|     }, | ||||
|     [items, setSelectState] | ||||
|   ); | ||||
|  | ||||
|   const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); | ||||
|   const anySelected = selectedCount > 0; | ||||
|  | ||||
|   return ( | ||||
|     <ModalContent onModalClose={onModalClose}> | ||||
|       <ModalHeader>Manage Import Lists</ModalHeader> | ||||
|       <ModalBody> | ||||
|         {isFetching ? <LoadingIndicator /> : null} | ||||
|  | ||||
|         {error ? <div>{errorMessage}</div> : null} | ||||
|  | ||||
|         {isPopulated && !!items.length && !isFetching && !isFetching ? ( | ||||
|           <Table | ||||
|             columns={COLUMNS} | ||||
|             horizontalScroll={true} | ||||
|             selectAll={true} | ||||
|             allSelected={allSelected} | ||||
|             allUnselected={allUnselected} | ||||
|             onSelectAllChange={onSelectAllChange} | ||||
|           > | ||||
|             <TableBody> | ||||
|               {items.map((item) => { | ||||
|                 return ( | ||||
|                   <ManageIndexersModalRow | ||||
|                     key={item.id} | ||||
|                     isSelected={selectedState[item.id]} | ||||
|                     {...item} | ||||
|                     columns={COLUMNS} | ||||
|                     onSelectedChange={onSelectedChange} | ||||
|                   /> | ||||
|                 ); | ||||
|               })} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         ) : null} | ||||
|       </ModalBody> | ||||
|  | ||||
|       <ModalFooter> | ||||
|         <div className={styles.leftButtons}> | ||||
|           <SpinnerButton | ||||
|             kind={kinds.DANGER} | ||||
|             isSpinning={isDeleting} | ||||
|             isDisabled={!anySelected} | ||||
|             onPress={onDeletePress} | ||||
|           > | ||||
|             Delete | ||||
|           </SpinnerButton> | ||||
|  | ||||
|           <SpinnerButton | ||||
|             isSpinning={isSaving} | ||||
|             isDisabled={!anySelected} | ||||
|             onPress={onEditPress} | ||||
|           > | ||||
|             Edit | ||||
|           </SpinnerButton> | ||||
|  | ||||
|           <SpinnerButton | ||||
|             isSpinning={isSaving && isSavingTags} | ||||
|             isDisabled={!anySelected} | ||||
|             onPress={onTagsPress} | ||||
|           > | ||||
|             Set Tags | ||||
|           </SpinnerButton> | ||||
|         </div> | ||||
|  | ||||
|         <Button onPress={onModalClose}>Close</Button> | ||||
|       </ModalFooter> | ||||
|  | ||||
|       <ManageIndexersEditModal | ||||
|         isOpen={isEditModalOpen} | ||||
|         onModalClose={onEditModalClose} | ||||
|         onSavePress={onSavePress} | ||||
|         indexerIds={selectedIds} | ||||
|       /> | ||||
|  | ||||
|       <TagsModal | ||||
|         isOpen={isTagsModalOpen} | ||||
|         ids={selectedIds} | ||||
|         onApplyTagsPress={onApplyTagsPress} | ||||
|         onModalClose={onTagsModalClose} | ||||
|       /> | ||||
|  | ||||
|       <ConfirmModal | ||||
|         isOpen={isDeleteModalOpen} | ||||
|         kind={kinds.DANGER} | ||||
|         title="Delete Import List(s)" | ||||
|         message={`Are you sure you want to delete ${selectedIds.length} import list(s)?`} | ||||
|         confirmLabel="Delete" | ||||
|         onConfirm={onConfirmDelete} | ||||
|         onCancel={onDeleteModalClose} | ||||
|       /> | ||||
|     </ModalContent> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageIndexersModalContent; | ||||
| @@ -0,0 +1,11 @@ | ||||
| .name, | ||||
| .tags, | ||||
| .enableRss, | ||||
| .enableAutomaticSearch, | ||||
| .enableInteractiveSearch, | ||||
| .priority, | ||||
| .implementation { | ||||
|   composes: cell from '~Components/Table/Cells/TableRowCell.css'; | ||||
|  | ||||
|   word-break: break-all; | ||||
| } | ||||
							
								
								
									
										13
									
								
								frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| // This file is automatically generated. | ||||
| // Please do not change this file! | ||||
| interface CssExports { | ||||
|   'enableAutomaticSearch': string; | ||||
|   'enableInteractiveSearch': string; | ||||
|   'enableRss': string; | ||||
|   'implementation': string; | ||||
|   'name': string; | ||||
|   'priority': string; | ||||
|   'tags': string; | ||||
| } | ||||
| export const cssExports: CssExports; | ||||
| export default cssExports; | ||||
| @@ -0,0 +1,92 @@ | ||||
| import React, { useCallback } from 'react'; | ||||
| import TableRowCell from 'Components/Table/Cells/TableRowCell'; | ||||
| import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; | ||||
| import Column from 'Components/Table/Column'; | ||||
| import TableRow from 'Components/Table/TableRow'; | ||||
| import TagListConnector from 'Components/TagListConnector'; | ||||
| import { SelectStateInputProps } from 'typings/props'; | ||||
| import styles from './ManageIndexersModalRow.css'; | ||||
|  | ||||
| interface ManageIndexersModalRowProps { | ||||
|   id: number; | ||||
|   name: string; | ||||
|   enableRss: boolean; | ||||
|   enableAutomaticSearch: boolean; | ||||
|   enableInteractiveSearch: boolean; | ||||
|   priority: number; | ||||
|   implementation: string; | ||||
|   tags: number[]; | ||||
|   columns: Column[]; | ||||
|   isSelected?: boolean; | ||||
|   onSelectedChange(result: SelectStateInputProps): void; | ||||
| } | ||||
|  | ||||
| function ManageIndexersModalRow(props: ManageIndexersModalRowProps) { | ||||
|   const { | ||||
|     id, | ||||
|     isSelected, | ||||
|     name, | ||||
|     enableRss, | ||||
|     enableAutomaticSearch, | ||||
|     enableInteractiveSearch, | ||||
|     priority, | ||||
|     implementation, | ||||
|     tags, | ||||
|     onSelectedChange, | ||||
|   } = props; | ||||
|  | ||||
|   const onSelectedChangeWrapper = useCallback( | ||||
|     (result: SelectStateInputProps) => { | ||||
|       onSelectedChange({ | ||||
|         ...result, | ||||
|       }); | ||||
|     }, | ||||
|     [onSelectedChange] | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <TableRow> | ||||
|       <TableSelectCell | ||||
|         id={id} | ||||
|         isSelected={isSelected} | ||||
|         onSelectedChange={onSelectedChangeWrapper} | ||||
|       /> | ||||
|  | ||||
|       <TableRowCell className={styles.name} title={name}> | ||||
|         {name} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.implementation} title={implementation}> | ||||
|         {implementation} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.enableRss} title={enableRss}> | ||||
|         {enableRss ? 'Yes' : 'No'} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell | ||||
|         className={styles.enableAutomaticSearch} | ||||
|         title={enableAutomaticSearch} | ||||
|       > | ||||
|         {enableAutomaticSearch ? 'Yes' : 'No'} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell | ||||
|         className={styles.enableInteractiveSearch} | ||||
|         title={enableInteractiveSearch} | ||||
|       > | ||||
|         {enableInteractiveSearch ? 'Yes' : 'No'} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.priority} title={priority}> | ||||
|         {priority} | ||||
|       </TableRowCell> | ||||
|  | ||||
|       <TableRowCell className={styles.tags} title={tags}> | ||||
|         <TagListConnector tags={tags} /> | ||||
|       </TableRowCell> | ||||
|     </TableRow> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ManageIndexersModalRow; | ||||
| @@ -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 ( | ||||
|     <Modal isOpen={isOpen} onModalClose={onModalClose}> | ||||
|       <TagsModalContent {...otherProps} onModalClose={onModalClose} /> | ||||
|     </Modal> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default TagsModal; | ||||
| @@ -0,0 +1,12 @@ | ||||
| .renameIcon { | ||||
|   margin-left: 5px; | ||||
| } | ||||
|  | ||||
| .message { | ||||
|   margin-top: 20px; | ||||
|   margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .result { | ||||
|   padding-top: 4px; | ||||
| } | ||||
							
								
								
									
										9
									
								
								frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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; | ||||
| @@ -0,0 +1,178 @@ | ||||
| import { uniq } from 'lodash'; | ||||
| import React, { useCallback, useMemo, useState } from 'react'; | ||||
| import { useSelector } from 'react-redux'; | ||||
| import AppState from 'App/State/AppState'; | ||||
| import { IndexerAppState } from 'App/State/SettingsAppState'; | ||||
| import { Tag } from 'App/State/TagsAppState'; | ||||
| import Form from 'Components/Form/Form'; | ||||
| import FormGroup from 'Components/Form/FormGroup'; | ||||
| import FormInputGroup from 'Components/Form/FormInputGroup'; | ||||
| import FormLabel from 'Components/Form/FormLabel'; | ||||
| import Label from 'Components/Label'; | ||||
| import Button from 'Components/Link/Button'; | ||||
| import ModalBody from 'Components/Modal/ModalBody'; | ||||
| import ModalContent from 'Components/Modal/ModalContent'; | ||||
| import ModalFooter from 'Components/Modal/ModalFooter'; | ||||
| import ModalHeader from 'Components/Modal/ModalHeader'; | ||||
| import { inputTypes, kinds, sizes } from 'Helpers/Props'; | ||||
| import createTagsSelector from 'Store/Selectors/createTagsSelector'; | ||||
| import Indexer from 'typings/Indexer'; | ||||
| import styles from './TagsModalContent.css'; | ||||
|  | ||||
| interface TagsModalContentProps { | ||||
|   ids: number[]; | ||||
|   onApplyTagsPress: (tags: number[], applyTags: string) => void; | ||||
|   onModalClose: () => void; | ||||
| } | ||||
|  | ||||
| function TagsModalContent(props: TagsModalContentProps) { | ||||
|   const { ids, onModalClose, onApplyTagsPress } = props; | ||||
|  | ||||
|   const allIndexers: IndexerAppState = useSelector( | ||||
|     (state: AppState) => state.settings.indexers | ||||
|   ); | ||||
|   const tagList: Tag[] = useSelector(createTagsSelector()); | ||||
|  | ||||
|   const [tags, setTags] = useState<number[]>([]); | ||||
|   const [applyTags, setApplyTags] = useState('add'); | ||||
|  | ||||
|   const seriesTags = useMemo(() => { | ||||
|     const tags = ids.reduce((acc: number[], id) => { | ||||
|       const s = allIndexers.items.find((s: Indexer) => s.id === id); | ||||
|  | ||||
|       if (s) { | ||||
|         acc.push(...s.tags); | ||||
|       } | ||||
|  | ||||
|       return acc; | ||||
|     }, []); | ||||
|  | ||||
|     return uniq(tags); | ||||
|   }, [ids, allIndexers]); | ||||
|  | ||||
|   const onTagsChange = useCallback( | ||||
|     ({ value }: { value: number[] }) => { | ||||
|       setTags(value); | ||||
|     }, | ||||
|     [setTags] | ||||
|   ); | ||||
|  | ||||
|   const onApplyTagsChange = useCallback( | ||||
|     ({ value }: { value: string }) => { | ||||
|       setApplyTags(value); | ||||
|     }, | ||||
|     [setApplyTags] | ||||
|   ); | ||||
|  | ||||
|   const onApplyPress = useCallback(() => { | ||||
|     onApplyTagsPress(tags, applyTags); | ||||
|   }, [tags, applyTags, onApplyTagsPress]); | ||||
|  | ||||
|   const applyTagsOptions = [ | ||||
|     { key: 'add', value: 'Add' }, | ||||
|     { key: 'remove', value: 'Remove' }, | ||||
|     { key: 'replace', value: 'Replace' }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <ModalContent onModalClose={onModalClose}> | ||||
|       <ModalHeader>Tags</ModalHeader> | ||||
|  | ||||
|       <ModalBody> | ||||
|         <Form> | ||||
|           <FormGroup> | ||||
|             <FormLabel>Tags</FormLabel> | ||||
|  | ||||
|             <FormInputGroup | ||||
|               type={inputTypes.TAG} | ||||
|               name="tags" | ||||
|               value={tags} | ||||
|               onChange={onTagsChange} | ||||
|             /> | ||||
|           </FormGroup> | ||||
|  | ||||
|           <FormGroup> | ||||
|             <FormLabel>Apply Tags</FormLabel> | ||||
|  | ||||
|             <FormInputGroup | ||||
|               type={inputTypes.SELECT} | ||||
|               name="applyTags" | ||||
|               value={applyTags} | ||||
|               values={applyTagsOptions} | ||||
|               helpTexts={[ | ||||
|                 'How to apply tags to the selected indexer(s)', | ||||
|                 'Add: Add the tags the existing list of tags', | ||||
|                 'Remove: Remove the entered tags', | ||||
|                 'Replace: Replace the tags with the entered tags (enter no tags to clear all tags)', | ||||
|               ]} | ||||
|               onChange={onApplyTagsChange} | ||||
|             /> | ||||
|           </FormGroup> | ||||
|  | ||||
|           <FormGroup> | ||||
|             <FormLabel>Result</FormLabel> | ||||
|  | ||||
|             <div className={styles.result}> | ||||
|               {seriesTags.map((id) => { | ||||
|                 const tag = tagList.find((t) => t.id === id); | ||||
|  | ||||
|                 if (!tag) { | ||||
|                   return null; | ||||
|                 } | ||||
|  | ||||
|                 const removeTag = | ||||
|                   (applyTags === 'remove' && tags.indexOf(id) > -1) || | ||||
|                   (applyTags === 'replace' && tags.indexOf(id) === -1); | ||||
|  | ||||
|                 return ( | ||||
|                   <Label | ||||
|                     key={tag.id} | ||||
|                     title={removeTag ? 'Removing tag' : 'Existing tag'} | ||||
|                     kind={removeTag ? kinds.INVERSE : kinds.INFO} | ||||
|                     size={sizes.LARGE} | ||||
|                   > | ||||
|                     {tag.label} | ||||
|                   </Label> | ||||
|                 ); | ||||
|               })} | ||||
|  | ||||
|               {(applyTags === 'add' || applyTags === 'replace') && | ||||
|                 tags.map((id) => { | ||||
|                   const tag = tagList.find((t) => t.id === id); | ||||
|  | ||||
|                   if (!tag) { | ||||
|                     return null; | ||||
|                   } | ||||
|  | ||||
|                   if (seriesTags.indexOf(id) > -1) { | ||||
|                     return null; | ||||
|                   } | ||||
|  | ||||
|                   return ( | ||||
|                     <Label | ||||
|                       key={tag.id} | ||||
|                       title={'Adding tag'} | ||||
|                       kind={kinds.SUCCESS} | ||||
|                       size={sizes.LARGE} | ||||
|                     > | ||||
|                       {tag.label} | ||||
|                     </Label> | ||||
|                   ); | ||||
|                 })} | ||||
|             </div> | ||||
|           </FormGroup> | ||||
|         </Form> | ||||
|       </ModalBody> | ||||
|  | ||||
|       <ModalFooter> | ||||
|         <Button onPress={onModalClose}>Cancel</Button> | ||||
|  | ||||
|         <Button kind={kinds.PRIMARY} onPress={onApplyPress}> | ||||
|           Apply | ||||
|         </Button> | ||||
|       </ModalFooter> | ||||
|     </ModalContent> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default TagsModalContent; | ||||
| @@ -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; | ||||
| @@ -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; | ||||
| @@ -1,4 +1,6 @@ | ||||
| import { createAction } from 'redux-actions'; | ||||
| import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; | ||||
| import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; | ||||
| import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; | ||||
| import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; | ||||
| import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; | ||||
| @@ -30,6 +32,9 @@ export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient | ||||
| export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient'; | ||||
| export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; | ||||
|  | ||||
| export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients'; | ||||
| export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients'; | ||||
|  | ||||
| // | ||||
| // Action Creators | ||||
|  | ||||
| @@ -44,6 +49,9 @@ export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); | ||||
| export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); | ||||
| export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); | ||||
|  | ||||
| export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS); | ||||
| export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS); | ||||
|  | ||||
| export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { | ||||
|   return { | ||||
|     section, | ||||
| @@ -95,7 +103,9 @@ export default { | ||||
|     [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), | ||||
|     [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), | ||||
|     [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), | ||||
|     [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') | ||||
|     [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'), | ||||
|     [BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk'), | ||||
|     [BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk') | ||||
|   }, | ||||
|  | ||||
|   // | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| import { createAction } from 'redux-actions'; | ||||
| import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; | ||||
| import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; | ||||
| import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; | ||||
| import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; | ||||
| import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; | ||||
| @@ -30,6 +32,9 @@ export const TEST_IMPORT_LIST = 'settings/importlists/testImportList'; | ||||
| export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList'; | ||||
| export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists'; | ||||
|  | ||||
| export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists'; | ||||
| export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists'; | ||||
|  | ||||
| // | ||||
| // Action Creators | ||||
|  | ||||
| @@ -44,6 +49,9 @@ export const testImportList = createThunk(TEST_IMPORT_LIST); | ||||
| export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST); | ||||
| export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS); | ||||
|  | ||||
| export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS); | ||||
| export const bulkEditImportLists = createThunk(BULK_EDIT_IMPORT_LISTS); | ||||
|  | ||||
| export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => { | ||||
|   return { | ||||
|     section, | ||||
| @@ -94,7 +102,9 @@ export default { | ||||
|     [DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'), | ||||
|     [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'), | ||||
|     [CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section), | ||||
|     [TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist') | ||||
|     [TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist'), | ||||
|     [BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk'), | ||||
|     [BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk') | ||||
|   }, | ||||
|  | ||||
|   // | ||||
|   | ||||
| @@ -11,6 +11,8 @@ import { createThunk } from 'Store/thunks'; | ||||
| import getSectionState from 'Utilities/State/getSectionState'; | ||||
| import selectProviderSchema from 'Utilities/State/selectProviderSchema'; | ||||
| import updateSectionState from 'Utilities/State/updateSectionState'; | ||||
| import createBulkEditItemHandler from '../Creators/createBulkEditItemHandler'; | ||||
| import createBulkRemoveItemHandler from '../Creators/createBulkRemoveItemHandler'; | ||||
|  | ||||
| // | ||||
| // Variables | ||||
| @@ -33,6 +35,9 @@ export const TEST_INDEXER = 'settings/indexers/testIndexer'; | ||||
| export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; | ||||
| export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; | ||||
|  | ||||
| export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers'; | ||||
| export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers'; | ||||
|  | ||||
| // | ||||
| // Action Creators | ||||
|  | ||||
| @@ -48,6 +53,9 @@ export const testIndexer = createThunk(TEST_INDEXER); | ||||
| export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); | ||||
| export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); | ||||
|  | ||||
| export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); | ||||
| export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS); | ||||
|  | ||||
| export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { | ||||
|   return { | ||||
|     section, | ||||
| @@ -99,7 +107,10 @@ export default { | ||||
|     [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), | ||||
|     [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), | ||||
|     [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), | ||||
|     [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') | ||||
|     [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'), | ||||
|  | ||||
|     [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk'), | ||||
|     [BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk') | ||||
|   }, | ||||
|  | ||||
|   // | ||||
|   | ||||
| @@ -1,5 +1,16 @@ | ||||
| import { createSelector } from 'reselect'; | ||||
|  | ||||
| export function createQualityProfileSelectorForHook(qualityProfileId) { | ||||
|   return createSelector( | ||||
|     (state) => state.settings.qualityProfiles.items, | ||||
|     (qualityProfiles) => { | ||||
|       return qualityProfiles.find((profile) => { | ||||
|         return profile.id === qualityProfileId; | ||||
|       }); | ||||
|     } | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function createQualityProfileSelector() { | ||||
|   return createSelector( | ||||
|     (state, { qualityProfileId }) => qualityProfileId, | ||||
|   | ||||
							
								
								
									
										27
									
								
								frontend/src/typings/ImportList.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/typings/ImportList.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import ModelBase from 'App/ModelBase'; | ||||
|  | ||||
| export interface Field { | ||||
|   order: number; | ||||
|   name: string; | ||||
|   label: string; | ||||
|   value: boolean | number | string; | ||||
|   type: string; | ||||
|   advanced: boolean; | ||||
|   privacy: string; | ||||
| } | ||||
|  | ||||
| interface ImportList extends ModelBase { | ||||
|   enable: boolean; | ||||
|   enableAutomaticAdd: boolean; | ||||
|   qualityProfileId: number; | ||||
|   rootFolderPath: string; | ||||
|   name: string; | ||||
|   fields: Field[]; | ||||
|   implementationName: string; | ||||
|   implementation: string; | ||||
|   configContract: string; | ||||
|   infoLink: string; | ||||
|   tags: number[]; | ||||
| } | ||||
|  | ||||
| export default ImportList; | ||||
							
								
								
									
										28
									
								
								frontend/src/typings/Indexer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								frontend/src/typings/Indexer.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| import ModelBase from 'App/ModelBase'; | ||||
|  | ||||
| export interface Field { | ||||
|   order: number; | ||||
|   name: string; | ||||
|   label: string; | ||||
|   value: boolean | number | string; | ||||
|   type: string; | ||||
|   advanced: boolean; | ||||
|   privacy: string; | ||||
| } | ||||
|  | ||||
| interface Indexer extends ModelBase { | ||||
|   enableRss: boolean; | ||||
|   enableAutomaticSearch: boolean; | ||||
|   enableInteractiveSearch: boolean; | ||||
|   protocol: string; | ||||
|   priority: number; | ||||
|   name: string; | ||||
|   fields: Field[]; | ||||
|   implementationName: string; | ||||
|   implementation: string; | ||||
|   configContract: string; | ||||
|   infoLink: string; | ||||
|   tags: number[]; | ||||
| } | ||||
|  | ||||
| export default Indexer; | ||||
							
								
								
									
										24
									
								
								frontend/src/typings/Notification.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								frontend/src/typings/Notification.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| @@ -1,9 +1,23 @@ | ||||
| { | ||||
|   "ApplyChanges": "Apply Changes", | ||||
|   "AutomaticAdd": "Automatic Add", | ||||
|   "Browser Reload Required": "Browser Reload Required", | ||||
|   "EditSelectedDownloadClients": "Edit Selected Download Clients", | ||||
|   "EditSelectedImportLists": "Edit Selected Import Lists", | ||||
|   "EditSelectedIndexers": "Edit Selected Indexers", | ||||
|   "EnableAutomaticSearch": "Enable Automatic Search", | ||||
|   "Enabled": "Enabled", | ||||
|   "EnableInteractiveSearch": "Enable Interactive Search", | ||||
|   "EnableRss": "Enable Rss", | ||||
|   "HiddenClickToShow": "Hidden, click to show", | ||||
|   "HideAdvanced": "Hide Advanced", | ||||
|   "Language": "Language", | ||||
|   "Language that Sonarr will use for UI": "Language that Sonarr will use for UI", | ||||
|   "Priority": "Priority", | ||||
|   "QualityProfile": "Quality Profile", | ||||
|   "RemoveCompletedDownloads": "Remove Completed Downloads", | ||||
|   "RemoveFailedDownloads": "Remove Failed Downloads", | ||||
|   "RootFolder": "Root Folder", | ||||
|   "ShowAdvanced": "Show Advanced", | ||||
|   "ShownClickToHide": "Shown, click to hide", | ||||
|   "UI Language": "UI Language" | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| using System.Collections.Generic; | ||||
| using System.Collections.Generic; | ||||
| using FluentValidation.Results; | ||||
| 
 | ||||
| namespace NzbDrone.Core.ThingiProvider | ||||
| @@ -12,9 +12,12 @@ namespace NzbDrone.Core.ThingiProvider | ||||
|         bool Exists(int id); | ||||
|         TProviderDefinition Find(int id); | ||||
|         TProviderDefinition Get(int id); | ||||
|         IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids); | ||||
|         TProviderDefinition Create(TProviderDefinition definition); | ||||
|         void Update(TProviderDefinition definition); | ||||
|         IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions); | ||||
|         void Delete(int id); | ||||
|         void Delete(IEnumerable<int> ids); | ||||
|         IEnumerable<TProviderDefinition> GetDefaultDefinitions(); | ||||
|         IEnumerable<TProviderDefinition> GetPresetDefinitions(TProviderDefinition providerDefinition); | ||||
|         void SetProviderCharacteristics(TProviderDefinition definition); | ||||
|   | ||||
| @@ -101,6 +101,11 @@ namespace NzbDrone.Core.ThingiProvider | ||||
|             return _providerRepository.Get(id); | ||||
|         } | ||||
| 
 | ||||
|         public IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids) | ||||
|         { | ||||
|             return _providerRepository.Get(ids); | ||||
|         } | ||||
| 
 | ||||
|         public TProviderDefinition Find(int id) | ||||
|         { | ||||
|             return _providerRepository.Find(id); | ||||
| @@ -120,12 +125,34 @@ namespace NzbDrone.Core.ThingiProvider | ||||
|             _eventAggregator.PublishEvent(new ProviderUpdatedEvent<TProvider>(definition)); | ||||
|         } | ||||
| 
 | ||||
|         public virtual IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions) | ||||
|         { | ||||
|             _providerRepository.UpdateMany(definitions.ToList()); | ||||
| 
 | ||||
|             foreach (var definition in definitions) | ||||
|             { | ||||
|                 _eventAggregator.PublishEvent(new ProviderUpdatedEvent<TProvider>(definition)); | ||||
|             } | ||||
| 
 | ||||
|             return definitions; | ||||
|         } | ||||
| 
 | ||||
|         public void Delete(int id) | ||||
|         { | ||||
|             _providerRepository.Delete(id); | ||||
|             _eventAggregator.PublishEvent(new ProviderDeletedEvent<TProvider>(id)); | ||||
|         } | ||||
| 
 | ||||
|         public void Delete(IEnumerable<int> ids) | ||||
|         { | ||||
|             _providerRepository.DeleteMany(ids); | ||||
| 
 | ||||
|             foreach (var id in ids) | ||||
|             { | ||||
|                 _eventAggregator.PublishEvent(new ProviderDeletedEvent<TProvider>(id)); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         public TProvider GetInstance(TProviderDefinition definition) | ||||
|         { | ||||
|             var type = GetImplementation(definition); | ||||
|   | ||||
| @@ -0,0 +1,34 @@ | ||||
| using System.Collections.Generic; | ||||
| using NzbDrone.Core.Download; | ||||
| 
 | ||||
| namespace Sonarr.Api.V3.DownloadClient | ||||
| { | ||||
|     public class DownloadClientBulkResource : ProviderBulkResource<DownloadClientBulkResource> | ||||
|     { | ||||
|         public bool? Enable { get; set; } | ||||
|         public int? Priority { get; set; } | ||||
|         public bool? RemoveCompletedDownloads { get; set; } | ||||
|         public bool? RemoveFailedDownloads { get; set; } | ||||
|     } | ||||
| 
 | ||||
|     public class DownloadClientBulkResourceMapper : ProviderBulkResourceMapper<DownloadClientBulkResource, DownloadClientDefinition> | ||||
|     { | ||||
|         public override List<DownloadClientDefinition> UpdateModel(DownloadClientBulkResource resource, List<DownloadClientDefinition> existingDefinitions) | ||||
|         { | ||||
|             if (resource == null) | ||||
|             { | ||||
|                 return new List<DownloadClientDefinition>(); | ||||
|             } | ||||
| 
 | ||||
|             existingDefinitions.ForEach(existing => | ||||
|             { | ||||
|                 existing.Enable = resource.Enable ?? existing.Enable; | ||||
|                 existing.Priority = resource.Priority ?? existing.Priority; | ||||
|                 existing.RemoveCompletedDownloads = resource.RemoveCompletedDownloads ?? existing.RemoveCompletedDownloads; | ||||
|                 existing.RemoveFailedDownloads = resource.RemoveFailedDownloads ?? existing.RemoveFailedDownloads; | ||||
|             }); | ||||
| 
 | ||||
|             return existingDefinitions; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -4,12 +4,13 @@ using Sonarr.Http; | ||||
| namespace Sonarr.Api.V3.DownloadClient | ||||
| { | ||||
|     [V3ApiController] | ||||
|     public class DownloadClientController : ProviderControllerBase<DownloadClientResource, IDownloadClient, DownloadClientDefinition> | ||||
|     public class DownloadClientController : ProviderControllerBase<DownloadClientResource, DownloadClientBulkResource, IDownloadClient, DownloadClientDefinition> | ||||
|     { | ||||
|         public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper(); | ||||
|         public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new DownloadClientBulkResourceMapper(); | ||||
| 
 | ||||
|         public DownloadClientController(IDownloadClientFactory downloadClientFactory) | ||||
|             : base(downloadClientFactory, "downloadclient", ResourceMapper) | ||||
|             : base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper) | ||||
|         { | ||||
|         } | ||||
|     } | ||||
|   | ||||
							
								
								
									
										32
									
								
								src/Sonarr.Api.V3/ImportLists/ImportListBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/Sonarr.Api.V3/ImportLists/ImportListBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| using System.Collections.Generic; | ||||
| using NzbDrone.Core.ImportLists; | ||||
| 
 | ||||
| namespace Sonarr.Api.V3.ImportLists | ||||
| { | ||||
|     public class ImportListBulkResource : ProviderBulkResource<ImportListBulkResource> | ||||
|     { | ||||
|         public bool? EnableAutomaticAdd { get; set; } | ||||
|         public string RootFolderPath { get; set; } | ||||
|         public int? QualityProfileId { get; set; } | ||||
|     } | ||||
| 
 | ||||
|     public class ImportListBulkResourceMapper : ProviderBulkResourceMapper<ImportListBulkResource, ImportListDefinition> | ||||
|     { | ||||
|         public override List<ImportListDefinition> UpdateModel(ImportListBulkResource resource, List<ImportListDefinition> existingDefinitions) | ||||
|         { | ||||
|             if (resource == null) | ||||
|             { | ||||
|                 return new List<ImportListDefinition>(); | ||||
|             } | ||||
| 
 | ||||
|             existingDefinitions.ForEach(existing => | ||||
|             { | ||||
|                 existing.EnableAutomaticAdd = resource.EnableAutomaticAdd ?? existing.EnableAutomaticAdd; | ||||
|                 existing.RootFolderPath = resource.RootFolderPath ?? existing.RootFolderPath; | ||||
|                 existing.QualityProfileId = resource.QualityProfileId ?? existing.QualityProfileId; | ||||
|             }); | ||||
| 
 | ||||
|             return existingDefinitions; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -6,12 +6,13 @@ using Sonarr.Http; | ||||
| namespace Sonarr.Api.V3.ImportLists | ||||
| { | ||||
|     [V3ApiController] | ||||
|     public class ImportListController : ProviderControllerBase<ImportListResource, IImportList, ImportListDefinition> | ||||
|     public class ImportListController : ProviderControllerBase<ImportListResource, ImportListBulkResource, IImportList, ImportListDefinition> | ||||
|     { | ||||
|         public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper(); | ||||
|         public static readonly ImportListBulkResourceMapper BulkResourceMapper = new ImportListBulkResourceMapper(); | ||||
| 
 | ||||
|         public ImportListController(IImportListFactory importListFactory, ProfileExistsValidator profileExistsValidator) | ||||
|             : base(importListFactory, "importlist", ResourceMapper) | ||||
|             : base(importListFactory, "importlist", ResourceMapper, BulkResourceMapper) | ||||
|         { | ||||
|             Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.QualityProfileId)); | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										34
									
								
								src/Sonarr.Api.V3/Indexers/IndexerBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/Sonarr.Api.V3/Indexers/IndexerBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| using System.Collections.Generic; | ||||
| using NzbDrone.Core.Indexers; | ||||
| 
 | ||||
| namespace Sonarr.Api.V3.Indexers | ||||
| { | ||||
|     public class IndexerBulkResource : ProviderBulkResource<IndexerBulkResource> | ||||
|     { | ||||
|         public bool? EnableRss { get; set; } | ||||
|         public bool? EnableAutomaticSearch { get; set; } | ||||
|         public bool? EnableInteractiveSearch { get; set; } | ||||
|         public int? Priority { get; set; } | ||||
|     } | ||||
| 
 | ||||
|     public class IndexerBulkResourceMapper : ProviderBulkResourceMapper<IndexerBulkResource, IndexerDefinition> | ||||
|     { | ||||
|         public override List<IndexerDefinition> UpdateModel(IndexerBulkResource resource, List<IndexerDefinition> existingDefinitions) | ||||
|         { | ||||
|             if (resource == null) | ||||
|             { | ||||
|                 return new List<IndexerDefinition>(); | ||||
|             } | ||||
| 
 | ||||
|             existingDefinitions.ForEach(existing => | ||||
|             { | ||||
|                 existing.EnableRss = resource.EnableRss ?? existing.EnableRss; | ||||
|                 existing.EnableAutomaticSearch = resource.EnableAutomaticSearch ?? existing.EnableAutomaticSearch; | ||||
|                 existing.EnableInteractiveSearch = resource.EnableInteractiveSearch ?? existing.EnableInteractiveSearch; | ||||
|                 existing.Priority = resource.Priority ?? existing.Priority; | ||||
|             }); | ||||
| 
 | ||||
|             return existingDefinitions; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -4,12 +4,13 @@ using Sonarr.Http; | ||||
| namespace Sonarr.Api.V3.Indexers | ||||
| { | ||||
|     [V3ApiController] | ||||
|     public class IndexerController : ProviderControllerBase<IndexerResource, IIndexer, IndexerDefinition> | ||||
|     public class IndexerController : ProviderControllerBase<IndexerResource, IndexerBulkResource, IIndexer, IndexerDefinition> | ||||
|     { | ||||
|         public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper(); | ||||
|         public static readonly IndexerBulkResourceMapper BulkResourceMapper = new IndexerBulkResourceMapper(); | ||||
| 
 | ||||
|         public IndexerController(IndexerFactory indexerFactory) | ||||
|             : base(indexerFactory, "indexer", ResourceMapper) | ||||
|             : base(indexerFactory, "indexer", ResourceMapper, BulkResourceMapper) | ||||
|         { | ||||
|         } | ||||
|     } | ||||
|   | ||||
							
								
								
									
										12
									
								
								src/Sonarr.Api.V3/Metadata/MetadataBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/Sonarr.Api.V3/Metadata/MetadataBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| using NzbDrone.Core.Extras.Metadata; | ||||
| 
 | ||||
| namespace Sonarr.Api.V3.Metadata | ||||
| { | ||||
|     public class MetadataBulkResource : ProviderBulkResource<MetadataBulkResource> | ||||
|     { | ||||
|     } | ||||
| 
 | ||||
|     public class MetadataBulkResourceMapper : ProviderBulkResourceMapper<MetadataBulkResource, MetadataDefinition> | ||||
|     { | ||||
|     } | ||||
| } | ||||
| @@ -4,12 +4,13 @@ using Sonarr.Http; | ||||
| namespace Sonarr.Api.V3.Metadata | ||||
| { | ||||
|     [V3ApiController] | ||||
|     public class MetadataController : ProviderControllerBase<MetadataResource, IMetadata, MetadataDefinition> | ||||
|     public class MetadataController : ProviderControllerBase<MetadataResource, MetadataBulkResource, IMetadata, MetadataDefinition> | ||||
|     { | ||||
|         public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper(); | ||||
|         public static readonly MetadataBulkResourceMapper BulkResourceMapper = new MetadataBulkResourceMapper(); | ||||
| 
 | ||||
|         public MetadataController(IMetadataFactory metadataFactory) | ||||
|             : base(metadataFactory, "metadata", ResourceMapper) | ||||
|             : base(metadataFactory, "metadata", ResourceMapper, BulkResourceMapper) | ||||
|         { | ||||
|         } | ||||
|     } | ||||
|   | ||||
							
								
								
									
										12
									
								
								src/Sonarr.Api.V3/Notifications/NotificationBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/Sonarr.Api.V3/Notifications/NotificationBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| using NzbDrone.Core.Notifications; | ||||
| 
 | ||||
| namespace Sonarr.Api.V3.Notifications | ||||
| { | ||||
|     public class NotificationBulkResource : ProviderBulkResource<NotificationBulkResource> | ||||
|     { | ||||
|     } | ||||
| 
 | ||||
|     public class NotificationBulkResourceMapper : ProviderBulkResourceMapper<NotificationBulkResource, NotificationDefinition> | ||||
|     { | ||||
|     } | ||||
| } | ||||
| @@ -4,12 +4,13 @@ using Sonarr.Http; | ||||
| namespace Sonarr.Api.V3.Notifications | ||||
| { | ||||
|     [V3ApiController] | ||||
|     public class NotificationController : ProviderControllerBase<NotificationResource, INotification, NotificationDefinition> | ||||
|     public class NotificationController : ProviderControllerBase<NotificationResource, NotificationBulkResource, INotification, NotificationDefinition> | ||||
|     { | ||||
|         public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); | ||||
|         public static readonly NotificationBulkResourceMapper BulkResourceMapper = new NotificationBulkResourceMapper(); | ||||
| 
 | ||||
|         public NotificationController(NotificationFactory notificationFactory) | ||||
|             : base(notificationFactory, "notification", ResourceMapper) | ||||
|             : base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper) | ||||
|         { | ||||
|         } | ||||
|     } | ||||
|   | ||||
							
								
								
									
										28
									
								
								src/Sonarr.Api.V3/ProviderBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/Sonarr.Api.V3/ProviderBulkResource.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| using System.Collections.Generic; | ||||
| using NzbDrone.Core.ThingiProvider; | ||||
| using Sonarr.Api.V3.Series; | ||||
| 
 | ||||
| namespace Sonarr.Api.V3 | ||||
| { | ||||
|     public class ProviderBulkResource<T> | ||||
|     { | ||||
|         public List<int> Ids { get; set; } | ||||
|         public List<int> Tags { get; set; } | ||||
|         public ApplyTags ApplyTags { get; set; } | ||||
|     } | ||||
| 
 | ||||
|     public class ProviderBulkResourceMapper<TProviderBulkResource, TProviderDefinition> | ||||
|         where TProviderBulkResource : ProviderBulkResource<TProviderBulkResource>, new() | ||||
|         where TProviderDefinition : ProviderDefinition, new() | ||||
|     { | ||||
|         public virtual List<TProviderDefinition> UpdateModel(TProviderBulkResource resource, List<TProviderDefinition> existingDefinitions) | ||||
|         { | ||||
|             if (resource == null) | ||||
|             { | ||||
|                 return new List<TProviderDefinition>(); | ||||
|             } | ||||
| 
 | ||||
|             return existingDefinitions; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -6,23 +6,31 @@ using Microsoft.AspNetCore.Mvc; | ||||
| using NzbDrone.Common.Serializer; | ||||
| using NzbDrone.Core.ThingiProvider; | ||||
| using NzbDrone.Core.Validation; | ||||
| using Sonarr.Api.V3.Series; | ||||
| using Sonarr.Http.REST; | ||||
| using Sonarr.Http.REST.Attributes; | ||||
| 
 | ||||
| namespace Sonarr.Api.V3 | ||||
| { | ||||
|     public abstract class ProviderControllerBase<TProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource> | ||||
|     public abstract class ProviderControllerBase<TProviderResource, TBulkProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource> | ||||
|         where TProviderDefinition : ProviderDefinition, new() | ||||
|         where TProvider : IProvider | ||||
|         where TProviderResource : ProviderResource<TProviderResource>, new() | ||||
|         where TBulkProviderResource : ProviderBulkResource<TBulkProviderResource>, new() | ||||
|     { | ||||
|         private readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory; | ||||
|         private readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper; | ||||
|         private readonly ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> _bulkResourceMapper; | ||||
| 
 | ||||
|         protected ProviderControllerBase(IProviderFactory<TProvider, TProviderDefinition> providerFactory, string resource, ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper) | ||||
|         protected ProviderControllerBase(IProviderFactory<TProvider, | ||||
|             TProviderDefinition> providerFactory, | ||||
|             string resource, | ||||
|             ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper, | ||||
|             ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> bulkResourceMapper) | ||||
|         { | ||||
|             _providerFactory = providerFactory; | ||||
|             _resourceMapper = resourceMapper; | ||||
|             _bulkResourceMapper = bulkResourceMapper; | ||||
| 
 | ||||
|             SharedValidator.RuleFor(c => c.Name).NotEmpty(); | ||||
|             SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique"); | ||||
| @@ -91,6 +99,39 @@ namespace Sonarr.Api.V3 | ||||
|             return Accepted(providerResource.Id); | ||||
|         } | ||||
| 
 | ||||
|         [HttpPut("bulk")] | ||||
|         [Consumes("application/json")] | ||||
|         public ActionResult<TProviderResource> UpdateProvider([FromBody] TBulkProviderResource providerResource) | ||||
|         { | ||||
|             var definitionsToUpdate = _providerFactory.Get(providerResource.Ids).ToList(); | ||||
| 
 | ||||
|             foreach (var definition in definitionsToUpdate) | ||||
|             { | ||||
|                 if (providerResource.Tags != null) | ||||
|                 { | ||||
|                     var newTags = providerResource.Tags; | ||||
|                     var applyTags = providerResource.ApplyTags; | ||||
| 
 | ||||
|                     switch (applyTags) | ||||
|                     { | ||||
|                         case ApplyTags.Add: | ||||
|                             newTags.ForEach(t => definition.Tags.Add(t)); | ||||
|                             break; | ||||
|                         case ApplyTags.Remove: | ||||
|                             newTags.ForEach(t => definition.Tags.Remove(t)); | ||||
|                             break; | ||||
|                         case ApplyTags.Replace: | ||||
|                             definition.Tags = new HashSet<int>(newTags); | ||||
|                             break; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             _bulkResourceMapper.UpdateModel(providerResource, definitionsToUpdate); | ||||
| 
 | ||||
|             return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x))); | ||||
|         } | ||||
| 
 | ||||
|         private TProviderDefinition GetDefinition(TProviderResource providerResource, bool validate, bool includeWarnings, bool forceValidate) | ||||
|         { | ||||
|             var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; | ||||
| @@ -112,6 +153,15 @@ namespace Sonarr.Api.V3 | ||||
|             return new { }; | ||||
|         } | ||||
| 
 | ||||
|         [HttpDelete("bulk")] | ||||
|         [Consumes("application/json")] | ||||
|         public object DeleteProviders([FromBody] TBulkProviderResource resource) | ||||
|         { | ||||
|             _providerFactory.Delete(resource.Ids); | ||||
| 
 | ||||
|             return new { }; | ||||
|         } | ||||
| 
 | ||||
|         [HttpGet("schema")] | ||||
|         [Produces("application/json")] | ||||
|         public List<TProviderResource> GetTemplates() | ||||
|   | ||||
		Reference in New Issue
	
	Block a user