New: Bulk Manage Applications, Download Clients

Co-authored-by: Qstick <qstick@gmail.com>
pull/1771/head
Bogdan 11 months ago
parent cb520b2264
commit 1706728230

@ -0,0 +1,48 @@
import SortDirection from 'Helpers/Props/SortDirection';
export interface Error {
responseJSON: {
message: string;
};
}
export interface AppSectionDeleteState {
isDeleting: boolean;
deleteError: Error;
}
export interface AppSectionSaveState {
isSaving: boolean;
saveError: Error;
}
export interface PagedAppSectionState {
pageSize: number;
}
export interface AppSectionSchemaState<T> {
isSchemaFetching: boolean;
isSchemaPopulated: boolean;
schemaError: Error;
schema: {
items: T[];
};
}
export interface AppSectionItemState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
item: T;
}
interface AppSectionState<T> {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: T[];
sortKey: string;
sortDirection: SortDirection;
}
export default AppSectionState;

@ -0,0 +1,43 @@
import IndexerAppState from './IndexerAppState';
import SettingsAppState from './SettingsAppState';
import TagsAppState from './TagsAppState';
interface FilterBuilderPropOption {
id: string;
name: string;
}
export interface FilterBuilderProp<T> {
name: string;
label: string;
type: string;
valueType?: string;
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
}
export interface PropertyFilter {
key: string;
value: boolean | string | number | string[] | number[];
type: string;
}
export interface Filter {
key: string;
label: string;
filers: PropertyFilter[];
}
export interface CustomFilter {
id: number;
type: string;
label: string;
filers: PropertyFilter[];
}
interface AppState {
indexers: IndexerAppState;
settings: SettingsAppState;
tags: TagsAppState;
}
export default AppState;

@ -0,0 +1,12 @@
import Indexer from 'typings/Indexer';
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from './AppSectionState';
interface IndexerAppState
extends AppSectionState<Indexer>,
AppSectionDeleteState,
AppSectionSaveState {}
export default IndexerAppState;

@ -0,0 +1,33 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Application from 'typings/Application';
import DownloadClient from 'typings/DownloadClient';
import Notification from 'typings/Notification';
import { UiSettings } from 'typings/UiSettings';
export interface ApplicationAppState
extends AppSectionState<Application>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface NotificationAppState
extends AppSectionState<Notification>,
AppSectionDeleteState {}
export type UiSettingsAppState = AppSectionState<UiSettings>;
interface SettingsAppState {
applications: ApplicationAppState;
downloadClients: DownloadClientAppState;
notifications: NotificationAppState;
uiSettings: UiSettingsAppState;
}
export default SettingsAppState;

@ -0,0 +1,12 @@
import ModelBase from 'App/ModelBase';
import AppSectionState, {
AppSectionDeleteState,
} from 'App/State/AppSectionState';
export interface Tag extends ModelBase {
label: string;
}
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {}
export default TagsAppState;

@ -9,13 +9,13 @@ import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.indexers, (state) => state.indexers,
(qualityProfiles) => { (indexers) => {
const { const {
isFetching, isFetching,
isPopulated, isPopulated,
error, error,
items items
} = qualityProfiles; } = indexers;
const tagList = items.map((item) => { const tagList = items.map((item) => {
return { return {

@ -10,7 +10,7 @@ function parseValue(props, value) {
} = props; } = props;
if (value == null || value === '') { if (value == null || value === '') {
return min; return null;
} }
let newValue = isFloat ? parseFloat(value) : parseInt(value); let newValue = isFloat ? parseFloat(value) : parseInt(value);

@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'input': string;
'selectCell': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -1,8 +1,10 @@
import React from 'react';
interface Column { interface Column {
name: string; name: string;
label: string; label: string | React.ReactNode;
columnLabel: string; columnLabel?: string;
isSortable: boolean; isSortable?: boolean;
isVisible: boolean; isVisible: boolean;
isModifiable?: boolean; isModifiable?: boolean;
} }

@ -121,6 +121,7 @@ function Table(props) {
} }
Table.propTypes = { Table.propTypes = {
...TableHeaderCell.props,
className: PropTypes.string, className: PropTypes.string,
horizontalScroll: PropTypes.bool.isRequired, horizontalScroll: PropTypes.bool.isRequired,
selectAll: PropTypes.bool.isRequired, selectAll: PropTypes.bool.isRequired,

@ -72,6 +72,7 @@ import {
faLanguage as fasLanguage, faLanguage as fasLanguage,
faLaptop as fasLaptop, faLaptop as fasLaptop,
faLevelUpAlt as fasLevelUpAlt, faLevelUpAlt as fasLevelUpAlt,
faListCheck as fasListCheck,
faLocationArrow as fasLocationArrow, faLocationArrow as fasLocationArrow,
faLock as fasLock, faLock as fasLock,
faMedkit as fasMedkit, faMedkit as fasMedkit,
@ -180,6 +181,7 @@ export const INTERACTIVE = fasUser;
export const KEYBOARD = farKeyboard; export const KEYBOARD = farKeyboard;
export const LOCK = fasLock; export const LOCK = fasLock;
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;

@ -190,7 +190,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => {
return ( return (
<SelectProvider items={items}> <SelectProvider items={items}>
<PageContent> <PageContent title={translate('Indexers')}>
<PageToolbar> <PageToolbar>
<PageToolbarSection> <PageToolbarSection>
<PageToolbarButton <PageToolbarButton

@ -7,7 +7,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { bulkDeleteIndexers } from 'Store/Actions/indexerIndexActions'; import { bulkDeleteIndexers } from 'Store/Actions/indexerActions';
import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector'; import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './DeleteIndexerModalContent.css'; import styles from './DeleteIndexerModalContent.css';
@ -34,7 +34,7 @@ function DeleteIndexerModalContent(props: DeleteIndexerModalContentProps) {
const onDeleteIndexerConfirmed = useCallback(() => { const onDeleteIndexerConfirmed = useCallback(() => {
dispatch( dispatch(
bulkDeleteIndexers({ bulkDeleteIndexers({
indexerIds, ids: indexerIds,
}) })
); );

@ -7,7 +7,7 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props'; import { inputTypes, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import styles from './EditIndexerModalContent.css'; import styles from './EditIndexerModalContent.css';
@ -15,6 +15,10 @@ interface SavePayload {
enable?: boolean; enable?: boolean;
appProfileId?: number; appProfileId?: number;
priority?: number; priority?: number;
minimumSeeders?: number;
seedRatio?: number;
seedTime?: number;
packSeedTime?: number;
} }
interface EditIndexerModalContentProps { interface EditIndexerModalContentProps {
@ -37,6 +41,14 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
const [enable, setEnable] = useState(NO_CHANGE); const [enable, setEnable] = useState(NO_CHANGE);
const [appProfileId, setAppProfileId] = useState<string | number>(NO_CHANGE); const [appProfileId, setAppProfileId] = useState<string | number>(NO_CHANGE);
const [priority, setPriority] = useState<null | string | number>(null); const [priority, setPriority] = useState<null | string | number>(null);
const [minimumSeeders, setMinimumSeeders] = useState<null | string | number>(
null
);
const [seedRatio, setSeedRatio] = useState<null | string | number>(null);
const [seedTime, setSeedTime] = useState<null | string | number>(null);
const [packSeedTime, setPackSeedTime] = useState<null | string | number>(
null
);
const save = useCallback(() => { const save = useCallback(() => {
let hasChanges = false; let hasChanges = false;
@ -57,12 +69,42 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
payload.priority = priority as number; payload.priority = priority as number;
} }
if (minimumSeeders !== null) {
hasChanges = true;
payload.minimumSeeders = minimumSeeders as number;
}
if (seedRatio !== null) {
hasChanges = true;
payload.seedRatio = seedRatio as number;
}
if (seedTime !== null) {
hasChanges = true;
payload.seedTime = seedTime as number;
}
if (packSeedTime !== null) {
hasChanges = true;
payload.packSeedTime = packSeedTime as number;
}
if (hasChanges) { if (hasChanges) {
onSavePress(payload); onSavePress(payload);
} }
onModalClose(); onModalClose();
}, [enable, appProfileId, priority, onSavePress, onModalClose]); }, [
enable,
appProfileId,
priority,
minimumSeeders,
seedRatio,
seedTime,
packSeedTime,
onSavePress,
onModalClose,
]);
const onInputChange = useCallback( const onInputChange = useCallback(
({ name, value }) => { ({ name, value }) => {
@ -76,6 +118,18 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
case 'priority': case 'priority':
setPriority(value); setPriority(value);
break; break;
case 'minimumSeeders':
setMinimumSeeders(value);
break;
case 'seedRatio':
setSeedRatio(value);
break;
case 'seedTime':
setSeedTime(value);
break;
case 'packSeedTime':
setPackSeedTime(value);
break;
default: default:
console.warn(`EditIndexersModalContent Unknown Input: '${name}'`); console.warn(`EditIndexersModalContent Unknown Input: '${name}'`);
} }
@ -94,7 +148,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
<ModalHeader>{translate('EditSelectedIndexers')}</ModalHeader> <ModalHeader>{translate('EditSelectedIndexers')}</ModalHeader>
<ModalBody> <ModalBody>
<FormGroup> <FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('Enable')}</FormLabel> <FormLabel>{translate('Enable')}</FormLabel>
<FormInputGroup <FormInputGroup
@ -106,21 +160,22 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('SyncProfile')}</FormLabel> <FormLabel>{translate('SyncProfile')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.APP_PROFILE_SELECT} type={inputTypes.APP_PROFILE_SELECT}
name="appProfileId" name="appProfileId"
value={appProfileId} value={appProfileId}
helpText={translate('AppProfileSelectHelpText')}
includeNoChange={true} includeNoChange={true}
includeNoChangeDisabled={false} includeNoChangeDisabled={false}
onChange={onInputChange} onChange={onInputChange}
/> />
</FormGroup> </FormGroup>
<FormGroup> <FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('Priority')}</FormLabel> <FormLabel>{translate('IndexerPriority')}</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.NUMBER} type={inputTypes.NUMBER}
@ -128,6 +183,57 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) {
value={priority} value={priority}
min={1} min={1}
max={50} max={50}
helpText={translate('IndexerPriorityHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('AppsMinimumSeeders')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minimumSeeders"
value={minimumSeeders}
helpText={translate('AppsMinimumSeedersHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('SeedRatio')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="seedRatio"
value={seedRatio}
helpText={translate('SeedRatioHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('SeedTime')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="seedTime"
value={seedTime}
unit={translate('minutes')}
helpText={translate('SeedTimeHelpText')}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('PackSeedTime')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="packSeedTime"
value={packSeedTime}
unit={translate('minutes')}
helpText={translate('PackSeedTimeHelpText')}
onChange={onInputChange} onChange={onInputChange}
/> />
</FormGroup> </FormGroup>

@ -2,11 +2,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { useSelect } from 'App/SelectContext'; import { useSelect } from 'App/SelectContext';
import AppState from 'App/State/AppState';
import SpinnerButton from 'Components/Link/SpinnerButton'; import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter'; import PageContentFooter from 'Components/Page/PageContentFooter';
import usePrevious from 'Helpers/Hooks/usePrevious'; import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import { saveIndexerEditor } from 'Store/Actions/indexerIndexActions'; import { bulkEditIndexers } from 'Store/Actions/indexerActions';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds'; import getSelectedIds from 'Utilities/Table/getSelectedIds';
import DeleteIndexerModal from './Delete/DeleteIndexerModal'; import DeleteIndexerModal from './Delete/DeleteIndexerModal';
@ -15,7 +16,7 @@ import TagsModal from './Tags/TagsModal';
import styles from './IndexerIndexSelectFooter.css'; import styles from './IndexerIndexSelectFooter.css';
const indexersEditorSelector = createSelector( const indexersEditorSelector = createSelector(
(state) => state.indexers, (state: AppState) => state.indexers,
(indexers) => { (indexers) => {
const { isSaving, isDeleting, deleteError } = indexers; const { isSaving, isDeleting, deleteError } = indexers;
@ -64,9 +65,9 @@ function IndexerIndexSelectFooter() {
setIsEditModalOpen(false); setIsEditModalOpen(false);
dispatch( dispatch(
saveIndexerEditor({ bulkEditIndexers({
...payload, ...payload,
indexerIds, ids: indexerIds,
}) })
); );
}, },
@ -87,8 +88,8 @@ function IndexerIndexSelectFooter() {
setIsTagsModalOpen(false); setIsTagsModalOpen(false);
dispatch( dispatch(
saveIndexerEditor({ bulkEditIndexers({
indexerIds, ids: indexerIds,
tags, tags,
applyTags, applyTags,
}) })

@ -19,7 +19,11 @@
.priority, .priority,
.protocol, .protocol,
.privacy { .privacy,
.minimumSeeders,
.seedRatio,
.seedTime,
.packSeedTime {
composes: cell; composes: cell;
flex: 0 0 90px; flex: 0 0 90px;

@ -8,9 +8,13 @@ interface CssExports {
'cell': string; 'cell': string;
'checkInput': string; 'checkInput': string;
'externalLink': string; 'externalLink': string;
'minimumSeeders': string;
'packSeedTime': string;
'priority': string; 'priority': string;
'privacy': string; 'privacy': string;
'protocol': string; 'protocol': string;
'seedRatio': string;
'seedTime': string;
'sortName': string; 'sortName': string;
'status': string; 'status': string;
'tags': string; 'tags': string;

@ -55,6 +55,23 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
const vipExpiration = const vipExpiration =
fields.find((field) => field.name === 'vipExpiration')?.value ?? ''; fields.find((field) => field.name === 'vipExpiration')?.value ?? '';
const minimumSeeders =
fields.find(
(field) => field.name === 'torrentBaseSettings.appMinimumSeeders'
)?.value ?? undefined;
const seedRatio =
fields.find((field) => field.name === 'torrentBaseSettings.seedRatio')
?.value ?? undefined;
const seedTime =
fields.find((field) => field.name === 'torrentBaseSettings.seedTime')
?.value ?? undefined;
const packSeedTime =
fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime')
?.value ?? undefined;
const rssUrl = `${window.location.origin}${ const rssUrl = `${window.location.origin}${
window.Prowlarr.urlBase window.Prowlarr.urlBase
}/${id}/api?apikey=${encodeURIComponent( }/${id}/api?apikey=${encodeURIComponent(
@ -213,6 +230,38 @@ function IndexerIndexRow(props: IndexerIndexRowProps) {
); );
} }
if (name === 'minimumSeeders') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{minimumSeeders}
</VirtualTableRowCell>
);
}
if (name === 'seedRatio') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{seedRatio}
</VirtualTableRowCell>
);
}
if (name === 'seedTime') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{seedTime}
</VirtualTableRowCell>
);
}
if (name === 'packSeedTime') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{packSeedTime}
</VirtualTableRowCell>
);
}
if (name === 'actions') { if (name === 'actions') {
return ( return (
<VirtualTableRowCell <VirtualTableRowCell

@ -12,7 +12,11 @@
.priority, .priority,
.privacy, .privacy,
.protocol { .protocol,
.minimumSeeders,
.seedRatio,
.seedTime,
.packSeedTime {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 90px; flex: 0 0 90px;

@ -5,9 +5,13 @@ interface CssExports {
'added': string; 'added': string;
'appProfileId': string; 'appProfileId': string;
'capabilities': string; 'capabilities': string;
'minimumSeeders': string;
'packSeedTime': string;
'priority': string; 'priority': string;
'privacy': string; 'privacy': string;
'protocol': string; 'protocol': string;
'seedRatio': string;
'seedTime': string;
'sortName': string; 'sortName': string;
'status': string; 'status': string;
'tags': string; 'tags': string;

@ -14,6 +14,7 @@ import {
setIndexerSort, setIndexerSort,
setIndexerTableOption, setIndexerTableOption,
} from 'Store/Actions/indexerIndexActions'; } from 'Store/Actions/indexerIndexActions';
import { SelectStateInputProps } from 'typings/props';
import IndexerIndexTableOptions from './IndexerIndexTableOptions'; import IndexerIndexTableOptions from './IndexerIndexTableOptions';
import styles from './IndexerIndexTableHeader.css'; import styles from './IndexerIndexTableHeader.css';
@ -45,7 +46,7 @@ function IndexerIndexTableHeader(props: IndexerIndexTableHeaderProps) {
); );
const onSelectAllChange = useCallback( const onSelectAllChange = useCallback(
({ value }) => { ({ value }: SelectStateInputProps) => {
selectDispatch({ selectDispatch({
type: value ? 'selectAll' : 'unselectAll', type: value ? 'selectAll' : 'unselectAll',
}); });

@ -9,8 +9,35 @@ import AppProfilesConnector from 'Settings/Profiles/App/AppProfilesConnector';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import ApplicationsConnector from './Applications/ApplicationsConnector'; import ApplicationsConnector from './Applications/ApplicationsConnector';
import ManageApplicationsModal from './Applications/Manage/ManageApplicationsModal';
class ApplicationSettings extends Component { class ApplicationSettings extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isManageApplicationsOpen: false
};
}
//
// Listeners
onManageApplicationsPress = () => {
this.setState({ isManageApplicationsOpen: true });
};
onManageApplicationsModalClose = () => {
this.setState({ isManageApplicationsOpen: false });
};
//
// Render
render() { render() {
const { const {
isTestingAll, isTestingAll,
@ -19,6 +46,8 @@ class ApplicationSettings extends Component {
onAppIndexerSyncPress onAppIndexerSyncPress
} = this.props; } = this.props;
const { isManageApplicationsOpen } = this.state;
return ( return (
<PageContent title={translate('Applications')}> <PageContent title={translate('Applications')}>
<SettingsToolbarConnector <SettingsToolbarConnector
@ -40,6 +69,12 @@ class ApplicationSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={onTestAllPress} onPress={onTestAllPress}
/> />
<PageToolbarButton
label={translate('ManageApplications')}
iconName={icons.MANAGE}
onPress={this.onManageApplicationsPress}
/>
</Fragment> </Fragment>
} }
/> />
@ -47,6 +82,11 @@ class ApplicationSettings extends Component {
<PageContentBody> <PageContentBody>
<ApplicationsConnector /> <ApplicationsConnector />
<AppProfilesConnector /> <AppProfilesConnector />
<ManageApplicationsModal
isOpen={isManageApplicationsOpen}
onModalClose={this.onManageApplicationsModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
); );

@ -0,0 +1,26 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageApplicationsEditModalContent from './ManageApplicationsEditModalContent';
interface ManageApplicationsEditModalProps {
isOpen: boolean;
applicationIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function ManageApplicationsEditModal(props: ManageApplicationsEditModalProps) {
const { isOpen, applicationIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageApplicationsEditModalContent
applicationIds={applicationIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ManageApplicationsEditModal;

@ -0,0 +1,16 @@
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.selected {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
}

@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'modalFooter': string;
'selected': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,108 @@
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import { ApplicationSyncLevel } from 'typings/Application';
import translate from 'Utilities/String/translate';
import styles from './ManageApplicationsEditModalContent.css';
interface SavePayload {
syncLevel?: ApplicationSyncLevel;
}
interface ManageApplicationsEditModalContentProps {
applicationIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const syncLevelOptions = [
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: ApplicationSyncLevel.Disabled, value: translate('Disabled') },
{ key: ApplicationSyncLevel.AddOnly, value: translate('AddOnly') },
{ key: ApplicationSyncLevel.FullSync, value: translate('FullSync') },
];
function ManageApplicationsEditModalContent(
props: ManageApplicationsEditModalContentProps
) {
const { applicationIds, onSavePress, onModalClose } = props;
const [syncLevel, setSyncLevel] = useState(NO_CHANGE);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (syncLevel !== NO_CHANGE) {
hasChanges = true;
payload.syncLevel = syncLevel as ApplicationSyncLevel;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [syncLevel, onSavePress, onModalClose]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'syncLevel':
setSyncLevel(value);
break;
default:
console.warn(`EditApplicationsModalContent Unknown Input: '${name}'`);
}
},
[]
);
const selectedCount = applicationIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedApplications')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('SyncLevel')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="syncLevel"
value={syncLevel}
values={syncLevelOptions}
helpText={`${translate('SyncLevelAddRemove')}<br>${translate(
'SyncLevelFull'
)}`}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountApplicationsSelected', [selectedCount])}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageApplicationsEditModalContent;

@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageApplicationsModalContent from './ManageApplicationsModalContent';
interface ManageApplicationsModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageApplicationsModal(props: ManageApplicationsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageApplicationsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageApplicationsModal;

@ -0,0 +1,16 @@
.leftButtons,
.rightButtons {
display: flex;
flex: 1 0 50%;
flex-wrap: wrap;
}
.rightButtons {
justify-content: flex-end;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}

@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'deleteButton': string;
'leftButtons': string;
'rightButtons': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,282 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { ApplicationAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import {
bulkDeleteApplications,
bulkEditApplications,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageApplicationsEditModal from './Edit/ManageApplicationsEditModal';
import ManageApplicationsModalRow from './ManageApplicationsModalRow';
import TagsModal from './Tags/TagsModal';
import styles from './ManageApplicationsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageApplicationsModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'syncLevel',
label: translate('SyncLevel'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: translate('Tags'),
isSortable: true,
isVisible: true,
},
];
interface ManageApplicationsModalContentProps {
onModalClose(): void;
}
function ManageApplicationsModalContent(
props: ManageApplicationsModalContentProps
) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
}: ApplicationAppState = useSelector(
createClientSideCollectionSelector('settings.applications')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteApplications({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditApplications({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onTagsPress = useCallback(() => {
setIsTagsModalOpen(true);
}, [setIsTagsModalOpen]);
const onTagsModalClose = useCallback(() => {
setIsTagsModalOpen(false);
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags: number[], applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
bulkEditApplications({
ids: selectedIds,
tags,
applyTags,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(
error,
'Unable to load download clients.'
);
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('ManageApplications')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoApplicationsFound')}</Alert>
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<ManageApplicationsModalRow
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}
>
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving}
isDisabled={!anySelected}
onPress={onEditPress}
>
{translate('Edit')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected}
onPress={onTagsPress}
>
{translate('SetTags')}
</SpinnerButton>
</div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageApplicationsEditModal
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
applicationIds={selectedIds}
/>
<TagsModal
isOpen={isTagsModalOpen}
ids={selectedIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedApplications')}
message={translate('DeleteSelectedApplicationsMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageApplicationsModalContent;

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

@ -0,0 +1,10 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'implementation': string;
'name': string;
'syncLevel': string;
'tags': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,82 @@
import React, { useCallback } from 'react';
import Label from 'Components/Label';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import TagListConnector from 'Components/TagListConnector';
import { kinds } from 'Helpers/Props';
import { ApplicationSyncLevel } from 'typings/Application';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import styles from './ManageApplicationsModalRow.css';
interface ManageApplicationsModalRowProps {
id: number;
name: string;
syncLevel: string;
implementation: string;
tags: number[];
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageApplicationsModalRow(props: ManageApplicationsModalRowProps) {
const {
id,
isSelected,
name,
syncLevel,
implementation,
tags,
onSelectedChange,
} = props;
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.syncLevel}>
{syncLevel === ApplicationSyncLevel.AddOnly && (
<Label kind={kinds.WARNING}>{translate('AddRemoveOnly')}</Label>
)}
{syncLevel === ApplicationSyncLevel.FullSync && (
<Label kind={kinds.SUCCESS}>{translate('FullSync')}</Label>
)}
{syncLevel === ApplicationSyncLevel.Disabled && (
<Label kind={kinds.DISABLED} outline={true}>
{translate('Disabled')}
</Label>
)}
</TableRowCell>
<TableRowCell className={styles.tags}>
<TagListConnector tags={tags} />
</TableRowCell>
</TableRow>
);
}
export default ManageApplicationsModalRow;

@ -0,0 +1,22 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import TagsModalContent from './TagsModalContent';
interface TagsModalProps {
isOpen: boolean;
ids: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModal(props: TagsModalProps) {
const { isOpen, onModalClose, ...otherProps } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<TagsModalContent {...otherProps} onModalClose={onModalClose} />
</Modal>
);
}
export default TagsModal;

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

@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'message': string;
'renameIcon': string;
'result': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,183 @@
import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { ApplicationAppState } from 'App/State/SettingsAppState';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import Application from 'typings/Application';
import translate from 'Utilities/String/translate';
import styles from './TagsModalContent.css';
interface TagsModalContentProps {
ids: number[];
onApplyTagsPress: (tags: number[], applyTags: string) => void;
onModalClose: () => void;
}
function TagsModalContent(props: TagsModalContentProps) {
const { ids, onModalClose, onApplyTagsPress } = props;
const allApplications: ApplicationAppState = useSelector(
(state: AppState) => state.settings.applications
);
const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const applicationsTags = useMemo(() => {
const tags = ids.reduce((acc: number[], id) => {
const s = allApplications.items.find((s: Application) => s.id === id);
if (s) {
acc.push(...s.tags);
}
return acc;
}, []);
return uniq(tags);
}, [ids, allApplications]);
const onTagsChange = useCallback(
({ value }: { value: number[] }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
({ value }: { value: string }) => {
setApplyTags(value);
},
[setApplyTags]
);
const onApplyPress = useCallback(() => {
onApplyTagsPress(tags, applyTags);
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: translate('Add') },
{ key: 'remove', value: translate('Remove') },
{ key: 'replace', value: translate('Replace') },
];
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('Tags')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
onChange={onTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ApplyTags')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="applyTags"
value={applyTags}
values={applyTagsOptions}
helpTexts={[
translate('ApplyTagsHelpTexts1'),
translate('ApplyTagsHelpTexts2'),
translate('ApplyTagsHelpTexts3'),
translate('ApplyTagsHelpTexts4'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}>
{applicationsTags.map((id) => {
const tag = tagList.find((t) => t.id === id);
if (!tag) {
return null;
}
const removeTag =
(applyTags === 'remove' && tags.indexOf(id) > -1) ||
(applyTags === 'replace' && tags.indexOf(id) === -1);
return (
<Label
key={tag.id}
title={
removeTag
? translate('RemovingTag')
: translate('ExistingTag')
}
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 (applicationsTags.indexOf(id) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={translate('AddingTag')}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
</div>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
{translate('Apply')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default TagsModalContent;

@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
class DownloadClientSettings extends Component { class DownloadClientSettings extends Component {
@ -21,7 +22,8 @@ class DownloadClientSettings extends Component {
this.state = { this.state = {
isSaving: false, isSaving: false,
hasPendingChanges: false hasPendingChanges: false,
isManageDownloadClientsOpen: false
}; };
} }
@ -36,6 +38,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();
@ -53,7 +63,8 @@ class DownloadClientSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageDownloadClientsOpen
} = this.state; } = this.state;
return ( return (
@ -71,6 +82,12 @@ class DownloadClientSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllDownloadClients} onPress={dispatchTestAllDownloadClients}
/> />
<PageToolbarButton
label={translate('ManageDownloadClients')}
iconName={icons.MANAGE}
onPress={this.onManageDownloadClientsPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -78,6 +95,11 @@ class DownloadClientSettings extends Component {
<PageContentBody> <PageContentBody>
<DownloadClientsConnector /> <DownloadClientsConnector />
<ManageDownloadClientsModal
isOpen={isManageDownloadClientsOpen}
onModalClose={this.onManageDownloadClientsModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
); );

@ -89,7 +89,7 @@ class DownloadClient extends Component {
kind={kinds.DISABLED} kind={kinds.DISABLED}
outline={true} outline={true}
> >
{translate('PrioritySettings', [priority])} {translate('Priority')}: {priority}
</Label> </Label>
} }
</div> </div>

@ -159,7 +159,7 @@ class EditDownloadClientModalContent extends Component {
<FormInputGroup <FormInputGroup
type={inputTypes.NUMBER} type={inputTypes.NUMBER}
name="priority" name="priority"
helpText={translate('PriorityHelpText')} helpText={translate('DownloadClientPriorityHelpText')}
min={1} min={1}
max={50} max={50}
{...priority} {...priority}

@ -0,0 +1,28 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageDownloadClientsEditModalContent from './ManageDownloadClientsEditModalContent';
interface ManageDownloadClientsEditModalProps {
isOpen: boolean;
downloadClientIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function ManageDownloadClientsEditModal(
props: ManageDownloadClientsEditModalProps
) {
const { isOpen, downloadClientIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageDownloadClientsEditModalContent
downloadClientIds={downloadClientIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ManageDownloadClientsEditModal;

@ -0,0 +1,16 @@
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.selected {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
}

@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'modalFooter': string;
'selected': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,129 @@
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ManageDownloadClientsEditModalContent.css';
interface SavePayload {
enable?: boolean;
priority?: number;
}
interface ManageDownloadClientsEditModalContentProps {
downloadClientIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const enableOptions = [
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'enabled', value: translate('Enabled') },
{ key: 'disabled', value: translate('Disabled') },
];
function ManageDownloadClientsEditModalContent(
props: ManageDownloadClientsEditModalContentProps
) {
const { downloadClientIds, onSavePress, onModalClose } = props;
const [enable, setEnable] = useState(NO_CHANGE);
const [priority, setPriority] = useState<null | string | number>(null);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (enable !== NO_CHANGE) {
hasChanges = true;
payload.enable = enable === 'enabled';
}
if (priority !== null) {
hasChanges = true;
payload.priority = priority as number;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [enable, priority, onSavePress, onModalClose]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'enable':
setEnable(value);
break;
case 'priority':
setPriority(value);
break;
default:
console.warn(
`EditDownloadClientsModalContent Unknown Input: '${name}'`
);
}
},
[]
);
const selectedCount = downloadClientIds.length;
return (
<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('ClientPriority')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
value={priority}
min={1}
max={50}
helpText={translate('DownloadClientPriorityHelpText')}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountDownloadClientsSelected', [selectedCount])}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageDownloadClientsEditModalContent;

@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageDownloadClientsModalContent from './ManageDownloadClientsModalContent';
interface ManageDownloadClientsModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageDownloadClientsModal(props: ManageDownloadClientsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageDownloadClientsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageDownloadClientsModal;

@ -0,0 +1,16 @@
.leftButtons,
.rightButtons {
display: flex;
flex: 1 0 50%;
flex-wrap: wrap;
}
.rightButtons {
justify-content: flex-end;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}

@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'deleteButton': string;
'leftButtons': string;
'rightButtons': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,240 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { DownloadClientAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import {
bulkDeleteDownloadClients,
bulkEditDownloadClients,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageDownloadClientsEditModal from './Edit/ManageDownloadClientsEditModal';
import ManageDownloadClientsModalRow from './ManageDownloadClientsModalRow';
import styles from './ManageDownloadClientsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageDownloadClientsModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'enable',
label: translate('Enabled'),
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: translate('ClientPriority'),
isSortable: true,
isVisible: true,
},
];
interface ManageDownloadClientsModalContentProps {
onModalClose(): void;
}
function ManageDownloadClientsModalContent(
props: ManageDownloadClientsModalContentProps
) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
}: DownloadClientAppState = useSelector(
createClientSideCollectionSelector('settings.downloadClients')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteDownloadClients({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditDownloadClients({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(
error,
'Unable to load download clients.'
);
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('ManageDownloadClients')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoDownloadClientsFound')}</Alert>
)}
{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}
>
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving}
isDisabled={!anySelected}
onPress={onEditPress}
>
{translate('Edit')}
</SpinnerButton>
</div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageDownloadClientsEditModal
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
downloadClientIds={selectedIds}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedDownloadClients')}
message={translate('DeleteSelectedDownloadClientsMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageDownloadClientsModalContent;

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

@ -0,0 +1,10 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'enable': string;
'implementation': string;
'name': string;
'priority': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -0,0 +1,70 @@
import React, { useCallback } from 'react';
import Label from 'Components/Label';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import { kinds } from 'Helpers/Props';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import styles from './ManageDownloadClientsModalRow.css';
interface ManageDownloadClientsModalRowProps {
id: number;
name: string;
enable: boolean;
priority: number;
implementation: string;
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageDownloadClientsModalRow(
props: ManageDownloadClientsModalRowProps
) {
const {
id,
isSelected,
name,
enable,
priority,
implementation,
onSelectedChange,
} = props;
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.enable}>
<Label kind={enable ? kinds.SUCCESS : kinds.DISABLED} outline={!enable}>
{enable ? translate('Yes') : translate('No')}
</Label>
</TableRowCell>
<TableRowCell className={styles.priority}>{priority}</TableRowCell>
</TableRow>
);
}
export default ManageDownloadClientsModalRow;

@ -0,0 +1,54 @@
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, updateItem } from '../baseActions';
function createBulkEditItemHandler(section, url) {
return function(getState, payload, dispatch) {
dispatch(set({ section, isSaving: true }));
const ajaxOptions = {
url: `${url}`,
method: 'PUT',
data: JSON.stringify(payload),
dataType: 'json'
};
const promise = createAjaxRequest(ajaxOptions).request;
promise.done((data) => {
dispatch(batchActions([
set({
section,
isSaving: false,
saveError: null
}),
...data.map((provider) => {
const {
...propsToUpdate
} = provider;
return updateItem({
id: provider.id,
section,
...propsToUpdate
});
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
return promise;
};
}
export default createBulkEditItemHandler;

@ -0,0 +1,48 @@
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { removeItem, set } from '../baseActions';
function createBulkRemoveItemHandler(section, url) {
return function(getState, payload, dispatch) {
const {
ids
} = payload;
dispatch(set({ section, isDeleting: true }));
const ajaxOptions = {
url: `${url}`,
method: 'DELETE',
data: JSON.stringify(payload),
dataType: 'json'
};
const promise = createAjaxRequest(ajaxOptions).request;
promise.done((data) => {
dispatch(batchActions([
set({
section,
isDeleting: false,
deleteError: null
}),
...ids.map((id) => {
return removeItem({ section, id });
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isDeleting: false,
deleteError: xhr
}));
});
return promise;
};
}
export default createBulkRemoveItemHandler;

@ -52,14 +52,14 @@ export default {
isFetching: false, isFetching: false,
isPopulated: false, isPopulated: false,
error: null, error: null,
isDeleting: false,
deleteError: null,
isSchemaFetching: false, isSchemaFetching: false,
isSchemaPopulated: false, isSchemaPopulated: false,
schemaError: null, schemaError: null,
schema: {}, schema: {},
isSaving: false, isSaving: false,
saveError: null, saveError: null,
isDeleting: false,
deleteError: null,
items: [], items: [],
pendingChanges: {} pendingChanges: {}
}, },

@ -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';
@ -29,6 +31,8 @@ export const DELETE_APPLICATION = 'settings/applications/deleteApplication';
export const TEST_APPLICATION = 'settings/applications/testApplication'; export const TEST_APPLICATION = 'settings/applications/testApplication';
export const CANCEL_TEST_APPLICATION = 'settings/applications/cancelTestApplication'; export const CANCEL_TEST_APPLICATION = 'settings/applications/cancelTestApplication';
export const TEST_ALL_APPLICATIONS = 'indexers/testAllApplications'; export const TEST_ALL_APPLICATIONS = 'indexers/testAllApplications';
export const BULK_EDIT_APPLICATIONS = 'settings/applications/bulkEditApplications';
export const BULK_DELETE_APPLICATIONS = 'settings/applications/bulkDeleteApplications';
// //
// Action Creators // Action Creators
@ -43,6 +47,8 @@ export const deleteApplication = createThunk(DELETE_APPLICATION);
export const testApplication = createThunk(TEST_APPLICATION); export const testApplication = createThunk(TEST_APPLICATION);
export const cancelTestApplication = createThunk(CANCEL_TEST_APPLICATION); export const cancelTestApplication = createThunk(CANCEL_TEST_APPLICATION);
export const testAllApplications = createThunk(TEST_ALL_APPLICATIONS); export const testAllApplications = createThunk(TEST_ALL_APPLICATIONS);
export const bulkEditApplications = createThunk(BULK_EDIT_APPLICATIONS);
export const bulkDeleteApplications = createThunk(BULK_DELETE_APPLICATIONS);
export const setApplicationValue = createAction(SET_APPLICATION_VALUE, (payload) => { export const setApplicationValue = createAction(SET_APPLICATION_VALUE, (payload) => {
return { return {
@ -77,6 +83,8 @@ export default {
selectedSchema: {}, selectedSchema: {},
isSaving: false, isSaving: false,
saveError: null, saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false, isTesting: false,
isTestingAll: false, isTestingAll: false,
items: [], items: [],
@ -95,7 +103,9 @@ export default {
[DELETE_APPLICATION]: createRemoveItemHandler(section, '/applications'), [DELETE_APPLICATION]: createRemoveItemHandler(section, '/applications'),
[TEST_APPLICATION]: createTestProviderHandler(section, '/applications'), [TEST_APPLICATION]: createTestProviderHandler(section, '/applications'),
[CANCEL_TEST_APPLICATION]: createCancelTestProviderHandler(section), [CANCEL_TEST_APPLICATION]: createCancelTestProviderHandler(section),
[TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications') [TEST_ALL_APPLICATIONS]: createTestAllProvidersHandler(section, '/applications'),
[BULK_EDIT_APPLICATIONS]: createBulkEditItemHandler(section, '/applications/bulk'),
[BULK_DELETE_APPLICATIONS]: createBulkRemoveItemHandler(section, '/applications/bulk')
}, },
// //

@ -75,6 +75,8 @@ export default {
selectedSchema: {}, selectedSchema: {},
isSaving: false, isSaving: false,
saveError: null, saveError: null,
isDeleting: false,
deleteError: null,
items: [], items: [],
pendingChanges: {} pendingChanges: {}
}, },

@ -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,8 @@ export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadCl
export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient'; 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_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients';
export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
// //
// Action Creators // Action Creators
@ -44,6 +48,8 @@ export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT);
export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); 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 bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS);
export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
return { return {
@ -78,6 +84,8 @@ export default {
selectedSchema: {}, selectedSchema: {},
isSaving: false, isSaving: false,
saveError: null, saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false, isTesting: false,
isTestingAll: false, isTestingAll: false,
items: [], items: [],
@ -120,7 +128,9 @@ export default {
}, },
[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_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk'),
[BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk')
}, },
// //

@ -74,6 +74,8 @@ export default {
selectedSchema: {}, selectedSchema: {},
isSaving: false, isSaving: false,
saveError: null, saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false, isTesting: false,
items: [], items: [],
pendingChanges: {} pendingChanges: {}

@ -74,6 +74,8 @@ export default {
selectedSchema: {}, selectedSchema: {},
isSaving: false, isSaving: false,
saveError: null, saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false, isTesting: false,
items: [], items: [],
pendingChanges: {} pendingChanges: {}

@ -13,6 +13,8 @@ import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate';
import getSectionState from 'Utilities/State/getSectionState'; import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState'; import updateSectionState from 'Utilities/State/updateSectionState';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import createBulkEditItemHandler from './Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from './Creators/createBulkRemoveItemHandler';
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
@ -95,6 +97,8 @@ export const DELETE_INDEXER = 'indexers/deleteIndexer';
export const TEST_INDEXER = 'indexers/testIndexer'; export const TEST_INDEXER = 'indexers/testIndexer';
export const CANCEL_TEST_INDEXER = 'indexers/cancelTestIndexer'; export const CANCEL_TEST_INDEXER = 'indexers/cancelTestIndexer';
export const TEST_ALL_INDEXERS = 'indexers/testAllIndexers'; export const TEST_ALL_INDEXERS = 'indexers/testAllIndexers';
export const BULK_EDIT_INDEXERS = 'indexers/bulkEditIndexers';
export const BULK_DELETE_INDEXERS = 'indexers/bulkDeleteIndexers';
// //
// Action Creators // Action Creators
@ -111,6 +115,8 @@ export const deleteIndexer = createThunk(DELETE_INDEXER);
export const testIndexer = createThunk(TEST_INDEXER); 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 bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return { return {
@ -163,7 +169,9 @@ export const actionHandlers = handleThunks({
[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_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'),
[BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk')
}); });
// //

@ -1,10 +1,6 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import { removeItem, set, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
@ -90,6 +86,30 @@ export const defaultState = {
isSortable: false, isSortable: false,
isVisible: true isVisible: true
}, },
{
name: 'minimumSeeders',
label: translate('MinimumSeeders'),
isSortable: true,
isVisible: false
},
{
name: 'seedRatio',
label: translate('SeedRatio'),
isSortable: true,
isVisible: false
},
{
name: 'seedTime',
label: translate('SeedTime'),
isSortable: true,
isVisible: false
},
{
name: 'packSeedTime',
label: translate('PackSeedTime'),
isSortable: true,
isVisible: false
},
{ {
name: 'tags', name: 'tags',
label: translate('Tags'), label: translate('Tags'),
@ -186,8 +206,6 @@ export const SET_INDEXER_SORT = 'indexerIndex/setIndexerSort';
export const SET_INDEXER_FILTER = 'indexerIndex/setIndexerFilter'; export const SET_INDEXER_FILTER = 'indexerIndex/setIndexerFilter';
export const SET_INDEXER_VIEW = 'indexerIndex/setIndexerView'; export const SET_INDEXER_VIEW = 'indexerIndex/setIndexerView';
export const SET_INDEXER_TABLE_OPTION = 'indexerIndex/setIndexerTableOption'; export const SET_INDEXER_TABLE_OPTION = 'indexerIndex/setIndexerTableOption';
export const SAVE_INDEXER_EDITOR = 'indexerIndex/saveIndexerEditor';
export const BULK_DELETE_INDEXERS = 'indexerIndex/bulkDeleteIndexers';
// //
// Action Creators // Action Creators
@ -196,89 +214,6 @@ export const setIndexerSort = createAction(SET_INDEXER_SORT);
export const setIndexerFilter = createAction(SET_INDEXER_FILTER); export const setIndexerFilter = createAction(SET_INDEXER_FILTER);
export const setIndexerView = createAction(SET_INDEXER_VIEW); export const setIndexerView = createAction(SET_INDEXER_VIEW);
export const setIndexerTableOption = createAction(SET_INDEXER_TABLE_OPTION); export const setIndexerTableOption = createAction(SET_INDEXER_TABLE_OPTION);
export const saveIndexerEditor = createThunk(SAVE_INDEXER_EDITOR);
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
//
// Action Handlers
export const actionHandlers = handleThunks({
[SAVE_INDEXER_EDITOR]: function(getState, payload, dispatch) {
dispatch(set({
section,
isSaving: true
}));
const promise = createAjaxRequest({
url: '/indexer/editor',
method: 'PUT',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(batchActions([
...data.map((indexer) => {
return updateItem({
id: indexer.id,
section: 'indexers',
...indexer
});
}),
set({
section,
isSaving: false,
saveError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
},
[BULK_DELETE_INDEXERS]: function(getState, payload, dispatch) {
dispatch(set({
section,
isDeleting: true
}));
const promise = createAjaxRequest({
url: '/indexer/editor',
method: 'DELETE',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done(() => {
dispatch(batchActions([
...payload.indexerIds.map((id) => {
return removeItem({ section: 'indexers', id });
}),
set({
section,
isDeleting: false,
deleteError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isDeleting: false,
deleteError: xhr
}));
});
}
});
// //
// Reducers // Reducers

@ -0,0 +1,19 @@
import ModelBase from 'App/ModelBase';
export enum ApplicationSyncLevel {
Disabled = 'disabled',
AddOnly = 'addOnly',
FullSync = 'fullSync',
}
interface Application extends ModelBase {
name: string;
syncLevel: ApplicationSyncLevel;
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}
export default Application;

@ -0,0 +1,26 @@
import ModelBase from 'App/ModelBase';
export interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string;
type: string;
advanced: boolean;
privacy: string;
}
interface DownloadClient extends ModelBase {
enable: boolean;
protocol: string;
priority: number;
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}
export default DownloadClient;

@ -0,0 +1,27 @@
import ModelBase from 'App/ModelBase';
export interface Field {
order: number;
name: string;
label: string;
value: boolean | number | string;
type: string;
advanced: boolean;
privacy: string;
}
interface Indexer extends ModelBase {
enable: boolean;
appProfileId: number;
protocol: string;
priority: number;
name: string;
fields: Field[];
implementationName: string;
implementation: string;
configContract: string;
infoLink: string;
tags: number[];
}
export default Indexer;

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

@ -0,0 +1,6 @@
export interface UiSettings {
showRelativeDates: boolean;
shortDateFormat: string;
longDateFormat: string;
timeFormat: string;
}

@ -0,0 +1,5 @@
export interface SelectStateInputProps {
id: number;
value: boolean;
shiftKey: boolean;
}

@ -48,6 +48,8 @@
"ApplyTagsHelpTexts3": "Remove: Remove the entered tags", "ApplyTagsHelpTexts3": "Remove: Remove the entered tags",
"ApplyTagsHelpTexts4": "Replace: Replace the tags with the entered tags (enter no tags to clear all tags)", "ApplyTagsHelpTexts4": "Replace: Replace the tags with the entered tags (enter no tags to clear all tags)",
"Apps": "Apps", "Apps": "Apps",
"AppsMinimumSeeders": "Apps Minimum Seeders",
"AppsMinimumSeedersHelpText": "Minimum seeders required by the Applications for the indexer to grab, empty is Sync profile's default",
"AreYouSureYouWantToDeleteCategory": "Are you sure you want to delete mapped category?", "AreYouSureYouWantToDeleteCategory": "Are you sure you want to delete mapped category?",
"AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?", "AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?",
"Artist": "Artist", "Artist": "Artist",
@ -102,7 +104,9 @@
"ConnectionLostMessage": "Prowlarr has lost its connection to the backend and will need to be reloaded to restore functionality.", "ConnectionLostMessage": "Prowlarr has lost its connection to the backend and will need to be reloaded to restore functionality.",
"Connections": "Connections", "Connections": "Connections",
"CouldNotConnectSignalR": "Could not connect to SignalR, UI won't update", "CouldNotConnectSignalR": "Could not connect to SignalR, UI won't update",
"CountIndexersSelected": "{0} indexers selected", "CountApplicationsSelected": "{0} application(s) selected",
"CountDownloadClientsSelected": "{0} download client(s) selected",
"CountIndexersSelected": "{0} indexer(s) selected",
"Custom": "Custom", "Custom": "Custom",
"CustomFilters": "Custom Filters", "CustomFilters": "Custom Filters",
"DBMigration": "DB Migration", "DBMigration": "DB Migration",
@ -122,6 +126,10 @@
"DeleteIndexerProxyMessageText": "Are you sure you want to delete the proxy '{0}'?", "DeleteIndexerProxyMessageText": "Are you sure you want to delete the proxy '{0}'?",
"DeleteNotification": "Delete Notification", "DeleteNotification": "Delete Notification",
"DeleteNotificationMessageText": "Are you sure you want to delete the notification '{0}'?", "DeleteNotificationMessageText": "Are you sure you want to delete the notification '{0}'?",
"DeleteSelectedApplications": "Delete Selected Applications",
"DeleteSelectedApplicationsMessageText": "Are you sure you want to delete {0} selected application(s)?",
"DeleteSelectedDownloadClients": "Delete Download Client(s)",
"DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {0} selected download client(s)?",
"DeleteSelectedIndexer": "Delete Selected Indexer", "DeleteSelectedIndexer": "Delete Selected Indexer",
"DeleteSelectedIndexers": "Delete Selected Indexers", "DeleteSelectedIndexers": "Delete Selected Indexers",
"DeleteSelectedIndexersMessageText": "Are you sure you want to delete {0} selected indexer(s)?", "DeleteSelectedIndexersMessageText": "Are you sure you want to delete {0} selected indexer(s)?",
@ -137,6 +145,7 @@
"Donations": "Donations", "Donations": "Donations",
"DownloadClient": "Download Client", "DownloadClient": "Download Client",
"DownloadClientCategory": "Download Client Category", "DownloadClientCategory": "Download Client Category",
"DownloadClientPriorityHelpText": "Prioritize multiple Download Clients. Round-Robin is used for clients with the same priority.",
"DownloadClientSettings": "Download Client Settings", "DownloadClientSettings": "Download Client Settings",
"DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures", "DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures",
"DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}", "DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}",
@ -145,6 +154,7 @@
"Duration": "Duration", "Duration": "Duration",
"Edit": "Edit", "Edit": "Edit",
"EditIndexer": "Edit Indexer", "EditIndexer": "Edit Indexer",
"EditSelectedDownloadClients": "Edit Selected Download Clients",
"EditSelectedIndexers": "Edit Selected Indexers", "EditSelectedIndexers": "Edit Selected Indexers",
"EditSyncProfile": "Edit Sync Profile", "EditSyncProfile": "Edit Sync Profile",
"ElapsedTime": "Elapsed Time", "ElapsedTime": "Elapsed Time",
@ -204,6 +214,7 @@
"Id": "Id", "Id": "Id",
"IgnoredAddresses": "Ignored Addresses", "IgnoredAddresses": "Ignored Addresses",
"IllRestartLater": "I'll restart later", "IllRestartLater": "I'll restart later",
"Implementation": "Implementation",
"IncludeHealthWarningsHelpText": "Include Health Warnings", "IncludeHealthWarningsHelpText": "Include Health Warnings",
"IncludeManualGrabsHelpText": "Include Manual Grabs made within Prowlarr", "IncludeManualGrabsHelpText": "Include Manual Grabs made within Prowlarr",
"Indexer": "Indexer", "Indexer": "Indexer",
@ -263,6 +274,8 @@
"Logs": "Logs", "Logs": "Logs",
"MIA": "MIA", "MIA": "MIA",
"MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details", "MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details",
"ManageApplications": "Manage Applications",
"ManageDownloadClients": "Manage Download Clients",
"Manual": "Manual", "Manual": "Manual",
"MappedCategories": "Mapped Categories", "MappedCategories": "Mapped Categories",
"MappedDrivesRunningAsService": "Mapped network drives are not available when running as a Windows Service. Please see the FAQ for more information", "MappedDrivesRunningAsService": "Mapped network drives are not available when running as a Windows Service. Please see the FAQ for more information",
@ -287,6 +300,7 @@
"NoBackupsAreAvailable": "No backups are available", "NoBackupsAreAvailable": "No backups are available",
"NoChange": "No Change", "NoChange": "No Change",
"NoChanges": "No Changes", "NoChanges": "No Changes",
"NoDownloadClientsFound": "No download clients found",
"NoHistoryFound": "No history found", "NoHistoryFound": "No history found",
"NoIndexersFound": "No indexers found", "NoIndexersFound": "No indexers found",
"NoLeaveIt": "No, Leave It", "NoLeaveIt": "No, Leave It",
@ -313,6 +327,8 @@
"OpenBrowserOnStart": "Open browser on start", "OpenBrowserOnStart": "Open browser on start",
"OpenThisModal": "Open This Modal", "OpenThisModal": "Open This Modal",
"Options": "Options", "Options": "Options",
"PackSeedTime": "Pack Seed Time",
"PackSeedTimeHelpText": "The time a pack (season or discography) torrent should be seeded before stopping, empty is app's default",
"PackageVersion": "Package Version", "PackageVersion": "Package Version",
"PageSize": "Page Size", "PageSize": "Page Size",
"PageSizeHelpText": "Number of items to show on each page", "PageSizeHelpText": "Number of items to show on each page",
@ -326,8 +342,6 @@
"PortNumber": "Port Number", "PortNumber": "Port Number",
"Presets": "Presets", "Presets": "Presets",
"Priority": "Priority", "Priority": "Priority",
"PriorityHelpText": "Prioritize multiple Download Clients. Round-Robin is used for clients with the same priority.",
"PrioritySettings": "Priority",
"Privacy": "Privacy", "Privacy": "Privacy",
"Private": "Private", "Private": "Private",
"Protocol": "Protocol", "Protocol": "Protocol",
@ -398,6 +412,10 @@
"SearchTypes": "Search Types", "SearchTypes": "Search Types",
"Season": "Season", "Season": "Season",
"Security": "Security", "Security": "Security",
"SeedRatio": "Seed Ratio",
"SeedRatioHelpText": "The ratio a torrent should reach before stopping, empty is app's default",
"SeedTime": "Seed Time",
"SeedTimeHelpText": "The time a torrent should be seeded before stopping, empty is app's default",
"Seeders": "Seeders", "Seeders": "Seeders",
"SelectAll": "Select All", "SelectAll": "Select All",
"SelectIndexers": "Select Indexers", "SelectIndexers": "Select Indexers",

@ -12,9 +12,10 @@ 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);
void Update(IEnumerable<TProviderDefinition> definitions); IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions);
void Delete(int id); void Delete(int id);
void Delete(IEnumerable<int> ids); void Delete(IEnumerable<int> ids);
IEnumerable<TProviderDefinition> GetDefaultDefinitions(); IEnumerable<TProviderDefinition> GetDefaultDefinitions();

@ -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,10 +125,12 @@ namespace NzbDrone.Core.ThingiProvider
_eventAggregator.PublishEvent(new ProviderUpdatedEvent<TProvider>(updatedDef)); _eventAggregator.PublishEvent(new ProviderUpdatedEvent<TProvider>(updatedDef));
} }
public virtual void Update(IEnumerable<TProviderDefinition> definitions) public virtual IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions)
{ {
_providerRepository.UpdateMany(definitions.ToList()); _providerRepository.UpdateMany(definitions.ToList());
_eventAggregator.PublishEvent(new ProviderBulkUpdatedEvent<TProvider>(definitions)); _eventAggregator.PublishEvent(new ProviderBulkUpdatedEvent<TProvider>(definitions));
return definitions;
} }
public void Delete(int id) public void Delete(int id)

@ -0,0 +1,28 @@
using System.Collections.Generic;
using NzbDrone.Core.Applications;
namespace Prowlarr.Api.V1.Applications
{
public class ApplicationBulkResource : ProviderBulkResource<ApplicationBulkResource>
{
public ApplicationSyncLevel? SyncLevel { get; set; }
}
public class ApplicationBulkResourceMapper : ProviderBulkResourceMapper<ApplicationBulkResource, ApplicationDefinition>
{
public override List<ApplicationDefinition> UpdateModel(ApplicationBulkResource resource, List<ApplicationDefinition> existingDefinitions)
{
if (resource == null)
{
return new List<ApplicationDefinition>();
}
existingDefinitions.ForEach(existing =>
{
existing.SyncLevel = resource.SyncLevel ?? existing.SyncLevel;
});
return existingDefinitions;
}
}
}

@ -1,15 +1,16 @@
using NzbDrone.Core.Applications; using NzbDrone.Core.Applications;
using Prowlarr.Http; using Prowlarr.Http;
namespace Prowlarr.Api.V1.Application namespace Prowlarr.Api.V1.Applications
{ {
[V1ApiController("applications")] [V1ApiController("applications")]
public class ApplicationController : ProviderControllerBase<ApplicationResource, IApplication, ApplicationDefinition> public class ApplicationController : ProviderControllerBase<ApplicationResource, ApplicationBulkResource, IApplication, ApplicationDefinition>
{ {
public static readonly ApplicationResourceMapper ResourceMapper = new ApplicationResourceMapper(); public static readonly ApplicationResourceMapper ResourceMapper = new ();
public static readonly ApplicationBulkResourceMapper BulkResourceMapper = new ();
public ApplicationController(ApplicationFactory applicationsFactory) public ApplicationController(ApplicationFactory applicationsFactory)
: base(applicationsFactory, "applications", ResourceMapper) : base(applicationsFactory, "applications", ResourceMapper, BulkResourceMapper)
{ {
} }
} }

@ -1,6 +1,6 @@
using NzbDrone.Core.Applications; using NzbDrone.Core.Applications;
namespace Prowlarr.Api.V1.Application namespace Prowlarr.Api.V1.Applications
{ {
public class ApplicationResource : ProviderResource<ApplicationResource> public class ApplicationResource : ProviderResource<ApplicationResource>
{ {

@ -0,0 +1,30 @@
using System.Collections.Generic;
using NzbDrone.Core.Download;
namespace Prowlarr.Api.V1.DownloadClient
{
public class DownloadClientBulkResource : ProviderBulkResource<DownloadClientBulkResource>
{
public bool? Enable { get; set; }
public int? Priority { 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;
});
return existingDefinitions;
}
}
}

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

@ -0,0 +1,12 @@
using NzbDrone.Core.IndexerProxies;
namespace Prowlarr.Api.V1.IndexerProxies
{
public class IndexerProxyBulkResource : ProviderBulkResource<IndexerProxyBulkResource>
{
}
public class IndexerProxyBulkResourceMapper : ProviderBulkResourceMapper<IndexerProxyBulkResource, IndexerProxyDefinition>
{
}
}

@ -1,16 +1,31 @@
using System;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.IndexerProxies; using NzbDrone.Core.IndexerProxies;
using Prowlarr.Http; using Prowlarr.Http;
namespace Prowlarr.Api.V1.IndexerProxies namespace Prowlarr.Api.V1.IndexerProxies
{ {
[V1ApiController] [V1ApiController]
public class IndexerProxyController : ProviderControllerBase<IndexerProxyResource, IIndexerProxy, IndexerProxyDefinition> public class IndexerProxyController : ProviderControllerBase<IndexerProxyResource, IndexerProxyBulkResource, IIndexerProxy, IndexerProxyDefinition>
{ {
public static readonly IndexerProxyResourceMapper ResourceMapper = new IndexerProxyResourceMapper(); public static readonly IndexerProxyResourceMapper ResourceMapper = new ();
public static readonly IndexerProxyBulkResourceMapper BulkResourceMapper = new ();
public IndexerProxyController(IndexerProxyFactory notificationFactory) public IndexerProxyController(IndexerProxyFactory notificationFactory)
: base(notificationFactory, "indexerProxy", ResourceMapper) : base(notificationFactory, "indexerProxy", ResourceMapper, BulkResourceMapper)
{ {
} }
[NonAction]
public override ActionResult<IndexerProxyResource> UpdateProvider([FromBody] IndexerProxyBulkResource providerResource)
{
throw new NotImplementedException();
}
[NonAction]
public override object DeleteProviders([FromBody] IndexerProxyBulkResource resource)
{
throw new NotImplementedException();
}
} }
} }

@ -0,0 +1,44 @@
using System.Collections.Generic;
using NzbDrone.Core.Indexers;
namespace Prowlarr.Api.V1.Indexers
{
public class IndexerBulkResource : ProviderBulkResource<IndexerBulkResource>
{
public bool? Enable { get; set; }
public int? AppProfileId { get; set; }
public int? Priority { get; set; }
public int? MinimumSeeders { get; set; }
public double? SeedRatio { get; set; }
public int? SeedTime { get; set; }
public int? PackSeedTime { get; set; }
}
public class IndexerBulkResourceMapper : ProviderBulkResourceMapper<IndexerBulkResource, IndexerDefinition>
{
public override List<IndexerDefinition> UpdateModel(IndexerBulkResource resource, List<IndexerDefinition> existingDefinitions)
{
if (resource == null)
{
return new List<IndexerDefinition>();
}
existingDefinitions.ForEach(existing =>
{
existing.Enable = resource.Enable ?? existing.Enable;
existing.AppProfileId = resource.AppProfileId ?? existing.AppProfileId;
existing.Priority = resource.Priority ?? existing.Priority;
if (existing.Protocol == DownloadProtocol.Torrent)
{
((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.AppMinimumSeeders = resource.MinimumSeeders ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.AppMinimumSeeders;
((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedRatio = resource.SeedRatio ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedRatio;
((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedTime = resource.SeedTime ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.SeedTime;
((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.PackSeedTime = resource.PackSeedTime ?? ((ITorrentIndexerSettings)existing.Settings).TorrentBaseSettings.PackSeedTime;
}
});
return existingDefinitions;
}
}
}

@ -4,10 +4,10 @@ using Prowlarr.Http;
namespace Prowlarr.Api.V1.Indexers namespace Prowlarr.Api.V1.Indexers
{ {
[V1ApiController] [V1ApiController]
public class IndexerController : ProviderControllerBase<IndexerResource, IIndexer, IndexerDefinition> public class IndexerController : ProviderControllerBase<IndexerResource, IndexerBulkResource, IIndexer, IndexerDefinition>
{ {
public IndexerController(IndexerFactory indexerFactory, IndexerResourceMapper resourceMapper) public IndexerController(IndexerFactory indexerFactory, IndexerResourceMapper resourceMapper, IndexerBulkResourceMapper bulkResourceMapper)
: base(indexerFactory, "indexer", resourceMapper) : base(indexerFactory, "indexer", resourceMapper, bulkResourceMapper)
{ {
} }
} }

@ -1,88 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Messaging.Commands;
using Prowlarr.Http;
namespace Prowlarr.Api.V1.Indexers
{
[V1ApiController("indexer/editor")]
public class IndexerEditorController : Controller
{
private readonly IIndexerFactory _indexerFactory;
private readonly IManageCommandQueue _commandQueueManager;
private readonly IndexerResourceMapper _resourceMapper;
public IndexerEditorController(IIndexerFactory indexerFactory, IManageCommandQueue commandQueueManager, IndexerResourceMapper resourceMapper)
{
_indexerFactory = indexerFactory;
_commandQueueManager = commandQueueManager;
_resourceMapper = resourceMapper;
}
[HttpPut]
[Consumes("application/json")]
public IActionResult SaveAll(IndexerEditorResource resource)
{
var indexersToUpdate = _indexerFactory.AllProviders(false).Select(x => (IndexerDefinition)x.Definition).Where(d => resource.IndexerIds.Contains(d.Id));
foreach (var indexer in indexersToUpdate)
{
if (resource.Enable.HasValue)
{
indexer.Enable = resource.Enable.Value;
}
if (resource.AppProfileId.HasValue)
{
indexer.AppProfileId = resource.AppProfileId.Value;
}
if (resource.Priority.HasValue)
{
indexer.Priority = resource.Priority.Value;
}
if (resource.Tags != null)
{
var newTags = resource.Tags;
var applyTags = resource.ApplyTags;
switch (applyTags)
{
case ApplyTags.Add:
newTags.ForEach(t => indexer.Tags.Add(t));
break;
case ApplyTags.Remove:
newTags.ForEach(t => indexer.Tags.Remove(t));
break;
case ApplyTags.Replace:
indexer.Tags = new HashSet<int>(newTags);
break;
}
}
}
_indexerFactory.Update(indexersToUpdate);
var indexers = _indexerFactory.All();
foreach (var definition in indexers)
{
_indexerFactory.SetProviderCharacteristics(definition);
}
return Accepted(_resourceMapper.ToResource(indexers));
}
[HttpDelete]
[Consumes("application/json")]
public object DeleteIndexers([FromBody] IndexerEditorResource resource)
{
_indexerFactory.Delete(resource.IndexerIds);
return new { };
}
}
}

@ -1,21 +0,0 @@
using System.Collections.Generic;
namespace Prowlarr.Api.V1.Indexers
{
public class IndexerEditorResource
{
public List<int> IndexerIds { get; set; }
public bool? Enable { get; set; }
public int? AppProfileId { get; set; }
public int? Priority { get; set; }
public List<int> Tags { get; set; }
public ApplyTags ApplyTags { get; set; }
}
public enum ApplyTags
{
Add,
Remove,
Replace
}
}

@ -0,0 +1,12 @@
using NzbDrone.Core.Notifications;
namespace Prowlarr.Api.V1.Notifications
{
public class NotificationBulkResource : ProviderBulkResource<NotificationBulkResource>
{
}
public class NotificationBulkResourceMapper : ProviderBulkResourceMapper<NotificationBulkResource, NotificationDefinition>
{
}
}

@ -1,16 +1,31 @@
using System;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Notifications; using NzbDrone.Core.Notifications;
using Prowlarr.Http; using Prowlarr.Http;
namespace Prowlarr.Api.V1.Notifications namespace Prowlarr.Api.V1.Notifications
{ {
[V1ApiController] [V1ApiController]
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 ();
public static readonly NotificationBulkResourceMapper BulkResourceMapper = new ();
public NotificationController(NotificationFactory notificationFactory) public NotificationController(NotificationFactory notificationFactory)
: base(notificationFactory, "notification", ResourceMapper) : base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper)
{ {
} }
[NonAction]
public override ActionResult<NotificationResource> UpdateProvider([FromBody] NotificationBulkResource providerResource)
{
throw new NotImplementedException();
}
[NonAction]
public override object DeleteProviders([FromBody] NotificationBulkResource resource)
{
throw new NotImplementedException();
}
} }
} }

@ -0,0 +1,39 @@
using System.Collections.Generic;
using NzbDrone.Core.ThingiProvider;
namespace Prowlarr.Api.V1
{
public class ProviderBulkResource<T>
{
public List<int> Ids { get; set; }
public List<int> Tags { get; set; }
public ApplyTags ApplyTags { get; set; }
public ProviderBulkResource()
{
Ids = new List<int>();
}
}
public enum ApplyTags
{
Add,
Remove,
Replace
}
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;
}
}
}

@ -10,18 +10,25 @@ using Prowlarr.Http.REST;
namespace Prowlarr.Api.V1 namespace Prowlarr.Api.V1
{ {
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 TBulkProviderResource : ProviderBulkResource<TBulkProviderResource>, new()
where TProvider : IProvider where TProvider : IProvider
where TProviderResource : ProviderResource<TProviderResource>, new() where TProviderResource : ProviderResource<TProviderResource>, new()
{ {
protected readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory; protected readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory;
protected readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper; protected 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");
@ -92,6 +99,47 @@ namespace Prowlarr.Api.V1
return Accepted(providerResource.Id); return Accepted(providerResource.Id);
} }
[HttpPut("bulk")]
[Consumes("application/json")]
[Produces("application/json")]
public virtual ActionResult<TProviderResource> UpdateProvider([FromBody] TBulkProviderResource providerResource)
{
if (!providerResource.Ids.Any())
{
throw new BadRequestException("ids must be provided");
}
var definitionsToUpdate = _providerFactory.Get(providerResource.Ids).ToList();
foreach (var definition in definitionsToUpdate)
{
_providerFactory.SetProviderCharacteristics(definition);
if (providerResource.Tags != null)
{
var newTags = providerResource.Tags;
var applyTags = providerResource.ApplyTags;
switch (applyTags)
{
case ApplyTags.Add:
newTags.ForEach(t => definition.Tags.Add(t));
break;
case ApplyTags.Remove:
newTags.ForEach(t => definition.Tags.Remove(t));
break;
case ApplyTags.Replace:
definition.Tags = new HashSet<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 definition = _resourceMapper.ToModel(providerResource); var definition = _resourceMapper.ToModel(providerResource);
@ -112,6 +160,16 @@ namespace Prowlarr.Api.V1
return new { }; return new { };
} }
[HttpDelete("bulk")]
[Consumes("application/json")]
[Produces("application/json")]
public virtual object DeleteProviders([FromBody] TBulkProviderResource resource)
{
_providerFactory.Delete(resource.Ids);
return new { };
}
[HttpGet("schema")] [HttpGet("schema")]
[Produces("application/json")] [Produces("application/json")]
public virtual List<TProviderResource> GetTemplates() public virtual List<TProviderResource> GetTemplates()

Loading…
Cancel
Save