diff --git a/frontend/src/Components/Form/AppProfileSelectInputConnector.js b/frontend/src/Components/Form/AppProfileSelectInputConnector.js
index fc40e9d3c..0ab181e2f 100644
--- a/frontend/src/Components/Form/AppProfileSelectInputConnector.js
+++ b/frontend/src/Components/Form/AppProfileSelectInputConnector.js
@@ -4,12 +4,13 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
-import sortByName from 'Utilities/Array/sortByName';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
import SelectInput from './SelectInput';
function createMapStateToProps() {
return createSelector(
- createSortedSectionSelector('settings.appProfiles', sortByName),
+ createSortedSectionSelector('settings.appProfiles', sortByProp('name')),
(state, { includeNoChange }) => includeNoChange,
(state, { includeMixed }) => includeMixed,
(appProfiles, includeNoChange, includeMixed) => {
@@ -23,16 +24,20 @@ function createMapStateToProps() {
if (includeNoChange) {
values.unshift({
key: 'noChange',
- value: 'No Change',
- disabled: true
+ get value() {
+ return translate('NoChange');
+ },
+ isDisabled: true
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
- value: '(Mixed)',
- disabled: true
+ get value() {
+ return `(${translate('Mixed')})`;
+ },
+ isDisabled: true
});
}
diff --git a/frontend/src/Components/Form/AvailabilitySelectInput.js b/frontend/src/Components/Form/AvailabilitySelectInput.js
deleted file mode 100644
index af9bdb2d6..000000000
--- a/frontend/src/Components/Form/AvailabilitySelectInput.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import SelectInput from './SelectInput';
-
-const availabilityOptions = [
- { key: 'announced', value: 'Announced' },
- { key: 'inCinemas', value: 'In Cinemas' },
- { key: 'released', value: 'Released' },
- { key: 'preDB', value: 'PreDB' }
-];
-
-function AvailabilitySelectInput(props) {
- const values = [...availabilityOptions];
-
- const {
- includeNoChange,
- includeMixed
- } = props;
-
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- value: 'No Change',
- disabled: true
- });
- }
-
- if (includeMixed) {
- values.unshift({
- key: 'mixed',
- value: '(Mixed)',
- disabled: true
- });
- }
-
- return (
-
- );
-}
-
-AvailabilitySelectInput.propTypes = {
- includeNoChange: PropTypes.bool.isRequired,
- includeMixed: PropTypes.bool.isRequired
-};
-
-AvailabilitySelectInput.defaultProps = {
- includeNoChange: false,
- includeMixed: false
-};
-
-export default AvailabilitySelectInput;
diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js
new file mode 100644
index 000000000..9cf7a429a
--- /dev/null
+++ b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDownloadClients } from 'Store/Actions/settingsActions';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput from './EnhancedSelectInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.downloadClients,
+ (state, { includeAny }) => includeAny,
+ (state, { protocol }) => protocol,
+ (downloadClients, includeAny, protocolFilter) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = downloadClients;
+
+ const values = items
+ .filter((downloadClient) => downloadClient.protocol === protocolFilter)
+ .sort(sortByProp('name'))
+ .map((downloadClient) => ({
+ key: downloadClient.id,
+ value: downloadClient.name,
+ hint: `(${downloadClient.id})`
+ }));
+
+ if (includeAny) {
+ values.unshift({
+ key: 0,
+ value: `(${translate('Any')})`
+ });
+ }
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ values
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ dispatchFetchDownloadClients: fetchDownloadClients
+};
+
+class DownloadClientSelectInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.dispatchFetchDownloadClients();
+ }
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ this.props.onChange({ name, value: parseInt(value) });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DownloadClientSelectInputConnector.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ includeAny: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ dispatchFetchDownloadClients: PropTypes.func.isRequired
+};
+
+DownloadClientSelectInputConnector.defaultProps = {
+ includeAny: false,
+ protocol: 'torrent'
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector);
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js
index 4df54092c..79b1c999c 100644
--- a/frontend/src/Components/Form/EnhancedSelectInput.js
+++ b/frontend/src/Components/Form/EnhancedSelectInput.js
@@ -20,6 +20,8 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css';
+const MINIMUM_DISTANCE_FROM_EDGE = 10;
+
function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@@ -137,18 +139,9 @@ class EnhancedSelectInput extends Component {
// Listeners
onComputeMaxHeight = (data) => {
- const {
- top,
- bottom
- } = data.offsets.reference;
-
const windowHeight = window.innerHeight;
- if ((/^botton/).test(data.placement)) {
- data.styles.maxHeight = windowHeight - bottom;
- } else {
- data.styles.maxHeight = top;
- }
+ data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
return data;
};
@@ -271,26 +264,29 @@ class EnhancedSelectInput extends Component {
this.setState({ isOpen: !this.state.isOpen });
};
- onSelect = (value) => {
- if (Array.isArray(this.props.value)) {
- let newValue = null;
- const index = this.props.value.indexOf(value);
+ onSelect = (newValue) => {
+ const { name, value, values, onChange } = this.props;
+
+ if (Array.isArray(value)) {
+ let arrayValue = null;
+ const index = value.indexOf(newValue);
+
if (index === -1) {
- newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v));
+ arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v));
} else {
- newValue = [...this.props.value];
- newValue.splice(index, 1);
+ arrayValue = [...value];
+ arrayValue.splice(index, 1);
}
- this.props.onChange({
- name: this.props.name,
- value: newValue
+ onChange({
+ name,
+ value: arrayValue
});
} else {
this.setState({ isOpen: false });
- this.props.onChange({
- name: this.props.name,
- value
+ onChange({
+ name,
+ value: newValue
});
}
};
@@ -457,6 +453,10 @@ class EnhancedSelectInput extends Component {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
+ },
+ preventOverflow: {
+ enabled: true,
+ boundariesElement: 'viewport'
}
}}
>
@@ -485,7 +485,7 @@ class EnhancedSelectInput extends Component {
values.map((v, index) => {
const hasParent = v.parentKey !== undefined;
const depth = hasParent ? 1 : 0;
- const parentSelected = hasParent && value.includes(v.parentKey);
+ const parentSelected = hasParent && Array.isArray(value) && value.includes(v.parentKey);
return (
{error.errorMessage}
+
+ {
+ error.detailedDescription ?
+ }
+ tooltip={error.detailedDescription}
+ kind={kinds.INVERSE}
+ position={tooltipPositions.TOP}
+ /> :
+ null
+ }
);
})
@@ -39,6 +53,18 @@ function Form(props) {
kind={kinds.WARNING}
>
{warning.errorMessage}
+
+ {
+ warning.detailedDescription ?
+ }
+ tooltip={warning.detailedDescription}
+ kind={kinds.INVERSE}
+ position={tooltipPositions.TOP}
+ /> :
+ null
+ }
);
})
diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js
deleted file mode 100644
index a7145363a..000000000
--- a/frontend/src/Components/Form/FormInputButton.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import Button from 'Components/Link/Button';
-import SpinnerButton from 'Components/Link/SpinnerButton';
-import { kinds } from 'Helpers/Props';
-import styles from './FormInputButton.css';
-
-function FormInputButton(props) {
- const {
- className,
- canSpin,
- isLastButton,
- ...otherProps
- } = props;
-
- if (canSpin) {
- return (
-
- );
- }
-
- return (
-
- );
-}
-
-FormInputButton.propTypes = {
- className: PropTypes.string.isRequired,
- isLastButton: PropTypes.bool.isRequired,
- canSpin: PropTypes.bool.isRequired
-};
-
-FormInputButton.defaultProps = {
- className: styles.button,
- isLastButton: true,
- canSpin: false
-};
-
-export default FormInputButton;
diff --git a/frontend/src/Components/Form/FormInputButton.tsx b/frontend/src/Components/Form/FormInputButton.tsx
new file mode 100644
index 000000000..f61779122
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.tsx
@@ -0,0 +1,38 @@
+import classNames from 'classnames';
+import React from 'react';
+import Button, { ButtonProps } from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import { kinds } from 'Helpers/Props';
+import styles from './FormInputButton.css';
+
+export interface FormInputButtonProps extends ButtonProps {
+ canSpin?: boolean;
+ isLastButton?: boolean;
+}
+
+function FormInputButton({
+ className = styles.button,
+ canSpin = false,
+ isLastButton = true,
+ ...otherProps
+}: FormInputButtonProps) {
+ if (canSpin) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default FormInputButton;
diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js
index f25946f59..5b3b42de2 100644
--- a/frontend/src/Components/Form/FormInputGroup.js
+++ b/frontend/src/Components/Form/FormInputGroup.js
@@ -5,11 +5,11 @@ import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AppProfileSelectInputConnector from './AppProfileSelectInputConnector';
import AutoCompleteInput from './AutoCompleteInput';
-import AvailabilitySelectInput from './AvailabilitySelectInput';
import CaptchaInputConnector from './CaptchaInputConnector';
import CardigannCaptchaInputConnector from './CardigannCaptchaInputConnector';
import CheckInput from './CheckInput';
import DeviceInputConnector from './DeviceInputConnector';
+import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector';
import EnhancedSelectInput from './EnhancedSelectInput';
import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
import FormInputHelpText from './FormInputHelpText';
@@ -36,9 +36,6 @@ function getComponent(type) {
case inputTypes.AUTO_COMPLETE:
return AutoCompleteInput;
- case inputTypes.AVAILABILITY_SELECT:
- return AvailabilitySelectInput;
-
case inputTypes.CAPTCHA:
return CaptchaInputConnector;
@@ -72,6 +69,9 @@ function getComponent(type) {
case inputTypes.CATEGORY_SELECT:
return NewznabCategorySelectInputConnector;
+ case inputTypes.DOWNLOAD_CLIENT_SELECT:
+ return DownloadClientSelectInputConnector;
+
case inputTypes.INDEXER_FLAGS_SELECT:
return IndexerFlagsSelectInputConnector;
@@ -256,14 +256,18 @@ FormInputGroup.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
+ isFloat: PropTypes.bool,
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
+ min: PropTypes.number,
+ max: PropTypes.number,
unit: PropTypes.string,
buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
helpText: PropTypes.string,
helpTexts: PropTypes.arrayOf(PropTypes.string),
helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
+ autoFocus: PropTypes.bool,
includeNoChange: PropTypes.bool,
includeNoChangeDisabled: PropTypes.bool,
selectedValueOptions: PropTypes.object,
diff --git a/frontend/src/Components/Form/FormInputHelpText.js b/frontend/src/Components/Form/FormInputHelpText.js
index 39a0a8e74..00024684e 100644
--- a/frontend/src/Components/Form/FormInputHelpText.js
+++ b/frontend/src/Components/Form/FormInputHelpText.js
@@ -25,7 +25,7 @@ function FormInputHelpText(props) {
isCheckInput && styles.isCheckInput
)}
>
-
+ {text}
{
link ?
diff --git a/frontend/src/Components/Form/FormLabel.css b/frontend/src/Components/Form/FormLabel.css
index 074b6091d..54a4678e8 100644
--- a/frontend/src/Components/Form/FormLabel.css
+++ b/frontend/src/Components/Form/FormLabel.css
@@ -2,8 +2,10 @@
display: flex;
justify-content: flex-end;
margin-right: $formLabelRightMarginWidth;
+ padding-top: 8px;
+ min-height: 35px;
+ text-align: end;
font-weight: bold;
- line-height: 35px;
}
.hasError {
diff --git a/frontend/src/Components/Form/HintedSelectInputOption.js b/frontend/src/Components/Form/HintedSelectInputOption.js
index 4957ece2a..4f59fc0a4 100644
--- a/frontend/src/Components/Form/HintedSelectInputOption.js
+++ b/frontend/src/Components/Form/HintedSelectInputOption.js
@@ -33,11 +33,11 @@ function HintedSelectInputOption(props) {
isMobile && styles.isMobile
)}
>
- {value}
+ {typeof value === 'function' ? value() : value}
{
hint != null &&
-
+
{hint}
}
@@ -48,7 +48,7 @@ function HintedSelectInputOption(props) {
HintedSelectInputOption.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- value: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
hint: PropTypes.node,
depth: PropTypes.number,
isSelected: PropTypes.bool.isRequired,
diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
index 07f6c9e25..a3fecf324 100644
--- a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
+++ b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
@@ -24,7 +24,7 @@ function HintedSelectInputSelectedValue(props) {
>
{
- isMultiSelect &&
+ isMultiSelect ?
value.map((key, index) => {
const v = valuesMap[key];
return (
@@ -32,26 +32,28 @@ function HintedSelectInputSelectedValue(props) {
{v ? v.value : key}
);
- })
+ }) :
+ null
}
{
- !isMultiSelect && value
+ isMultiSelect ? null : value
}
{
- hint != null && includeHint &&
+ hint != null && includeHint ?
{hint}
-
+
:
+ null
}
);
}
HintedSelectInputSelectedValue.propTypes = {
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number]))]).isRequired,
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number]))]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
hint: PropTypes.string,
isMultiSelect: PropTypes.bool.isRequired,
diff --git a/frontend/src/Components/Form/IndexersSelectInputConnector.js b/frontend/src/Components/Form/IndexersSelectInputConnector.js
index bc411d5cc..fade1e758 100644
--- a/frontend/src/Components/Form/IndexersSelectInputConnector.js
+++ b/frontend/src/Components/Form/IndexersSelectInputConnector.js
@@ -1,18 +1,20 @@
-import _ from 'lodash';
+import { groupBy, map } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import sortByProp from 'Utilities/Array/sortByProp';
import titleCase from 'Utilities/String/titleCase';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
- (state) => state.indexers,
+ createSortedSectionSelector('indexers', sortByProp('name')),
(value, indexers) => {
const values = [];
- const groupedIndexers = _(indexers.items).groupBy((x) => x.protocol).map((val, key) => ({ protocol: key, indexers: val })).value();
+ const groupedIndexers = map(groupBy(indexers.items, 'protocol'), (val, key) => ({ protocol: key, indexers: val }));
groupedIndexers.forEach((element) => {
values.push({
@@ -21,10 +23,12 @@ function createMapStateToProps() {
});
if (element.indexers && element.indexers.length > 0) {
- element.indexers.forEach((subCat) => {
+ element.indexers.forEach((indexer) => {
values.push({
- key: subCat.id,
- value: subCat.name,
+ key: indexer.id,
+ value: indexer.name,
+ hint: `(${indexer.id})`,
+ isDisabled: !indexer.enable,
parentKey: element.protocol === 'usenet' ? -1 : -2
});
});
@@ -49,7 +53,6 @@ class IndexersSelectInputConnector extends Component {
// Render
render() {
-
return (
+
+
+
);
}
}
diff --git a/frontend/src/Components/Form/NumberInput.js b/frontend/src/Components/Form/NumberInput.js
index 8db1cd7b6..cac274d95 100644
--- a/frontend/src/Components/Form/NumberInput.js
+++ b/frontend/src/Components/Form/NumberInput.js
@@ -10,7 +10,7 @@ function parseValue(props, value) {
} = props;
if (value == null || value === '') {
- return min;
+ return null;
}
let newValue = isFloat ? parseFloat(value) : parseInt(value);
@@ -41,7 +41,7 @@ class NumberInput extends Component {
componentDidUpdate(prevProps, prevState) {
const { value } = this.props;
- if (value !== prevProps.value && !this.state.isFocused) {
+ if (!isNaN(value) && value !== prevProps.value && !this.state.isFocused) {
this.setState({
value: value == null ? '' : value.toString()
});
diff --git a/frontend/src/Components/Form/PasswordInput.css b/frontend/src/Components/Form/PasswordInput.css
deleted file mode 100644
index 6cb162784..000000000
--- a/frontend/src/Components/Form/PasswordInput.css
+++ /dev/null
@@ -1,5 +0,0 @@
-.input {
- composes: input from '~Components/Form/TextInput.css';
-
- font-family: $passwordFamily;
-}
diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js
index fef54fd5a..dbc4cfdb4 100644
--- a/frontend/src/Components/Form/PasswordInput.js
+++ b/frontend/src/Components/Form/PasswordInput.js
@@ -1,7 +1,5 @@
-import PropTypes from 'prop-types';
import React from 'react';
import TextInput from './TextInput';
-import styles from './PasswordInput.css';
// Prevent a user from copying (or cutting) the password from the input
function onCopy(e) {
@@ -13,17 +11,14 @@ function PasswordInput(props) {
return (
);
}
PasswordInput.propTypes = {
- className: PropTypes.string.isRequired
-};
-
-PasswordInput.defaultProps = {
- className: styles.input
+ ...TextInput.props
};
export default PasswordInput;
diff --git a/frontend/src/Components/Form/PathInputConnector.js b/frontend/src/Components/Form/PathInputConnector.js
index 3917a8d3f..563437f9a 100644
--- a/frontend/src/Components/Form/PathInputConnector.js
+++ b/frontend/src/Components/Form/PathInputConnector.js
@@ -68,6 +68,7 @@ class PathInputConnector extends Component {
}
PathInputConnector.propTypes = {
+ ...PathInput.props,
includeFiles: PropTypes.bool.isRequired,
dispatchFetchPaths: PropTypes.func.isRequired,
dispatchClearPaths: PropTypes.func.isRequired
diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
index 2b32e0e38..878e3a7ce 100644
--- a/frontend/src/Components/Form/ProviderFieldFormGroup.js
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -67,6 +67,7 @@ function ProviderFieldFormGroup(props) {
name,
label,
helpText,
+ helpTextWarning,
helpLink,
placeholder,
value,
@@ -100,6 +101,7 @@ function ProviderFieldFormGroup(props) {
name={name}
label={label}
helpText={helpText}
+ helpTextWarning={helpTextWarning}
helpLink={helpLink}
placeholder={placeholder}
value={value}
@@ -126,6 +128,7 @@ ProviderFieldFormGroup.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.string,
helpText: PropTypes.string,
+ helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
placeholder: PropTypes.string,
value: PropTypes.any,
diff --git a/frontend/src/Components/Form/SelectInput.css b/frontend/src/Components/Form/SelectInput.css
index aa1dfc79b..f6806b065 100644
--- a/frontend/src/Components/Form/SelectInput.css
+++ b/frontend/src/Components/Form/SelectInput.css
@@ -1,7 +1,14 @@
.select {
+ @add-mixin truncate;
+
composes: input from '~Components/Form/Input.css';
- padding: 0 11px;
+ padding: 0 30px 0 11px;
+ background-image: none, linear-gradient(-135deg, transparent 50%, var(--inputBackgroundColor) 50%), linear-gradient(-225deg, transparent 50%, var(--inputBackgroundColor) 50%), linear-gradient(var(--inputBackgroundColor) 42%, var(--textColor) 42%);
+ background-position: right 30px center, right bottom, right bottom, right bottom;
+ background-size: 1px 100%, 35px 27px, 30px 35px, 30px 100%;
+ background-repeat: no-repeat;
+ appearance: none;
}
.hasError {
diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js
index 0a60ffe1e..553501afc 100644
--- a/frontend/src/Components/Form/SelectInput.js
+++ b/frontend/src/Components/Form/SelectInput.js
@@ -61,7 +61,7 @@ class SelectInput extends Component {
value={key}
{...otherOptionProps}
>
- {optionValue}
+ {typeof optionValue === 'function' ? optionValue() : optionValue}
);
})
@@ -75,7 +75,7 @@ SelectInput.propTypes = {
className: PropTypes.string,
disabledClassName: PropTypes.string,
name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.func]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool,
hasError: PropTypes.bool,
diff --git a/frontend/src/Components/Icon.js b/frontend/src/Components/Icon.js
index e8c7c5178..d200b8c08 100644
--- a/frontend/src/Components/Icon.js
+++ b/frontend/src/Components/Icon.js
@@ -41,7 +41,7 @@ class Icon extends PureComponent {
return (
{icon}
@@ -58,7 +58,7 @@ Icon.propTypes = {
name: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
- title: PropTypes.string,
+ title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
isSpinning: PropTypes.bool.isRequired,
fixedWidth: PropTypes.bool.isRequired
};
diff --git a/frontend/src/Components/Label.js b/frontend/src/Components/Label.js
deleted file mode 100644
index 844da8165..000000000
--- a/frontend/src/Components/Label.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { kinds, sizes } from 'Helpers/Props';
-import styles from './Label.css';
-
-function Label(props) {
- const {
- className,
- kind,
- size,
- outline,
- children,
- ...otherProps
- } = props;
-
- return (
-
- {children}
-
- );
-}
-
-Label.propTypes = {
- className: PropTypes.string.isRequired,
- title: PropTypes.string,
- kind: PropTypes.oneOf(kinds.all).isRequired,
- size: PropTypes.oneOf(sizes.all).isRequired,
- outline: PropTypes.bool.isRequired,
- children: PropTypes.node.isRequired
-};
-
-Label.defaultProps = {
- className: styles.label,
- kind: kinds.DEFAULT,
- size: sizes.SMALL,
- outline: false
-};
-
-export default Label;
diff --git a/frontend/src/Components/Label.tsx b/frontend/src/Components/Label.tsx
new file mode 100644
index 000000000..9ab360f42
--- /dev/null
+++ b/frontend/src/Components/Label.tsx
@@ -0,0 +1,33 @@
+import classNames from 'classnames';
+import React, { ComponentProps, ReactNode } from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import { Kind } from 'Helpers/Props/kinds';
+import { Size } from 'Helpers/Props/sizes';
+import styles from './Label.css';
+
+export interface LabelProps extends ComponentProps<'span'> {
+ kind?: Extract;
+ size?: Extract;
+ outline?: boolean;
+ children: ReactNode;
+}
+
+export default function Label({
+ className = styles.label,
+ kind = kinds.DEFAULT,
+ size = sizes.SMALL,
+ outline = false,
+ ...otherProps
+}: LabelProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/Components/Link/Button.js b/frontend/src/Components/Link/Button.js
deleted file mode 100644
index cbe4691d4..000000000
--- a/frontend/src/Components/Link/Button.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { align, kinds, sizes } from 'Helpers/Props';
-import Link from './Link';
-import styles from './Button.css';
-
-class Button extends Component {
-
- //
- // Render
-
- render() {
- const {
- className,
- buttonGroupPosition,
- kind,
- size,
- children,
- ...otherProps
- } = this.props;
-
- return (
-
- {children}
-
- );
- }
-
-}
-
-Button.propTypes = {
- className: PropTypes.string.isRequired,
- buttonGroupPosition: PropTypes.oneOf(align.all),
- kind: PropTypes.oneOf(kinds.all),
- size: PropTypes.oneOf(sizes.all),
- children: PropTypes.node
-};
-
-Button.defaultProps = {
- className: styles.button,
- kind: kinds.DEFAULT,
- size: sizes.MEDIUM
-};
-
-export default Button;
diff --git a/frontend/src/Components/Link/Button.tsx b/frontend/src/Components/Link/Button.tsx
new file mode 100644
index 000000000..cf2293f59
--- /dev/null
+++ b/frontend/src/Components/Link/Button.tsx
@@ -0,0 +1,37 @@
+import classNames from 'classnames';
+import React from 'react';
+import { align, kinds, sizes } from 'Helpers/Props';
+import { Kind } from 'Helpers/Props/kinds';
+import { Size } from 'Helpers/Props/sizes';
+import Link, { LinkProps } from './Link';
+import styles from './Button.css';
+
+export interface ButtonProps extends Omit {
+ buttonGroupPosition?: Extract<
+ (typeof align.all)[number],
+ keyof typeof styles
+ >;
+ kind?: Extract;
+ size?: Extract;
+ children: Required;
+}
+
+export default function Button({
+ className = styles.button,
+ buttonGroupPosition,
+ kind = kinds.DEFAULT,
+ size = sizes.MEDIUM,
+ ...otherProps
+}: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/Components/Link/ClipboardButton.js b/frontend/src/Components/Link/ClipboardButton.js
deleted file mode 100644
index 55843f05f..000000000
--- a/frontend/src/Components/Link/ClipboardButton.js
+++ /dev/null
@@ -1,139 +0,0 @@
-import Clipboard from 'clipboard';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import FormInputButton from 'Components/Form/FormInputButton';
-import Icon from 'Components/Icon';
-import { icons, kinds } from 'Helpers/Props';
-import getUniqueElememtId from 'Utilities/getUniqueElementId';
-import styles from './ClipboardButton.css';
-
-class ClipboardButton extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._id = getUniqueElememtId();
- this._successTimeout = null;
- this._testResultTimeout = null;
-
- this.state = {
- showSuccess: false,
- showError: false
- };
- }
-
- componentDidMount() {
- this._clipboard = new Clipboard(`#${this._id}`, {
- text: () => this.props.value,
- container: document.getElementById(this._id)
- });
-
- this._clipboard.on('success', this.onSuccess);
- }
-
- componentDidUpdate() {
- const {
- showSuccess,
- showError
- } = this.state;
-
- if (showSuccess || showError) {
- this._testResultTimeout = setTimeout(this.resetState, 3000);
- }
- }
-
- componentWillUnmount() {
- if (this._clipboard) {
- this._clipboard.destroy();
- }
-
- if (this._testResultTimeout) {
- clearTimeout(this._testResultTimeout);
- }
- }
-
- //
- // Control
-
- resetState = () => {
- this.setState({
- showSuccess: false,
- showError: false
- });
- };
-
- //
- // Listeners
-
- onSuccess = () => {
- this.setState({
- showSuccess: true
- });
- };
-
- onError = () => {
- this.setState({
- showError: true
- });
- };
-
- //
- // Render
-
- render() {
- const {
- value,
- className,
- ...otherProps
- } = this.props;
-
- const {
- showSuccess,
- showError
- } = this.state;
-
- const showStateIcon = showSuccess || showError;
- const iconName = showError ? icons.DANGER : icons.CHECK;
- const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
-
- return (
-
-
- {
- showSuccess &&
-
-
-
- }
-
- {
-
-
-
- }
-
-
- );
- }
-}
-
-ClipboardButton.propTypes = {
- className: PropTypes.string.isRequired,
- value: PropTypes.string.isRequired
-};
-
-ClipboardButton.defaultProps = {
- className: styles.button
-};
-
-export default ClipboardButton;
diff --git a/frontend/src/Components/Link/ClipboardButton.tsx b/frontend/src/Components/Link/ClipboardButton.tsx
new file mode 100644
index 000000000..dfce115ac
--- /dev/null
+++ b/frontend/src/Components/Link/ClipboardButton.tsx
@@ -0,0 +1,76 @@
+import copy from 'copy-to-clipboard';
+import React, { useCallback, useEffect, useState } from 'react';
+import FormInputButton from 'Components/Form/FormInputButton';
+import Icon from 'Components/Icon';
+import { icons, kinds } from 'Helpers/Props';
+import { ButtonProps } from './Button';
+import styles from './ClipboardButton.css';
+
+export interface ClipboardButtonProps extends Omit {
+ value: string;
+}
+
+export type ClipboardState = 'success' | 'error' | null;
+
+export default function ClipboardButton({
+ id,
+ value,
+ className = styles.button,
+ ...otherProps
+}: ClipboardButtonProps) {
+ const [state, setState] = useState(null);
+
+ useEffect(() => {
+ if (!state) {
+ return;
+ }
+
+ const timeoutId = setTimeout(() => {
+ setState(null);
+ }, 3000);
+
+ return () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ };
+ }, [state]);
+
+ const handleClick = useCallback(async () => {
+ try {
+ if ('clipboard' in navigator) {
+ await navigator.clipboard.writeText(value);
+ } else {
+ copy(value);
+ }
+
+ setState('success');
+ } catch (e) {
+ setState('error');
+ console.error(`Failed to copy to clipboard`, e);
+ }
+ }, [value]);
+
+ return (
+
+
+ {state ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/Components/Link/Link.js b/frontend/src/Components/Link/Link.js
deleted file mode 100644
index 6b5baca4e..000000000
--- a/frontend/src/Components/Link/Link.js
+++ /dev/null
@@ -1,98 +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) {
- 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.Prowlarr.urlBase}/${to.replace(/^\//, '')}`;
- linkProps.target = target;
- }
- }
-
- 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.string,
- target: PropTypes.string,
- isDisabled: PropTypes.bool,
- noRouter: PropTypes.bool,
- onPress: PropTypes.func
-};
-
-Link.defaultProps = {
- component: 'button',
- noRouter: false
-};
-
-export default Link;
diff --git a/frontend/src/Components/Link/Link.tsx b/frontend/src/Components/Link/Link.tsx
new file mode 100644
index 000000000..6f1fd1ff7
--- /dev/null
+++ b/frontend/src/Components/Link/Link.tsx
@@ -0,0 +1,93 @@
+import classNames from 'classnames';
+import React, {
+ ComponentPropsWithoutRef,
+ ElementType,
+ SyntheticEvent,
+ useCallback,
+} from 'react';
+import { Link as RouterLink } from 'react-router-dom';
+import styles from './Link.css';
+
+export type LinkProps =
+ ComponentPropsWithoutRef & {
+ component?: C;
+ to?: string;
+ target?: string;
+ isDisabled?: LinkProps['disabled'];
+ noRouter?: boolean;
+ onPress?(event: SyntheticEvent): void;
+ };
+
+export default function Link({
+ className,
+ component,
+ to,
+ target,
+ type,
+ isDisabled,
+ noRouter,
+ onPress,
+ ...otherProps
+}: LinkProps) {
+ const Component = component || 'button';
+
+ const onClick = useCallback(
+ (event: SyntheticEvent) => {
+ if (isDisabled) {
+ return;
+ }
+
+ onPress?.(event);
+ },
+ [isDisabled, onPress]
+ );
+
+ const linkClass = classNames(
+ className,
+ styles.link,
+ to && styles.to,
+ isDisabled && 'isDisabled'
+ );
+
+ if (to) {
+ const toLink = /\w+?:\/\//.test(to);
+
+ if (toLink || noRouter) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/Components/Link/SpinnerErrorButton.js b/frontend/src/Components/Link/SpinnerErrorButton.js
index 81d34f7c2..b0f39bc26 100644
--- a/frontend/src/Components/Link/SpinnerErrorButton.js
+++ b/frontend/src/Components/Link/SpinnerErrorButton.js
@@ -97,6 +97,7 @@ class SpinnerErrorButton extends Component {
render() {
const {
+ kind,
isSpinning,
error,
children,
@@ -112,7 +113,7 @@ class SpinnerErrorButton extends Component {
const showIcon = wasSuccessful || hasWarning || hasError;
let iconName = icons.CHECK;
- let iconKind = kinds.SUCCESS;
+ let iconKind = kind === kinds.PRIMARY ? kinds.DEFAULT : kinds.SUCCESS;
if (hasWarning) {
iconName = icons.WARNING;
@@ -126,6 +127,7 @@ class SpinnerErrorButton extends Component {
return (
@@ -154,6 +156,7 @@ class SpinnerErrorButton extends Component {
}
SpinnerErrorButton.propTypes = {
+ kind: PropTypes.oneOf(kinds.all),
isSpinning: PropTypes.bool.isRequired,
error: PropTypes.object,
children: PropTypes.node.isRequired
diff --git a/frontend/src/Components/Markdown/InlineMarkdown.js b/frontend/src/Components/Markdown/InlineMarkdown.js
index dc9ea9bf3..993bb241e 100644
--- a/frontend/src/Components/Markdown/InlineMarkdown.js
+++ b/frontend/src/Components/Markdown/InlineMarkdown.js
@@ -10,27 +10,55 @@ class InlineMarkdown extends Component {
render() {
const {
className,
- data
+ data,
+ blockClassName
} = this.props;
- // For now only replace links
+ // For now only replace links or code blocks (not both)
const markdownBlocks = [];
if (data) {
- const regex = RegExp(/\[(.+?)\]\((.+?)\)/g);
+ const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g);
let endIndex = 0;
let match = null;
- while ((match = regex.exec(data)) !== null) {
+
+ while ((match = linkRegex.exec(data)) !== null) {
if (match.index > endIndex) {
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
}
+
markdownBlocks.push({match[1]});
endIndex = match.index + match[0].length;
}
- if (endIndex !== data.length) {
+ if (endIndex !== data.length && markdownBlocks.length > 0) {
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
}
+
+ const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g);
+
+ endIndex = 0;
+ match = null;
+ let matchedCode = false;
+
+ while ((match = codeRegex.exec(data)) !== null) {
+ matchedCode = true;
+
+ if (match.index > endIndex) {
+ markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
+ }
+
+ markdownBlocks.push({match[0].substring(1, match[0].length - 1)}
);
+ endIndex = match.index + match[0].length;
+ }
+
+ if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) {
+ markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
+ }
+
+ if (markdownBlocks.length === 0) {
+ markdownBlocks.push(data);
+ }
}
return {markdownBlocks};
@@ -39,7 +67,8 @@ class InlineMarkdown extends Component {
InlineMarkdown.propTypes = {
className: PropTypes.string,
- data: PropTypes.string
+ data: PropTypes.string,
+ blockClassName: PropTypes.string
};
export default InlineMarkdown;
diff --git a/frontend/src/Components/Menu/FilterMenuContent.js b/frontend/src/Components/Menu/FilterMenuContent.js
index 1fdb2476f..7bc23c066 100644
--- a/frontend/src/Components/Menu/FilterMenuContent.js
+++ b/frontend/src/Components/Menu/FilterMenuContent.js
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
+import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import FilterMenuItem from './FilterMenuItem';
import MenuContent from './MenuContent';
@@ -33,25 +34,33 @@ class FilterMenuContent extends Component {
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
- {filter.label}
+ {typeof filter.label === 'function' ? filter.label() : filter.label}
);
})
}
{
- customFilters.map((filter) => {
- return (
-
- {filter.label}
-
- );
- })
+ customFilters.length > 0 ?
+ :
+ null
+ }
+
+ {
+ customFilters
+ .sort(sortByProp('label'))
+ .map((filter) => {
+ return (
+
+ {filter.label}
+
+ );
+ })
}
{
diff --git a/frontend/src/Components/Modal/ModalContent.js b/frontend/src/Components/Modal/ModalContent.js
index 8883bf2b9..1d3862a13 100644
--- a/frontend/src/Components/Modal/ModalContent.js
+++ b/frontend/src/Components/Modal/ModalContent.js
@@ -3,6 +3,7 @@ import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
import styles from './ModalContent.css';
function ModalContent(props) {
@@ -28,6 +29,7 @@ function ModalContent(props) {
}
diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js
index 77bed6a31..a2b0fca32 100644
--- a/frontend/src/Components/Page/ErrorPage.js
+++ b/frontend/src/Components/Page/ErrorPage.js
@@ -7,6 +7,7 @@ function ErrorPage(props) {
const {
version,
isLocalStorageSupported,
+ translationsError,
indexersError,
indexerStatusError,
indexerCategoriesError,
@@ -21,6 +22,8 @@ function ErrorPage(props) {
if (!isLocalStorageSupported) {
errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
+ } else if (translationsError) {
+ errorMessage = getErrorMessage(translationsError, 'Failed to load translations from API');
} else if (indexersError) {
errorMessage = getErrorMessage(indexersError, 'Failed to load indexers from API');
} else if (indexerStatusError) {
@@ -55,6 +58,7 @@ function ErrorPage(props) {
ErrorPage.propTypes = {
version: PropTypes.string.isRequired,
isLocalStorageSupported: PropTypes.bool.isRequired,
+ translationsError: PropTypes.object,
indexersError: PropTypes.object,
indexerStatusError: PropTypes.object,
indexerCategoriesError: PropTypes.object,
diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js
index 1d8b0cc55..b032c1eb3 100644
--- a/frontend/src/Components/Page/Header/PageHeader.js
+++ b/frontend/src/Components/Page/Header/PageHeader.js
@@ -7,7 +7,7 @@ import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import IndexerSearchInputConnector from './IndexerSearchInputConnector';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
-import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
+import PageHeaderActionsMenu from './PageHeaderActionsMenu';
import styles from './PageHeader.css';
class PageHeader extends Component {
@@ -78,6 +78,7 @@ class PageHeader extends Component {
aria-label="Donate"
to="https://prowlarr.com/donate"
size={14}
+ title={translate('Donate')}
/>
-
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js
deleted file mode 100644
index 87fee6b0d..000000000
--- a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Icon from 'Components/Icon';
-import Menu from 'Components/Menu/Menu';
-import MenuButton from 'Components/Menu/MenuButton';
-import MenuContent from 'Components/Menu/MenuContent';
-import MenuItem from 'Components/Menu/MenuItem';
-import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
-import { align, icons, kinds } from 'Helpers/Props';
-import translate from 'Utilities/String/translate';
-import styles from './PageHeaderActionsMenu.css';
-
-function PageHeaderActionsMenu(props) {
- const {
- formsAuth,
- onKeyboardShortcutsPress,
- onRestartPress,
- onShutdownPress
- } = props;
-
- return (
-