Convert Download Client settings to TypeScript

This commit is contained in:
Mark McDowall 2025-01-03 17:31:05 -08:00
parent 6838f068bc
commit 92db4769be
No known key found for this signature in database
43 changed files with 1555 additions and 2156 deletions

View file

@ -11,7 +11,7 @@ import Switch from 'Components/Router/Switch';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
import SeriesIndex from 'Series/Index/SeriesIndex';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import DownloadClientSettings from 'Settings/DownloadClients/DownloadClientSettings';
import GeneralSettings from 'Settings/General/GeneralSettings';
import ImportListSettings from 'Settings/ImportLists/ImportListSettings';
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
@ -113,7 +113,7 @@ function AppRoutes() {
<Route
path="/settings/downloadclients"
component={DownloadClientSettingsConnector}
component={DownloadClientSettings}
/>
<Route path="/settings/importlists" component={ImportListSettings} />

View file

@ -20,12 +20,14 @@ import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification';
import QualityDefinition from 'typings/QualityDefinition';
import QualityProfile from 'typings/QualityProfile';
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
import General from 'typings/Settings/General';
import IndexerOptions from 'typings/Settings/IndexerOptions';
import MediaManagement from 'typings/Settings/MediaManagement';
import NamingConfig from 'typings/Settings/NamingConfig';
import NamingExample from 'typings/Settings/NamingExample';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import RemotePathMapping from 'typings/Settings/RemotePathMapping';
import UiSettings from 'typings/Settings/UiSettings';
import MetadataAppState from './MetadataAppState';
@ -52,10 +54,15 @@ export interface DelayProfileAppState
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState,
AppSectionSaveState {
AppSectionSaveState,
AppSectionSchemaState<Presets<DownloadClient>> {
isTestingAll: boolean;
}
export interface DownloadClientOptionsAppState
extends AppSectionItemState<DownloadClientOptions>,
AppSectionSaveState {}
export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
@ -131,6 +138,13 @@ export interface ImportListExclusionsSettingsAppState
pendingChanges: Partial<ImportListExclusion>;
}
export interface RemotePathMappingsAppState
extends AppSectionState<RemotePathMapping>,
AppSectionDeleteState,
AppSectionSaveState {
pendingChanges: Partial<RemotePathMapping>;
}
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
export type LanguageSettingsAppState = AppSectionState<Language>;
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
@ -142,6 +156,7 @@ interface SettingsAppState {
customFormats: CustomFormatAppState;
delayProfiles: DelayProfileAppState;
downloadClients: DownloadClientAppState;
downloadClientOptions: DownloadClientOptionsAppState;
general: GeneralAppState;
importListExclusions: ImportListExclusionsSettingsAppState;
importListOptions: ImportListOptionsSettingsAppState;
@ -158,6 +173,7 @@ interface SettingsAppState {
qualityDefinitions: QualityDefinitionsAppState;
qualityProfiles: QualityProfilesAppState;
releaseProfiles: ReleaseProfilesAppState;
remotePathMappings: RemotePathMappingsAppState;
ui: UiSettingsAppState;
}

View file

@ -0,0 +1,39 @@
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
export default function useDownloadClientHostOptions() {
return useSelector(
createSelector(
(state: AppState) => state.settings.downloadClients.items,
(downloadClients) => {
const hosts = downloadClients.reduce<Record<string, string[]>>(
(acc, downloadClient) => {
const name = downloadClient.name;
const host = downloadClient.fields.find((field) => {
return field.name === 'host';
});
if (host) {
const hostValue = host.value as string;
const group = (acc[hostValue] = acc[hostValue] || []);
group.push(name);
}
return acc;
},
{}
);
return Object.keys(hosts).map((host) => {
return {
key: host,
value: host,
hint: `${hosts[host].join(', ')}`,
};
});
}
)
);
}

View file

@ -1,123 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbar from 'Settings/SettingsToolbar';
import translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector';
class DownloadClientSettings extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._saveCallback = null;
this.state = {
isSaving: false,
hasPendingChanges: false,
isManageDownloadClientsOpen: false
};
}
//
// Listeners
onChildMounted = (saveCallback) => {
this._saveCallback = saveCallback;
};
onChildStateChange = (payload) => {
this.setState(payload);
};
onManageDownloadClientsPress = () => {
this.setState({ isManageDownloadClientsOpen: true });
};
onManageDownloadClientsModalClose = () => {
this.setState({ isManageDownloadClientsOpen: false });
};
onSavePress = () => {
if (this._saveCallback) {
this._saveCallback();
}
};
//
// Render
render() {
const {
isTestingAll,
dispatchTestAllDownloadClients
} = this.props;
const {
isSaving,
hasPendingChanges,
isManageDownloadClientsOpen
} = this.state;
return (
<PageContent title={translate('DownloadClientSettings')}>
<SettingsToolbar
isSaving={isSaving}
hasPendingChanges={hasPendingChanges}
additionalButtons={
<Fragment>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('TestAllClients')}
iconName={icons.TEST}
isSpinning={isTestingAll}
onPress={dispatchTestAllDownloadClients}
/>
<PageToolbarButton
label={translate('ManageClients')}
iconName={icons.MANAGE}
onPress={this.onManageDownloadClientsPress}
/>
</Fragment>
}
onSavePress={this.onSavePress}
/>
<PageContentBody>
<DownloadClientsConnector />
<DownloadClientOptionsConnector
onChildMounted={this.onChildMounted}
onChildStateChange={this.onChildStateChange}
/>
<RemotePathMappingsConnector />
<ManageDownloadClientsModal
isOpen={isManageDownloadClientsOpen}
onModalClose={this.onManageDownloadClientsModalClose}
/>
</PageContentBody>
</PageContent>
);
}
}
DownloadClientSettings.propTypes = {
isTestingAll: PropTypes.bool.isRequired,
dispatchTestAllDownloadClients: PropTypes.func.isRequired
};
export default DownloadClientSettings;

View file

@ -0,0 +1,109 @@
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbar from 'Settings/SettingsToolbar';
import { testAllDownloadClients } from 'Store/Actions/settingsActions';
import {
SaveCallback,
SettingsStateChange,
} from 'typings/Settings/SettingsState';
import translate from 'Utilities/String/translate';
import DownloadClients from './DownloadClients/DownloadClients';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
import DownloadClientOptions from './Options/DownloadClientOptions';
import RemotePathMappings from './RemotePathMappings/RemotePathMappings';
function DownloadClientSettings() {
const dispatch = useDispatch();
const isTestingAll = useSelector(
(state: AppState) => state.settings.downloadClients.isTestingAll
);
const saveOptions = useRef<() => void>();
const [isSaving, setIsSaving] = useState(false);
const [hasPendingChanges, setHasPendingChanges] = useState(false);
const [
isManageDownloadClientsModalOpen,
setIsManageDownloadClientsModalOpen,
] = useState(false);
const handleSetChildSave = useCallback((saveCallback: SaveCallback) => {
saveOptions.current = saveCallback;
}, []);
const handleChildStateChange = useCallback(
({ isSaving, hasPendingChanges }: SettingsStateChange) => {
setIsSaving(isSaving);
setHasPendingChanges(hasPendingChanges);
},
[]
);
const handleManageDownloadClientsPress = useCallback(() => {
setIsManageDownloadClientsModalOpen(true);
}, []);
const handleManageDownloadClientsModalClose = useCallback(() => {
setIsManageDownloadClientsModalOpen(false);
}, []);
const handleSavePress = useCallback(() => {
saveOptions.current?.();
}, []);
const handleTestAllIndexersPress = useCallback(() => {
dispatch(testAllDownloadClients());
}, [dispatch]);
return (
<PageContent title={translate('DownloadClientSettings')}>
<SettingsToolbar
isSaving={isSaving}
hasPendingChanges={hasPendingChanges}
additionalButtons={
<>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('TestAllClients')}
iconName={icons.TEST}
isSpinning={isTestingAll}
onPress={handleTestAllIndexersPress}
/>
<PageToolbarButton
label={translate('ManageClients')}
iconName={icons.MANAGE}
onPress={handleManageDownloadClientsPress}
/>
</>
}
onSavePress={handleSavePress}
/>
<PageContentBody>
<DownloadClients />
<DownloadClientOptions
setChildSave={handleSetChildSave}
onChildStateChange={handleChildStateChange}
/>
<RemotePathMappings />
<ManageDownloadClientsModal
isOpen={isManageDownloadClientsModalOpen}
onModalClose={handleManageDownloadClientsModalClose}
/>
</PageContentBody>
</PageContent>
);
}
export default DownloadClientSettings;

View file

@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { testAllDownloadClients } from 'Store/Actions/settingsActions';
import DownloadClientSettings from './DownloadClientSettings';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.downloadClients.isTestingAll,
(isTestingAll) => {
return {
isTestingAll
};
}
);
}
const mapDispatchToProps = {
dispatchTestAllDownloadClients: testAllDownloadClients
};
export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSettings);

View file

@ -1,111 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import { sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem';
import styles from './AddDownloadClientItem.css';
class AddDownloadClientItem extends Component {
//
// Listeners
onDownloadClientSelect = () => {
const {
implementation
} = this.props;
this.props.onDownloadClientSelect({ implementation });
};
//
// Render
render() {
const {
implementation,
implementationName,
infoLink,
presets,
onDownloadClientSelect
} = this.props;
const hasPresets = !!presets && !!presets.length;
return (
<div
className={styles.downloadClient}
>
<Link
className={styles.underlay}
onPress={this.onDownloadClientSelect}
/>
<div className={styles.overlay}>
<div className={styles.name}>
{implementationName}
</div>
<div className={styles.actions}>
{
hasPresets &&
<span>
<Button
size={sizes.SMALL}
onPress={this.onDownloadClientSelect}
>
{translate('Custom')}
</Button>
<Menu className={styles.presetsMenu}>
<Button
className={styles.presetsMenuButton}
size={sizes.SMALL}
>
{translate('Presets')}
</Button>
<MenuContent>
{
presets.map((preset) => {
return (
<AddDownloadClientPresetMenuItem
key={preset.name}
name={preset.name}
implementation={implementation}
onPress={onDownloadClientSelect}
/>
);
})
}
</MenuContent>
</Menu>
</span>
}
<Button
to={infoLink}
size={sizes.SMALL}
>
{translate('MoreInfo')}
</Button>
</div>
</div>
</div>
);
}
}
AddDownloadClientItem.propTypes = {
implementation: PropTypes.string.isRequired,
implementationName: PropTypes.string.isRequired,
infoLink: PropTypes.string.isRequired,
presets: PropTypes.arrayOf(PropTypes.object),
onDownloadClientSelect: PropTypes.func.isRequired
};
export default AddDownloadClientItem;

View file

@ -0,0 +1,88 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import { sizes } from 'Helpers/Props';
import { selectDownloadClientSchema } from 'Store/Actions/settingsActions';
import DownloadClient from 'typings/DownloadClient';
import translate from 'Utilities/String/translate';
import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem';
import styles from './AddDownloadClientItem.css';
interface AddDownloadClientItemProps {
implementation: string;
implementationName: string;
infoLink: string;
presets?: DownloadClient[];
onDownloadClientSelect: () => void;
}
function AddDownloadClientItem({
implementation,
implementationName,
infoLink,
presets,
onDownloadClientSelect,
}: AddDownloadClientItemProps) {
const dispatch = useDispatch();
const hasPresets = !!presets && !!presets.length;
const handleDownloadClientSelect = useCallback(() => {
dispatch(
selectDownloadClientSchema({
implementation,
implementationName,
})
);
onDownloadClientSelect();
}, [implementation, implementationName, dispatch, onDownloadClientSelect]);
return (
<div className={styles.downloadClient}>
<Link className={styles.underlay} onPress={handleDownloadClientSelect} />
<div className={styles.overlay}>
<div className={styles.name}>{implementationName}</div>
<div className={styles.actions}>
{hasPresets ? (
<span>
<Button size={sizes.SMALL} onPress={handleDownloadClientSelect}>
{translate('Custom')}
</Button>
<Menu className={styles.presetsMenu}>
<Button className={styles.presetsMenuButton} size={sizes.SMALL}>
{translate('Presets')}
</Button>
<MenuContent>
{presets.map((preset) => {
return (
<AddDownloadClientPresetMenuItem
key={preset.name}
name={preset.name}
implementation={implementation}
implementationName={implementationName}
onPress={onDownloadClientSelect}
/>
);
})}
</MenuContent>
</Menu>
</span>
) : null}
<Button to={infoLink} size={sizes.SMALL}>
{translate('MoreInfo')}
</Button>
</div>
</div>
</div>
);
}
export default AddDownloadClientItem;

View file

@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddDownloadClientModalContentConnector from './AddDownloadClientModalContentConnector';
function AddDownloadClientModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddDownloadClientModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddDownloadClientModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddDownloadClientModal;

View file

@ -0,0 +1,27 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddDownloadClientModalContent, {
AddDownloadClientModalContentProps,
} from './AddDownloadClientModalContent';
interface AddDownloadClientModalProps
extends AddDownloadClientModalContentProps {
isOpen: boolean;
}
function AddDownloadClientModal({
isOpen,
onModalClose,
...otherProps
}: AddDownloadClientModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<AddDownloadClientModalContent
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default AddDownloadClientModal;

View file

@ -1,122 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AddDownloadClientItem from './AddDownloadClientItem';
import styles from './AddDownloadClientModalContent.css';
class AddDownloadClientModalContent extends Component {
//
// Render
render() {
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
usenetDownloadClients,
torrentDownloadClients,
onDownloadClientSelect,
onModalClose
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('AddDownloadClient')}
</ModalHeader>
<ModalBody>
{
isSchemaFetching &&
<LoadingIndicator />
}
{
!isSchemaFetching && !!schemaError &&
<Alert kind={kinds.DANGER}>
{translate('AddDownloadClientError')}
</Alert>
}
{
isSchemaPopulated && !schemaError &&
<div>
<Alert kind={kinds.INFO}>
<div>
{translate('SupportedDownloadClients')}
</div>
<div>
{translate('SupportedDownloadClientsMoreInfo')}
</div>
</Alert>
<FieldSet legend={translate('Usenet')}>
<div className={styles.downloadClients}>
{
usenetDownloadClients.map((downloadClient) => {
return (
<AddDownloadClientItem
key={downloadClient.implementation}
implementation={downloadClient.implementation}
{...downloadClient}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})
}
</div>
</FieldSet>
<FieldSet legend={translate('Torrents')}>
<div className={styles.downloadClients}>
{
torrentDownloadClients.map((downloadClient) => {
return (
<AddDownloadClientItem
key={downloadClient.implementation}
implementation={downloadClient.implementation}
{...downloadClient}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})
}
</div>
</FieldSet>
</div>
}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
AddDownloadClientModalContent.propTypes = {
isSchemaFetching: PropTypes.bool.isRequired,
isSchemaPopulated: PropTypes.bool.isRequired,
schemaError: PropTypes.object,
usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
onDownloadClientSelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddDownloadClientModalContent;

View file

@ -0,0 +1,117 @@
import React, { useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 { kinds } from 'Helpers/Props';
import { fetchDownloadClientSchema } from 'Store/Actions/settingsActions';
import DownloadClient from 'typings/DownloadClient';
import translate from 'Utilities/String/translate';
import AddDownloadClientItem from './AddDownloadClientItem';
import styles from './AddDownloadClientModalContent.css';
export interface AddDownloadClientModalContentProps {
onDownloadClientSelect: () => void;
onModalClose: () => void;
}
function AddDownloadClientModalContent({
onDownloadClientSelect,
onModalClose,
}: AddDownloadClientModalContentProps) {
const dispatch = useDispatch();
const { isSchemaFetching, isSchemaPopulated, schemaError, schema } =
useSelector((state: AppState) => state.settings.downloadClients);
const { usenetDownloadClients, torrentDownloadClients } = useMemo(() => {
return schema.reduce<{
usenetDownloadClients: DownloadClient[];
torrentDownloadClients: DownloadClient[];
}>(
(acc, item) => {
if (item.protocol === 'usenet') {
acc.usenetDownloadClients.push(item);
} else if (item.protocol === 'torrent') {
acc.torrentDownloadClients.push(item);
}
return acc;
},
{
usenetDownloadClients: [],
torrentDownloadClients: [],
}
);
}, [schema]);
useEffect(() => {
dispatch(fetchDownloadClientSchema());
}, [dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('AddDownloadClient')}</ModalHeader>
<ModalBody>
{isSchemaFetching ? <LoadingIndicator /> : null}
{!isSchemaFetching && !!schemaError ? (
<Alert kind={kinds.DANGER}>
{translate('AddDownloadClientError')}
</Alert>
) : null}
{isSchemaPopulated && !schemaError ? (
<div>
<Alert kind={kinds.INFO}>
<div>{translate('SupportedDownloadClients')}</div>
<div>{translate('SupportedDownloadClientsMoreInfo')}</div>
</Alert>
<FieldSet legend={translate('Usenet')}>
<div className={styles.downloadClients}>
{usenetDownloadClients.map((downloadClient) => {
return (
<AddDownloadClientItem
key={downloadClient.implementation}
{...downloadClient}
implementation={downloadClient.implementation}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})}
</div>
</FieldSet>
<FieldSet legend={translate('Torrents')}>
<div className={styles.downloadClients}>
{torrentDownloadClients.map((downloadClient) => {
return (
<AddDownloadClientItem
key={downloadClient.implementation}
{...downloadClient}
implementation={downloadClient.implementation}
onDownloadClientSelect={onDownloadClientSelect}
/>
);
})}
</div>
</FieldSet>
</div>
) : null}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default AddDownloadClientModalContent;

View file

@ -1,75 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchDownloadClientSchema, selectDownloadClientSchema } from 'Store/Actions/settingsActions';
import AddDownloadClientModalContent from './AddDownloadClientModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.downloadClients,
(downloadClients) => {
const {
isSchemaFetching,
isSchemaPopulated,
schemaError,
schema
} = downloadClients;
const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' });
const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' });
return {
isSchemaFetching,
isSchemaPopulated,
schemaError,
usenetDownloadClients,
torrentDownloadClients
};
}
);
}
const mapDispatchToProps = {
fetchDownloadClientSchema,
selectDownloadClientSchema
};
class AddDownloadClientModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchDownloadClientSchema();
}
//
// Listeners
onDownloadClientSelect = ({ implementation }) => {
this.props.selectDownloadClientSchema({ implementation });
this.props.onModalClose({ downloadClientSelected: true });
};
//
// Render
render() {
return (
<AddDownloadClientModalContent
{...this.props}
onDownloadClientSelect={this.onDownloadClientSelect}
/>
);
}
}
AddDownloadClientModalContentConnector.propTypes = {
fetchDownloadClientSchema: PropTypes.func.isRequired,
selectDownloadClientSchema: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddDownloadClientModalContentConnector);

View file

@ -1,49 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MenuItem from 'Components/Menu/MenuItem';
class AddDownloadClientPresetMenuItem extends Component {
//
// Listeners
onPress = () => {
const {
name,
implementation
} = this.props;
this.props.onPress({
name,
implementation
});
};
//
// Render
render() {
const {
name,
implementation,
...otherProps
} = this.props;
return (
<MenuItem
{...otherProps}
onPress={this.onPress}
>
{name}
</MenuItem>
);
}
}
AddDownloadClientPresetMenuItem.propTypes = {
name: PropTypes.string.isRequired,
implementation: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired
};
export default AddDownloadClientPresetMenuItem;

View file

@ -0,0 +1,41 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import MenuItem from 'Components/Menu/MenuItem';
import { selectDownloadClientSchema } from 'Store/Actions/settingsActions';
interface AddDownloadClientPresetMenuItemProps {
name: string;
implementation: string;
implementationName: string;
onPress: () => void;
}
function AddDownloadClientPresetMenuItem({
name,
implementation,
implementationName,
onPress,
...otherProps
}: AddDownloadClientPresetMenuItemProps) {
const dispatch = useDispatch();
const handlePress = useCallback(() => {
dispatch(
selectDownloadClientSchema({
implementation,
implementationName,
presetName: name,
})
);
onPress();
}, [name, implementation, implementationName, dispatch, onPress]);
return (
<MenuItem {...otherProps} onPress={handlePress}>
{name}
</MenuItem>
);
}
export default AddDownloadClientPresetMenuItem;

View file

@ -1,136 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
import styles from './DownloadClient.css';
class DownloadClient extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditDownloadClientModalOpen: false,
isDeleteDownloadClientModalOpen: false
};
}
//
// Listeners
onEditDownloadClientPress = () => {
this.setState({ isEditDownloadClientModalOpen: true });
};
onEditDownloadClientModalClose = () => {
this.setState({ isEditDownloadClientModalOpen: false });
};
onDeleteDownloadClientPress = () => {
this.setState({
isEditDownloadClientModalOpen: false,
isDeleteDownloadClientModalOpen: true
});
};
onDeleteDownloadClientModalClose = () => {
this.setState({ isDeleteDownloadClientModalOpen: false });
};
onConfirmDeleteDownloadClient = () => {
this.props.onConfirmDeleteDownloadClient(this.props.id);
};
//
// Render
render() {
const {
id,
name,
enable,
priority,
tags,
tagList
} = this.props;
return (
<Card
className={styles.downloadClient}
overlayContent={true}
onPress={this.onEditDownloadClientPress}
>
<div className={styles.name}>
{name}
</div>
<div className={styles.enabled}>
{
enable ?
<Label kind={kinds.SUCCESS}>
{translate('Enabled')}
</Label> :
<Label
kind={kinds.DISABLED}
outline={true}
>
{translate('Disabled')}
</Label>
}
{
priority > 1 &&
<Label
kind={kinds.DISABLED}
outline={true}
>
{translate('PrioritySettings', { priority })}
</Label>
}
</div>
<TagList
tags={tags}
tagList={tagList}
/>
<EditDownloadClientModalConnector
id={id}
isOpen={this.state.isEditDownloadClientModalOpen}
onModalClose={this.onEditDownloadClientModalClose}
onDeleteDownloadClientPress={this.onDeleteDownloadClientPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteDownloadClientModalOpen}
kind={kinds.DANGER}
title={translate('DeleteDownloadClient')}
message={translate('DeleteDownloadClientMessageText', { name })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteDownloadClient}
onCancel={this.onDeleteDownloadClientModalClose}
/>
</Card>
);
}
}
DownloadClient.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
enable: PropTypes.bool.isRequired,
priority: PropTypes.number.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteDownloadClient: PropTypes.func.isRequired
};
export default DownloadClient;

View file

@ -0,0 +1,105 @@
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props';
import { deleteDownloadClient } from 'Store/Actions/settingsActions';
import useTags from 'Tags/useTags';
import translate from 'Utilities/String/translate';
import EditDownloadClientModal from './EditDownloadClientModal';
import styles from './DownloadClient.css';
interface DownloadClientProps {
id: number;
name: string;
enable: boolean;
priority: number;
tags: number[];
}
function DownloadClient({
id,
name,
enable,
priority,
tags,
}: DownloadClientProps) {
const dispatch = useDispatch();
const tagList = useTags();
const [isEditDownloadClientModalOpen, setIsEditDownloadClientModalOpen] =
useState(false);
const [isDeleteDownloadClientModalOpen, setIsDeleteDownloadClientModalOpen] =
useState(false);
const handleEditDownloadClientPress = useCallback(() => {
setIsEditDownloadClientModalOpen(true);
}, []);
const handleEditDownloadClientModalClose = useCallback(() => {
setIsEditDownloadClientModalOpen(false);
}, []);
const handleDeleteDownloadClientPress = useCallback(() => {
setIsEditDownloadClientModalOpen(false);
setIsDeleteDownloadClientModalOpen(true);
}, []);
const handleDeleteDownloadClientModalClose = useCallback(() => {
setIsDeleteDownloadClientModalOpen(false);
}, []);
const handleConfirmDeleteDownloadClient = useCallback(() => {
dispatch(deleteDownloadClient({ id }));
}, [id, dispatch]);
return (
<Card
className={styles.downloadClient}
overlayContent={true}
onPress={handleEditDownloadClientPress}
>
<div className={styles.name}>{name}</div>
<div className={styles.enabled}>
{enable ? (
<Label kind={kinds.SUCCESS}>{translate('Enabled')}</Label>
) : (
<Label kind={kinds.DISABLED} outline={true}>
{translate('Disabled')}
</Label>
)}
{priority > 1 ? (
<Label kind={kinds.DISABLED} outline={true}>
{translate('PrioritySettings', { priority })}
</Label>
) : null}
</div>
<TagList tags={tags} tagList={tagList} />
<EditDownloadClientModal
id={id}
isOpen={isEditDownloadClientModalOpen}
onModalClose={handleEditDownloadClientModalClose}
onDeleteDownloadClientPress={handleDeleteDownloadClientPress}
/>
<ConfirmModal
isOpen={isDeleteDownloadClientModalOpen}
kind={kinds.DANGER}
title={translate('DeleteDownloadClient')}
message={translate('DeleteDownloadClientMessageText', { name })}
confirmLabel={translate('Delete')}
onConfirm={handleConfirmDeleteDownloadClient}
onCancel={handleDeleteDownloadClientModalClose}
/>
</Card>
);
}
export default DownloadClient;

View file

@ -1,118 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AddDownloadClientModal from './AddDownloadClientModal';
import DownloadClient from './DownloadClient';
import EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
import styles from './DownloadClients.css';
class DownloadClients extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddDownloadClientModalOpen: false,
isEditDownloadClientModalOpen: false
};
}
//
// Listeners
onAddDownloadClientPress = () => {
this.setState({ isAddDownloadClientModalOpen: true });
};
onAddDownloadClientModalClose = ({ downloadClientSelected = false } = {}) => {
this.setState({
isAddDownloadClientModalOpen: false,
isEditDownloadClientModalOpen: downloadClientSelected
});
};
onEditDownloadClientModalClose = () => {
this.setState({ isEditDownloadClientModalOpen: false });
};
//
// Render
render() {
const {
items,
onConfirmDeleteDownloadClient,
tagList,
...otherProps
} = this.props;
const {
isAddDownloadClientModalOpen,
isEditDownloadClientModalOpen
} = this.state;
return (
<FieldSet legend={translate('DownloadClients')}>
<PageSectionContent
errorMessage={translate('DownloadClientsLoadError')}
{...otherProps}
>
<div className={styles.downloadClients}>
{
items.map((item) => {
return (
<DownloadClient
key={item.id}
{...item}
tagList={tagList}
onConfirmDeleteDownloadClient={onConfirmDeleteDownloadClient}
/>
);
})
}
<Card
className={styles.addDownloadClient}
onPress={this.onAddDownloadClientPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
<AddDownloadClientModal
isOpen={isAddDownloadClientModalOpen}
onModalClose={this.onAddDownloadClientModalClose}
/>
<EditDownloadClientModalConnector
isOpen={isEditDownloadClientModalOpen}
onModalClose={this.onEditDownloadClientModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
DownloadClients.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteDownloadClient: PropTypes.func.isRequired
};
export default DownloadClients;

View file

@ -0,0 +1,94 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { DownloadClientAppState } from 'App/State/SettingsAppState';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import { fetchDownloadClients } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import DownloadClientModel from 'typings/DownloadClient';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import AddDownloadClientModal from './AddDownloadClientModal';
import DownloadClient from './DownloadClient';
import EditDownloadClientModal from './EditDownloadClientModal';
import styles from './DownloadClients.css';
function DownloadClients() {
const dispatch = useDispatch();
const { error, isFetching, isPopulated, items } = useSelector(
createSortedSectionSelector<DownloadClientModel, DownloadClientAppState>(
'settings.downloadClients',
sortByProp('name')
)
);
const [isAddDownloadClientModalOpen, setIsAddDownloadClientModalOpen] =
useState(false);
const [isEditDownloadClientModalOpen, setIsEditDownloadClientModalOpen] =
useState(false);
const handleAddDownloadClientPress = useCallback(() => {
setIsAddDownloadClientModalOpen(true);
}, []);
const handleDownloadClientSelect = useCallback(() => {
setIsAddDownloadClientModalOpen(false);
setIsEditDownloadClientModalOpen(true);
}, []);
const handleAddDownloadClientModalClose = useCallback(() => {
setIsAddDownloadClientModalOpen(false);
}, []);
const handleEditDownloadClientModalClose = useCallback(() => {
setIsEditDownloadClientModalOpen(false);
}, []);
useEffect(() => {
dispatch(fetchDownloadClients());
}, [dispatch]);
return (
<FieldSet legend={translate('DownloadClients')}>
<PageSectionContent
errorMessage={translate('DownloadClientsLoadError')}
error={error}
isFetching={isFetching}
isPopulated={isPopulated}
>
<div className={styles.downloadClients}>
{items.map((item) => {
return <DownloadClient key={item.id} {...item} />;
})}
<Card
className={styles.addDownloadClient}
onPress={handleAddDownloadClientPress}
>
<div className={styles.center}>
<Icon name={icons.ADD} size={45} />
</div>
</Card>
</div>
<AddDownloadClientModal
isOpen={isAddDownloadClientModalOpen}
onDownloadClientSelect={handleDownloadClientSelect}
onModalClose={handleAddDownloadClientModalClose}
/>
<EditDownloadClientModal
isOpen={isEditDownloadClientModalOpen}
onModalClose={handleEditDownloadClientModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
export default DownloadClients;

View file

@ -1,63 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import DownloadClients from './DownloadClients';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.downloadClients', sortByProp('name')),
createTagsSelector(),
(downloadClients, tagList) => {
return {
...downloadClients,
tagList
};
}
);
}
const mapDispatchToProps = {
fetchDownloadClients,
deleteDownloadClient
};
class DownloadClientsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchDownloadClients();
}
//
// Listeners
onConfirmDeleteDownloadClient = (id) => {
this.props.deleteDownloadClient({ id });
};
//
// Render
render() {
return (
<DownloadClients
{...this.props}
onConfirmDeleteDownloadClient={this.onConfirmDeleteDownloadClient}
/>
);
}
}
DownloadClientsConnector.propTypes = {
fetchDownloadClients: PropTypes.func.isRequired,
deleteDownloadClient: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientsConnector);

View file

@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import EditDownloadClientModalContentConnector from './EditDownloadClientModalContentConnector';
function EditDownloadClientModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditDownloadClientModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditDownloadClientModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditDownloadClientModal;

View file

@ -0,0 +1,46 @@
import React, { useCallback } from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { useDispatch } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
cancelSaveDownloadClient,
cancelTestDownloadClient,
} from 'Store/Actions/settingsActions';
import EditDownloadClientModalContent, {
EditDownloadClientModalContentProps,
} from './EditDownloadClientModalContent';
const section = 'settings.downloadClients';
interface EditDownloadClientModalProps
extends EditDownloadClientModalContentProps {
isOpen: boolean;
}
function EditDownloadClientModal({
isOpen,
onModalClose,
...otherProps
}: EditDownloadClientModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section }));
dispatch(cancelTestDownloadClient({ section }));
dispatch(cancelSaveDownloadClient({ section }));
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
<EditDownloadClientModalContent
{...otherProps}
onModalClose={handleModalClose}
/>
</Modal>
);
}
export default EditDownloadClientModal;

View file

@ -1,65 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { cancelSaveDownloadClient, cancelTestDownloadClient } from 'Store/Actions/settingsActions';
import EditDownloadClientModal from './EditDownloadClientModal';
function createMapDispatchToProps(dispatch, props) {
const section = 'settings.downloadClients';
return {
dispatchClearPendingChanges() {
dispatch(clearPendingChanges({ section }));
},
dispatchCancelTestDownloadClient() {
dispatch(cancelTestDownloadClient({ section }));
},
dispatchCancelSaveDownloadClient() {
dispatch(cancelSaveDownloadClient({ section }));
}
};
}
class EditDownloadClientModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.dispatchClearPendingChanges();
this.props.dispatchCancelTestDownloadClient();
this.props.dispatchCancelSaveDownloadClient();
this.props.onModalClose();
};
//
// Render
render() {
const {
dispatchClearPendingChanges,
dispatchCancelTestDownloadClient,
dispatchCancelSaveDownloadClient,
...otherProps
} = this.props;
return (
<EditDownloadClientModal
{...otherProps}
onModalClose={this.onModalClose}
/>
);
}
}
EditDownloadClientModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired,
dispatchCancelTestDownloadClient: PropTypes.func.isRequired,
dispatchCancelSaveDownloadClient: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(EditDownloadClientModalConnector);

View file

@ -1,250 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
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 ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import translate from 'Utilities/String/translate';
import styles from './EditDownloadClientModalContent.css';
class EditDownloadClientModalContent extends Component {
//
// Render
render() {
const {
advancedSettings,
isFetching,
error,
isSaving,
isTesting,
saveError,
item,
onInputChange,
onFieldChange,
onModalClose,
onSavePress,
onTestPress,
onDeleteDownloadClientPress,
...otherProps
} = this.props;
const {
id,
implementationName,
name,
enable,
protocol,
priority,
removeCompletedDownloads,
removeFailedDownloads,
fields,
tags,
message
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? translate('EditDownloadClientImplementation', { implementationName }) : translate('AddDownloadClientImplementation', { implementationName })}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>
{translate('AddDownloadClientError')}
</Alert>
}
{
!isFetching && !error &&
<Form {...otherProps}>
{
!!message &&
<Alert
className={styles.message}
kind={message.value.type}
>
{message.value.message}
</Alert>
}
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Enable')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enable"
{...enable}
onChange={onInputChange}
/>
</FormGroup>
{
fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={advancedSettings}
provider="downloadClient"
providerData={item}
{...field}
onChange={onFieldChange}
/>
);
})
}
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('ClientPriority')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
helpText={translate('DownloadClientPriorityHelpText')}
min={1}
max={50}
{...priority}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('DownloadClientSeriesTagHelpText')}
{...tags}
onChange={onInputChange}
/>
</FormGroup>
<FieldSet
size={sizes.SMALL}
legend={translate('CompletedDownloadHandling')}
>
<FormGroup>
<FormLabel>{translate('RemoveCompleted')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeCompletedDownloads"
helpText={translate('RemoveCompletedDownloadsHelpText')}
{...removeCompletedDownloads}
onChange={onInputChange}
/>
</FormGroup>
{
protocol.value !== 'torrent' &&
<FormGroup>
<FormLabel>{translate('RemoveFailed')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeFailedDownloads"
helpText={translate('RemoveFailedDownloadsHelpText')}
{...removeFailedDownloads}
onChange={onInputChange}
/>
</FormGroup>
}
</FieldSet>
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteDownloadClientPress}
>
{translate('Delete')}
</Button>
}
<AdvancedSettingsButton
showLabel={false}
/>
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
onPress={onTestPress}
>
{translate('Test')}
</SpinnerErrorButton>
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
}
EditDownloadClientModalContent.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isTesting: PropTypes.bool.isRequired,
item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onFieldChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onDeleteDownloadClientPress: PropTypes.func
};
export default EditDownloadClientModalContent;

View file

@ -0,0 +1,274 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { DownloadClientAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
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 ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 usePrevious from 'Helpers/Hooks/usePrevious';
import useShowAdvancedSettings from 'Helpers/Hooks/useShowAdvancedSettings';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import {
saveDownloadClient,
setDownloadClientFieldValue,
setDownloadClientValue,
testDownloadClient,
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import DownloadClient from 'typings/DownloadClient';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import styles from './EditDownloadClientModalContent.css';
export interface EditDownloadClientModalContentProps {
id?: number;
onModalClose: () => void;
onDeleteDownloadClientPress?: () => void;
}
function EditDownloadClientModalContent({
id,
onModalClose,
onDeleteDownloadClientPress,
}: EditDownloadClientModalContentProps) {
const dispatch = useDispatch();
const showAdvancedSettings = useShowAdvancedSettings();
const {
isFetching,
error,
isSaving,
isTesting = false,
saveError,
item,
validationErrors,
validationWarnings,
} = useSelector(
createProviderSettingsSelectorHook<DownloadClient, DownloadClientAppState>(
'downloadClients',
id
)
);
const wasSaving = usePrevious(isSaving);
const {
implementationName,
name,
enable,
protocol,
priority,
removeCompletedDownloads,
removeFailedDownloads,
fields,
tags,
message,
} = item;
const handleInputChange = useCallback(
(change: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setDownloadClientValue(change));
},
[dispatch]
);
const handleFieldChange = useCallback(
(change: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setDownloadClientFieldValue(change));
},
[dispatch]
);
const handleTestPress = useCallback(() => {
dispatch(testDownloadClient({ id }));
}, [id, dispatch]);
const handleSavePress = useCallback(() => {
dispatch(saveDownloadClient({ id }));
}, [id, dispatch]);
useEffect(() => {
if (wasSaving && !isSaving && !saveError) {
onModalClose();
}
}, [isSaving, wasSaving, saveError, onModalClose]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id
? translate('EditDownloadClientImplementation', {
implementationName,
})
: translate('AddDownloadClientImplementation', {
implementationName,
})}
</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>
{translate('AddDownloadClientError')}
</Alert>
) : null}
{!isFetching && !error ? (
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
{!!message && (
<Alert className={styles.message} kind={message.value.type}>
{message.value.message}
</Alert>
)}
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Enable')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enable"
{...enable}
onChange={handleInputChange}
/>
</FormGroup>
{fields.map((field) => {
return (
<ProviderFieldFormGroup
key={field.name}
advancedSettings={showAdvancedSettings}
provider="downloadClient"
providerData={item}
{...field}
onChange={handleFieldChange}
/>
);
})}
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('ClientPriority')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
helpText={translate('DownloadClientPriorityHelpText')}
min={1}
max={50}
{...priority}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('DownloadClientSeriesTagHelpText')}
{...tags}
onChange={handleInputChange}
/>
</FormGroup>
<FieldSet
size={sizes.SMALL}
legend={translate('CompletedDownloadHandling')}
>
<FormGroup>
<FormLabel>{translate('RemoveCompleted')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeCompletedDownloads"
helpText={translate('RemoveCompletedDownloadsHelpText')}
{...removeCompletedDownloads}
onChange={handleInputChange}
/>
</FormGroup>
{protocol.value === 'torrent' ? null : (
<FormGroup>
<FormLabel>{translate('RemoveFailed')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeFailedDownloads"
helpText={translate('RemoveFailedDownloadsHelpText')}
{...removeFailedDownloads}
onChange={handleInputChange}
/>
</FormGroup>
)}
</FieldSet>
</Form>
) : null}
</ModalBody>
<ModalFooter>
{id ? (
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteDownloadClientPress}
>
{translate('Delete')}
</Button>
) : null}
<AdvancedSettingsButton showLabel={false} />
<SpinnerErrorButton
isSpinning={isTesting}
error={saveError}
onPress={handleTestPress}
>
{translate('Test')}
</SpinnerErrorButton>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
export default EditDownloadClientModalContent;

View file

@ -1,93 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import {
saveDownloadClient,
setDownloadClientFieldValue,
setDownloadClientValue,
testDownloadClient
} from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditDownloadClientModalContent from './EditDownloadClientModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createProviderSettingsSelector('downloadClients'),
(advancedSettings, downloadClient) => {
return {
advancedSettings,
...downloadClient
};
}
);
}
const mapDispatchToProps = {
setDownloadClientValue,
setDownloadClientFieldValue,
saveDownloadClient,
testDownloadClient
};
class EditDownloadClientModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setDownloadClientValue({ name, value });
};
onFieldChange = ({ name, value }) => {
this.props.setDownloadClientFieldValue({ name, value });
};
onSavePress = () => {
this.props.saveDownloadClient({ id: this.props.id });
};
onTestPress = () => {
this.props.testDownloadClient({ id: this.props.id });
};
//
// Render
render() {
return (
<EditDownloadClientModalContent
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
EditDownloadClientModalContentConnector.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setDownloadClientValue: PropTypes.func.isRequired,
setDownloadClientFieldValue: PropTypes.func.isRequired,
saveDownloadClient: PropTypes.func.isRequired,
testDownloadClient: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditDownloadClientModalContentConnector);

View file

@ -1,115 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
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 LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function DownloadClientOptions(props) {
const {
advancedSettings,
isFetching,
error,
settings,
hasSettings,
onInputChange
} = props;
return (
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && error &&
<Alert kind={kinds.DANGER}>
{translate('DownloadClientOptionsLoadError')}
</Alert>
}
{
hasSettings && !isFetching && !error && advancedSettings &&
<div>
<FieldSet legend={translate('CompletedDownloadHandling')}>
<Form>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('Enable')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableCompletedDownloadHandling"
helpText={translate('EnableCompletedDownloadHandlingHelpText')}
onChange={onInputChange}
{...settings.enableCompletedDownloadHandling}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('AutoRedownloadFailed')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="autoRedownloadFailed"
helpText={translate('AutoRedownloadFailedHelpText')}
onChange={onInputChange}
{...settings.autoRedownloadFailed}
/>
</FormGroup>
{
settings.autoRedownloadFailed.value ?
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('AutoRedownloadFailedFromInteractiveSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="autoRedownloadFailedFromInteractiveSearch"
helpText={translate('AutoRedownloadFailedFromInteractiveSearchHelpText')}
onChange={onInputChange}
{...settings.autoRedownloadFailedFromInteractiveSearch}
/>
</FormGroup> :
null
}
</Form>
<Alert kind={kinds.INFO}>
{translate('RemoveDownloadsAlert')}
</Alert>
</FieldSet>
</div>
}
</div>
);
}
DownloadClientOptions.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
settings: PropTypes.object.isRequired,
hasSettings: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired
};
export default DownloadClientOptions;

View file

@ -0,0 +1,149 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
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 LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useShowAdvancedSettings from 'Helpers/Hooks/useShowAdvancedSettings';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
fetchDownloadClientOptions,
saveDownloadClientOptions,
setDownloadClientOptionsValue,
} from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
const SECTION = 'downloadClientOptions';
interface DownloadClientOptionsProps {
setChildSave(saveCallback: () => void): void;
onChildStateChange(payload: unknown): void;
}
function DownloadClientOptions({
setChildSave,
onChildStateChange,
}: DownloadClientOptionsProps) {
const dispatch = useDispatch();
const showAdvancedSettings = useShowAdvancedSettings();
const {
isFetching,
isPopulated,
isSaving,
error,
hasPendingChanges,
hasSettings,
settings,
} = useSelector(createSettingsSectionSelector(SECTION));
const handleInputChange = useCallback(
(change: InputChanged) => {
// @ts-expect-error - actions aren't typed
dispatch(setDownloadClientOptionsValue(change));
},
[dispatch]
);
useEffect(() => {
dispatch(fetchDownloadClientOptions());
setChildSave(() => dispatch(saveDownloadClientOptions()));
return () => {
dispatch(clearPendingChanges({ section: `settings.${SECTION}` }));
};
}, [dispatch, setChildSave]);
useEffect(() => {
onChildStateChange({
isSaving,
hasPendingChanges,
});
}, [onChildStateChange, isSaving, hasPendingChanges]);
return (
<div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>
{translate('DownloadClientOptionsLoadError')}
</Alert>
) : null}
{hasSettings && isPopulated && !error && showAdvancedSettings ? (
<div>
<FieldSet legend={translate('CompletedDownloadHandling')}>
<Form>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('Enable')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableCompletedDownloadHandling"
helpText={translate(
'EnableCompletedDownloadHandlingHelpText'
)}
onChange={handleInputChange}
{...settings.enableCompletedDownloadHandling}
/>
</FormGroup>
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('AutoRedownloadFailed')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="autoRedownloadFailed"
helpText={translate('AutoRedownloadFailedHelpText')}
onChange={handleInputChange}
{...settings.autoRedownloadFailed}
/>
</FormGroup>
{settings.autoRedownloadFailed.value ? (
<FormGroup
advancedSettings={showAdvancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>
{translate('AutoRedownloadFailedFromInteractiveSearch')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="autoRedownloadFailedFromInteractiveSearch"
helpText={translate(
'AutoRedownloadFailedFromInteractiveSearchHelpText'
)}
onChange={handleInputChange}
{...settings.autoRedownloadFailedFromInteractiveSearch}
/>
</FormGroup>
) : null}
</Form>
<Alert kind={kinds.INFO}>{translate('RemoveDownloadsAlert')}</Alert>
</FieldSet>
</div>
) : null}
</div>
);
}
export default DownloadClientOptions;

View file

@ -1,101 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { fetchDownloadClientOptions, saveDownloadClientOptions, setDownloadClientOptionsValue } from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import DownloadClientOptions from './DownloadClientOptions';
const SECTION = 'downloadClientOptions';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
createSettingsSectionSelector(SECTION),
(advancedSettings, sectionSettings) => {
return {
advancedSettings,
...sectionSettings
};
}
);
}
const mapDispatchToProps = {
dispatchFetchDownloadClientOptions: fetchDownloadClientOptions,
dispatchSetDownloadClientOptionsValue: setDownloadClientOptionsValue,
dispatchSaveDownloadClientOptions: saveDownloadClientOptions,
dispatchClearPendingChanges: clearPendingChanges
};
class DownloadClientOptionsConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
dispatchFetchDownloadClientOptions,
dispatchSaveDownloadClientOptions,
onChildMounted
} = this.props;
dispatchFetchDownloadClientOptions();
onChildMounted(dispatchSaveDownloadClientOptions);
}
componentDidUpdate(prevProps) {
const {
hasPendingChanges,
isSaving,
onChildStateChange
} = this.props;
if (
prevProps.isSaving !== isSaving ||
prevProps.hasPendingChanges !== hasPendingChanges
) {
onChildStateChange({
isSaving,
hasPendingChanges
});
}
}
componentWillUnmount() {
this.props.dispatchClearPendingChanges({ section: `settings.${SECTION}` });
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.dispatchSetDownloadClientOptionsValue({ name, value });
};
//
// Render
render() {
return (
<DownloadClientOptions
onInputChange={this.onInputChange}
{...this.props}
/>
);
}
}
DownloadClientOptionsConnector.propTypes = {
isSaving: PropTypes.bool.isRequired,
hasPendingChanges: PropTypes.bool.isRequired,
dispatchFetchDownloadClientOptions: PropTypes.func.isRequired,
dispatchSetDownloadClientOptionsValue: PropTypes.func.isRequired,
dispatchSaveDownloadClientOptions: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired,
onChildMounted: PropTypes.func.isRequired,
onChildStateChange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientOptionsConnector);

View file

@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import EditRemotePathMappingModalContentConnector from './EditRemotePathMappingModalContentConnector';
function EditRemotePathMappingModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditRemotePathMappingModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditRemotePathMappingModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditRemotePathMappingModal;

View file

@ -0,0 +1,37 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditRemotePathMappingModalContent, {
EditRemotePathMappingModalContentProps,
} from './EditRemotePathMappingModalContent';
interface EditRemotePathMappingModalProps
extends EditRemotePathMappingModalContentProps {
isOpen: boolean;
}
function EditRemotePathMappingModal({
isOpen,
onModalClose,
...otherProps
}: EditRemotePathMappingModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'settings.remotePathMappings' }));
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
<EditRemotePathMappingModalContent
{...otherProps}
onModalClose={handleModalClose}
/>
</Modal>
);
}
export default EditRemotePathMappingModal;

View file

@ -1,43 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditRemotePathMappingModal from './EditRemotePathMappingModal';
function mapStateToProps() {
return {};
}
const mapDispatchToProps = {
clearPendingChanges
};
class EditRemotePathMappingModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'settings.remotePathMappings' });
this.props.onModalClose();
};
//
// Render
render() {
return (
<EditRemotePathMappingModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditRemotePathMappingModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(mapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalConnector);

View file

@ -1,154 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
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 Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 } from 'Helpers/Props';
import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape';
import translate from 'Utilities/String/translate';
import styles from './EditRemotePathMappingModalContent.css';
function EditRemotePathMappingModalContent(props) {
const {
id,
isFetching,
error,
isSaving,
saveError,
item,
downloadClientHosts,
onInputChange,
onSavePress,
onModalClose,
onDeleteRemotePathMappingPress,
...otherProps
} = props;
const {
host,
remotePath,
localPath
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? translate('EditRemotePathMapping') : translate('AddRemotePathMapping')}
</ModalHeader>
<ModalBody className={styles.body}>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>
{translate('AddRemotePathMappingError')}
</Alert>
}
{
!isFetching && !error &&
<Form {...otherProps}>
<FormGroup>
<FormLabel>{translate('Host')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="host"
helpText={translate('RemotePathMappingHostHelpText')}
{...host}
values={downloadClientHosts}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemotePath')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="remotePath"
helpText={translate('RemotePathMappingRemotePathHelpText')}
{...remotePath}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('LocalPath')}</FormLabel>
<FormInputGroup
type={inputTypes.PATH}
name="localPath"
helpText={translate('RemotePathMappingLocalPathHelpText')}
{...localPath}
onChange={onInputChange}
/>
</FormGroup>
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteRemotePathMappingPress}
>
{translate('Delete')}
</Button>
}
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
const remotePathMappingShape = {
host: PropTypes.shape(stringSettingShape).isRequired,
remotePath: PropTypes.shape(stringSettingShape).isRequired,
localPath: PropTypes.shape(stringSettingShape).isRequired
};
EditRemotePathMappingModalContent.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.shape(remotePathMappingShape).isRequired,
downloadClientHosts: PropTypes.arrayOf(PropTypes.object).isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteRemotePathMappingPress: PropTypes.func
};
export default EditRemotePathMappingModalContent;

View file

@ -0,0 +1,214 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
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 Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
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 useDownloadClientHostOptions from 'DownloadClient/useDownloadClientHostOptions';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds } from 'Helpers/Props';
import {
saveRemotePathMapping,
setRemotePathMappingValue,
} from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import { InputChanged } from 'typings/inputs';
import RemotePathMapping from 'typings/Settings/RemotePathMapping';
import translate from 'Utilities/String/translate';
import styles from './EditRemotePathMappingModalContent.css';
const newRemotePathMapping: RemotePathMapping & { [key: string]: unknown } = {
id: 0,
host: '',
remotePath: '',
localPath: '',
};
function createRemotePathMappingSelector(id?: number) {
return createSelector(
(state: AppState) => state.settings.remotePathMappings,
(remotePathMappings) => {
const { isFetching, error, isSaving, saveError, pendingChanges, items } =
remotePathMappings;
const mapping = id
? items.find((i) => i.id === id)!
: newRemotePathMapping;
const settings = selectSettings(mapping, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings,
};
}
);
}
export interface EditRemotePathMappingModalContentProps {
id?: number;
onDeleteRemotePathMappingPress?: () => void;
onModalClose: () => void;
}
function EditRemotePathMappingModalContent({
id,
onDeleteRemotePathMappingPress,
onModalClose,
}: EditRemotePathMappingModalContentProps) {
const dispatch = useDispatch();
const downloadClientHosts = useDownloadClientHostOptions();
const {
isFetching,
error,
isSaving,
saveError,
item,
validationErrors,
validationWarnings,
} = useSelector(createRemotePathMappingSelector(id));
const { host, remotePath, localPath } = item;
const wasSaving = usePrevious(isSaving);
const handleInputChange = useCallback(
(change: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setRemotePathMappingValue(change));
},
[dispatch]
);
const handleSavePress = useCallback(() => {
dispatch(saveRemotePathMapping({ id }));
}, [id, dispatch]);
useEffect(() => {
if (!id) {
Object.keys(newRemotePathMapping).forEach((name) => {
if (name === 'id') {
return;
}
dispatch(
// @ts-expect-error - actions are not typed
setRemotePathMappingValue({
name,
value: newRemotePathMapping[name],
})
);
});
}
}, [id, dispatch]);
useEffect(() => {
if (wasSaving && !isSaving && !saveError) {
onModalClose();
}
}, [isSaving, wasSaving, saveError, onModalClose]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id
? translate('EditRemotePathMapping')
: translate('AddRemotePathMapping')}
</ModalHeader>
<ModalBody className={styles.body}>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>
{translate('AddRemotePathMappingError')}
</Alert>
) : null}
{!isFetching && !error ? (
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
<FormGroup>
<FormLabel>{translate('Host')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="host"
helpText={translate('RemotePathMappingHostHelpText')}
{...host}
values={downloadClientHosts}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemotePath')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="remotePath"
helpText={translate('RemotePathMappingRemotePathHelpText')}
{...remotePath}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('LocalPath')}</FormLabel>
<FormInputGroup
type={inputTypes.PATH}
name="localPath"
helpText={translate('RemotePathMappingLocalPathHelpText')}
includeFiles={false}
{...localPath}
onChange={handleInputChange}
/>
</FormGroup>
</Form>
) : null}
</ModalBody>
<ModalFooter>
{id ? (
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteRemotePathMappingPress}
>
{translate('Delete')}
</Button>
) : null}
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
export default EditRemotePathMappingModalContent;

View file

@ -1,147 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveRemotePathMapping, setRemotePathMappingValue } from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import EditRemotePathMappingModalContent from './EditRemotePathMappingModalContent';
const newRemotePathMapping = {
host: '',
remotePath: '',
localPath: ''
};
const selectDownloadClientHosts = createSelector(
(state) => state.settings.downloadClients.items,
(downloadClients) => {
const hosts = downloadClients.reduce((acc, downloadClient) => {
const name = downloadClient.name;
const host = downloadClient.fields.find((field) => {
return field.name === 'host';
});
if (host) {
const group = acc[host.value] = acc[host.value] || [];
group.push(name);
}
return acc;
}, {});
return Object.keys(hosts).map((host) => {
return {
key: host,
value: host,
hint: `${hosts[host].join(', ')}`
};
});
}
);
function createRemotePathMappingSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.remotePathMappings,
selectDownloadClientHosts,
(id, remotePathMappings, downloadClientHosts) => {
const {
isFetching,
error,
isSaving,
saveError,
pendingChanges,
items
} = remotePathMappings;
const mapping = id ? items.find((i) => i.id === id) : newRemotePathMapping;
const settings = selectSettings(mapping, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings,
downloadClientHosts
};
}
);
}
function createMapStateToProps() {
return createSelector(
createRemotePathMappingSelector(),
(remotePathMapping) => {
return {
...remotePathMapping
};
}
);
}
const mapDispatchToProps = {
dispatchSetRemotePathMappingValue: setRemotePathMappingValue,
dispatchSaveRemotePathMapping: saveRemotePathMapping
};
class EditRemotePathMappingModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.id) {
Object.keys(newRemotePathMapping).forEach((name) => {
this.props.dispatchSetRemotePathMappingValue({
name,
value: newRemotePathMapping[name]
});
});
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.dispatchSetRemotePathMappingValue({ name, value });
};
onSavePress = () => {
this.props.dispatchSaveRemotePathMapping({ id: this.props.id });
};
//
// Render
render() {
return (
<EditRemotePathMappingModalContent
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
/>
);
}
}
EditRemotePathMappingModalContentConnector.propTypes = {
id: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
dispatchSetRemotePathMappingValue: PropTypes.func.isRequired,
dispatchSaveRemotePathMapping: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalContentConnector);

View file

@ -1,115 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
import styles from './RemotePathMapping.css';
class RemotePathMapping extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditRemotePathMappingModalOpen: false,
isDeleteRemotePathMappingModalOpen: false
};
}
//
// Listeners
onEditRemotePathMappingPress = () => {
this.setState({ isEditRemotePathMappingModalOpen: true });
};
onEditRemotePathMappingModalClose = () => {
this.setState({ isEditRemotePathMappingModalOpen: false });
};
onDeleteRemotePathMappingPress = () => {
this.setState({
isEditRemotePathMappingModalOpen: false,
isDeleteRemotePathMappingModalOpen: true
});
};
onDeleteRemotePathMappingModalClose = () => {
this.setState({ isDeleteRemotePathMappingModalOpen: false });
};
onConfirmDeleteRemotePathMapping = () => {
this.props.onConfirmDeleteRemotePathMapping(this.props.id);
};
//
// Render
render() {
const {
id,
host,
remotePath,
localPath
} = this.props;
return (
<div
className={classNames(
styles.remotePathMapping
)}
>
<div className={styles.host}>{host}</div>
<div className={styles.path}>{remotePath}</div>
<div className={styles.path}>{localPath}</div>
<div className={styles.actions}>
<Link
onPress={this.onEditRemotePathMappingPress}
>
<Icon name={icons.EDIT} />
</Link>
</div>
<EditRemotePathMappingModalConnector
id={id}
isOpen={this.state.isEditRemotePathMappingModalOpen}
onModalClose={this.onEditRemotePathMappingModalClose}
onDeleteRemotePathMappingPress={this.onDeleteRemotePathMappingPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteRemotePathMappingModalOpen}
kind={kinds.DANGER}
title={translate('DeleteRemotePathMapping')}
message={translate('DeleteRemotePathMappingMessageText')}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteRemotePathMapping}
onCancel={this.onDeleteRemotePathMappingModalClose}
/>
</div>
);
}
}
RemotePathMapping.propTypes = {
id: PropTypes.number.isRequired,
host: PropTypes.string.isRequired,
remotePath: PropTypes.string.isRequired,
localPath: PropTypes.string.isRequired,
onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired
};
RemotePathMapping.defaultProps = {
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default RemotePathMapping;

View file

@ -0,0 +1,91 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import { icons, kinds } from 'Helpers/Props';
import { deleteRemotePathMapping } from 'Store/Actions/settingsActions';
import translate from 'Utilities/String/translate';
import EditRemotePathMappingModal from './EditRemotePathMappingModal';
import styles from './RemotePathMapping.css';
interface RemotePathMappingProps {
id: number;
host: string;
remotePath: string;
localPath: string;
}
function RemotePathMapping({
id,
host,
remotePath,
localPath,
}: RemotePathMappingProps) {
const dispatch = useDispatch();
const [
isEditRemotePathMappingModalOpen,
setIsEditRemotePathMappingModalOpen,
] = useState(false);
const [
isDeleteRemotePathMappingModalOpen,
setIsDeleteRemotePathMappingModalOpen,
] = useState(false);
const handleEditRemotePathMappingPress = useCallback(() => {
setIsEditRemotePathMappingModalOpen(true);
}, []);
const handleEditRemotePathMappingModalClose = useCallback(() => {
setIsEditRemotePathMappingModalOpen(false);
}, []);
const handleDeleteRemotePathMappingPress = useCallback(() => {
setIsEditRemotePathMappingModalOpen(false);
setIsDeleteRemotePathMappingModalOpen(true);
}, []);
const handleDeleteRemotePathMappingModalClose = useCallback(() => {
setIsDeleteRemotePathMappingModalOpen(false);
}, []);
const handleConfirmDeleteRemotePathMapping = useCallback(() => {
dispatch(deleteRemotePathMapping({ id }));
}, [id, dispatch]);
return (
<div className={classNames(styles.remotePathMapping)}>
<div className={styles.host}>{host}</div>
<div className={styles.path}>{remotePath}</div>
<div className={styles.path}>{localPath}</div>
<div className={styles.actions}>
<Link onPress={handleEditRemotePathMappingPress}>
<Icon name={icons.EDIT} />
</Link>
</div>
<EditRemotePathMappingModal
id={id}
isOpen={isEditRemotePathMappingModalOpen}
onModalClose={handleEditRemotePathMappingModalClose}
onDeleteRemotePathMappingPress={handleDeleteRemotePathMappingPress}
/>
<ConfirmModal
isOpen={isDeleteRemotePathMappingModalOpen}
kind={kinds.DANGER}
title={translate('DeleteRemotePathMapping')}
message={translate('DeleteRemotePathMappingMessageText')}
confirmLabel={translate('Delete')}
onConfirm={handleConfirmDeleteRemotePathMapping}
onCancel={handleDeleteRemotePathMappingModalClose}
/>
</div>
);
}
export default RemotePathMapping;

View file

@ -1,114 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
import RemotePathMapping from './RemotePathMapping';
import styles from './RemotePathMappings.css';
class RemotePathMappings extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddRemotePathMappingModalOpen: false
};
}
//
// Listeners
onAddRemotePathMappingPress = () => {
this.setState({ isAddRemotePathMappingModalOpen: true });
};
onModalClose = () => {
this.setState({ isAddRemotePathMappingModalOpen: false });
};
//
// Render
render() {
const {
items,
onConfirmDeleteRemotePathMapping,
...otherProps
} = this.props;
return (
<FieldSet legend={translate('RemotePathMappings')}>
<PageSectionContent
errorMessage={translate('RemotePathMappingsLoadError')}
{...otherProps}
>
<Alert kind={kinds.INFO}>
<InlineMarkdown data={translate('RemotePathMappingsInfo', { wikiLink: 'https://wiki.servarr.com/sonarr/settings#remote-path-mappings' })} />
</Alert>
<div className={styles.remotePathMappingsHeader}>
<div className={styles.host}>
{translate('Host')}
</div>
<div className={styles.path}>
{translate('RemotePath')}
</div>
<div className={styles.path}>
{translate('LocalPath')}
</div>
</div>
<div>
{
items.map((item, index) => {
return (
<RemotePathMapping
key={item.id}
{...item}
{...otherProps}
index={index}
onConfirmDeleteRemotePathMapping={onConfirmDeleteRemotePathMapping}
/>
);
})
}
</div>
<div className={styles.addRemotePathMapping}>
<Link
className={styles.addButton}
onPress={this.onAddRemotePathMappingPress}
>
<Icon name={icons.ADD} />
</Link>
</div>
<EditRemotePathMappingModalConnector
isOpen={this.state.isAddRemotePathMappingModalOpen}
onModalClose={this.onModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
RemotePathMappings.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired
};
export default RemotePathMappings;

View file

@ -0,0 +1,86 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons, kinds } from 'Helpers/Props';
import { fetchRemotePathMappings } from 'Store/Actions/settingsActions';
import translate from 'Utilities/String/translate';
import EditRemotePathMappingModal from './EditRemotePathMappingModal';
import RemotePathMapping from './RemotePathMapping';
import styles from './RemotePathMappings.css';
function RemotePathMappings() {
const dispatch = useDispatch();
const { isFetching, isPopulated, error, items } = useSelector(
(state: AppState) => state.settings.remotePathMappings
);
const [isAddRemotePathMappingModalOpen, setIsAddRemotePathMappingModalOpen] =
useState(false);
const handleAddRemotePathMappingPress = useCallback(() => {
setIsAddRemotePathMappingModalOpen(true);
}, []);
const handleAddRemotePathMappingModalClose = useCallback(() => {
setIsAddRemotePathMappingModalOpen(false);
}, []);
useEffect(() => {
dispatch(fetchRemotePathMappings());
}, [dispatch]);
return (
<FieldSet legend={translate('RemotePathMappings')}>
<PageSectionContent
errorMessage={translate('RemotePathMappingsLoadError')}
error={error}
isFetching={isFetching}
isPopulated={isPopulated}
>
<Alert kind={kinds.INFO}>
<InlineMarkdown
data={translate('RemotePathMappingsInfo', {
wikiLink:
'https://wiki.servarr.com/sonarr/settings#remote-path-mappings',
})}
/>
</Alert>
<div className={styles.remotePathMappingsHeader}>
<div className={styles.host}>{translate('Host')}</div>
<div className={styles.path}>{translate('RemotePath')}</div>
<div className={styles.path}>{translate('LocalPath')}</div>
</div>
<div>
{items.map((item) => {
return <RemotePathMapping key={item.id} {...item} />;
})}
</div>
<div className={styles.addRemotePathMapping}>
<Link
className={styles.addButton}
onPress={handleAddRemotePathMappingPress}
>
<Icon name={icons.ADD} />
</Link>
</div>
<EditRemotePathMappingModal
isOpen={isAddRemotePathMappingModalOpen}
onModalClose={handleAddRemotePathMappingModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
export default RemotePathMappings;

View file

@ -1,59 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { deleteRemotePathMapping, fetchRemotePathMappings } from 'Store/Actions/settingsActions';
import RemotePathMappings from './RemotePathMappings';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.remotePathMappings,
(remotePathMappings) => {
return {
...remotePathMappings
};
}
);
}
const mapDispatchToProps = {
dispatchFetchRemotePathMappings: fetchRemotePathMappings,
dispatchDeleteRemotePathMapping: deleteRemotePathMapping
};
class RemotePathMappingsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchRemotePathMappings();
}
//
// Listeners
onConfirmDeleteRemotePathMapping = (id) => {
this.props.dispatchDeleteRemotePathMapping({ id });
};
//
// Render
render() {
return (
<RemotePathMappings
{...this.state}
{...this.props}
onConfirmDeleteRemotePathMapping={this.onConfirmDeleteRemotePathMapping}
/>
);
}
}
RemotePathMappingsConnector.propTypes = {
dispatchFetchRemotePathMappings: PropTypes.func.isRequired,
dispatchDeleteRemotePathMapping: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector);

View file

@ -210,6 +210,10 @@ function EditDelayProfileModalContent({
useEffect(() => {
if (!id) {
Object.keys(newDelayProfile).forEach((name) => {
if (name === 'id') {
return;
}
dispatch(
// @ts-expect-error - actions are not typed
setDelayProfileValue({

View file

@ -0,0 +1,6 @@
export default interface DownloadClientOptions {
downloadClientWorkingFolders: string;
enableCompletedDownloadHandling: boolean;
autoRedownloadFailed: boolean;
autoRedownloadFailedFromInteractiveSearch: boolean;
}

View file

@ -0,0 +1,9 @@
import ModelBase from 'App/ModelBase';
interface RemotePathMapping extends ModelBase {
host: string;
localPath: string;
remotePath: string;
}
export default RemotePathMapping;