New: Bulk Manage Import Lists, Indexers, Clients

This commit is contained in:
Qstick 2023-05-24 23:13:27 -05:00
parent 5fd543cf8c
commit 73ccab53d5
71 changed files with 2984 additions and 22 deletions

View File

@ -1,14 +1,33 @@
import AppSectionState, { import AppSectionState, {
AppSectionDeleteState, AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState, AppSectionSchemaState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
import Language from 'Language/Language'; import Language from 'Language/Language';
import DownloadClient from 'typings/DownloadClient'; 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 QualityProfile from 'typings/QualityProfile';
import { UiSettings } from 'typings/UiSettings'; import { UiSettings } from 'typings/UiSettings';
export interface DownloadClientAppState export interface DownloadClientAppState
extends AppSectionState<DownloadClient>, 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 {} AppSectionDeleteState {}
export interface QualityProfilesAppState export interface QualityProfilesAppState
@ -20,6 +39,9 @@ export type UiSettingsAppState = AppSectionState<UiSettings>;
interface SettingsAppState { interface SettingsAppState {
downloadClients: DownloadClientAppState; downloadClients: DownloadClientAppState;
importLists: ImportListAppState;
indexers: IndexerAppState;
notifications: NotificationAppState;
language: LanguageSettingsAppState; language: LanguageSettingsAppState;
uiSettings: UiSettingsAppState; uiSettings: UiSettingsAppState;
qualityProfiles: QualityProfilesAppState; qualityProfiles: QualityProfilesAppState;

View File

@ -261,6 +261,8 @@ FormInputGroup.propTypes = {
values: PropTypes.arrayOf(PropTypes.any), values: PropTypes.arrayOf(PropTypes.any),
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all), kind: PropTypes.oneOf(kinds.all),
min: PropTypes.number,
max: PropTypes.number,
unit: PropTypes.string, unit: PropTypes.string,
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
helpText: PropTypes.string, helpText: PropTypes.string,

View File

@ -65,6 +65,7 @@ import {
faInfoCircle as fasInfoCircle, faInfoCircle as fasInfoCircle,
faLaptop as fasLaptop, faLaptop as fasLaptop,
faLevelUpAlt as fasLevelUpAlt, faLevelUpAlt as fasLevelUpAlt,
faListCheck as fasListCheck,
faMedkit as fasMedkit, faMedkit as fasMedkit,
faMinus as fasMinus, faMinus as fasMinus,
faPause as fasPause, faPause as fasPause,
@ -158,6 +159,7 @@ export const INFO = fasInfoCircle;
export const INTERACTIVE = fasUser; export const INTERACTIVE = fasUser;
export const KEYBOARD = farKeyboard; export const KEYBOARD = farKeyboard;
export const LOGOUT = fasSignOutAlt; export const LOGOUT = fasSignOutAlt;
export const MANAGE = fasListCheck;
export const MEDIA_INFO = farFileInvoice; export const MEDIA_INFO = farFileInvoice;
export const MISSING = fasExclamationTriangle; export const MISSING = fasExclamationTriangle;
export const MONITORED = fasBookmark; export const MONITORED = fasBookmark;

View File

@ -7,6 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector';
@ -22,7 +23,8 @@ class DownloadClientSettings extends Component {
this.state = { this.state = {
isSaving: false, isSaving: false,
hasPendingChanges: false hasPendingChanges: false,
isManageDownloadClientsOpen: false
}; };
} }
@ -37,6 +39,14 @@ class DownloadClientSettings extends Component {
this.setState(payload); this.setState(payload);
}; };
onManageDownloadClientsPress = () => {
this.setState({ isManageDownloadClientsOpen: true });
};
onManageDownloadClientsModalClose = () => {
this.setState({ isManageDownloadClientsOpen: false });
};
onSavePress = () => { onSavePress = () => {
if (this._saveCallback) { if (this._saveCallback) {
this._saveCallback(); this._saveCallback();
@ -54,7 +64,8 @@ class DownloadClientSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageDownloadClientsOpen
} = this.state; } = this.state;
return ( return (
@ -72,6 +83,12 @@ class DownloadClientSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllDownloadClients} onPress={dispatchTestAllDownloadClients}
/> />
<PageToolbarButton
label="Manage Clients"
iconName={icons.MANAGE}
onPress={this.onManageDownloadClientsPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -86,6 +103,11 @@ class DownloadClientSettings extends Component {
/> />
<RemotePathMappingsConnector /> <RemotePathMappingsConnector />
<ManageDownloadClientsModal
isOpen={isManageDownloadClientsOpen}
onModalClose={this.onManageDownloadClientsModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
); );

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,11 @@
.name,
.enable,
.tags,
.priority,
.removeCompletedDownloads,
.removeFailedDownloads,
.implementation {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector'; import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
import ImportListsConnector from './ImportLists/ImportListsConnector'; import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
class ImportListSettings extends Component { class ImportListSettings extends Component {
@ -18,7 +19,8 @@ class ImportListSettings extends Component {
super(props, context); super(props, context);
this.state = { this.state = {
hasPendingChanges: false hasPendingChanges: false,
isManageImportListsOpen: false
}; };
} }
@ -29,6 +31,14 @@ class ImportListSettings extends Component {
this._listOptions = ref; this._listOptions = ref;
}; };
onManageImportListsPress = () => {
this.setState({ isManageImportListsOpen: true });
};
onManageImportListsModalClose = () => {
this.setState({ isManageImportListsOpen: false });
};
onHasPendingChange = (hasPendingChanges) => { onHasPendingChange = (hasPendingChanges) => {
this.setState({ this.setState({
hasPendingChanges hasPendingChanges
@ -50,7 +60,8 @@ class ImportListSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageImportListsOpen
} = this.state; } = this.state;
return ( return (
@ -68,6 +79,12 @@ class ImportListSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllImportLists} onPress={dispatchTestAllImportLists}
/> />
<PageToolbarButton
label="Manage Lists"
iconName={icons.MANAGE}
onPress={this.onManageImportListsPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -76,6 +93,10 @@ class ImportListSettings extends Component {
<PageContentBody> <PageContentBody>
<ImportListsConnector /> <ImportListsConnector />
<ImportListsExclusionsConnector /> <ImportListsExclusionsConnector />
<ManageImportListsModal
isOpen={isManageImportListsOpen}
onModalClose={this.onManageImportListsModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
); );

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,10 @@
.name,
.tags,
.enableAutomaticAdd,
.qualityProfileId,
.rootFolderPath,
.implementation {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.result {
padding-top: 4px;
}

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

View File

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

View File

@ -7,6 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import IndexersConnector from './Indexers/IndexersConnector'; import IndexersConnector from './Indexers/IndexersConnector';
import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal';
import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; import IndexerOptionsConnector from './Options/IndexerOptionsConnector';
class IndexerSettings extends Component { class IndexerSettings extends Component {
@ -21,7 +22,8 @@ class IndexerSettings extends Component {
this.state = { this.state = {
isSaving: false, isSaving: false,
hasPendingChanges: false hasPendingChanges: false,
isManageIndexersOpen: false
}; };
} }
@ -36,6 +38,14 @@ class IndexerSettings extends Component {
this.setState(payload); this.setState(payload);
}; };
onManageIndexersPress = () => {
this.setState({ isManageIndexersOpen: true });
};
onManageIndexersModalClose = () => {
this.setState({ isManageIndexersOpen: false });
};
onSavePress = () => { onSavePress = () => {
if (this._saveCallback) { if (this._saveCallback) {
this._saveCallback(); this._saveCallback();
@ -53,7 +63,8 @@ class IndexerSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageIndexersOpen
} = this.state; } = this.state;
return ( return (
@ -71,6 +82,12 @@ class IndexerSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllIndexers} onPress={dispatchTestAllIndexers}
/> />
<PageToolbarButton
label="Manage Indexers"
iconName={icons.MANAGE}
onPress={this.onManageIndexersPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -83,6 +100,11 @@ class IndexerSettings extends Component {
onChildMounted={this.onChildMounted} onChildMounted={this.onChildMounted}
onChildStateChange={this.onChildStateChange} onChildStateChange={this.onChildStateChange}
/> />
<ManageIndexersModal
isOpen={isManageIndexersOpen}
onModalClose={this.onManageIndexersModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
); );

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -0,0 +1,11 @@
.name,
.tags,
.enableRss,
.enableAutomaticSearch,
.enableInteractiveSearch,
.priority,
.implementation {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
.renameIcon {
margin-left: 5px;
}
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.result {
padding-top: 4px;
}

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,6 @@
import { createAction } from 'redux-actions'; 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 createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; 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 CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; 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 // Action Creators
@ -44,6 +49,9 @@ export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); 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) => { export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
return { return {
section, section,
@ -95,7 +103,9 @@ export default {
[DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'),
[TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'),
[CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), [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')
}, },
// //

View File

@ -1,4 +1,6 @@
import { createAction } from 'redux-actions'; 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 createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; 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 CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList';
export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists'; 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 // Action Creators
@ -44,6 +49,9 @@ export const testImportList = createThunk(TEST_IMPORT_LIST);
export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST); export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST);
export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS); 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) => { export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => {
return { return {
section, section,
@ -94,7 +102,9 @@ export default {
[DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'), [DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'),
[TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'), [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'),
[CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section), [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')
}, },
// //

View File

@ -11,6 +11,8 @@ import { createThunk } from 'Store/thunks';
import getSectionState from 'Utilities/State/getSectionState'; import getSectionState from 'Utilities/State/getSectionState';
import selectProviderSchema from 'Utilities/State/selectProviderSchema'; import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import updateSectionState from 'Utilities/State/updateSectionState'; import updateSectionState from 'Utilities/State/updateSectionState';
import createBulkEditItemHandler from '../Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from '../Creators/createBulkRemoveItemHandler';
// //
// Variables // Variables
@ -33,6 +35,9 @@ export const TEST_INDEXER = 'settings/indexers/testIndexer';
export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; 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 // Action Creators
@ -48,6 +53,9 @@ export const testIndexer = createThunk(TEST_INDEXER);
export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); 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) => { export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return { return {
section, section,
@ -99,7 +107,10 @@ export default {
[DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
[TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
[CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), [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')
}, },
// //

View File

@ -1,5 +1,16 @@
import { createSelector } from 'reselect'; 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() { function createQualityProfileSelector() {
return createSelector( return createSelector(
(state, { qualityProfileId }) => qualityProfileId, (state, { qualityProfileId }) => qualityProfileId,

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

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

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

View File

@ -1,9 +1,23 @@
{ {
"ApplyChanges": "Apply Changes",
"AutomaticAdd": "Automatic Add",
"Browser Reload Required": "Browser Reload Required", "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", "HiddenClickToShow": "Hidden, click to show",
"HideAdvanced": "Hide Advanced", "HideAdvanced": "Hide Advanced",
"Language": "Language", "Language": "Language",
"Language that Sonarr will use for UI": "Language that Sonarr will use for UI", "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", "ShowAdvanced": "Show Advanced",
"ShownClickToHide": "Shown, click to hide", "ShownClickToHide": "Shown, click to hide",
"UI Language": "UI Language" "UI Language": "UI Language"

View File

@ -1,4 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using FluentValidation.Results; using FluentValidation.Results;
namespace NzbDrone.Core.ThingiProvider namespace NzbDrone.Core.ThingiProvider
@ -12,9 +12,12 @@ namespace NzbDrone.Core.ThingiProvider
bool Exists(int id); bool Exists(int id);
TProviderDefinition Find(int id); TProviderDefinition Find(int id);
TProviderDefinition Get(int id); TProviderDefinition Get(int id);
IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids);
TProviderDefinition Create(TProviderDefinition definition); TProviderDefinition Create(TProviderDefinition definition);
void Update(TProviderDefinition definition); void Update(TProviderDefinition definition);
IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions);
void Delete(int id); void Delete(int id);
void Delete(IEnumerable<int> ids);
IEnumerable<TProviderDefinition> GetDefaultDefinitions(); IEnumerable<TProviderDefinition> GetDefaultDefinitions();
IEnumerable<TProviderDefinition> GetPresetDefinitions(TProviderDefinition providerDefinition); IEnumerable<TProviderDefinition> GetPresetDefinitions(TProviderDefinition providerDefinition);
void SetProviderCharacteristics(TProviderDefinition definition); void SetProviderCharacteristics(TProviderDefinition definition);

View File

@ -101,6 +101,11 @@ namespace NzbDrone.Core.ThingiProvider
return _providerRepository.Get(id); return _providerRepository.Get(id);
} }
public IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids)
{
return _providerRepository.Get(ids);
}
public TProviderDefinition Find(int id) public TProviderDefinition Find(int id)
{ {
return _providerRepository.Find(id); return _providerRepository.Find(id);
@ -120,12 +125,34 @@ namespace NzbDrone.Core.ThingiProvider
_eventAggregator.PublishEvent(new ProviderUpdatedEvent<TProvider>(definition)); _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) public void Delete(int id)
{ {
_providerRepository.Delete(id); _providerRepository.Delete(id);
_eventAggregator.PublishEvent(new ProviderDeletedEvent<TProvider>(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) public TProvider GetInstance(TProviderDefinition definition)
{ {
var type = GetImplementation(definition); var type = GetImplementation(definition);

View File

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

View File

@ -4,12 +4,13 @@ using Sonarr.Http;
namespace Sonarr.Api.V3.DownloadClient namespace Sonarr.Api.V3.DownloadClient
{ {
[V3ApiController] [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 DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper();
public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new DownloadClientBulkResourceMapper();
public DownloadClientController(IDownloadClientFactory downloadClientFactory) public DownloadClientController(IDownloadClientFactory downloadClientFactory)
: base(downloadClientFactory, "downloadclient", ResourceMapper) : base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper)
{ {
} }
} }

View 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;
}
}
}

View File

@ -6,12 +6,13 @@ using Sonarr.Http;
namespace Sonarr.Api.V3.ImportLists namespace Sonarr.Api.V3.ImportLists
{ {
[V3ApiController] [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 ImportListResourceMapper ResourceMapper = new ImportListResourceMapper();
public static readonly ImportListBulkResourceMapper BulkResourceMapper = new ImportListBulkResourceMapper();
public ImportListController(IImportListFactory importListFactory, ProfileExistsValidator profileExistsValidator) public ImportListController(IImportListFactory importListFactory, ProfileExistsValidator profileExistsValidator)
: base(importListFactory, "importlist", ResourceMapper) : base(importListFactory, "importlist", ResourceMapper, BulkResourceMapper)
{ {
Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.QualityProfileId)); Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.QualityProfileId));

View 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;
}
}
}

View File

@ -4,12 +4,13 @@ using Sonarr.Http;
namespace Sonarr.Api.V3.Indexers namespace Sonarr.Api.V3.Indexers
{ {
[V3ApiController] [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 IndexerResourceMapper ResourceMapper = new IndexerResourceMapper();
public static readonly IndexerBulkResourceMapper BulkResourceMapper = new IndexerBulkResourceMapper();
public IndexerController(IndexerFactory indexerFactory) public IndexerController(IndexerFactory indexerFactory)
: base(indexerFactory, "indexer", ResourceMapper) : base(indexerFactory, "indexer", ResourceMapper, BulkResourceMapper)
{ {
} }
} }

View 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>
{
}
}

View File

@ -4,12 +4,13 @@ using Sonarr.Http;
namespace Sonarr.Api.V3.Metadata namespace Sonarr.Api.V3.Metadata
{ {
[V3ApiController] [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 MetadataResourceMapper ResourceMapper = new MetadataResourceMapper();
public static readonly MetadataBulkResourceMapper BulkResourceMapper = new MetadataBulkResourceMapper();
public MetadataController(IMetadataFactory metadataFactory) public MetadataController(IMetadataFactory metadataFactory)
: base(metadataFactory, "metadata", ResourceMapper) : base(metadataFactory, "metadata", ResourceMapper, BulkResourceMapper)
{ {
} }
} }

View 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>
{
}
}

View File

@ -4,12 +4,13 @@ using Sonarr.Http;
namespace Sonarr.Api.V3.Notifications namespace Sonarr.Api.V3.Notifications
{ {
[V3ApiController] [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 NotificationResourceMapper ResourceMapper = new NotificationResourceMapper();
public static readonly NotificationBulkResourceMapper BulkResourceMapper = new NotificationBulkResourceMapper();
public NotificationController(NotificationFactory notificationFactory) public NotificationController(NotificationFactory notificationFactory)
: base(notificationFactory, "notification", ResourceMapper) : base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper)
{ {
} }
} }

View 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;
}
}
}

View File

@ -6,23 +6,31 @@ using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
using Sonarr.Api.V3.Series;
using Sonarr.Http.REST; using Sonarr.Http.REST;
using Sonarr.Http.REST.Attributes; using Sonarr.Http.REST.Attributes;
namespace Sonarr.Api.V3 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 TProviderDefinition : ProviderDefinition, new()
where TProvider : IProvider where TProvider : IProvider
where TProviderResource : ProviderResource<TProviderResource>, new() where TProviderResource : ProviderResource<TProviderResource>, new()
where TBulkProviderResource : ProviderBulkResource<TBulkProviderResource>, new()
{ {
private readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory; private readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory;
private readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper; 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; _providerFactory = providerFactory;
_resourceMapper = resourceMapper; _resourceMapper = resourceMapper;
_bulkResourceMapper = bulkResourceMapper;
SharedValidator.RuleFor(c => c.Name).NotEmpty(); 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"); 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); 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) private TProviderDefinition GetDefinition(TProviderResource providerResource, bool validate, bool includeWarnings, bool forceValidate)
{ {
var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null; var existingDefinition = providerResource.Id > 0 ? _providerFactory.Find(providerResource.Id) : null;
@ -112,6 +153,15 @@ namespace Sonarr.Api.V3
return new { }; return new { };
} }
[HttpDelete("bulk")]
[Consumes("application/json")]
public object DeleteProviders([FromBody] TBulkProviderResource resource)
{
_providerFactory.Delete(resource.Ids);
return new { };
}
[HttpGet("schema")] [HttpGet("schema")]
[Produces("application/json")] [Produces("application/json")]
public List<TProviderResource> GetTemplates() public List<TProviderResource> GetTemplates()