New: Bulk Manage Applications, Download Clients

Co-authored-by: Qstick <qstick@gmail.com>
pull/2683/head
Bogdan 10 months ago
parent f7c135faaf
commit f436d730fe

@ -0,0 +1,5 @@
interface ModelBase {
id: number;
}
export default ModelBase;

@ -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,41 @@
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 {
settings: SettingsAppState;
tags: TagsAppState;
}
export default AppState;

@ -0,0 +1,40 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import Indexer from 'typings/Indexer';
import Notification from 'typings/Notification';
import { UiSettings } from 'typings/UiSettings';
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface ImportListAppState
extends AppSectionState<ImportList>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface IndexerAppState
extends AppSectionState<Indexer>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface NotificationAppState
extends AppSectionState<Notification>,
AppSectionDeleteState {}
export type UiSettingsAppState = AppSectionState<UiSettings>;
interface SettingsAppState {
downloadClients: DownloadClientAppState;
importLists: ImportListAppState;
indexers: IndexerAppState;
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;

@ -392,10 +392,7 @@ class AuthorDetails extends Component {
name={icons.ARROW_UP} name={icons.ARROW_UP}
size={30} size={30}
title={translate('GoToAuthorListing')} title={translate('GoToAuthorListing')}
to={{ to={'/'}
pathname: '/',
state: { restoreScrollPosition: true }
}}
/> />
<IconButton <IconButton

@ -160,9 +160,9 @@ class AuthorEditorFooter extends Component {
} = this.state; } = this.state;
const monitoredOptions = [ const monitoredOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true }, { key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'monitored', value: 'Monitored' }, { key: 'monitored', value: translate('Monitored') },
{ key: 'unmonitored', value: 'Unmonitored' } { key: 'unmonitored', value: translate('Unmonitored') }
]; ];
return ( return (

@ -89,9 +89,9 @@ class BookEditorFooter extends Component {
} = this.state; } = this.state;
const monitoredOptions = [ const monitoredOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true }, { key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'monitored', value: 'Monitored' }, { key: 'monitored', value: translate('Monitored') },
{ key: 'unmonitored', value: 'Unmonitored' } { key: 'unmonitored', value: translate('Unmonitored') }
]; ];
return ( return (

@ -88,9 +88,9 @@ class BookshelfFooter extends Component {
} = this.state; } = this.state;
const monitoredOptions = [ const monitoredOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true }, { key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'monitored', value: 'Monitored' }, { key: 'monitored', value: translate('Monitored') },
{ key: 'unmonitored', value: 'Unmonitored' } { key: 'unmonitored', value: translate('Unmonitored') }
]; ];
const noChanges = monitored === NO_CHANGE && const noChanges = monitored === NO_CHANGE &&

@ -4,7 +4,9 @@ import React from 'react';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import styles from './Alert.css'; import styles from './Alert.css';
function Alert({ className, kind, children, ...otherProps }) { function Alert(props) {
const { className, kind, children, ...otherProps } = props;
return ( return (
<div <div
className={classNames( className={classNames(
@ -19,8 +21,8 @@ function Alert({ className, kind, children, ...otherProps }) {
} }
Alert.propTypes = { Alert.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string,
kind: PropTypes.oneOf(kinds.all).isRequired, kind: PropTypes.oneOf(kinds.all),
children: PropTypes.node.isRequired children: PropTypes.node.isRequired
}; };

@ -210,7 +210,7 @@ class FilterBuilderRow extends Component {
key: availablePropFilter.name, key: availablePropFilter.name,
value: availablePropFilter.label value: availablePropFilter.label
}; };
}); }).sort((a, b) => a.value.localeCompare(b.value));
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);

@ -1,4 +1,6 @@
.tag { .tag {
display: flex;
&.isLastTag { &.isLastTag {
.or { .or {
display: none; display: none;

@ -6,7 +6,7 @@ import styles from './FilterBuilderRowValueTag.css';
function FilterBuilderRowValueTag(props) { function FilterBuilderRowValueTag(props) {
return ( return (
<span <div
className={styles.tag} className={styles.tag}
> >
<TagInputTag <TagInputTag
@ -15,12 +15,13 @@ function FilterBuilderRowValueTag(props) {
/> />
{ {
!props.isLastTag && props.isLastTag ?
<span className={styles.or}> null :
<div className={styles.or}>
or or
</span> </div>
} }
</span> </div>
); );
} }

@ -59,6 +59,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
max-width: 90%; max-width: 90%;
max-height: 100%;
width: 350px !important; width: 350px !important;
height: auto !important; height: auto !important;
} }

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import { inputTypes } from 'Helpers/Props'; import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
// import translate from 'Utilities/String/translate'; // import translate from 'Utilities/String/translate';
import AutoCompleteInput from './AutoCompleteInput'; import AutoCompleteInput from './AutoCompleteInput';
@ -270,16 +270,27 @@ FormInputGroup.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
containerClassName: PropTypes.string.isRequired, containerClassName: PropTypes.string.isRequired,
inputClassName: PropTypes.string, inputClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
min: PropTypes.number,
max: PropTypes.number,
unit: PropTypes.string, unit: PropTypes.string,
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
helpText: PropTypes.string, helpText: PropTypes.string,
helpTexts: PropTypes.arrayOf(PropTypes.string), helpTexts: PropTypes.arrayOf(PropTypes.string),
helpTextWarning: PropTypes.string, helpTextWarning: PropTypes.string,
helpLink: PropTypes.string, helpLink: PropTypes.string,
autoFocus: PropTypes.bool,
includeNoChange: PropTypes.bool,
includeNoChangeDisabled: PropTypes.bool,
selectedValueOptions: PropTypes.object,
pending: PropTypes.bool, pending: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.object), errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object) warnings: PropTypes.arrayOf(PropTypes.object),
onChange: PropTypes.func.isRequired
}; };
FormInputGroup.defaultProps = { FormInputGroup.defaultProps = {

@ -4,16 +4,18 @@ import React from 'react';
import { sizes } from 'Helpers/Props'; import { sizes } from 'Helpers/Props';
import styles from './FormLabel.css'; import styles from './FormLabel.css';
function FormLabel({ function FormLabel(props) {
children, const {
className, children,
errorClassName, className,
size, errorClassName,
name, size,
hasError, name,
isAdvanced, hasError,
...otherProps isAdvanced,
}) { ...otherProps
} = props;
return ( return (
<label <label
{...otherProps} {...otherProps}
@ -31,13 +33,13 @@ function FormLabel({
} }
FormLabel.propTypes = { FormLabel.propTypes = {
children: PropTypes.node.isRequired, children: PropTypes.oneOfType([PropTypes.node, PropTypes.string]).isRequired,
className: PropTypes.string, className: PropTypes.string,
errorClassName: PropTypes.string, errorClassName: PropTypes.string,
size: PropTypes.oneOf(sizes.all), size: PropTypes.oneOf(sizes.all),
name: PropTypes.string, name: PropTypes.string,
hasError: PropTypes.bool, hasError: PropTypes.bool,
isAdvanced: PropTypes.bool.isRequired isAdvanced: PropTypes.bool
}; };
FormLabel.defaultProps = { FormLabel.defaultProps = {

@ -6,15 +6,17 @@ import { createSelector } from 'reselect';
import { metadataProfileNames } from 'Helpers/Props'; import { metadataProfileNames } from 'Helpers/Props';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByName from 'Utilities/Array/sortByName'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate';
import SelectInput from './SelectInput'; import SelectInput from './SelectInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createSortedSectionSelector('settings.metadataProfiles', sortByName), createSortedSectionSelector('settings.metadataProfiles', sortByName),
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(state, { includeMixed }) => includeMixed, (state, { includeMixed }) => includeMixed,
(state, { includeNone }) => includeNone, (state, { includeNone }) => includeNone,
(metadataProfiles, includeNoChange, includeMixed, includeNone) => { (metadataProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed, includeNone) => {
const profiles = metadataProfiles.items.filter((item) => item.name !== metadataProfileNames.NONE); const profiles = metadataProfiles.items.filter((item) => item.name !== metadataProfileNames.NONE);
const noneProfile = metadataProfiles.items.find((item) => item.name === metadataProfileNames.NONE); const noneProfile = metadataProfiles.items.find((item) => item.name === metadataProfileNames.NONE);
@ -36,8 +38,8 @@ function createMapStateToProps() {
if (includeNoChange) { if (includeNoChange) {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: 'No Change', value: translate('NoChange'),
disabled: true disabled: includeNoChangeDisabled
}); });
} }
@ -68,8 +70,8 @@ class MetadataProfileSelectInputConnector extends Component {
values values
} = this.props; } = this.props;
if (!value || !_.some(values, (option) => parseInt(option.key) === value)) { if (!value || !values.some((option) => option.key === value || parseInt(option.key) === value)) {
const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key))); const firstValue = values.find((option) => !isNaN(parseInt(option.key)));
if (firstValue) { if (firstValue) {
this.onChange({ name, value: firstValue.key }); this.onChange({ name, value: firstValue.key });
@ -81,7 +83,12 @@ class MetadataProfileSelectInputConnector extends Component {
// Listeners // Listeners
onChange = ({ name, value }) => { onChange = ({ name, value }) => {
this.props.onChange({ name, value: parseInt(value) }); const { includeNoChange } = this.props;
this.props.onChange({
name,
value: includeNoChange && value === 'noChange' ? value : parseInt(value)
});
}; };
// //
@ -107,7 +114,8 @@ MetadataProfileSelectInputConnector.propTypes = {
}; };
MetadataProfileSelectInputConnector.defaultProps = { MetadataProfileSelectInputConnector.defaultProps = {
includeNoChange: false includeNoChange: false,
includeNone: true
}; };
export default connect(createMapStateToProps)(MetadataProfileSelectInputConnector); export default connect(createMapStateToProps)(MetadataProfileSelectInputConnector);

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import monitorOptions from 'Utilities/Author/monitorOptions'; import monitorOptions from 'Utilities/Author/monitorOptions';
import translate from 'Utilities/String/translate';
import SelectInput from './SelectInput'; import SelectInput from './SelectInput';
function MonitorBooksSelectInput(props) { function MonitorBooksSelectInput(props) {
@ -16,7 +17,7 @@ function MonitorBooksSelectInput(props) {
if (includeNoChange) { if (includeNoChange) {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: 'No Change', value: translate('NoChange'),
disabled: true disabled: true
}); });
} }

@ -1,6 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import monitorNewItemsOptions from 'Utilities/Author/monitorNewItemsOptions'; import monitorNewItemsOptions from 'Utilities/Author/monitorNewItemsOptions';
import translate from 'Utilities/String/translate';
import SelectInput from './SelectInput'; import SelectInput from './SelectInput';
function MonitorNewItemsSelectInput(props) { function MonitorNewItemsSelectInput(props) {
@ -15,7 +16,7 @@ function MonitorNewItemsSelectInput(props) {
if (includeNoChange) { if (includeNoChange) {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: 'No Change', value: translate('NoChange'),
disabled: true disabled: true
}); });
} }

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

@ -5,14 +5,16 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import sortByName from 'Utilities/Array/sortByName'; import sortByName from 'Utilities/Array/sortByName';
import translate from 'Utilities/String/translate';
import SelectInput from './SelectInput'; import SelectInput from './SelectInput';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
createSortedSectionSelector('settings.qualityProfiles', sortByName), createSortedSectionSelector('settings.qualityProfiles', sortByName),
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(state, { includeMixed }) => includeMixed, (state, { includeMixed }) => includeMixed,
(qualityProfiles, includeNoChange, includeMixed) => { (qualityProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed) => {
const values = _.map(qualityProfiles.items, (qualityProfile) => { const values = _.map(qualityProfiles.items, (qualityProfile) => {
return { return {
key: qualityProfile.id, key: qualityProfile.id,
@ -23,8 +25,8 @@ function createMapStateToProps() {
if (includeNoChange) { if (includeNoChange) {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: 'No Change', value: translate('NoChange'),
disabled: true disabled: includeNoChangeDisabled
}); });
} }
@ -55,8 +57,8 @@ class QualityProfileSelectInputConnector extends Component {
values values
} = this.props; } = this.props;
if (!value || !_.some(values, (option) => parseInt(option.key) === value)) { if (!value || !values.some((option) => option.key === value || parseInt(option.key) === value)) {
const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key))); const firstValue = values.find((option) => !isNaN(parseInt(option.key)));
if (firstValue) { if (firstValue) {
this.onChange({ name, value: firstValue.key }); this.onChange({ name, value: firstValue.key });
@ -68,7 +70,12 @@ class QualityProfileSelectInputConnector extends Component {
// Listeners // Listeners
onChange = ({ name, value }) => { onChange = ({ name, value }) => {
this.props.onChange({ name, value: parseInt(value) }); const { includeNoChange } = this.props;
this.props.onChange({
name,
value: includeNoChange && value === 'noChange' ? value : parseInt(value)
});
}; };
// //

@ -63,7 +63,7 @@ class RootFolderSelectInput extends Component {
render() { render() {
const { const {
value, includeNoChange,
...otherProps ...otherProps
} = this.props; } = this.props;
@ -71,7 +71,6 @@ class RootFolderSelectInput extends Component {
<div> <div>
<EnhancedSelectInput <EnhancedSelectInput
{...otherProps} {...otherProps}
value={value || ''}
selectedValueComponent={RootFolderSelectInputSelectedValue} selectedValueComponent={RootFolderSelectInputSelectedValue}
optionComponent={RootFolderSelectInputOption} optionComponent={RootFolderSelectInputOption}
onChange={this.onChange} onChange={this.onChange}
@ -93,7 +92,12 @@ RootFolderSelectInput.propTypes = {
values: PropTypes.arrayOf(PropTypes.object).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object, saveError: PropTypes.object,
includeNoChange: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired onChange: PropTypes.func.isRequired
}; };
RootFolderSelectInput.defaultProps = {
includeNoChange: false
};
export default RootFolderSelectInput; export default RootFolderSelectInput;

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import translate from 'Utilities/String/translate';
import RootFolderSelectInput from './RootFolderSelectInput'; import RootFolderSelectInput from './RootFolderSelectInput';
const ADD_NEW_KEY = 'addNew'; const ADD_NEW_KEY = 'addNew';
@ -12,7 +13,8 @@ function createMapStateToProps() {
(state, { value }) => value, (state, { value }) => value,
(state, { includeMissingValue }) => includeMissingValue, (state, { includeMissingValue }) => includeMissingValue,
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(rootFolders, value, includeMissingValue, includeNoChange) => { (state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
(rootFolders, value, includeMissingValue, includeNoChange, includeNoChangeDisabled = true) => {
const values = rootFolders.items.map((rootFolder) => { const values = rootFolders.items.map((rootFolder) => {
return { return {
key: rootFolder.path, key: rootFolder.path,
@ -27,8 +29,8 @@ function createMapStateToProps() {
values.unshift({ values.unshift({
key: 'noChange', key: 'noChange',
value: '', value: '',
name: 'No Change', name: translate('NoChange'),
isDisabled: true, isDisabled: includeNoChangeDisabled,
isMissing: false isMissing: false
}); });
} }

@ -75,7 +75,7 @@ SelectInput.propTypes = {
className: PropTypes.string, className: PropTypes.string,
disabledClassName: PropTypes.string, disabledClassName: PropTypes.string,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired, values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool, isDisabled: PropTypes.bool,
hasError: PropTypes.bool, hasError: PropTypes.bool,

@ -75,6 +75,18 @@ class TagInput extends Component {
// //
// Listeners // Listeners
onTagEdit = ({ value, ...otherProps }) => {
const currentValue = this.state.value;
if (currentValue && this.props.onTagReplace) {
this.props.onTagReplace(otherProps, { name: currentValue });
} else {
this.props.onTagDelete(otherProps);
}
this.setState({ value });
};
onInputContainerPress = () => { onInputContainerPress = () => {
this._autosuggestRef.input.focus(); this._autosuggestRef.input.focus();
}; };
@ -188,6 +200,7 @@ class TagInput extends Component {
const { const {
tags, tags,
kind, kind,
canEdit,
tagComponent, tagComponent,
onTagDelete onTagDelete
} = this.props; } = this.props;
@ -199,8 +212,10 @@ class TagInput extends Component {
kind={kind} kind={kind}
inputProps={inputProps} inputProps={inputProps}
isFocused={this.state.isFocused} isFocused={this.state.isFocused}
canEdit={canEdit}
tagComponent={tagComponent} tagComponent={tagComponent}
onTagDelete={onTagDelete} onTagDelete={onTagDelete}
onTagEdit={this.onTagEdit}
onInputContainerPress={this.onInputContainerPress} onInputContainerPress={this.onInputContainerPress}
/> />
); );
@ -225,7 +240,7 @@ class TagInput extends Component {
<AutoSuggestInput <AutoSuggestInput
{...otherProps} {...otherProps}
forwardedRef={this._setAutosuggestRef} forwardedRef={this._setAutosuggestRef}
className={styles.internalInput} className={className}
inputContainerClassName={classNames( inputContainerClassName={classNames(
inputContainerClassName, inputContainerClassName,
isFocused && styles.isFocused, isFocused && styles.isFocused,
@ -262,11 +277,13 @@ TagInput.propTypes = {
placeholder: PropTypes.string.isRequired, placeholder: PropTypes.string.isRequired,
delimiters: PropTypes.arrayOf(PropTypes.string).isRequired, delimiters: PropTypes.arrayOf(PropTypes.string).isRequired,
minQueryLength: PropTypes.number.isRequired, minQueryLength: PropTypes.number.isRequired,
canEdit: PropTypes.bool,
hasError: PropTypes.bool, hasError: PropTypes.bool,
hasWarning: PropTypes.bool, hasWarning: PropTypes.bool,
tagComponent: PropTypes.elementType.isRequired, tagComponent: PropTypes.elementType.isRequired,
onTagAdd: PropTypes.func.isRequired, onTagAdd: PropTypes.func.isRequired,
onTagDelete: PropTypes.func.isRequired onTagDelete: PropTypes.func.isRequired,
onTagReplace: PropTypes.func
}; };
TagInput.defaultProps = { TagInput.defaultProps = {
@ -277,6 +294,7 @@ TagInput.defaultProps = {
placeholder: '', placeholder: '',
delimiters: ['Tab', 'Enter', ' ', ','], delimiters: ['Tab', 'Enter', ' ', ','],
minQueryLength: 1, minQueryLength: 1,
canEdit: false,
tagComponent: TagInputTag tagComponent: TagInputTag
}; };

@ -138,6 +138,7 @@ class TagInputConnector extends Component {
<TagInput <TagInput
onTagAdd={this.onTagAdd} onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete} onTagDelete={this.onTagDelete}
onTagReplace={this.onTagReplace}
{...this.props} {...this.props}
/> />
); );

@ -28,8 +28,10 @@ class TagInputInput extends Component {
tags, tags,
inputProps, inputProps,
kind, kind,
canEdit,
tagComponent: TagComponent, tagComponent: TagComponent,
onTagDelete onTagDelete,
onTagEdit
} = this.props; } = this.props;
return ( return (
@ -46,8 +48,10 @@ class TagInputInput extends Component {
index={index} index={index}
tag={tag} tag={tag}
kind={kind} kind={kind}
canEdit={canEdit}
isLastTag={index === tags.length - 1} isLastTag={index === tags.length - 1}
onDelete={onTagDelete} onDelete={onTagDelete}
onEdit={onTagEdit}
/> />
); );
}) })
@ -66,8 +70,10 @@ TagInputInput.propTypes = {
inputProps: PropTypes.object.isRequired, inputProps: PropTypes.object.isRequired,
kind: PropTypes.oneOf(kinds.all).isRequired, kind: PropTypes.oneOf(kinds.all).isRequired,
isFocused: PropTypes.bool.isRequired, isFocused: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
tagComponent: PropTypes.elementType.isRequired, tagComponent: PropTypes.elementType.isRequired,
onTagDelete: PropTypes.func.isRequired, onTagDelete: PropTypes.func.isRequired,
onTagEdit: PropTypes.func.isRequired,
onInputContainerPress: PropTypes.func.isRequired onInputContainerPress: PropTypes.func.isRequired
}; };

@ -1,5 +1,34 @@
.tag { .tag {
composes: link from '~Components/Link/Link.css'; display: flex;
justify-content: center;
flex-direction: column;
max-width: 100%;
height: 31px; height: 31px;
} }
.link {
max-width: 100%;
}
.linkWithEdit {
max-width: calc(100% - 9px - 4px - 2px);
}
.editContainer {
display: inline-block;
margin-left: 4px;
padding-left: 2px;
border-left: 1px solid #eee;
}
.editButton {
composes: button from '~Components/Link/IconButton.css';
width: 9px;
}
.label {
composes: label from '~Components/Label.css';
max-width: 100%;
}

@ -1,6 +1,11 @@
// This file is automatically generated. // This file is automatically generated.
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'editButton': string;
'editContainer': string;
'label': string;
'link': string;
'linkWithEdit': string;
'tag': string; 'tag': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;

@ -1,8 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Label from 'Components/Label'; import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import { kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape'; import tagShape from 'Helpers/Props/Shapes/tagShape';
import styles from './TagInputTag.css'; import styles from './TagInputTag.css';
@ -24,24 +25,61 @@ class TagInputTag extends Component {
}); });
}; };
onEdit = () => {
const {
index,
tag,
onEdit
} = this.props;
onEdit({
index,
id: tag.id,
value: tag.name
});
};
// //
// Render // Render
render() { render() {
const { const {
tag, tag,
kind kind,
canEdit
} = this.props; } = this.props;
return ( return (
<Link <div
className={styles.tag} className={styles.tag}
tabIndex={-1} tabIndex={-1}
onPress={this.onDelete}
> >
<Label kind={kind}> <Label
{tag.name} className={styles.label}
kind={kind}
>
<Link
className={canEdit ? styles.linkWithEdit : styles.link}
tabIndex={-1}
onPress={this.onDelete}
>
{tag.name}
</Link>
{
canEdit ?
<div className={styles.editContainer}>
<IconButton
className={styles.editButton}
name={icons.EDIT}
size={9}
onPress={this.onEdit}
/>
</div> :
null
}
</Label> </Label>
</Link> </div>
); );
} }
} }
@ -50,7 +88,9 @@ TagInputTag.propTypes = {
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
tag: PropTypes.shape(tagShape), tag: PropTypes.shape(tagShape),
kind: PropTypes.oneOf(kinds.all).isRequired, kind: PropTypes.oneOf(kinds.all).isRequired,
onDelete: PropTypes.func.isRequired canEdit: PropTypes.bool.isRequired,
onDelete: PropTypes.func.isRequired,
onEdit: PropTypes.func.isRequired
}; };
export default TagInputTag; export default TagInputTag;

@ -71,6 +71,20 @@ class TextTagInputConnector extends Component {
}); });
}; };
onTagReplace = (tagToReplace, newTag) => {
const {
name,
valueArray,
onChange
} = this.props;
const newValue = [...valueArray];
newValue.splice(tagToReplace.index, 1);
newValue.push(newTag.name.trim());
onChange({ name, value: newValue });
};
// //
// Render // Render
@ -80,6 +94,7 @@ class TextTagInputConnector extends Component {
tagList={[]} tagList={[]}
onTagAdd={this.onTagAdd} onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete} onTagDelete={this.onTagDelete}
onTagReplace={this.onTagReplace}
{...this.props} {...this.props}
/> />
); );

@ -31,6 +31,7 @@ function Label(props) {
Label.propTypes = { Label.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
title: PropTypes.string,
kind: PropTypes.oneOf(kinds.all).isRequired, kind: PropTypes.oneOf(kinds.all).isRequired,
size: PropTypes.oneOf(sizes.all).isRequired, size: PropTypes.oneOf(sizes.all).isRequired,
outline: PropTypes.bool.isRequired, outline: PropTypes.bool.isRequired,

@ -39,11 +39,13 @@ function IconButton(props) {
} }
IconButton.propTypes = { IconButton.propTypes = {
...Link.propTypes,
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
iconClassName: PropTypes.string, iconClassName: PropTypes.string,
kind: PropTypes.string, kind: PropTypes.string,
name: PropTypes.object.isRequired, name: PropTypes.object.isRequired,
size: PropTypes.number, size: PropTypes.number,
title: PropTypes.string,
isSpinning: PropTypes.bool, isSpinning: PropTypes.bool,
isDisabled: PropTypes.bool isDisabled: PropTypes.bool
}; };

@ -1,110 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
class Link extends Component {
//
// Listeners
onClick = (event) => {
const {
isDisabled,
onPress
} = this.props;
if (!isDisabled && onPress) {
onPress(event);
}
};
//
// Render
render() {
const {
className,
component,
to,
target,
isDisabled,
noRouter,
onPress,
...otherProps
} = this.props;
const linkProps = { target };
let el = component;
if (to && typeof to === 'string') {
if ((/\w+?:\/\//).test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
el = RouterLink;
linkProps.to = `${window.Readarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
} else if (to && typeof to === 'object') {
el = RouterLink;
linkProps.target = target;
if (to.pathname.startsWith(`${window.Readarr.urlBase}/`)) {
linkProps.to = to;
} else {
const pathname = `${window.Readarr.urlBase}/${to.pathname.replace(/^\//, '')}`;
linkProps.to = {
...to,
pathname
};
}
}
if (el === 'button' || el === 'input') {
linkProps.type = otherProps.type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const props = {
...otherProps,
...linkProps
};
props.onClick = this.onClick;
return (
React.createElement(el, props)
);
}
}
Link.propTypes = {
className: PropTypes.string,
component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
to: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
target: PropTypes.string,
isDisabled: PropTypes.bool,
noRouter: PropTypes.bool,
onPress: PropTypes.func
};
Link.defaultProps = {
component: 'button',
noRouter: false
};
export default Link;

@ -0,0 +1,96 @@
import classNames from 'classnames';
import React, {
ComponentClass,
FunctionComponent,
SyntheticEvent,
useCallback,
} from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
interface ReactRouterLinkProps {
to?: string;
}
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
className?: string;
component?:
| string
| FunctionComponent<LinkProps>
| ComponentClass<LinkProps, unknown>;
to?: string;
target?: string;
isDisabled?: boolean;
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
}
function Link(props: LinkProps) {
const {
className,
component = 'button',
to,
target,
type,
isDisabled,
noRouter = false,
onPress,
...otherProps
} = props;
const onClick = useCallback(
(event: SyntheticEvent) => {
if (!isDisabled && onPress) {
onPress(event);
}
},
[isDisabled, onPress]
);
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
target,
};
let el = component;
if (to) {
if (/\w+?:\/\//.test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
el = RouterLink;
linkProps.to = `${window.Readarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const elementProps = {
...otherProps,
type,
...linkProps,
};
elementProps.onClick = onClick;
return React.createElement(el, elementProps);
}
export default Link;

@ -42,6 +42,7 @@ function SpinnerButton(props) {
} }
SpinnerButton.propTypes = { SpinnerButton.propTypes = {
...Button.Props,
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
isSpinning: PropTypes.bool.isRequired, isSpinning: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool, isDisabled: PropTypes.bool,

@ -53,10 +53,7 @@ class PageHeader extends Component {
<div className={styles.logoContainer}> <div className={styles.logoContainer}>
<Link <Link
className={styles.logoLink} className={styles.logoLink}
to={{ to={'/'}
pathname: '/',
state: { restoreScrollPosition: true }
}}
> >
<img <img
className={styles.logo} className={styles.logo}

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

@ -52,6 +52,7 @@ function Table(props) {
scrollDirections.HORIZONTAL : scrollDirections.HORIZONTAL :
scrollDirections.NONE scrollDirections.NONE
} }
autoFocus={false}
> >
<table className={className}> <table className={className}>
<TableHeader> <TableHeader>
@ -120,6 +121,7 @@ function Table(props) {
} }
Table.propTypes = { Table.propTypes = {
...TableHeaderCell.props,
className: PropTypes.string, className: PropTypes.string,
horizontalScroll: PropTypes.bool.isRequired, horizontalScroll: PropTypes.bool.isRequired,
selectAll: PropTypes.bool.isRequired, selectAll: PropTypes.bool.isRequired,

@ -0,0 +1,11 @@
import { useEffect, useRef } from 'react';
export default function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

@ -0,0 +1,113 @@
import { cloneDeep } from 'lodash';
import { useReducer } from 'react';
import ModelBase from 'App/ModelBase';
import areAllSelected from 'Utilities/Table/areAllSelected';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
export type SelectedState = Record<number, boolean>;
export interface SelectState {
selectedState: SelectedState;
lastToggled: number | null;
allSelected: boolean;
allUnselected: boolean;
}
export type SelectAction =
| { type: 'reset' }
| { type: 'selectAll'; items: ModelBase[] }
| { type: 'unselectAll'; items: ModelBase[] }
| {
type: 'toggleSelected';
id: number;
isSelected: boolean;
shiftKey: boolean;
items: ModelBase[];
}
| {
type: 'removeItem';
id: number;
}
| {
type: 'updateItems';
items: ModelBase[];
};
export type Dispatch = (action: SelectAction) => void;
const initialState = {
selectedState: {},
lastToggled: null,
allSelected: false,
allUnselected: true,
items: [],
};
function getSelectedState(items: ModelBase[], existingState: SelectedState) {
return items.reduce((acc: SelectedState, item) => {
const id = item.id;
acc[id] = existingState[id] ?? false;
return acc;
}, {});
}
function selectReducer(state: SelectState, action: SelectAction): SelectState {
const { selectedState } = state;
switch (action.type) {
case 'reset': {
return cloneDeep(initialState);
}
case 'selectAll': {
return {
...selectAll(selectedState, true),
};
}
case 'unselectAll': {
return {
...selectAll(selectedState, false),
};
}
case 'toggleSelected': {
const result = {
...toggleSelected(
state,
action.items,
action.id,
action.isSelected,
action.shiftKey
),
};
return result;
}
case 'updateItems': {
const nextSelectedState = getSelectedState(action.items, selectedState);
return {
...state,
...areAllSelected(nextSelectedState),
selectedState: nextSelectedState,
};
}
default: {
throw new Error(`Unhandled action type: ${action.type}`);
}
}
}
export default function useSelectState(): [SelectState, Dispatch] {
const selectedState = getSelectedState([], {});
const [state, dispatch] = useReducer(selectReducer, {
selectedState,
lastToggled: null,
allSelected: false,
allUnselected: true,
});
return [state, dispatch];
}

@ -0,0 +1,6 @@
enum SortDirection {
Ascending = 'ascending',
Descending = 'descending',
}
export default SortDirection;

@ -68,6 +68,7 @@ import {
faInfoCircle as fasInfoCircle, faInfoCircle as fasInfoCircle,
faLaptop as fasLaptop, faLaptop as fasLaptop,
faLevelUpAlt as fasLevelUpAlt, faLevelUpAlt as fasLevelUpAlt,
faListCheck as fasListCheck,
faLongArrowAltRight as fasLongArrowAltRight, faLongArrowAltRight as fasLongArrowAltRight,
faMedkit as fasMedkit, faMedkit as fasMedkit,
faMinus as fasMinus, faMinus as fasMinus,
@ -166,6 +167,7 @@ export const INFO = fasInfoCircle;
export const INTERACTIVE = fasUser; export const INTERACTIVE = fasUser;
export const KEYBOARD = farKeyboard; export const KEYBOARD = farKeyboard;
export const LOGOUT = fasSignOutAlt; export const LOGOUT = fasSignOutAlt;
export const MANAGE = fasListCheck;
export const MEDIA_INFO = farFileInvoice; export const MEDIA_INFO = farFileInvoice;
export const MISSING = fasExclamationTriangle; export const MISSING = fasExclamationTriangle;
export const MONITORED = fasBookmark; export const MONITORED = fasBookmark;

@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector';
@ -23,7 +24,8 @@ class DownloadClientSettings extends Component {
this.state = { this.state = {
isSaving: false, isSaving: false,
hasPendingChanges: false hasPendingChanges: false,
isManageDownloadClientsOpen: false
}; };
} }
@ -38,6 +40,14 @@ class DownloadClientSettings extends Component {
this.setState(payload); this.setState(payload);
}; };
onManageDownloadClientsPress = () => {
this.setState({ isManageDownloadClientsOpen: true });
};
onManageDownloadClientsModalClose = () => {
this.setState({ isManageDownloadClientsOpen: false });
};
onSavePress = () => { onSavePress = () => {
if (this._saveCallback) { if (this._saveCallback) {
this._saveCallback(); this._saveCallback();
@ -55,7 +65,8 @@ class DownloadClientSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageDownloadClientsOpen
} = this.state; } = this.state;
return ( return (
@ -73,6 +84,12 @@ class DownloadClientSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllDownloadClients} onPress={dispatchTestAllDownloadClients}
/> />
<PageToolbarButton
label={translate('ManageClients')}
iconName={icons.MANAGE}
onPress={this.onManageDownloadClientsPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -87,6 +104,11 @@ class DownloadClientSettings extends Component {
/> />
<RemotePathMappingsConnector /> <RemotePathMappingsConnector />
<ManageDownloadClientsModal
isOpen={isManageDownloadClientsOpen}
onModalClose={this.onManageDownloadClientsModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
); );

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

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

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

@ -0,0 +1,180 @@
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ManageDownloadClientsEditModalContent.css';
interface SavePayload {
enable?: boolean;
removeCompletedDownloads?: boolean;
removeFailedDownloads?: boolean;
priority?: number;
}
interface ManageDownloadClientsEditModalContentProps {
downloadClientIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const enableOptions = [
{ key: NO_CHANGE, value: 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 [removeCompletedDownloads, setRemoveCompletedDownloads] =
useState(NO_CHANGE);
const [removeFailedDownloads, setRemoveFailedDownloads] = 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 (removeCompletedDownloads !== NO_CHANGE) {
hasChanges = true;
payload.removeCompletedDownloads = removeCompletedDownloads === 'enabled';
}
if (removeFailedDownloads !== NO_CHANGE) {
hasChanges = true;
payload.removeFailedDownloads = removeFailedDownloads === 'enabled';
}
if (priority !== null) {
hasChanges = true;
payload.priority = priority as number;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [
enable,
priority,
removeCompletedDownloads,
removeFailedDownloads,
onSavePress,
onModalClose,
]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'enable':
setEnable(value);
break;
case 'priority':
setPriority(value);
break;
case 'removeCompletedDownloads':
setRemoveCompletedDownloads(value);
break;
case 'removeFailedDownloads':
setRemoveFailedDownloads(value);
break;
default:
console.warn(
`EditDownloadClientsModalContent Unknown Input: '${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('Priority')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
value={priority}
min={1}
max={50}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemoveCompletedDownloads')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="removeCompletedDownloads"
value={removeCompletedDownloads}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemoveFailedDownloads')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="removeFailedDownloads"
value={removeFailedDownloads}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('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,300 @@
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 TagsModal from './Tags/TagsModal';
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('Priority'),
isSortable: true,
isVisible: true,
},
{
name: 'removeCompletedDownloads',
label: translate('RemoveCompleted'),
isSortable: true,
isVisible: true,
},
{
name: 'removeFailedDownloads',
label: translate('RemoveFailed'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: 'Tags',
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 [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(bulkDeleteDownloadClients({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditDownloadClients({
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(
bulkEditDownloadClients({
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('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>
<SpinnerButton
isSpinning={isSaving && isSavingTags}
isDisabled={!anySelected}
onPress={onTagsPress}
>
Set Tags
</SpinnerButton>
</div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageDownloadClientsEditModal
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
downloadClientIds={selectedIds}
/>
<TagsModal
isOpen={isTagsModalOpen}
ids={selectedIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<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,11 @@
.name,
.enable,
.tags,
.priority,
.removeCompletedDownloads,
.removeFailedDownloads,
.implementation {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

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

@ -0,0 +1,89 @@
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 { 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;
removeCompletedDownloads: boolean;
removeFailedDownloads: boolean;
implementation: string;
tags: number[];
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageDownloadClientsModalRow(
props: ManageDownloadClientsModalRowProps
) {
const {
id,
isSelected,
name,
enable,
priority,
removeCompletedDownloads,
removeFailedDownloads,
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.enable}>
<Label kind={enable ? kinds.SUCCESS : kinds.DISABLED} outline={!enable}>
{enable ? translate('Yes') : translate('No')}
</Label>
</TableRowCell>
<TableRowCell className={styles.priority}>{priority}</TableRowCell>
<TableRowCell className={styles.removeCompletedDownloads}>
{removeCompletedDownloads ? translate('Yes') : translate('No')}
</TableRowCell>
<TableRowCell className={styles.removeFailedDownloads}>
{removeFailedDownloads ? translate('Yes') : translate('No')}
</TableRowCell>
<TableRowCell className={styles.tags}>
<TagListConnector tags={tags} />
</TableRowCell>
</TableRow>
);
}
export default ManageDownloadClientsModalRow;

@ -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,185 @@
import { uniq } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { DownloadClientAppState } 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 DownloadClient from 'typings/DownloadClient';
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 allDownloadClients: DownloadClientAppState = useSelector(
(state: AppState) => state.settings.downloadClients
);
const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const downloadClientsTags = useMemo(() => {
const tags = ids.reduce((acc: number[], id) => {
const s = allDownloadClients.items.find(
(s: DownloadClient) => s.id === id
);
if (s) {
acc.push(...s.tags);
}
return acc;
}, []);
return uniq(tags);
}, [ids, allDownloadClients]);
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('ApplyTagsHelpTextHowToApplyDownloadClients'),
translate('ApplyTagsHelpTextAdd'),
translate('ApplyTagsHelpTextRemove'),
translate('ApplyTagsHelpTextReplace'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}>
{downloadClientsTags.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 (downloadClientsTags.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;

@ -9,6 +9,7 @@ import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector'; import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
import ImportListsConnector from './ImportLists/ImportListsConnector'; import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
class ImportListSettings extends Component { class ImportListSettings extends Component {
@ -19,7 +20,8 @@ class ImportListSettings extends Component {
super(props, context); super(props, context);
this.state = { this.state = {
hasPendingChanges: false hasPendingChanges: false,
isManageImportListsOpen: false
}; };
} }
@ -30,6 +32,14 @@ class ImportListSettings extends Component {
this._listOptions = ref; this._listOptions = ref;
}; };
onManageImportListsPress = () => {
this.setState({ isManageImportListsOpen: true });
};
onManageImportListsModalClose = () => {
this.setState({ isManageImportListsOpen: false });
};
onHasPendingChange = (hasPendingChanges) => { onHasPendingChange = (hasPendingChanges) => {
this.setState({ this.setState({
hasPendingChanges hasPendingChanges
@ -51,7 +61,8 @@ class ImportListSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageImportListsOpen
} = this.state; } = this.state;
return ( return (
@ -69,6 +80,12 @@ class ImportListSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllImportLists} onPress={dispatchTestAllImportLists}
/> />
<PageToolbarButton
label={translate('ManageLists')}
iconName={icons.MANAGE}
onPress={this.onManageImportListsPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -77,6 +94,10 @@ class ImportListSettings extends Component {
<PageContentBody> <PageContentBody>
<ImportListsConnector /> <ImportListsConnector />
<ImportListsExclusionsConnector /> <ImportListsExclusionsConnector />
<ManageImportListsModal
isOpen={isManageImportListsOpen}
onModalClose={this.onManageImportListsModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
); );

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

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

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

@ -0,0 +1,184 @@
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ManageImportListsEditModalContent.css';
interface SavePayload {
enableAutomaticAdd?: boolean;
qualityProfileId?: number;
metadataProfileId?: number;
rootFolderPath?: string;
}
interface ManageImportListsEditModalContentProps {
importListIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const autoAddOptions = [
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'enabled', value: translate('Enabled') },
{ key: 'disabled', value: translate('Disabled') },
];
function ManageImportListsEditModalContent(
props: ManageImportListsEditModalContentProps
) {
const { importListIds, onSavePress, onModalClose } = props;
const [enableAutomaticAdd, setEnableAutomaticAdd] = useState(NO_CHANGE);
const [qualityProfileId, setQualityProfileId] = useState<string | number>(
NO_CHANGE
);
const [metadataProfileId, setMetadataProfileId] = useState<string | number>(
NO_CHANGE
);
const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (enableAutomaticAdd !== NO_CHANGE) {
hasChanges = true;
payload.enableAutomaticAdd = enableAutomaticAdd === 'enabled';
}
if (qualityProfileId !== NO_CHANGE) {
hasChanges = true;
payload.qualityProfileId = qualityProfileId as number;
}
if (metadataProfileId !== NO_CHANGE) {
hasChanges = true;
payload.metadataProfileId = metadataProfileId as number;
}
if (rootFolderPath !== NO_CHANGE) {
hasChanges = true;
payload.rootFolderPath = rootFolderPath;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [
enableAutomaticAdd,
qualityProfileId,
metadataProfileId,
rootFolderPath,
onSavePress,
onModalClose,
]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'enableAutomaticAdd':
setEnableAutomaticAdd(value);
break;
case 'qualityProfileId':
setQualityProfileId(value);
break;
case 'metadataProfileId':
setMetadataProfileId(value);
break;
case 'rootFolderPath':
setRootFolderPath(value);
break;
default:
console.warn(`EditImportListModalContent Unknown Input: '${name}'`);
}
},
[]
);
const selectedCount = importListIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedImportLists')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('AutomaticAdd')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="enableAutomaticAdd"
value={enableAutomaticAdd}
values={autoAddOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
includeNoChange={true}
includeNoChangeDisabled={false}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MetadataProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.METADATA_PROFILE_SELECT}
name="metadataProfileId"
value={metadataProfileId}
includeNoChange={true}
includeNoChangeDisabled={false}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
value={rootFolderPath}
includeNoChange={true}
includeNoChangeDisabled={false}
selectedValueOptions={{ includeFreeSpace: false }}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountImportListsSelected', [selectedCount])}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageImportListsEditModalContent;

@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import ManageImportListsModalContent from './ManageImportListsModalContent';
interface ManageImportListsModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageImportListsModal(props: ManageImportListsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal size={sizes.EXTRA_LARGE} isOpen={isOpen} onModalClose={onModalClose}>
<ManageImportListsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageImportListsModal;

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

@ -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,297 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { ImportListAppState } 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 {
bulkDeleteImportLists,
bulkEditImportLists,
} 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 ManageImportListsEditModal from './Edit/ManageImportListsEditModal';
import ManageImportListsModalRow from './ManageImportListsModalRow';
import TagsModal from './Tags/TagsModal';
import styles from './ManageImportListsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageImportListsModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'qualityProfileId',
label: translate('QualityProfile'),
isSortable: true,
isVisible: true,
},
{
name: 'metadataProfileId',
label: translate('MetadataProfile'),
isSortable: true,
isVisible: true,
},
{
name: 'rootFolderPath',
label: translate('RootFolder'),
isSortable: true,
isVisible: true,
},
{
name: 'enableAutomaticAdd',
label: translate('AutoAdd'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: translate('Tags'),
isSortable: true,
isVisible: true,
},
];
interface ManageImportListsModalContentProps {
onModalClose(): void;
}
function ManageImportListsModalContent(
props: ManageImportListsModalContentProps
) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
}: ImportListAppState = useSelector(
createClientSideCollectionSelector('settings.importLists')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteImportLists({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditImportLists({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onTagsPress = useCallback(() => {
setIsTagsModalOpen(true);
}, [setIsTagsModalOpen]);
const onTagsModalClose = useCallback(() => {
setIsTagsModalOpen(false);
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags: number[], applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
bulkEditImportLists({
ids: selectedIds,
tags,
applyTags,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load import lists.');
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('ManageImportLists')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoImportListsFound')}</Alert>
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<ManageImportListsModalRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
columns={COLUMNS}
onSelectedChange={onSelectedChange}
/>
);
})}
</TableBody>
</Table>
) : null}
</ModalBody>
<ModalFooter>
<div className={styles.leftButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected}
onPress={onDeletePress}
>
{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>
<ManageImportListsEditModal
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
importListIds={selectedIds}
/>
<TagsModal
isOpen={isTagsModalOpen}
ids={selectedIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedImportLists')}
message={translate('DeleteSelectedImportListsMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageImportListsModalContent;

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

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

@ -0,0 +1,95 @@
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import TagListConnector from 'Components/TagListConnector';
import { createMetadataProfileSelectorForHook } from 'Store/Selectors/createMetadataProfileSelector';
import { createQualityProfileSelectorForHook } from 'Store/Selectors/createQualityProfileSelector';
import { SelectStateInputProps } from 'typings/props';
import styles from './ManageImportListsModalRow.css';
interface ManageImportListsModalRowProps {
id: number;
name: string;
rootFolderPath: string;
qualityProfileId: number;
metadataProfileId: number;
implementation: string;
tags: number[];
enableAutomaticAdd: boolean;
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageImportListsModalRow(props: ManageImportListsModalRowProps) {
const {
id,
isSelected,
name,
rootFolderPath,
qualityProfileId,
metadataProfileId,
implementation,
enableAutomaticAdd,
tags,
onSelectedChange,
} = props;
const qualityProfile = useSelector(
createQualityProfileSelectorForHook(qualityProfileId)
);
const metadataProfile = useSelector(
createMetadataProfileSelectorForHook(metadataProfileId)
);
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.qualityProfileId}>
{qualityProfile?.name ?? 'None'}
</TableRowCell>
<TableRowCell className={styles.metadataProfileId}>
{metadataProfile?.name ?? 'None'}
</TableRowCell>
<TableRowCell className={styles.rootFolderPath}>
{rootFolderPath}
</TableRowCell>
<TableRowCell className={styles.enableAutomaticAdd}>
{enableAutomaticAdd ? 'Yes' : 'No'}
</TableRowCell>
<TableRowCell className={styles.tags}>
<TagListConnector tags={tags} />
</TableRowCell>
</TableRow>
);
}
export default ManageImportListsModalRow;

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

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

@ -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 { ImportListAppState } from 'App/State/SettingsAppState';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import ImportList from 'typings/ImportList';
import 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 allImportLists: ImportListAppState = useSelector(
(state: AppState) => state.settings.importLists
);
const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const importListsTags = useMemo(() => {
const tags = ids.reduce((acc: number[], id) => {
const s = allImportLists.items.find((s: ImportList) => s.id === id);
if (s) {
acc.push(...s.tags);
}
return acc;
}, []);
return uniq(tags);
}, [ids, allImportLists]);
const onTagsChange = useCallback(
({ value }: { value: number[] }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
({ value }: { value: string }) => {
setApplyTags(value);
},
[setApplyTags]
);
const onApplyPress = useCallback(() => {
onApplyTagsPress(tags, applyTags);
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: 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('ApplyTagsHelpTextHowToApplyImportLists'),
translate('ApplyTagsHelpTextAdd'),
translate('ApplyTagsHelpTextRemove'),
translate('ApplyTagsHelpTextReplace'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}>
{importListsTags.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 (importListsTags.indexOf(id) > -1) {
return null;
}
return (
<Label
key={tag.id}
title={translate('AddingTag')}
kind={kinds.SUCCESS}
size={sizes.LARGE}
>
{tag.label}
</Label>
);
})}
</div>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button kind={kinds.PRIMARY} onPress={onApplyPress}>
{translate('Apply')}
</Button>
</ModalFooter>
</ModalContent>
);
}
export default TagsModalContent;

@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import IndexersConnector from './Indexers/IndexersConnector'; import IndexersConnector from './Indexers/IndexersConnector';
import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal';
import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; import IndexerOptionsConnector from './Options/IndexerOptionsConnector';
class IndexerSettings extends Component { class IndexerSettings extends Component {
@ -22,7 +23,8 @@ class IndexerSettings extends Component {
this.state = { this.state = {
isSaving: false, isSaving: false,
hasPendingChanges: false hasPendingChanges: false,
isManageIndexersOpen: false
}; };
} }
@ -37,6 +39,14 @@ class IndexerSettings extends Component {
this.setState(payload); this.setState(payload);
}; };
onManageIndexersPress = () => {
this.setState({ isManageIndexersOpen: true });
};
onManageIndexersModalClose = () => {
this.setState({ isManageIndexersOpen: false });
};
onSavePress = () => { onSavePress = () => {
if (this._saveCallback) { if (this._saveCallback) {
this._saveCallback(); this._saveCallback();
@ -54,7 +64,8 @@ class IndexerSettings extends Component {
const { const {
isSaving, isSaving,
hasPendingChanges hasPendingChanges,
isManageIndexersOpen
} = this.state; } = this.state;
return ( return (
@ -72,6 +83,12 @@ class IndexerSettings extends Component {
isSpinning={isTestingAll} isSpinning={isTestingAll}
onPress={dispatchTestAllIndexers} onPress={dispatchTestAllIndexers}
/> />
<PageToolbarButton
label={translate('ManageIndexers')}
iconName={icons.MANAGE}
onPress={this.onManageIndexersPress}
/>
</Fragment> </Fragment>
} }
onSavePress={this.onSavePress} onSavePress={this.onSavePress}
@ -84,6 +101,11 @@ class IndexerSettings extends Component {
onChildMounted={this.onChildMounted} onChildMounted={this.onChildMounted}
onChildStateChange={this.onChildStateChange} onChildStateChange={this.onChildStateChange}
/> />
<ManageIndexersModal
isOpen={isManageIndexersOpen}
onModalClose={this.onManageIndexersModalClose}
/>
</PageContentBody> </PageContentBody>
</PageContent> </PageContent>
); );

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

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

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

@ -0,0 +1,178 @@
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ManageIndexersEditModalContent.css';
interface SavePayload {
enableRss?: boolean;
enableAutomaticSearch?: boolean;
enableInteractiveSearch?: boolean;
priority?: number;
}
interface ManageIndexersEditModalContentProps {
indexerIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const enableOptions = [
{ key: NO_CHANGE, value: translate('NoChange'), disabled: true },
{ key: 'enabled', value: translate('Enabled') },
{ key: 'disabled', value: translate('Disabled') },
];
function ManageIndexersEditModalContent(
props: ManageIndexersEditModalContentProps
) {
const { indexerIds, onSavePress, onModalClose } = props;
const [enableRss, setEnableRss] = useState(NO_CHANGE);
const [enableAutomaticSearch, setEnableAutomaticSearch] = useState(NO_CHANGE);
const [enableInteractiveSearch, setEnableInteractiveSearch] =
useState(NO_CHANGE);
const [priority, setPriority] = useState<null | string | number>(null);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (enableRss !== NO_CHANGE) {
hasChanges = true;
payload.enableRss = enableRss === 'enabled';
}
if (enableAutomaticSearch !== NO_CHANGE) {
hasChanges = true;
payload.enableAutomaticSearch = enableAutomaticSearch === 'enabled';
}
if (enableInteractiveSearch !== NO_CHANGE) {
hasChanges = true;
payload.enableInteractiveSearch = enableInteractiveSearch === 'enabled';
}
if (priority !== null) {
hasChanges = true;
payload.priority = priority as number;
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [
enableRss,
enableAutomaticSearch,
enableInteractiveSearch,
priority,
onSavePress,
onModalClose,
]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'enableRss':
setEnableRss(value);
break;
case 'enableAutomaticSearch':
setEnableAutomaticSearch(value);
break;
case 'enableInteractiveSearch':
setEnableInteractiveSearch(value);
break;
case 'priority':
setPriority(value);
break;
default:
console.warn(`EditIndexersModalContent Unknown Input: '${name}'`);
}
},
[]
);
const selectedCount = indexerIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedIndexers')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('EnableRSS')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="enableRss"
value={enableRss}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('EnableAutomaticSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="enableAutomaticSearch"
value={enableAutomaticSearch}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('EnableInteractiveSearch')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="enableInteractiveSearch"
value={enableInteractiveSearch}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Priority')}</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="priority"
value={priority}
min={1}
max={50}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountIndexersSelected', [selectedCount])}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageIndexersEditModalContent;

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

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

@ -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,295 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IndexerAppState } 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 {
bulkDeleteIndexers,
bulkEditIndexers,
} 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 ManageIndexersEditModal from './Edit/ManageIndexersEditModal';
import ManageIndexersModalRow from './ManageIndexersModalRow';
import TagsModal from './Tags/TagsModal';
import styles from './ManageIndexersModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageIndexersModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'implementation',
label: translate('Implementation'),
isSortable: true,
isVisible: true,
},
{
name: 'enableRss',
label: translate('EnableRSS'),
isSortable: true,
isVisible: true,
},
{
name: 'enableAutomaticSearch',
label: translate('EnableAutomaticSearch'),
isSortable: true,
isVisible: true,
},
{
name: 'enableInteractiveSearch',
label: translate('EnableInteractiveSearch'),
isSortable: true,
isVisible: true,
},
{
name: 'priority',
label: translate('Priority'),
isSortable: true,
isVisible: true,
},
{
name: 'tags',
label: translate('Tags'),
isSortable: true,
isVisible: true,
},
];
interface ManageIndexersModalContentProps {
onModalClose(): void;
}
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
}: IndexerAppState = useSelector(
createClientSideCollectionSelector('settings.indexers')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false);
const [isSavingTags, setIsSavingTags] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteIndexers({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditIndexers({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onTagsPress = useCallback(() => {
setIsTagsModalOpen(true);
}, [setIsTagsModalOpen]);
const onTagsModalClose = useCallback(() => {
setIsTagsModalOpen(false);
}, [setIsTagsModalOpen]);
const onApplyTagsPress = useCallback(
(tags: number[], applyTags: string) => {
setIsSavingTags(true);
setIsTagsModalOpen(false);
dispatch(
bulkEditIndexers({
ids: selectedIds,
tags,
applyTags,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load indexers.');
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('ManageIndexers')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
<Alert kind={kinds.INFO}>{translate('NoIndexersFound')}</Alert>
)}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
>
<TableBody>
{items.map((item) => {
return (
<ManageIndexersModalRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
columns={COLUMNS}
onSelectedChange={onSelectedChange}
/>
);
})}
</TableBody>
</Table>
) : null}
</ModalBody>
<ModalFooter>
<div className={styles.leftButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected}
onPress={onDeletePress}
>
{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>
<ManageIndexersEditModal
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
indexerIds={selectedIds}
/>
<TagsModal
isOpen={isTagsModalOpen}
ids={selectedIds}
onApplyTagsPress={onApplyTagsPress}
onModalClose={onTagsModalClose}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedIndexers')}
message={translate('DeleteSelectedIndexersMessageText', [
selectedIds.length,
])}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageIndexersModalContent;

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

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

@ -0,0 +1,100 @@
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 { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import styles from './ManageIndexersModalRow.css';
interface ManageIndexersModalRowProps {
id: number;
name: string;
enableRss: boolean;
enableAutomaticSearch: boolean;
enableInteractiveSearch: boolean;
priority: number;
implementation: string;
tags: number[];
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageIndexersModalRow(props: ManageIndexersModalRowProps) {
const {
id,
isSelected,
name,
enableRss,
enableAutomaticSearch,
enableInteractiveSearch,
priority,
implementation,
tags,
onSelectedChange,
} = props;
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.implementation}>
{implementation}
</TableRowCell>
<TableRowCell className={styles.enableRss}>
<Label
kind={enableRss ? kinds.SUCCESS : kinds.DISABLED}
outline={!enableRss}
>
{enableRss ? translate('Yes') : translate('No')}
</Label>
</TableRowCell>
<TableRowCell className={styles.enableAutomaticSearch}>
<Label
kind={enableAutomaticSearch ? kinds.SUCCESS : kinds.DISABLED}
outline={!enableAutomaticSearch}
>
{enableAutomaticSearch ? translate('Yes') : translate('No')}
</Label>
</TableRowCell>
<TableRowCell className={styles.enableInteractiveSearch}>
<Label
kind={enableInteractiveSearch ? kinds.SUCCESS : kinds.DISABLED}
outline={!enableInteractiveSearch}
>
{enableInteractiveSearch ? translate('Yes') : translate('No')}
</Label>
</TableRowCell>
<TableRowCell className={styles.priority}>{priority}</TableRowCell>
<TableRowCell className={styles.tags}>
<TagListConnector tags={tags} />
</TableRowCell>
</TableRow>
);
}
export default ManageIndexersModalRow;

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

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

@ -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 { IndexerAppState } from 'App/State/SettingsAppState';
import { Tag } from 'App/State/TagsAppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Label from 'Components/Label';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import Indexer from 'typings/Indexer';
import 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 allIndexers: IndexerAppState = useSelector(
(state: AppState) => state.settings.indexers
);
const tagList: Tag[] = useSelector(createTagsSelector());
const [tags, setTags] = useState<number[]>([]);
const [applyTags, setApplyTags] = useState('add');
const indexersTags = useMemo(() => {
const tags = ids.reduce((acc: number[], id) => {
const s = allIndexers.items.find((s: Indexer) => s.id === id);
if (s) {
acc.push(...s.tags);
}
return acc;
}, []);
return uniq(tags);
}, [ids, allIndexers]);
const onTagsChange = useCallback(
({ value }: { value: number[] }) => {
setTags(value);
},
[setTags]
);
const onApplyTagsChange = useCallback(
({ value }: { value: string }) => {
setApplyTags(value);
},
[setApplyTags]
);
const onApplyPress = useCallback(() => {
onApplyTagsPress(tags, applyTags);
}, [tags, applyTags, onApplyTagsPress]);
const applyTagsOptions = [
{ key: 'add', value: 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('ApplyTagsHelpTextHowToApplyIndexers'),
translate('ApplyTagsHelpTextAdd'),
translate('ApplyTagsHelpTextRemove'),
translate('ApplyTagsHelpTextReplace'),
]}
onChange={onApplyTagsChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Result')}</FormLabel>
<div className={styles.result}>
{indexersTags.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 (indexersTags.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;

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

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

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

@ -1,4 +1,6 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
@ -29,6 +31,8 @@ export const DELETE_IMPORT_LIST = 'settings/importlists/deleteImportList';
export const TEST_IMPORT_LIST = 'settings/importlists/testImportList'; export const TEST_IMPORT_LIST = 'settings/importlists/testImportList';
export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList'; export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList';
export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists'; export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists';
export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists';
export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists';
// //
// Action Creators // Action Creators
@ -43,6 +47,8 @@ export const deleteImportList = createThunk(DELETE_IMPORT_LIST);
export const testImportList = createThunk(TEST_IMPORT_LIST); export const testImportList = createThunk(TEST_IMPORT_LIST);
export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST); export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST);
export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS); export const testAllImportLists = createThunk(TEST_ALL_IMPORT_LISTS);
export const bulkEditImportLists = createThunk(BULK_EDIT_IMPORT_LISTS);
export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS);
export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => { export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => {
return { return {
@ -77,6 +83,8 @@ export default {
selectedSchema: {}, selectedSchema: {},
isSaving: false, isSaving: false,
saveError: null, saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false, isTesting: false,
isTestingAll: false, isTestingAll: false,
items: [], items: [],
@ -95,7 +103,9 @@ export default {
[DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'), [DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'),
[TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'), [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'),
[CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section), [CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section),
[TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist') [TEST_ALL_IMPORT_LISTS]: createTestAllProvidersHandler(section, '/importlist'),
[BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk'),
[BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk')
}, },
// //

@ -1,4 +1,6 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
@ -32,6 +34,8 @@ export const DELETE_INDEXER = 'settings/indexers/deleteIndexer';
export const TEST_INDEXER = 'settings/indexers/testIndexer'; export const TEST_INDEXER = 'settings/indexers/testIndexer';
export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer';
export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers';
export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers';
export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers';
// //
// Action Creators // Action Creators
@ -47,6 +51,8 @@ export const deleteIndexer = createThunk(DELETE_INDEXER);
export const testIndexer = createThunk(TEST_INDEXER); export const testIndexer = createThunk(TEST_INDEXER);
export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER);
export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); export const testAllIndexers = createThunk(TEST_ALL_INDEXERS);
export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS);
export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS);
export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => {
return { return {
@ -81,6 +87,8 @@ export default {
selectedSchema: {}, selectedSchema: {},
isSaving: false, isSaving: false,
saveError: null, saveError: null,
isDeleting: false,
deleteError: null,
isTesting: false, isTesting: false,
isTestingAll: false, isTestingAll: false,
items: [], items: [],
@ -99,7 +107,9 @@ export default {
[DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'),
[TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'),
[CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section),
[TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'),
[BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk'),
[BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk')
}, },
// //

@ -1,5 +1,16 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
export function createMetadataProfileSelectorForHook(metadataProfileId) {
return createSelector(
(state) => state.settings.metadataProfiles.items,
(metadataProfiles) => {
return metadataProfiles.find((profile) => {
return profile.id === metadataProfileId;
});
}
);
}
function createMetadataProfileSelector() { function createMetadataProfileSelector() {
return createSelector( return createSelector(
(state, { metadataProfileId }) => metadataProfileId, (state, { metadataProfileId }) => metadataProfileId,

@ -1,5 +1,16 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
export function createQualityProfileSelectorForHook(qualityProfileId) {
return createSelector(
(state) => state.settings.qualityProfiles.items,
(qualityProfiles) => {
return qualityProfiles.find((profile) => {
return profile.id === qualityProfileId;
});
}
);
}
function createQualityProfileSelector() { function createQualityProfileSelector() {
return createSelector( return createSelector(
(state, { qualityProfileId }) => qualityProfileId, (state, { qualityProfileId }) => qualityProfileId,

@ -23,7 +23,7 @@ export function fetchTranslations() {
}); });
} }
export default function translate(key, args) { export default function translate(key, args = []) {
const translation = translations[key] || key; const translation = translations[key] || key;
if (args) { if (args) {

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

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save