New: Bulk Manage Applications, Download Clients

Co-authored-by: Qstick <qstick@gmail.com>
pull/1771/head
Bogdan 10 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() {
return createSelector(
(state) => state.indexers,
(qualityProfiles) => {
(indexers) => {
const {
isFetching,
isPopulated,
error,
items
} = qualityProfiles;
} = indexers;
const tagList = items.map((item) => {
return {

@ -10,7 +10,7 @@ function parseValue(props, value) {
} = props;
if (value == null || value === '') {
return min;
return null;
}
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 {
name: string;
label: string;
columnLabel: string;
isSortable: boolean;
label: string | React.ReactNode;
columnLabel?: string;
isSortable?: boolean;
isVisible: boolean;
isModifiable?: boolean;
}

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

@ -72,6 +72,7 @@ import {
faLanguage as fasLanguage,
faLaptop as fasLaptop,
faLevelUpAlt as fasLevelUpAlt,
faListCheck as fasListCheck,
faLocationArrow as fasLocationArrow,
faLock as fasLock,
faMedkit as fasMedkit,
@ -180,6 +181,7 @@ export const INTERACTIVE = fasUser;
export const KEYBOARD = farKeyboard;
export const LOCK = fasLock;
export const LOGOUT = fasSignOutAlt;
export const MANAGE = fasListCheck;
export const MEDIA_INFO = farFileInvoice;
export const MISSING = fasExclamationTriangle;
export const MONITORED = fasBookmark;

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

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

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

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

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

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

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

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

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

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

@ -9,8 +9,35 @@ import AppProfilesConnector from 'Settings/Profiles/App/AppProfilesConnector';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import ApplicationsConnector from './Applications/ApplicationsConnector';
import ManageApplicationsModal from './Applications/Manage/ManageApplicationsModal';
class ApplicationSettings extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isManageApplicationsOpen: false
};
}
//
// Listeners
onManageApplicationsPress = () => {
this.setState({ isManageApplicationsOpen: true });
};
onManageApplicationsModalClose = () => {
this.setState({ isManageApplicationsOpen: false });
};
//
// Render
render() {
const {
isTestingAll,
@ -19,6 +46,8 @@ class ApplicationSettings extends Component {
onAppIndexerSyncPress
} = this.props;
const { isManageApplicationsOpen } = this.state;
return (
<PageContent title={translate('Applications')}>
<SettingsToolbarConnector
@ -40,6 +69,12 @@ class ApplicationSettings extends Component {
isSpinning={isTestingAll}
onPress={onTestAllPress}
/>
<PageToolbarButton
label={translate('ManageApplications')}
iconName={icons.MANAGE}
onPress={this.onManageApplicationsPress}
/>
</Fragment>
}
/>
@ -47,6 +82,11 @@ class ApplicationSettings extends Component {
<PageContentBody>
<ApplicationsConnector />
<AppProfilesConnector />
<ManageApplicationsModal
isOpen={isManageApplicationsOpen}
onModalClose={this.onManageApplicationsModalClose}
/>
</PageContentBody>
</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 translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
class DownloadClientSettings extends Component {
@ -21,7 +22,8 @@ class DownloadClientSettings extends Component {
this.state = {
isSaving: false,
hasPendingChanges: false
hasPendingChanges: false,
isManageDownloadClientsOpen: false
};
}
@ -36,6 +38,14 @@ class DownloadClientSettings extends Component {
this.setState(payload);
};
onManageDownloadClientsPress = () => {
this.setState({ isManageDownloadClientsOpen: true });
};
onManageDownloadClientsModalClose = () => {
this.setState({ isManageDownloadClientsOpen: false });
};
onSavePress = () => {
if (this._saveCallback) {
this._saveCallback();
@ -53,7 +63,8 @@ class DownloadClientSettings extends Component {
const {
isSaving,
hasPendingChanges
hasPendingChanges,
isManageDownloadClientsOpen
} = this.state;
return (
@ -71,6 +82,12 @@ class DownloadClientSettings extends Component {
isSpinning={isTestingAll}
onPress={dispatchTestAllDownloadClients}
/>
<PageToolbarButton
label={translate('ManageDownloadClients')}
iconName={icons.MANAGE}
onPress={this.onManageDownloadClientsPress}
/>
</Fragment>
}
onSavePress={this.onSavePress}
@ -78,6 +95,11 @@ class DownloadClientSettings extends Component {
<PageContentBody>
<DownloadClientsConnector />
<ManageDownloadClientsModal
isOpen={isManageDownloadClientsOpen}
onModalClose={this.onManageDownloadClientsModalClose}
/>
</PageContentBody>
</PageContent>
);

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

@ -159,7 +159,7 @@ class EditDownloadClientModalContent extends Component {
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
helpText={translate('PriorityHelpText')}
helpText={translate('DownloadClientPriorityHelpText')}
min={1}
max={50}
{...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,
isPopulated: false,
error: null,
isDeleting: false,
deleteError: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: {},
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
pendingChanges: {}
},

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

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

@ -1,4 +1,6 @@
import { createAction } from 'redux-actions';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
@ -30,6 +32,8 @@ export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadCl
export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient';
export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient';
export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients';
export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients';
export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients';
//
// Action Creators
@ -44,6 +48,8 @@ export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT);
export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT);
export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT);
export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS);
export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS);
export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS);
export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
return {
@ -78,6 +84,8 @@ export default {
selectedSchema: {},
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false,
isTestingAll: false,
items: [],
@ -120,7 +128,9 @@ export default {
},
[CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section),
[TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient')
[TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'),
[BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk'),
[BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk')
},
//

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

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

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

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

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

@ -12,9 +12,10 @@ namespace NzbDrone.Core.ThingiProvider
bool Exists(int id);
TProviderDefinition Find(int id);
TProviderDefinition Get(int id);
IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids);
TProviderDefinition Create(TProviderDefinition definition);
void Update(TProviderDefinition definition);
void Update(IEnumerable<TProviderDefinition> definitions);
IEnumerable<TProviderDefinition> Update(IEnumerable<TProviderDefinition> definitions);
void Delete(int id);
void Delete(IEnumerable<int> ids);
IEnumerable<TProviderDefinition> GetDefaultDefinitions();

@ -101,6 +101,11 @@ namespace NzbDrone.Core.ThingiProvider
return _providerRepository.Get(id);
}
public IEnumerable<TProviderDefinition> Get(IEnumerable<int> ids)
{
return _providerRepository.Get(ids);
}
public TProviderDefinition Find(int id)
{
return _providerRepository.Find(id);
@ -120,10 +125,12 @@ namespace NzbDrone.Core.ThingiProvider
_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());
_eventAggregator.PublishEvent(new ProviderBulkUpdatedEvent<TProvider>(definitions));
return definitions;
}
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 Prowlarr.Http;
namespace Prowlarr.Api.V1.Application
namespace Prowlarr.Api.V1.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)
: base(applicationsFactory, "applications", ResourceMapper)
: base(applicationsFactory, "applications", ResourceMapper, BulkResourceMapper)
{
}
}

@ -1,6 +1,6 @@
using NzbDrone.Core.Applications;
namespace Prowlarr.Api.V1.Application
namespace Prowlarr.Api.V1.Applications
{
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
{
[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)
: 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 Prowlarr.Http;
namespace Prowlarr.Api.V1.IndexerProxies
{
[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)
: 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
{
[V1ApiController]
public class IndexerController : ProviderControllerBase<IndexerResource, IIndexer, IndexerDefinition>
public class IndexerController : ProviderControllerBase<IndexerResource, IndexerBulkResource, IIndexer, IndexerDefinition>
{
public IndexerController(IndexerFactory indexerFactory, IndexerResourceMapper resourceMapper)
: base(indexerFactory, "indexer", resourceMapper)
public IndexerController(IndexerFactory indexerFactory, IndexerResourceMapper resourceMapper, IndexerBulkResourceMapper bulkResourceMapper)
: 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 Prowlarr.Http;
namespace Prowlarr.Api.V1.Notifications
{
[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)
: 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
{
public abstract class ProviderControllerBase<TProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource>
public abstract class ProviderControllerBase<TProviderResource, TBulkProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource>
where TProviderDefinition : ProviderDefinition, new()
where TBulkProviderResource : ProviderBulkResource<TBulkProviderResource>, new()
where TProvider : IProvider
where TProviderResource : ProviderResource<TProviderResource>, new()
{
protected readonly IProviderFactory<TProvider, TProviderDefinition> _providerFactory;
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;
_resourceMapper = resourceMapper;
_bulkResourceMapper = bulkResourceMapper;
SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique");
@ -92,6 +99,47 @@ namespace Prowlarr.Api.V1
return Accepted(providerResource.Id);
}
[HttpPut("bulk")]
[Consumes("application/json")]
[Produces("application/json")]
public virtual ActionResult<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)
{
var definition = _resourceMapper.ToModel(providerResource);
@ -112,6 +160,16 @@ namespace Prowlarr.Api.V1
return new { };
}
[HttpDelete("bulk")]
[Consumes("application/json")]
[Produces("application/json")]
public virtual object DeleteProviders([FromBody] TBulkProviderResource resource)
{
_providerFactory.Delete(resource.Ids);
return new { };
}
[HttpGet("schema")]
[Produces("application/json")]
public virtual List<TProviderResource> GetTemplates()

Loading…
Cancel
Save