{
@@ -192,7 +196,7 @@ class FilterBuilderModalContent extends Component {
- Save
+ {translate('Save')}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
index 01c24b460..0b00c0f03 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
@@ -3,6 +3,7 @@ import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import IconButton from 'Components/Link/IconButton';
import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props';
+import sortByProp from 'Utilities/Array/sortByProp';
import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue';
import DateFilterBuilderRowValue from './DateFilterBuilderRowValue';
import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector';
@@ -11,7 +12,9 @@ import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValu
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
-import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
+import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
+import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue';
+import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue';
@@ -76,7 +79,13 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return QualityFilterBuilderRowValueConnector;
case filterBuilderValueTypes.QUALITY_PROFILE:
- return QualityProfileFilterBuilderRowValueConnector;
+ return QualityProfileFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.QUEUE_STATUS:
+ return QueueStatusFilterBuilderRowValue;
+
+ case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
+ return SeasonsMonitoredStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SERIES:
return SeriesFilterBuilderRowValue;
@@ -224,7 +233,7 @@ class FilterBuilderRow extends Component {
key: name,
value: typeof label === 'function' ? label() : label
};
- }).sort((a, b) => a.value.localeCompare(b.value));
+ }).sort(sortByProp('value'));
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
index 68fa5c557..217626c90 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
-import TagInput from 'Components/Form/TagInput';
+import TagInput from 'Components/Form/Tag/TagInput';
import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import convertToBytes from 'Utilities/Number/convertToBytes';
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
index a7aed80b6..d1419327a 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js
@@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { filterBuilderTypes } from 'Helpers/Props';
import * as filterTypes from 'Helpers/Props/filterTypes';
-import sortByName from 'Utilities/Array/sortByName';
+import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createTagListSelector() {
@@ -38,7 +38,7 @@ function createTagListSelector() {
}
return acc;
- }, []).sort(sortByName);
+ }, []).sort(sortByProp('name'));
}
return _.uniqBy(items, 'id');
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
index 7b6d6313a..063a97346 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
-import TagInputTag from 'Components/Form/TagInputTag';
+import TagInputTag from 'Components/Form/Tag/TagInputTag';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './FilterBuilderRowValueTag.css';
diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx
new file mode 100644
index 000000000..50036cb90
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
+import sortByProp from 'Utilities/Array/sortByProp';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+function createQualityProfilesSelector() {
+ return createSelector(
+ (state: AppState) => state.settings.qualityProfiles.items,
+ (qualityProfiles) => {
+ return qualityProfiles;
+ }
+ );
+}
+
+function QualityProfileFilterBuilderRowValue(
+ props: FilterBuilderRowValueProps
+) {
+ const qualityProfiles = useSelector(createQualityProfilesSelector());
+
+ const tagList = qualityProfiles
+ .map(({ id, name }) => ({ id, name }))
+ .sort(sortByProp('name'));
+
+ return
;
+}
+
+export default QualityProfileFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js
deleted file mode 100644
index 4a8b82283..000000000
--- a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import FilterBuilderRowValue from './FilterBuilderRowValue';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.settings.qualityProfiles,
- (qualityProfiles) => {
- const tagList = qualityProfiles.items.map((qualityProfile) => {
- const {
- id,
- name
- } = qualityProfile;
-
- return {
- id,
- name
- };
- });
-
- return {
- tagList
- };
- }
- );
-}
-
-export default connect(createMapStateToProps)(FilterBuilderRowValue);
diff --git a/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx
new file mode 100644
index 000000000..1127493a5
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import translate from 'Utilities/String/translate';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
+
+const statusTagList = [
+ {
+ id: 'queued',
+ get name() {
+ return translate('Queued');
+ },
+ },
+ {
+ id: 'paused',
+ get name() {
+ return translate('Paused');
+ },
+ },
+ {
+ id: 'downloading',
+ get name() {
+ return translate('Downloading');
+ },
+ },
+ {
+ id: 'completed',
+ get name() {
+ return translate('Completed');
+ },
+ },
+ {
+ id: 'failed',
+ get name() {
+ return translate('Failed');
+ },
+ },
+ {
+ id: 'warning',
+ get name() {
+ return translate('Warning');
+ },
+ },
+ {
+ id: 'delay',
+ get name() {
+ return translate('Delay');
+ },
+ },
+ {
+ id: 'downloadClientUnavailable',
+ get name() {
+ return translate('DownloadClientUnavailable');
+ },
+ },
+ {
+ id: 'fallback',
+ get name() {
+ return translate('Fallback');
+ },
+ },
+];
+
+function QueueStatusFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
+ return
;
+}
+
+export default QueueStatusFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js
new file mode 100644
index 000000000..b84260e3c
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import translate from 'Utilities/String/translate';
+import FilterBuilderRowValue from './FilterBuilderRowValue';
+
+const seasonsMonitoredStatusList = [
+ {
+ id: 'all',
+ get name() {
+ return translate('SeasonsMonitoredAll');
+ }
+ },
+ {
+ id: 'partial',
+ get name() {
+ return translate('SeasonsMonitoredPartial');
+ }
+ },
+ {
+ id: 'none',
+ get name() {
+ return translate('SeasonsMonitoredNone');
+ }
+ }
+];
+
+function SeasonsMonitoredStatusFilterBuilderRowValue(props) {
+ return (
+
+ );
+}
+
+export default SeasonsMonitoredStatusFilterBuilderRowValue;
diff --git a/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx
index 2eae79c80..88b34509a 100644
--- a/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx
+++ b/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { useSelector } from 'react-redux';
import Series from 'Series/Series';
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
-import sortByName from 'Utilities/Array/sortByName';
+import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
@@ -11,7 +11,7 @@ function SeriesFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
const tagList = allSeries
.map((series) => ({ id: series.id, name: series.title }))
- .sort(sortByName);
+ .sort(sortByProp('name'));
return
;
}
diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
index 3464300f1..e017f72e7 100644
--- a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
+++ b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
@@ -2,7 +2,7 @@ import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
-const seriesStatusList = [
+const statusTagList = [
{
id: 'continuing',
get name() {
@@ -32,7 +32,7 @@ const seriesStatusList = [
function SeriesStatusFilterBuilderRowValue(props) {
return (
);
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
index 7407f729a..9f378d5a2 100644
--- a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
@@ -37,8 +37,8 @@ class CustomFilter extends Component {
dispatchSetFilter
} = this.props;
- // Assume that delete and then unmounting means the delete was successful.
- // Moving this check to a ancestor would be more accurate, but would have
+ // Assume that delete and then unmounting means the deletion was successful.
+ // Moving this check to an ancestor would be more accurate, but would have
// more boilerplate.
if (this.state.isDeleting && id === selectedFilterKey) {
dispatchSetFilter({ selectedFilterKey: 'all' });
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js
index 28eb91599..99cb6ec5c 100644
--- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js
@@ -5,6 +5,7 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
+import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import CustomFilter from './CustomFilter';
import styles from './CustomFiltersModalContent.css';
@@ -31,7 +32,7 @@ function CustomFiltersModalContent(props) {
{
customFilters
- .sort((a, b) => a.label.localeCompare(b.label))
+ .sort((a, b) => sortByProp(a, b, 'label'))
.map((customFilter) => {
return (
{
- this.props.onChange({
- name: this.props.name,
- value: newValue
- });
- };
-
- onInputBlur = () => {
- this.setState({ suggestions: [] });
- };
-
- onSuggestionsFetchRequested = ({ value }) => {
- const { values } = this.props;
- const lowerCaseValue = jdu.replace(value).toLowerCase();
-
- const filteredValues = values.filter((v) => {
- return jdu.replace(v).toLowerCase().contains(lowerCaseValue);
- });
-
- this.setState({ suggestions: filteredValues });
- };
-
- onSuggestionsClearRequested = () => {
- this.setState({ suggestions: [] });
- };
-
- //
- // Render
-
- render() {
- const {
- name,
- value,
- ...otherProps
- } = this.props;
-
- const { suggestions } = this.state;
-
- return (
-
- );
- }
-}
-
-AutoCompleteInput.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.string,
- values: PropTypes.arrayOf(PropTypes.string).isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-AutoCompleteInput.defaultProps = {
- value: ''
-};
-
-export default AutoCompleteInput;
diff --git a/frontend/src/Components/Form/AutoCompleteInput.tsx b/frontend/src/Components/Form/AutoCompleteInput.tsx
new file mode 100644
index 000000000..7ba114125
--- /dev/null
+++ b/frontend/src/Components/Form/AutoCompleteInput.tsx
@@ -0,0 +1,81 @@
+import jdu from 'jdu';
+import React, { SyntheticEvent, useCallback, useState } from 'react';
+import {
+ ChangeEvent,
+ SuggestionsFetchRequestedParams,
+} from 'react-autosuggest';
+import { InputChanged } from 'typings/inputs';
+import AutoSuggestInput from './AutoSuggestInput';
+
+interface AutoCompleteInputProps {
+ name: string;
+ value?: string;
+ values: string[];
+ onChange: (change: InputChanged) => unknown;
+}
+
+function AutoCompleteInput({
+ name,
+ value = '',
+ values,
+ onChange,
+ ...otherProps
+}: AutoCompleteInputProps) {
+ const [suggestions, setSuggestions] = useState([]);
+
+ const getSuggestionValue = useCallback((item: string) => {
+ return item;
+ }, []);
+
+ const renderSuggestion = useCallback((item: string) => {
+ return item;
+ }, []);
+
+ const handleInputChange = useCallback(
+ (_event: SyntheticEvent, { newValue }: ChangeEvent) => {
+ onChange({
+ name,
+ value: newValue,
+ });
+ },
+ [name, onChange]
+ );
+
+ const handleInputBlur = useCallback(() => {
+ setSuggestions([]);
+ }, [setSuggestions]);
+
+ const handleSuggestionsFetchRequested = useCallback(
+ ({ value: newValue }: SuggestionsFetchRequestedParams) => {
+ const lowerCaseValue = jdu.replace(newValue).toLowerCase();
+
+ const filteredValues = values.filter((v) => {
+ return jdu.replace(v).toLowerCase().includes(lowerCaseValue);
+ });
+
+ setSuggestions(filteredValues);
+ },
+ [values, setSuggestions]
+ );
+
+ const handleSuggestionsClearRequested = useCallback(() => {
+ setSuggestions([]);
+ }, [setSuggestions]);
+
+ return (
+
+ );
+}
+
+export default AutoCompleteInput;
diff --git a/frontend/src/Components/Form/AutoSuggestInput.js b/frontend/src/Components/Form/AutoSuggestInput.js
deleted file mode 100644
index 34ec7530b..000000000
--- a/frontend/src/Components/Form/AutoSuggestInput.js
+++ /dev/null
@@ -1,257 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Autosuggest from 'react-autosuggest';
-import { Manager, Popper, Reference } from 'react-popper';
-import Portal from 'Components/Portal';
-import styles from './AutoSuggestInput.css';
-
-class AutoSuggestInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._scheduleUpdate = null;
- }
-
- componentDidUpdate(prevProps) {
- if (
- this._scheduleUpdate &&
- prevProps.suggestions !== this.props.suggestions
- ) {
- this._scheduleUpdate();
- }
- }
-
- //
- // Control
-
- renderInputComponent = (inputProps) => {
- const { renderInputComponent } = this.props;
-
- return (
-
- {({ ref }) => {
- if (renderInputComponent) {
- return renderInputComponent(inputProps, ref);
- }
-
- return (
-
-
-
- );
- }}
-
- );
- };
-
- renderSuggestionsContainer = ({ containerProps, children }) => {
- return (
-
-
- {({ ref: popperRef, style, scheduleUpdate }) => {
- this._scheduleUpdate = scheduleUpdate;
-
- return (
-
- );
- }}
-
-
- );
- };
-
- //
- // Listeners
-
- onComputeMaxHeight = (data) => {
- const {
- top,
- bottom,
- width
- } = data.offsets.reference;
-
- const windowHeight = window.innerHeight;
-
- if ((/^botton/).test(data.placement)) {
- data.styles.maxHeight = windowHeight - bottom;
- } else {
- data.styles.maxHeight = top;
- }
-
- data.styles.width = width;
-
- return data;
- };
-
- onInputChange = (event, { newValue }) => {
- this.props.onChange({
- name: this.props.name,
- value: newValue
- });
- };
-
- onInputKeyDown = (event) => {
- const {
- name,
- value,
- suggestions,
- onChange
- } = this.props;
-
- if (
- event.key === 'Tab' &&
- suggestions.length &&
- suggestions[0] !== this.props.value
- ) {
- event.preventDefault();
-
- if (value) {
- onChange({
- name,
- value: suggestions[0]
- });
- }
- }
- };
-
- //
- // Render
-
- render() {
- const {
- forwardedRef,
- className,
- inputContainerClassName,
- name,
- value,
- placeholder,
- suggestions,
- hasError,
- hasWarning,
- getSuggestionValue,
- renderSuggestion,
- onInputChange,
- onInputKeyDown,
- onInputFocus,
- onInputBlur,
- onSuggestionsFetchRequested,
- onSuggestionsClearRequested,
- onSuggestionSelected,
- ...otherProps
- } = this.props;
-
- const inputProps = {
- className: classNames(
- className,
- hasError && styles.hasError,
- hasWarning && styles.hasWarning
- ),
- name,
- value,
- placeholder,
- autoComplete: 'off',
- spellCheck: false,
- onChange: onInputChange || this.onInputChange,
- onKeyDown: onInputKeyDown || this.onInputKeyDown,
- onFocus: onInputFocus,
- onBlur: onInputBlur
- };
-
- const theme = {
- container: inputContainerClassName,
- containerOpen: styles.suggestionsContainerOpen,
- suggestionsContainer: styles.suggestionsContainer,
- suggestionsList: styles.suggestionsList,
- suggestion: styles.suggestion,
- suggestionHighlighted: styles.suggestionHighlighted
- };
-
- return (
-
-
-
- );
- }
-}
-
-AutoSuggestInput.propTypes = {
- forwardedRef: PropTypes.func,
- className: PropTypes.string.isRequired,
- inputContainerClassName: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
- placeholder: PropTypes.string,
- suggestions: PropTypes.array.isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- enforceMaxHeight: PropTypes.bool.isRequired,
- minHeight: PropTypes.number.isRequired,
- maxHeight: PropTypes.number.isRequired,
- getSuggestionValue: PropTypes.func.isRequired,
- renderInputComponent: PropTypes.elementType,
- renderSuggestion: PropTypes.func.isRequired,
- onInputChange: PropTypes.func,
- onInputKeyDown: PropTypes.func,
- onInputFocus: PropTypes.func,
- onInputBlur: PropTypes.func.isRequired,
- onSuggestionsFetchRequested: PropTypes.func.isRequired,
- onSuggestionsClearRequested: PropTypes.func.isRequired,
- onSuggestionSelected: PropTypes.func,
- onChange: PropTypes.func.isRequired
-};
-
-AutoSuggestInput.defaultProps = {
- className: styles.input,
- inputContainerClassName: styles.inputContainer,
- enforceMaxHeight: true,
- minHeight: 50,
- maxHeight: 200
-};
-
-export default AutoSuggestInput;
diff --git a/frontend/src/Components/Form/AutoSuggestInput.tsx b/frontend/src/Components/Form/AutoSuggestInput.tsx
new file mode 100644
index 000000000..b3a7c31b0
--- /dev/null
+++ b/frontend/src/Components/Form/AutoSuggestInput.tsx
@@ -0,0 +1,259 @@
+import classNames from 'classnames';
+import React, {
+ FocusEvent,
+ FormEvent,
+ KeyboardEvent,
+ KeyboardEventHandler,
+ MutableRefObject,
+ ReactNode,
+ Ref,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useRef,
+} from 'react';
+import Autosuggest, {
+ AutosuggestPropsBase,
+ BlurEvent,
+ ChangeEvent,
+ RenderInputComponentProps,
+ RenderSuggestionsContainerParams,
+} from 'react-autosuggest';
+import { Manager, Popper, Reference } from 'react-popper';
+import Portal from 'Components/Portal';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import { InputChanged } from 'typings/inputs';
+import styles from './AutoSuggestInput.css';
+
+interface AutoSuggestInputProps
+ extends Omit, 'renderInputComponent' | 'inputProps'> {
+ forwardedRef?: MutableRefObject | null>;
+ className?: string;
+ inputContainerClassName?: string;
+ name: string;
+ value?: string;
+ placeholder?: string;
+ suggestions: T[];
+ hasError?: boolean;
+ hasWarning?: boolean;
+ enforceMaxHeight?: boolean;
+ minHeight?: number;
+ maxHeight?: number;
+ renderInputComponent?: (
+ inputProps: RenderInputComponentProps,
+ ref: Ref
+ ) => ReactNode;
+ onInputChange: (
+ event: FormEvent,
+ params: ChangeEvent
+ ) => unknown;
+ onInputKeyDown?: KeyboardEventHandler;
+ onInputFocus?: (event: SyntheticEvent) => unknown;
+ onInputBlur: (
+ event: FocusEvent,
+ params?: BlurEvent
+ ) => unknown;
+ onChange?: (change: InputChanged) => unknown;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function AutoSuggestInput(props: AutoSuggestInputProps) {
+ const {
+ // TODO: forwaredRef should be replaces with React.forwardRef
+ forwardedRef,
+ className = styles.input,
+ inputContainerClassName = styles.inputContainer,
+ name,
+ value = '',
+ placeholder,
+ suggestions,
+ enforceMaxHeight = true,
+ hasError,
+ hasWarning,
+ minHeight = 50,
+ maxHeight = 200,
+ getSuggestionValue,
+ renderSuggestion,
+ renderInputComponent,
+ onInputChange,
+ onInputKeyDown,
+ onInputFocus,
+ onInputBlur,
+ onSuggestionsFetchRequested,
+ onSuggestionsClearRequested,
+ onSuggestionSelected,
+ onChange,
+ ...otherProps
+ } = props;
+
+ const updater = useRef<(() => void) | null>(null);
+ const previousSuggestions = usePrevious(suggestions);
+
+ const handleComputeMaxHeight = useCallback(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (data: any) => {
+ const { top, bottom, width } = data.offsets.reference;
+
+ if (enforceMaxHeight) {
+ data.styles.maxHeight = maxHeight;
+ } else {
+ const windowHeight = window.innerHeight;
+
+ if (/^botton/.test(data.placement)) {
+ data.styles.maxHeight = windowHeight - bottom;
+ } else {
+ data.styles.maxHeight = top;
+ }
+ }
+
+ data.styles.width = width;
+
+ return data;
+ },
+ [enforceMaxHeight, maxHeight]
+ );
+
+ const createRenderInputComponent = useCallback(
+ (inputProps: RenderInputComponentProps) => {
+ return (
+
+ {({ ref }) => {
+ if (renderInputComponent) {
+ return renderInputComponent(inputProps, ref);
+ }
+
+ return (
+
+
+
+ );
+ }}
+
+ );
+ },
+ [renderInputComponent]
+ );
+
+ const renderSuggestionsContainer = useCallback(
+ ({ containerProps, children }: RenderSuggestionsContainerParams) => {
+ return (
+
+
+ {({ ref: popperRef, style, scheduleUpdate }) => {
+ updater.current = scheduleUpdate;
+
+ return (
+
+ );
+ }}
+
+
+ );
+ },
+ [minHeight, handleComputeMaxHeight]
+ );
+
+ const handleInputKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (
+ event.key === 'Tab' &&
+ suggestions.length &&
+ suggestions[0] !== value
+ ) {
+ event.preventDefault();
+
+ if (value) {
+ onSuggestionSelected?.(event, {
+ suggestion: suggestions[0],
+ suggestionValue: value,
+ suggestionIndex: 0,
+ sectionIndex: null,
+ method: 'enter',
+ });
+ }
+ }
+ },
+ [value, suggestions, onSuggestionSelected]
+ );
+
+ const inputProps = {
+ className: classNames(
+ className,
+ hasError && styles.hasError,
+ hasWarning && styles.hasWarning
+ ),
+ name,
+ value,
+ placeholder,
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: onInputChange,
+ onKeyDown: onInputKeyDown || handleInputKeyDown,
+ onFocus: onInputFocus,
+ onBlur: onInputBlur,
+ };
+
+ const theme = {
+ container: inputContainerClassName,
+ containerOpen: styles.suggestionsContainerOpen,
+ suggestionsContainer: styles.suggestionsContainer,
+ suggestionsList: styles.suggestionsList,
+ suggestion: styles.suggestion,
+ suggestionHighlighted: styles.suggestionHighlighted,
+ };
+
+ useEffect(() => {
+ if (updater.current && suggestions !== previousSuggestions) {
+ updater.current();
+ }
+ }, [suggestions, previousSuggestions]);
+
+ return (
+
+
+
+ );
+}
+
+export default AutoSuggestInput;
diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js
deleted file mode 100644
index b422198b5..000000000
--- a/frontend/src/Components/Form/CaptchaInput.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import ReCAPTCHA from 'react-google-recaptcha';
-import Icon from 'Components/Icon';
-import { icons } from 'Helpers/Props';
-import FormInputButton from './FormInputButton';
-import TextInput from './TextInput';
-import styles from './CaptchaInput.css';
-
-function CaptchaInput(props) {
- const {
- className,
- name,
- value,
- hasError,
- hasWarning,
- refreshing,
- siteKey,
- secretToken,
- onChange,
- onRefreshPress,
- onCaptchaChange
- } = props;
-
- return (
-
-
-
-
-
-
-
-
-
- {
- !!siteKey && !!secretToken &&
-
-
-
- }
-
- );
-}
-
-CaptchaInput.propTypes = {
- className: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.string.isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- refreshing: PropTypes.bool.isRequired,
- siteKey: PropTypes.string,
- secretToken: PropTypes.string,
- onChange: PropTypes.func.isRequired,
- onRefreshPress: PropTypes.func.isRequired,
- onCaptchaChange: PropTypes.func.isRequired
-};
-
-CaptchaInput.defaultProps = {
- className: styles.input,
- value: ''
-};
-
-export default CaptchaInput;
diff --git a/frontend/src/Components/Form/CaptchaInput.tsx b/frontend/src/Components/Form/CaptchaInput.tsx
new file mode 100644
index 000000000..d5a3f11f7
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.tsx
@@ -0,0 +1,118 @@
+import classNames from 'classnames';
+import React, { useCallback, useEffect } from 'react';
+import ReCAPTCHA from 'react-google-recaptcha';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import Icon from 'Components/Icon';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import { icons } from 'Helpers/Props';
+import {
+ getCaptchaCookie,
+ refreshCaptcha,
+ resetCaptcha,
+} from 'Store/Actions/captchaActions';
+import { InputChanged } from 'typings/inputs';
+import FormInputButton from './FormInputButton';
+import TextInput from './TextInput';
+import styles from './CaptchaInput.css';
+
+interface CaptchaInputProps {
+ className?: string;
+ name: string;
+ value?: string;
+ provider: string;
+ providerData: object;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ refreshing: boolean;
+ siteKey?: string;
+ secretToken?: string;
+ onChange: (change: InputChanged) => unknown;
+}
+
+function CaptchaInput({
+ className = styles.input,
+ name,
+ value = '',
+ provider,
+ providerData,
+ hasError,
+ hasWarning,
+ refreshing,
+ siteKey,
+ secretToken,
+ onChange,
+}: CaptchaInputProps) {
+ const { token } = useSelector((state: AppState) => state.captcha);
+ const dispatch = useDispatch();
+ const previousToken = usePrevious(token);
+
+ const handleCaptchaChange = useCallback(
+ (token: string | null) => {
+ // If the captcha has expired `captchaResponse` will be null.
+ // In the event it's null don't try to get the captchaCookie.
+ // TODO: Should we clear the cookie? or reset the captcha?
+
+ if (!token) {
+ return;
+ }
+
+ dispatch(
+ getCaptchaCookie({
+ provider,
+ providerData,
+ captchaResponse: token,
+ })
+ );
+ },
+ [provider, providerData, dispatch]
+ );
+
+ const handleRefreshPress = useCallback(() => {
+ dispatch(refreshCaptcha({ provider, providerData }));
+ }, [provider, providerData, dispatch]);
+
+ useEffect(() => {
+ if (token && token !== previousToken) {
+ onChange({ name, value: token });
+ }
+ }, [name, token, previousToken, onChange]);
+
+ useEffect(() => {
+ dispatch(resetCaptcha());
+ }, [dispatch]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {siteKey && secretToken ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+export default CaptchaInput;
diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js
deleted file mode 100644
index ad83bf02f..000000000
--- a/frontend/src/Components/Form/CaptchaInputConnector.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions';
-import CaptchaInput from './CaptchaInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.captcha,
- (captcha) => {
- return captcha;
- }
- );
-}
-
-const mapDispatchToProps = {
- refreshCaptcha,
- getCaptchaCookie,
- resetCaptcha
-};
-
-class CaptchaInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidUpdate(prevProps) {
- const {
- name,
- token,
- onChange
- } = this.props;
-
- if (token && token !== prevProps.token) {
- onChange({ name, value: token });
- }
- }
-
- componentWillUnmount = () => {
- this.props.resetCaptcha();
- };
-
- //
- // Listeners
-
- onRefreshPress = () => {
- const {
- provider,
- providerData
- } = this.props;
-
- this.props.refreshCaptcha({ provider, providerData });
- };
-
- onCaptchaChange = (captchaResponse) => {
- // If the captcha has expired `captchaResponse` will be null.
- // In the event it's null don't try to get the captchaCookie.
- // TODO: Should we clear the cookie? or reset the captcha?
-
- if (!captchaResponse) {
- return;
- }
-
- const {
- provider,
- providerData
- } = this.props;
-
- this.props.getCaptchaCookie({ provider, providerData, captchaResponse });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-CaptchaInputConnector.propTypes = {
- provider: PropTypes.string.isRequired,
- providerData: PropTypes.object.isRequired,
- name: PropTypes.string.isRequired,
- token: PropTypes.string,
- onChange: PropTypes.func.isRequired,
- refreshCaptcha: PropTypes.func.isRequired,
- getCaptchaCookie: PropTypes.func.isRequired,
- resetCaptcha: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector);
diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js
deleted file mode 100644
index 26d915880..000000000
--- a/frontend/src/Components/Form/CheckInput.js
+++ /dev/null
@@ -1,191 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Icon from 'Components/Icon';
-import { icons, kinds } from 'Helpers/Props';
-import FormInputHelpText from './FormInputHelpText';
-import styles from './CheckInput.css';
-
-class CheckInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._checkbox = null;
- }
-
- componentDidMount() {
- this.setIndeterminate();
- }
-
- componentDidUpdate() {
- this.setIndeterminate();
- }
-
- //
- // Control
-
- setIndeterminate() {
- if (!this._checkbox) {
- return;
- }
-
- const {
- value,
- uncheckedValue,
- checkedValue
- } = this.props;
-
- this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
- }
-
- toggleChecked = (checked, shiftKey) => {
- const {
- name,
- value,
- checkedValue,
- uncheckedValue
- } = this.props;
-
- const newValue = checked ? checkedValue : uncheckedValue;
-
- if (value !== newValue) {
- this.props.onChange({
- name,
- value: newValue,
- shiftKey
- });
- }
- };
-
- //
- // Listeners
-
- setRef = (ref) => {
- this._checkbox = ref;
- };
-
- onClick = (event) => {
- if (this.props.isDisabled) {
- return;
- }
-
- const shiftKey = event.nativeEvent.shiftKey;
- const checked = !this._checkbox.checked;
-
- event.preventDefault();
- this.toggleChecked(checked, shiftKey);
- };
-
- onChange = (event) => {
- const checked = event.target.checked;
- const shiftKey = event.nativeEvent.shiftKey;
-
- this.toggleChecked(checked, shiftKey);
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- containerClassName,
- name,
- value,
- checkedValue,
- uncheckedValue,
- helpText,
- helpTextWarning,
- isDisabled,
- kind
- } = this.props;
-
- const isChecked = value === checkedValue;
- const isUnchecked = value === uncheckedValue;
- const isIndeterminate = !isChecked && !isUnchecked;
- const isCheckClass = `${kind}IsChecked`;
-
- return (
-
-
-
- );
- }
-}
-
-CheckInput.propTypes = {
- className: PropTypes.string.isRequired,
- containerClassName: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- checkedValue: PropTypes.bool,
- uncheckedValue: PropTypes.bool,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
- helpText: PropTypes.string,
- helpTextWarning: PropTypes.string,
- isDisabled: PropTypes.bool,
- kind: PropTypes.oneOf(kinds.all).isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-CheckInput.defaultProps = {
- className: styles.input,
- containerClassName: styles.container,
- checkedValue: true,
- uncheckedValue: false,
- kind: kinds.PRIMARY
-};
-
-export default CheckInput;
diff --git a/frontend/src/Components/Form/CheckInput.tsx b/frontend/src/Components/Form/CheckInput.tsx
new file mode 100644
index 000000000..b7080cfdd
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.tsx
@@ -0,0 +1,141 @@
+import classNames from 'classnames';
+import React, { SyntheticEvent, useCallback, useEffect, useRef } from 'react';
+import Icon from 'Components/Icon';
+import { icons } from 'Helpers/Props';
+import { Kind } from 'Helpers/Props/kinds';
+import { CheckInputChanged } from 'typings/inputs';
+import FormInputHelpText from './FormInputHelpText';
+import styles from './CheckInput.css';
+
+interface ChangeEvent extends SyntheticEvent {
+ target: EventTarget & T;
+}
+
+interface CheckInputProps {
+ className?: string;
+ containerClassName?: string;
+ name: string;
+ checkedValue?: boolean;
+ uncheckedValue?: boolean;
+ value?: string | boolean;
+ helpText?: string;
+ helpTextWarning?: string;
+ isDisabled?: boolean;
+ kind?: Extract;
+ onChange: (changes: CheckInputChanged) => void;
+}
+
+function CheckInput(props: CheckInputProps) {
+ const {
+ className = styles.input,
+ containerClassName = styles.container,
+ name,
+ value,
+ checkedValue = true,
+ uncheckedValue = false,
+ helpText,
+ helpTextWarning,
+ isDisabled,
+ kind = 'primary',
+ onChange,
+ } = props;
+
+ const inputRef = useRef(null);
+
+ const isChecked = value === checkedValue;
+ const isUnchecked = value === uncheckedValue;
+ const isIndeterminate = !isChecked && !isUnchecked;
+ const isCheckClass: keyof typeof styles = `${kind}IsChecked`;
+
+ const toggleChecked = useCallback(
+ (checked: boolean, shiftKey: boolean) => {
+ const newValue = checked ? checkedValue : uncheckedValue;
+
+ if (value !== newValue) {
+ onChange({
+ name,
+ value: newValue,
+ shiftKey,
+ });
+ }
+ },
+ [name, value, checkedValue, uncheckedValue, onChange]
+ );
+
+ const handleClick = useCallback(
+ (event: SyntheticEvent) => {
+ if (isDisabled) {
+ return;
+ }
+
+ const shiftKey = event.nativeEvent.shiftKey;
+ const checked = !(inputRef.current?.checked ?? false);
+
+ event.preventDefault();
+ toggleChecked(checked, shiftKey);
+ },
+ [isDisabled, toggleChecked]
+ );
+
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ const checked = event.target.checked;
+ const shiftKey = event.nativeEvent.shiftKey;
+
+ toggleChecked(checked, shiftKey);
+ },
+ [toggleChecked]
+ );
+
+ useEffect(() => {
+ if (!inputRef.current) {
+ return;
+ }
+
+ inputRef.current.indeterminate =
+ value !== uncheckedValue && value !== checkedValue;
+ }, [value, uncheckedValue, checkedValue]);
+
+ return (
+
+
+
+ );
+}
+
+export default CheckInput;
diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js
deleted file mode 100644
index 55c239cb8..000000000
--- a/frontend/src/Components/Form/DeviceInput.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Icon from 'Components/Icon';
-import { icons } from 'Helpers/Props';
-import tagShape from 'Helpers/Props/Shapes/tagShape';
-import FormInputButton from './FormInputButton';
-import TagInput from './TagInput';
-import styles from './DeviceInput.css';
-
-class DeviceInput extends Component {
-
- onTagAdd = (device) => {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- // New tags won't have an ID, only a name.
- const deviceId = device.id || device.name;
-
- onChange({
- name,
- value: [...value, deviceId]
- });
- };
-
- onTagDelete = ({ index }) => {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = value.slice();
- newValue.splice(index, 1);
-
- onChange({
- name,
- value: newValue
- });
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- name,
- items,
- selectedDevices,
- hasError,
- hasWarning,
- isFetching,
- onRefreshPress
- } = this.props;
-
- return (
-
-
-
-
-
-
-
- );
- }
-}
-
-DeviceInput.propTypes = {
- className: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
- items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
- selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- isFetching: PropTypes.bool.isRequired,
- isPopulated: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- onRefreshPress: PropTypes.func.isRequired
-};
-
-DeviceInput.defaultProps = {
- className: styles.deviceInputWrapper,
- inputClassName: styles.input
-};
-
-export default DeviceInput;
diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js
deleted file mode 100644
index 2af9a79f6..000000000
--- a/frontend/src/Components/Form/DeviceInputConnector.js
+++ /dev/null
@@ -1,104 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
-import DeviceInput from './DeviceInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state, { value }) => value,
- (state) => state.providerOptions.devices || defaultState,
- (value, devices) => {
-
- return {
- ...devices,
- selectedDevices: value.map((valueDevice) => {
- // Disable equality ESLint rule so we don't need to worry about
- // a type mismatch between the value items and the device ID.
- // eslint-disable-next-line eqeqeq
- const device = devices.items.find((d) => d.id == valueDevice);
-
- if (device) {
- return {
- id: device.id,
- name: `${device.name} (${device.id})`
- };
- }
-
- return {
- id: valueDevice,
- name: `Unknown (${valueDevice})`
- };
- })
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- dispatchFetchOptions: fetchOptions,
- dispatchClearOptions: clearOptions
-};
-
-class DeviceInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount = () => {
- this._populate();
- };
-
- componentWillUnmount = () => {
- this.props.dispatchClearOptions({ section: 'devices' });
- };
-
- //
- // Control
-
- _populate() {
- const {
- provider,
- providerData,
- dispatchFetchOptions
- } = this.props;
-
- dispatchFetchOptions({
- section: 'devices',
- action: 'getDevices',
- provider,
- providerData
- });
- }
-
- //
- // Listeners
-
- onRefreshPress = () => {
- this._populate();
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-DeviceInputConnector.propTypes = {
- provider: PropTypes.string.isRequired,
- providerData: PropTypes.object.isRequired,
- name: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- dispatchFetchOptions: PropTypes.func.isRequired,
- dispatchClearOptions: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector);
diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js
deleted file mode 100644
index f0ebf534b..000000000
--- a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { fetchDownloadClients } from 'Store/Actions/settingsActions';
-import sortByName from 'Utilities/Array/sortByName';
-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 filteredItems = items.filter((item) => item.protocol === protocolFilter);
-
- const values = _.map(filteredItems.sort(sortByName), (downloadClient) => {
- return {
- key: downloadClient.id,
- value: downloadClient.name
- };
- });
-
- 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
deleted file mode 100644
index cc4215025..000000000
--- a/frontend/src/Components/Form/EnhancedSelectInput.js
+++ /dev/null
@@ -1,608 +0,0 @@
-import classNames from 'classnames';
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { Manager, Popper, Reference } from 'react-popper';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-import Measure from 'Components/Measure';
-import Modal from 'Components/Modal/Modal';
-import ModalBody from 'Components/Modal/ModalBody';
-import Portal from 'Components/Portal';
-import Scroller from 'Components/Scroller/Scroller';
-import { icons, scrollDirections, sizes } from 'Helpers/Props';
-import { isMobile as isMobileUtil } from 'Utilities/browser';
-import * as keyCodes from 'Utilities/Constants/keyCodes';
-import getUniqueElememtId from 'Utilities/getUniqueElementId';
-import HintedSelectInputOption from './HintedSelectInputOption';
-import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
-import TextInput from './TextInput';
-import styles from './EnhancedSelectInput.css';
-
-function isArrowKey(keyCode) {
- return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
-}
-
-function getSelectedOption(selectedIndex, values) {
- return values[selectedIndex];
-}
-
-function findIndex(startingIndex, direction, values) {
- let indexToTest = startingIndex + direction;
-
- while (indexToTest !== startingIndex) {
- if (indexToTest < 0) {
- indexToTest = values.length - 1;
- } else if (indexToTest >= values.length) {
- indexToTest = 0;
- }
-
- if (getSelectedOption(indexToTest, values).isDisabled) {
- indexToTest = indexToTest + direction;
- } else {
- return indexToTest;
- }
- }
-}
-
-function previousIndex(selectedIndex, values) {
- return findIndex(selectedIndex, -1, values);
-}
-
-function nextIndex(selectedIndex, values) {
- return findIndex(selectedIndex, 1, values);
-}
-
-function getSelectedIndex(props) {
- const {
- value,
- values
- } = props;
-
- if (Array.isArray(value)) {
- return values.findIndex((v) => {
- return value.size && v.key === value[0];
- });
- }
-
- return values.findIndex((v) => {
- return v.key === value;
- });
-}
-
-function isSelectedItem(index, props) {
- const {
- value,
- values
- } = props;
-
- if (Array.isArray(value)) {
- return value.includes(values[index].key);
- }
-
- return values[index].key === value;
-}
-
-function getKey(selectedIndex, values) {
- return values[selectedIndex].key;
-}
-
-class EnhancedSelectInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._scheduleUpdate = null;
- this._buttonId = getUniqueElememtId();
- this._optionsId = getUniqueElememtId();
-
- this.state = {
- isOpen: false,
- selectedIndex: getSelectedIndex(props),
- width: 0,
- isMobile: isMobileUtil()
- };
- }
-
- componentDidUpdate(prevProps) {
- if (this._scheduleUpdate) {
- this._scheduleUpdate();
- }
-
- if (!Array.isArray(this.props.value)) {
- if (prevProps.value !== this.props.value || prevProps.values !== this.props.values) {
- this.setState({
- selectedIndex: getSelectedIndex(this.props)
- });
- }
- }
- }
-
- //
- // Control
-
- _addListener() {
- window.addEventListener('click', this.onWindowClick);
- }
-
- _removeListener() {
- window.removeEventListener('click', this.onWindowClick);
- }
-
- //
- // 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;
- }
-
- return data;
- };
-
- onWindowClick = (event) => {
- const button = document.getElementById(this._buttonId);
- const options = document.getElementById(this._optionsId);
-
- if (!button || !event.target.isConnected || this.state.isMobile) {
- return;
- }
-
- if (
- !button.contains(event.target) &&
- options &&
- !options.contains(event.target) &&
- this.state.isOpen
- ) {
- this.setState({ isOpen: false });
- this._removeListener();
- }
- };
-
- onFocus = () => {
- if (this.state.isOpen) {
- this._removeListener();
- this.setState({ isOpen: false });
- }
- };
-
- onBlur = () => {
- if (!this.props.isEditable) {
- // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
- const origIndex = getSelectedIndex(this.props);
-
- if (origIndex !== this.state.selectedIndex) {
- this.setState({ selectedIndex: origIndex });
- }
- }
- };
-
- onKeyDown = (event) => {
- const {
- values
- } = this.props;
-
- const {
- isOpen,
- selectedIndex
- } = this.state;
-
- const keyCode = event.keyCode;
- const newState = {};
-
- if (!isOpen) {
- if (isArrowKey(keyCode)) {
- event.preventDefault();
- newState.isOpen = true;
- }
-
- if (
- selectedIndex == null || selectedIndex === -1 ||
- getSelectedOption(selectedIndex, values).isDisabled
- ) {
- if (keyCode === keyCodes.UP_ARROW) {
- newState.selectedIndex = previousIndex(0, values);
- } else if (keyCode === keyCodes.DOWN_ARROW) {
- newState.selectedIndex = nextIndex(values.length - 1, values);
- }
- }
-
- this.setState(newState);
- return;
- }
-
- if (keyCode === keyCodes.UP_ARROW) {
- event.preventDefault();
- newState.selectedIndex = previousIndex(selectedIndex, values);
- }
-
- if (keyCode === keyCodes.DOWN_ARROW) {
- event.preventDefault();
- newState.selectedIndex = nextIndex(selectedIndex, values);
- }
-
- if (keyCode === keyCodes.ENTER) {
- event.preventDefault();
- newState.isOpen = false;
- this.onSelect(getKey(selectedIndex, values));
- }
-
- if (keyCode === keyCodes.TAB) {
- newState.isOpen = false;
- this.onSelect(getKey(selectedIndex, values));
- }
-
- if (keyCode === keyCodes.ESCAPE) {
- event.preventDefault();
- event.stopPropagation();
- newState.isOpen = false;
- newState.selectedIndex = getSelectedIndex(this.props);
- }
-
- if (!_.isEmpty(newState)) {
- this.setState(newState);
- }
- };
-
- onPress = () => {
- if (this.state.isOpen) {
- this._removeListener();
- } else {
- this._addListener();
- }
-
- if (!this.state.isOpen && this.props.onOpen) {
- this.props.onOpen();
- }
-
- this.setState({ isOpen: !this.state.isOpen });
- };
-
- onSelect = (value) => {
- if (Array.isArray(this.props.value)) {
- let newValue = null;
- const index = this.props.value.indexOf(value);
- if (index === -1) {
- newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v));
- } else {
- newValue = [...this.props.value];
- newValue.splice(index, 1);
- }
- this.props.onChange({
- name: this.props.name,
- value: newValue
- });
- } else {
- this.setState({ isOpen: false });
-
- this.props.onChange({
- name: this.props.name,
- value
- });
- }
- };
-
- onMeasure = ({ width }) => {
- this.setState({ width });
- };
-
- onOptionsModalClose = () => {
- this.setState({ isOpen: false });
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- disabledClassName,
- name,
- value,
- values,
- isDisabled,
- isEditable,
- isFetching,
- hasError,
- hasWarning,
- valueOptions,
- selectedValueOptions,
- selectedValueComponent: SelectedValueComponent,
- optionComponent: OptionComponent,
- onChange
- } = this.props;
-
- const {
- selectedIndex,
- width,
- isOpen,
- isMobile
- } = this.state;
-
- const isMultiSelect = Array.isArray(value);
- const selectedOption = getSelectedOption(selectedIndex, values);
- let selectedValue = value;
-
- if (!values.length) {
- selectedValue = isMultiSelect ? [] : '';
- }
-
- return (
-
-
-
- {({ ref }) => (
-
- )}
-
-
-
- {({ ref, style, scheduleUpdate }) => {
- this._scheduleUpdate = scheduleUpdate;
-
- return (
-
- {
- isOpen && !isMobile ?
-
- {
- values.map((v, index) => {
- const hasParent = v.parentKey !== undefined;
- const depth = hasParent ? 1 : 0;
- const parentSelected = hasParent && value.includes(v.parentKey);
- return (
-
- {v.value}
-
- );
- })
- }
- :
- null
- }
-
- );
- }
- }
-
-
-
-
- {
- isMobile ?
-
-
-
-
-
-
-
-
-
- {
- values.map((v, index) => {
- const hasParent = v.parentKey !== undefined;
- const depth = hasParent ? 1 : 0;
- const parentSelected = hasParent && value.includes(v.parentKey);
- return (
-
- {v.value}
-
- );
- })
- }
-
-
- :
- null
- }
-
- );
- }
-}
-
-EnhancedSelectInput.propTypes = {
- className: PropTypes.string,
- disabledClassName: PropTypes.string,
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- isDisabled: PropTypes.bool.isRequired,
- isFetching: PropTypes.bool.isRequired,
- isEditable: PropTypes.bool.isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- valueOptions: PropTypes.object.isRequired,
- selectedValueOptions: PropTypes.object.isRequired,
- selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
- optionComponent: PropTypes.elementType,
- onOpen: PropTypes.func,
- onChange: PropTypes.func.isRequired
-};
-
-EnhancedSelectInput.defaultProps = {
- className: styles.enhancedSelect,
- disabledClassName: styles.isDisabled,
- isDisabled: false,
- isFetching: false,
- isEditable: false,
- valueOptions: {},
- selectedValueOptions: {},
- selectedValueComponent: HintedSelectInputSelectedValue,
- optionComponent: HintedSelectInputOption
-};
-
-export default EnhancedSelectInput;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js
deleted file mode 100644
index f2af4a585..000000000
--- a/frontend/src/Components/Form/EnhancedSelectInputConnector.js
+++ /dev/null
@@ -1,159 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
-import EnhancedSelectInput from './EnhancedSelectInput';
-
-const importantFieldNames = [
- 'baseUrl',
- 'apiPath',
- 'apiKey'
-];
-
-function getProviderDataKey(providerData) {
- if (!providerData || !providerData.fields) {
- return null;
- }
-
- const fields = providerData.fields
- .filter((f) => importantFieldNames.includes(f.name))
- .map((f) => f.value);
-
- return fields;
-}
-
-function getSelectOptions(items) {
- if (!items) {
- return [];
- }
-
- return items.map((option) => {
- return {
- key: option.value,
- value: option.name,
- hint: option.hint,
- parentKey: option.parentValue
- };
- });
-}
-
-function createMapStateToProps() {
- return createSelector(
- (state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState,
- (options) => {
- if (options) {
- return {
- isFetching: options.isFetching,
- values: getSelectOptions(options.items)
- };
- }
- }
- );
-}
-
-const mapDispatchToProps = {
- dispatchFetchOptions: fetchOptions,
- dispatchClearOptions: clearOptions
-};
-
-class EnhancedSelectInputConnector extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- refetchRequired: false
- };
- }
-
- componentDidMount = () => {
- this._populate();
- };
-
- componentDidUpdate = (prevProps) => {
- const prevKey = getProviderDataKey(prevProps.providerData);
- const nextKey = getProviderDataKey(this.props.providerData);
-
- if (!_.isEqual(prevKey, nextKey)) {
- this.setState({ refetchRequired: true });
- }
- };
-
- componentWillUnmount = () => {
- this._cleanup();
- };
-
- //
- // Listeners
-
- onOpen = () => {
- if (this.state.refetchRequired) {
- this._populate();
- }
- };
-
- //
- // Control
-
- _populate() {
- const {
- provider,
- providerData,
- selectOptionsProviderAction,
- dispatchFetchOptions
- } = this.props;
-
- if (selectOptionsProviderAction) {
- this.setState({ refetchRequired: false });
- dispatchFetchOptions({
- section: selectOptionsProviderAction,
- action: selectOptionsProviderAction,
- provider,
- providerData
- });
- }
- }
-
- _cleanup() {
- const {
- selectOptionsProviderAction,
- dispatchClearOptions
- } = this.props;
-
- if (selectOptionsProviderAction) {
- dispatchClearOptions({ section: selectOptionsProviderAction });
- }
- }
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-EnhancedSelectInputConnector.propTypes = {
- provider: PropTypes.string.isRequired,
- providerData: PropTypes.object.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- selectOptionsProviderAction: PropTypes.string,
- onChange: PropTypes.func.isRequired,
- isFetching: PropTypes.bool.isRequired,
- dispatchFetchOptions: PropTypes.func.isRequired,
- dispatchClearOptions: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector);
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js
deleted file mode 100644
index b2783dbaa..000000000
--- a/frontend/src/Components/Form/EnhancedSelectInputOption.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import { icons } from 'Helpers/Props';
-import CheckInput from './CheckInput';
-import styles from './EnhancedSelectInputOption.css';
-
-class EnhancedSelectInputOption extends Component {
-
- //
- // Listeners
-
- onPress = (e) => {
- e.preventDefault();
-
- const {
- id,
- onSelect
- } = this.props;
-
- onSelect(id);
- };
-
- onCheckPress = () => {
- // CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation.
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- id,
- depth,
- isSelected,
- isDisabled,
- isHidden,
- isMultiSelect,
- isMobile,
- children
- } = this.props;
-
- return (
-
-
- {
- depth !== 0 &&
-
- }
-
- {
- isMultiSelect &&
-
- }
-
- {children}
-
- {
- isMobile &&
-
-
-
- }
-
- );
- }
-}
-
-EnhancedSelectInputOption.propTypes = {
- className: PropTypes.string.isRequired,
- id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- depth: PropTypes.number.isRequired,
- isSelected: PropTypes.bool.isRequired,
- isDisabled: PropTypes.bool.isRequired,
- isHidden: PropTypes.bool.isRequired,
- isMultiSelect: PropTypes.bool.isRequired,
- isMobile: PropTypes.bool.isRequired,
- children: PropTypes.node.isRequired,
- onSelect: PropTypes.func.isRequired
-};
-
-EnhancedSelectInputOption.defaultProps = {
- className: styles.option,
- depth: 0,
- isDisabled: false,
- isHidden: false,
- isMultiSelect: false
-};
-
-export default EnhancedSelectInputOption;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
deleted file mode 100644
index 21ddebb02..000000000
--- a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import styles from './EnhancedSelectInputSelectedValue.css';
-
-function EnhancedSelectInputSelectedValue(props) {
- const {
- className,
- children,
- isDisabled
- } = props;
-
- return (
-
- {children}
-
- );
-}
-
-EnhancedSelectInputSelectedValue.propTypes = {
- className: PropTypes.string.isRequired,
- children: PropTypes.node,
- isDisabled: PropTypes.bool.isRequired
-};
-
-EnhancedSelectInputSelectedValue.defaultProps = {
- className: styles.selectedValue,
- isDisabled: false
-};
-
-export default EnhancedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js
deleted file mode 100644
index 79ad3fe8a..000000000
--- a/frontend/src/Components/Form/Form.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Alert from 'Components/Alert';
-import { kinds } from 'Helpers/Props';
-import styles from './Form.css';
-
-function Form(props) {
- const {
- children,
- validationErrors,
- validationWarnings,
- // eslint-disable-next-line no-unused-vars
- ...otherProps
- } = props;
-
- return (
-
- {
- validationErrors.length || validationWarnings.length ?
-
- {
- validationErrors.map((error, index) => {
- return (
-
- {error.errorMessage}
-
- );
- })
- }
-
- {
- validationWarnings.map((warning, index) => {
- return (
-
- {warning.errorMessage}
-
- );
- })
- }
-
:
- null
- }
-
- {children}
-
- );
-}
-
-Form.propTypes = {
- children: PropTypes.node.isRequired,
- validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired,
- validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired
-};
-
-Form.defaultProps = {
- validationErrors: [],
- validationWarnings: []
-};
-
-export default Form;
diff --git a/frontend/src/Components/Form/Form.tsx b/frontend/src/Components/Form/Form.tsx
new file mode 100644
index 000000000..d522019e7
--- /dev/null
+++ b/frontend/src/Components/Form/Form.tsx
@@ -0,0 +1,45 @@
+import React, { ReactNode } from 'react';
+import Alert from 'Components/Alert';
+import { kinds } from 'Helpers/Props';
+import { ValidationError, ValidationWarning } from 'typings/pending';
+import styles from './Form.css';
+
+export interface FormProps {
+ children: ReactNode;
+ validationErrors?: ValidationError[];
+ validationWarnings?: ValidationWarning[];
+}
+
+function Form({
+ children,
+ validationErrors = [],
+ validationWarnings = [],
+}: FormProps) {
+ return (
+
+ {validationErrors.length || validationWarnings.length ? (
+
+ {validationErrors.map((error, index) => {
+ return (
+
+ {error.errorMessage}
+
+ );
+ })}
+
+ {validationWarnings.map((warning, index) => {
+ return (
+
+ {warning.errorMessage}
+
+ );
+ })}
+
+ ) : null}
+
+ {children}
+
+ );
+}
+
+export default Form;
diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js
deleted file mode 100644
index f538daa2f..000000000
--- a/frontend/src/Components/Form/FormGroup.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { map } from 'Helpers/elementChildren';
-import { sizes } from 'Helpers/Props';
-import styles from './FormGroup.css';
-
-function FormGroup(props) {
- const {
- className,
- children,
- size,
- advancedSettings,
- isAdvanced,
- ...otherProps
- } = props;
-
- if (!advancedSettings && isAdvanced) {
- return null;
- }
-
- const childProps = isAdvanced ? { isAdvanced } : {};
-
- return (
-
- {
- map(children, (child) => {
- return React.cloneElement(child, childProps);
- })
- }
-
- );
-}
-
-FormGroup.propTypes = {
- className: PropTypes.string.isRequired,
- children: PropTypes.node.isRequired,
- size: PropTypes.oneOf(sizes.all).isRequired,
- advancedSettings: PropTypes.bool.isRequired,
- isAdvanced: PropTypes.bool.isRequired
-};
-
-FormGroup.defaultProps = {
- className: styles.group,
- size: sizes.SMALL,
- advancedSettings: false,
- isAdvanced: false
-};
-
-export default FormGroup;
diff --git a/frontend/src/Components/Form/FormGroup.tsx b/frontend/src/Components/Form/FormGroup.tsx
new file mode 100644
index 000000000..1dd879897
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.tsx
@@ -0,0 +1,43 @@
+import classNames from 'classnames';
+import React, { Children, ComponentPropsWithoutRef, ReactNode } from 'react';
+import { Size } from 'Helpers/Props/sizes';
+import styles from './FormGroup.css';
+
+interface FormGroupProps extends ComponentPropsWithoutRef<'div'> {
+ className?: string;
+ children: ReactNode;
+ size?: Extract;
+ advancedSettings?: boolean;
+ isAdvanced?: boolean;
+}
+
+function FormGroup(props: FormGroupProps) {
+ const {
+ className = styles.group,
+ children,
+ size = 'small',
+ advancedSettings = false,
+ isAdvanced = false,
+ ...otherProps
+ } = props;
+
+ if (!advancedSettings && isAdvanced) {
+ return null;
+ }
+
+ const childProps = isAdvanced ? { isAdvanced } : {};
+
+ return (
+
+ {Children.map(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return child;
+ }
+
+ return React.cloneElement(child, childProps);
+ })}
+
+ );
+}
+
+export default FormGroup;
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..e5149ab14
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.tsx
@@ -0,0 +1,39 @@
+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,
+ kind = kinds.PRIMARY,
+ ...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
deleted file mode 100644
index 49f08c90b..000000000
--- a/frontend/src/Components/Form/FormInputGroup.js
+++ /dev/null
@@ -1,297 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Link from 'Components/Link/Link';
-import { inputTypes, kinds } from 'Helpers/Props';
-import translate from 'Utilities/String/translate';
-import AutoCompleteInput from './AutoCompleteInput';
-import CaptchaInputConnector from './CaptchaInputConnector';
-import CheckInput from './CheckInput';
-import DeviceInputConnector from './DeviceInputConnector';
-import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector';
-import EnhancedSelectInput from './EnhancedSelectInput';
-import EnhancedSelectInputConnector from './EnhancedSelectInputConnector';
-import FormInputHelpText from './FormInputHelpText';
-import IndexerSelectInputConnector from './IndexerSelectInputConnector';
-import KeyValueListInput from './KeyValueListInput';
-import MonitorEpisodesSelectInput from './MonitorEpisodesSelectInput';
-import MonitorNewItemsSelectInput from './MonitorNewItemsSelectInput';
-import NumberInput from './NumberInput';
-import OAuthInputConnector from './OAuthInputConnector';
-import PasswordInput from './PasswordInput';
-import PathInputConnector from './PathInputConnector';
-import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
-import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
-import SeriesTypeSelectInput from './SeriesTypeSelectInput';
-import TagInputConnector from './TagInputConnector';
-import TagSelectInputConnector from './TagSelectInputConnector';
-import TextArea from './TextArea';
-import TextInput from './TextInput';
-import TextTagInputConnector from './TextTagInputConnector';
-import UMaskInput from './UMaskInput';
-import styles from './FormInputGroup.css';
-
-function getComponent(type) {
- switch (type) {
- case inputTypes.AUTO_COMPLETE:
- return AutoCompleteInput;
-
- case inputTypes.CAPTCHA:
- return CaptchaInputConnector;
-
- case inputTypes.CHECK:
- return CheckInput;
-
- case inputTypes.DEVICE:
- return DeviceInputConnector;
-
- case inputTypes.KEY_VALUE_LIST:
- return KeyValueListInput;
-
- case inputTypes.MONITOR_EPISODES_SELECT:
- return MonitorEpisodesSelectInput;
-
- case inputTypes.MONITOR_NEW_ITEMS_SELECT:
- return MonitorNewItemsSelectInput;
-
- case inputTypes.NUMBER:
- return NumberInput;
-
- case inputTypes.OAUTH:
- return OAuthInputConnector;
-
- case inputTypes.PASSWORD:
- return PasswordInput;
-
- case inputTypes.PATH:
- return PathInputConnector;
-
- case inputTypes.QUALITY_PROFILE_SELECT:
- return QualityProfileSelectInputConnector;
-
- case inputTypes.INDEXER_SELECT:
- return IndexerSelectInputConnector;
-
- case inputTypes.DOWNLOAD_CLIENT_SELECT:
- return DownloadClientSelectInputConnector;
-
- case inputTypes.ROOT_FOLDER_SELECT:
- return RootFolderSelectInputConnector;
-
- case inputTypes.SELECT:
- return EnhancedSelectInput;
-
- case inputTypes.DYNAMIC_SELECT:
- return EnhancedSelectInputConnector;
-
- case inputTypes.SERIES_TYPE_SELECT:
- return SeriesTypeSelectInput;
-
- case inputTypes.TAG:
- return TagInputConnector;
-
- case inputTypes.TEXT_AREA:
- return TextArea;
-
- case inputTypes.TEXT_TAG:
- return TextTagInputConnector;
-
- case inputTypes.TAG_SELECT:
- return TagSelectInputConnector;
-
- case inputTypes.UMASK:
- return UMaskInput;
-
- default:
- return TextInput;
- }
-}
-
-function FormInputGroup(props) {
- const {
- className,
- containerClassName,
- inputClassName,
- type,
- unit,
- buttons,
- helpText,
- helpTexts,
- helpTextWarning,
- helpLink,
- pending,
- errors,
- warnings,
- ...otherProps
- } = props;
-
- const InputComponent = getComponent(type);
- const checkInput = type === inputTypes.CHECK;
- const hasError = !!errors.length;
- const hasWarning = !hasError && !!warnings.length;
- const buttonsArray = React.Children.toArray(buttons);
- const lastButtonIndex = buttonsArray.length - 1;
- const hasButton = !!buttonsArray.length;
-
- return (
-
-
-
-
-
- {
- unit &&
-
- {unit}
-
- }
-
-
- {
- buttonsArray.map((button, index) => {
- return React.cloneElement(
- button,
- {
- isLastButton: index === lastButtonIndex
- }
- );
- })
- }
-
- {/*
- {
- pending &&
-
- }
-
*/}
-
-
- {
- !checkInput && helpText &&
-
- }
-
- {
- !checkInput && helpTexts &&
-
- {
- helpTexts.map((text, index) => {
- return (
-
- );
- })
- }
-
- }
-
- {
- (!checkInput || helpText) && helpTextWarning &&
-
- }
-
- {
- helpLink &&
-
- {translate('MoreInfo')}
-
- }
-
- {
- errors.map((error, index) => {
- return (
-
- );
- })
- }
-
- {
- warnings.map((warning, index) => {
- return (
-
- );
- })
- }
-
- );
-}
-
-FormInputGroup.propTypes = {
- className: PropTypes.string.isRequired,
- containerClassName: PropTypes.string.isRequired,
- inputClassName: PropTypes.string,
- name: PropTypes.string.isRequired,
- value: PropTypes.any,
- values: PropTypes.arrayOf(PropTypes.any),
- 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,
- pending: PropTypes.bool,
- errors: PropTypes.arrayOf(PropTypes.object),
- warnings: PropTypes.arrayOf(PropTypes.object),
- onChange: PropTypes.func.isRequired
-};
-
-FormInputGroup.defaultProps = {
- className: styles.inputGroup,
- containerClassName: styles.inputGroupContainer,
- type: inputTypes.TEXT,
- buttons: [],
- helpTexts: [],
- errors: [],
- warnings: []
-};
-
-export default FormInputGroup;
diff --git a/frontend/src/Components/Form/FormInputGroup.tsx b/frontend/src/Components/Form/FormInputGroup.tsx
new file mode 100644
index 000000000..98c6e586a
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.tsx
@@ -0,0 +1,303 @@
+import React, { FocusEvent, ReactNode } from 'react';
+import Link from 'Components/Link/Link';
+import { inputTypes } from 'Helpers/Props';
+import { InputType } from 'Helpers/Props/inputTypes';
+import { Kind } from 'Helpers/Props/kinds';
+import { ValidationError, ValidationWarning } from 'typings/pending';
+import translate from 'Utilities/String/translate';
+import AutoCompleteInput from './AutoCompleteInput';
+import CaptchaInput from './CaptchaInput';
+import CheckInput from './CheckInput';
+import { FormInputButtonProps } from './FormInputButton';
+import FormInputHelpText from './FormInputHelpText';
+import KeyValueListInput from './KeyValueListInput';
+import NumberInput from './NumberInput';
+import OAuthInput from './OAuthInput';
+import PasswordInput from './PasswordInput';
+import PathInput from './PathInput';
+import DownloadClientSelectInput from './Select/DownloadClientSelectInput';
+import EnhancedSelectInput from './Select/EnhancedSelectInput';
+import IndexerFlagsSelectInput from './Select/IndexerFlagsSelectInput';
+import IndexerSelectInput from './Select/IndexerSelectInput';
+import LanguageSelectInput from './Select/LanguageSelectInput';
+import MonitorEpisodesSelectInput from './Select/MonitorEpisodesSelectInput';
+import MonitorNewItemsSelectInput from './Select/MonitorNewItemsSelectInput';
+import ProviderDataSelectInput from './Select/ProviderOptionSelectInput';
+import QualityProfileSelectInput from './Select/QualityProfileSelectInput';
+import RootFolderSelectInput from './Select/RootFolderSelectInput';
+import SeriesTypeSelectInput from './Select/SeriesTypeSelectInput';
+import UMaskInput from './Select/UMaskInput';
+import DeviceInput from './Tag/DeviceInput';
+import SeriesTagInput from './Tag/SeriesTagInput';
+import TagSelectInput from './Tag/TagSelectInput';
+import TextTagInput from './Tag/TextTagInput';
+import TextArea from './TextArea';
+import TextInput from './TextInput';
+import styles from './FormInputGroup.css';
+
+function getComponent(type: InputType) {
+ switch (type) {
+ case inputTypes.AUTO_COMPLETE:
+ return AutoCompleteInput;
+
+ case inputTypes.CAPTCHA:
+ return CaptchaInput;
+
+ case inputTypes.CHECK:
+ return CheckInput;
+
+ case inputTypes.DEVICE:
+ return DeviceInput;
+
+ case inputTypes.KEY_VALUE_LIST:
+ return KeyValueListInput;
+
+ case inputTypes.LANGUAGE_SELECT:
+ return LanguageSelectInput;
+
+ case inputTypes.MONITOR_EPISODES_SELECT:
+ return MonitorEpisodesSelectInput;
+
+ case inputTypes.MONITOR_NEW_ITEMS_SELECT:
+ return MonitorNewItemsSelectInput;
+
+ case inputTypes.NUMBER:
+ return NumberInput;
+
+ case inputTypes.OAUTH:
+ return OAuthInput;
+
+ case inputTypes.PASSWORD:
+ return PasswordInput;
+
+ case inputTypes.PATH:
+ return PathInput;
+
+ case inputTypes.QUALITY_PROFILE_SELECT:
+ return QualityProfileSelectInput;
+
+ case inputTypes.INDEXER_SELECT:
+ return IndexerSelectInput;
+
+ case inputTypes.INDEXER_FLAGS_SELECT:
+ return IndexerFlagsSelectInput;
+
+ case inputTypes.DOWNLOAD_CLIENT_SELECT:
+ return DownloadClientSelectInput;
+
+ case inputTypes.ROOT_FOLDER_SELECT:
+ return RootFolderSelectInput;
+
+ case inputTypes.SELECT:
+ return EnhancedSelectInput;
+
+ case inputTypes.DYNAMIC_SELECT:
+ return ProviderDataSelectInput;
+
+ case inputTypes.TAG:
+ case inputTypes.SERIES_TAG:
+ return SeriesTagInput;
+
+ case inputTypes.SERIES_TYPE_SELECT:
+ return SeriesTypeSelectInput;
+
+ case inputTypes.TEXT_AREA:
+ return TextArea;
+
+ case inputTypes.TEXT_TAG:
+ return TextTagInput;
+
+ case inputTypes.TAG_SELECT:
+ return TagSelectInput;
+
+ case inputTypes.UMASK:
+ return UMaskInput;
+
+ default:
+ return TextInput;
+ }
+}
+
+// TODO: Remove once all parent components are updated to TSX and we can refactor to a consistent type
+interface ValidationMessage {
+ message: string;
+}
+
+interface FormInputGroupProps {
+ className?: string;
+ containerClassName?: string;
+ inputClassName?: string;
+ name: string;
+ value?: unknown;
+ values?: unknown[];
+ isDisabled?: boolean;
+ type?: InputType;
+ kind?: Kind;
+ min?: number;
+ max?: number;
+ unit?: string;
+ buttons?: ReactNode | ReactNode[];
+ helpText?: string;
+ helpTexts?: string[];
+ helpTextWarning?: string;
+ helpLink?: string;
+ placeholder?: string;
+ autoFocus?: boolean;
+ includeNoChange?: boolean;
+ includeNoChangeDisabled?: boolean;
+ valueOptions?: object;
+ selectedValueOptions?: object;
+ indexerFlags?: number;
+ pending?: boolean;
+ canEdit?: boolean;
+ includeAny?: boolean;
+ delimiters?: string[];
+ readOnly?: boolean;
+ errors?: (ValidationMessage | ValidationError)[];
+ warnings?: (ValidationMessage | ValidationWarning)[];
+ onChange: (args: T) => void;
+ onFocus?: (event: FocusEvent) => void;
+}
+
+function FormInputGroup(props: FormInputGroupProps) {
+ const {
+ className = styles.inputGroup,
+ containerClassName = styles.inputGroupContainer,
+ inputClassName,
+ type = 'text',
+ unit,
+ buttons = [],
+ helpText,
+ helpTexts = [],
+ helpTextWarning,
+ helpLink,
+ pending,
+ errors = [],
+ warnings = [],
+ ...otherProps
+ } = props;
+
+ const InputComponent = getComponent(type);
+ const checkInput = type === inputTypes.CHECK;
+ const hasError = !!errors.length;
+ const hasWarning = !hasError && !!warnings.length;
+ const buttonsArray = React.Children.toArray(buttons);
+ const lastButtonIndex = buttonsArray.length - 1;
+ const hasButton = !!buttonsArray.length;
+
+ return (
+
+
+
+ {/* @ts-expect-error - need to pass through all the expected options */}
+
+
+ {unit && (
+
+ {unit}
+
+ )}
+
+
+ {buttonsArray.map((button, index) => {
+ if (!React.isValidElement
(button)) {
+ return button;
+ }
+
+ return React.cloneElement(button, {
+ isLastButton: index === lastButtonIndex,
+ });
+ })}
+
+ {/*
+ {
+ pending &&
+
+ }
+
*/}
+
+
+ {!checkInput && helpText ?
: null}
+
+ {!checkInput && helpTexts ? (
+
+ {helpTexts.map((text, index) => {
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+ {(!checkInput || helpText) && helpTextWarning ? (
+
+ ) : null}
+
+ {helpLink ?
{translate('MoreInfo')} : null}
+
+ {errors.map((error, index) => {
+ return 'errorMessage' in error ? (
+
+ ) : (
+
+ );
+ })}
+
+ {warnings.map((warning, index) => {
+ return 'errorMessage' in warning ? (
+
+ ) : (
+
+ );
+ })}
+
+ );
+}
+
+export default FormInputGroup;
diff --git a/frontend/src/Components/Form/FormInputHelpText.js b/frontend/src/Components/Form/FormInputHelpText.js
deleted file mode 100644
index 00024684e..000000000
--- a/frontend/src/Components/Form/FormInputHelpText.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import { icons } from 'Helpers/Props';
-import styles from './FormInputHelpText.css';
-
-function FormInputHelpText(props) {
- const {
- className,
- text,
- link,
- tooltip,
- isError,
- isWarning,
- isCheckInput
- } = props;
-
- return (
-
- {text}
-
- {
- link ?
-
-
- :
- null
- }
-
- {
- !link && tooltip ?
- :
- null
- }
-
- );
-}
-
-FormInputHelpText.propTypes = {
- className: PropTypes.string.isRequired,
- text: PropTypes.string.isRequired,
- link: PropTypes.string,
- tooltip: PropTypes.string,
- isError: PropTypes.bool,
- isWarning: PropTypes.bool,
- isCheckInput: PropTypes.bool
-};
-
-FormInputHelpText.defaultProps = {
- className: styles.helpText,
- isError: false,
- isWarning: false,
- isCheckInput: false
-};
-
-export default FormInputHelpText;
diff --git a/frontend/src/Components/Form/FormInputHelpText.tsx b/frontend/src/Components/Form/FormInputHelpText.tsx
new file mode 100644
index 000000000..1531d9585
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.tsx
@@ -0,0 +1,55 @@
+import classNames from 'classnames';
+import React from 'react';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import { icons } from 'Helpers/Props';
+import styles from './FormInputHelpText.css';
+
+interface FormInputHelpTextProps {
+ className?: string;
+ text: string;
+ link?: string;
+ tooltip?: string;
+ isError?: boolean;
+ isWarning?: boolean;
+ isCheckInput?: boolean;
+}
+
+function FormInputHelpText({
+ className = styles.helpText,
+ text,
+ link,
+ tooltip,
+ isError = false,
+ isWarning = false,
+ isCheckInput = false,
+}: FormInputHelpTextProps) {
+ return (
+
+ {text}
+
+ {link ? (
+
+
+
+ ) : null}
+
+ {!link && tooltip ? (
+
+ ) : null}
+
+ );
+}
+
+export default FormInputHelpText;
diff --git a/frontend/src/Components/Form/FormLabel.js b/frontend/src/Components/Form/FormLabel.js
deleted file mode 100644
index d4a4bcffc..000000000
--- a/frontend/src/Components/Form/FormLabel.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import { sizes } from 'Helpers/Props';
-import styles from './FormLabel.css';
-
-function FormLabel(props) {
- const {
- children,
- className,
- errorClassName,
- size,
- name,
- hasError,
- isAdvanced,
- ...otherProps
- } = props;
-
- return (
-
- );
-}
-
-FormLabel.propTypes = {
- children: PropTypes.oneOfType([PropTypes.node, PropTypes.string]).isRequired,
- className: PropTypes.string,
- errorClassName: PropTypes.string,
- size: PropTypes.oneOf(sizes.all),
- name: PropTypes.string,
- hasError: PropTypes.bool,
- isAdvanced: PropTypes.bool
-};
-
-FormLabel.defaultProps = {
- className: styles.label,
- errorClassName: styles.hasError,
- isAdvanced: false,
- size: sizes.LARGE
-};
-
-export default FormLabel;
diff --git a/frontend/src/Components/Form/FormLabel.tsx b/frontend/src/Components/Form/FormLabel.tsx
new file mode 100644
index 000000000..4f29e6ac6
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.tsx
@@ -0,0 +1,42 @@
+import classNames from 'classnames';
+import React, { ReactNode } from 'react';
+import { Size } from 'Helpers/Props/sizes';
+import styles from './FormLabel.css';
+
+interface FormLabelProps {
+ children: ReactNode;
+ className?: string;
+ errorClassName?: string;
+ size?: Extract;
+ name?: string;
+ hasError?: boolean;
+ isAdvanced?: boolean;
+}
+
+function FormLabel(props: FormLabelProps) {
+ const {
+ children,
+ className = styles.label,
+ errorClassName = styles.hasError,
+ size = 'large',
+ name,
+ hasError,
+ isAdvanced = false,
+ } = props;
+
+ return (
+
+ );
+}
+
+export default FormLabel;
diff --git a/frontend/src/Components/Form/HintedSelectInputOption.js b/frontend/src/Components/Form/HintedSelectInputOption.js
deleted file mode 100644
index 4957ece2a..000000000
--- a/frontend/src/Components/Form/HintedSelectInputOption.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import EnhancedSelectInputOption from './EnhancedSelectInputOption';
-import styles from './HintedSelectInputOption.css';
-
-function HintedSelectInputOption(props) {
- const {
- id,
- value,
- hint,
- depth,
- isSelected,
- isDisabled,
- isMultiSelect,
- isMobile,
- ...otherProps
- } = props;
-
- return (
-
-
-
{value}
-
- {
- hint != null &&
-
- {hint}
-
- }
-
-
- );
-}
-
-HintedSelectInputOption.propTypes = {
- id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- value: PropTypes.string.isRequired,
- hint: PropTypes.node,
- depth: PropTypes.number,
- isSelected: PropTypes.bool.isRequired,
- isDisabled: PropTypes.bool.isRequired,
- isMultiSelect: PropTypes.bool.isRequired,
- isMobile: PropTypes.bool.isRequired
-};
-
-HintedSelectInputOption.defaultProps = {
- isDisabled: false,
- isHidden: false,
- isMultiSelect: false
-};
-
-export default HintedSelectInputOption;
diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
deleted file mode 100644
index a3fecf324..000000000
--- a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React from 'react';
-import Label from 'Components/Label';
-import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
-import styles from './HintedSelectInputSelectedValue.css';
-
-function HintedSelectInputSelectedValue(props) {
- const {
- value,
- values,
- hint,
- isMultiSelect,
- includeHint,
- ...otherProps
- } = props;
-
- const valuesMap = isMultiSelect && _.keyBy(values, 'key');
-
- return (
-
-
- {
- isMultiSelect ?
- value.map((key, index) => {
- const v = valuesMap[key];
- return (
-
- );
- }) :
- null
- }
-
- {
- isMultiSelect ? null : value
- }
-
-
- {
- hint != null && includeHint ?
-
- {hint}
-
:
- null
- }
-
- );
-}
-
-HintedSelectInputSelectedValue.propTypes = {
- 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,
- includeHint: PropTypes.bool.isRequired
-};
-
-HintedSelectInputSelectedValue.defaultProps = {
- isMultiSelect: false,
- includeHint: true
-};
-
-export default HintedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/IndexerSelectInputConnector.js b/frontend/src/Components/Form/IndexerSelectInputConnector.js
deleted file mode 100644
index 91c31198f..000000000
--- a/frontend/src/Components/Form/IndexerSelectInputConnector.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { fetchIndexers } from 'Store/Actions/settingsActions';
-import sortByName from 'Utilities/Array/sortByName';
-import translate from 'Utilities/String/translate';
-import EnhancedSelectInput from './EnhancedSelectInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.settings.indexers,
- (state, { includeAny }) => includeAny,
- (indexers, includeAny) => {
- const {
- isFetching,
- isPopulated,
- error,
- items
- } = indexers;
-
- const values = _.map(items.sort(sortByName), (indexer) => {
- return {
- key: indexer.id,
- value: indexer.name
- };
- });
-
- if (includeAny) {
- values.unshift({
- key: 0,
- value: `(${translate('Any')})`
- });
- }
-
- return {
- isFetching,
- isPopulated,
- error,
- values
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- dispatchFetchIndexers: fetchIndexers
-};
-
-class IndexerSelectInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount() {
- if (!this.props.isPopulated) {
- this.props.dispatchFetchIndexers();
- }
- }
-
- //
- // Listeners
-
- onChange = ({ name, value }) => {
- this.props.onChange({ name, value: parseInt(value) });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-IndexerSelectInputConnector.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,
- dispatchFetchIndexers: PropTypes.func.isRequired
-};
-
-IndexerSelectInputConnector.defaultProps = {
- includeAny: false
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSelectInputConnector);
diff --git a/frontend/src/Components/Form/KeyValueListInput.js b/frontend/src/Components/Form/KeyValueListInput.js
deleted file mode 100644
index 3e73d74f3..000000000
--- a/frontend/src/Components/Form/KeyValueListInput.js
+++ /dev/null
@@ -1,156 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import KeyValueListInputItem from './KeyValueListInputItem';
-import styles from './KeyValueListInput.css';
-
-class KeyValueListInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isFocused: false
- };
- }
-
- //
- // Listeners
-
- onItemChange = (index, itemValue) => {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = [...value];
-
- if (index == null) {
- newValue.push(itemValue);
- } else {
- newValue.splice(index, 1, itemValue);
- }
-
- onChange({
- name,
- value: newValue
- });
- };
-
- onRemoveItem = (index) => {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = [...value];
- newValue.splice(index, 1);
-
- onChange({
- name,
- value: newValue
- });
- };
-
- onFocus = () => {
- this.setState({
- isFocused: true
- });
- };
-
- onBlur = () => {
- this.setState({
- isFocused: false
- });
-
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const newValue = value.reduce((acc, v) => {
- if (v.key || v.value) {
- acc.push(v);
- }
-
- return acc;
- }, []);
-
- if (newValue.length !== value.length) {
- onChange({
- name,
- value: newValue
- });
- }
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- value,
- keyPlaceholder,
- valuePlaceholder,
- hasError,
- hasWarning
- } = this.props;
-
- const { isFocused } = this.state;
-
- return (
-
- {
- [...value, { key: '', value: '' }].map((v, index) => {
- return (
-
- );
- })
- }
-
- );
- }
-}
-
-KeyValueListInput.propTypes = {
- className: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.object).isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- keyPlaceholder: PropTypes.string,
- valuePlaceholder: PropTypes.string,
- onChange: PropTypes.func.isRequired
-};
-
-KeyValueListInput.defaultProps = {
- className: styles.inputContainer,
- value: []
-};
-
-export default KeyValueListInput;
diff --git a/frontend/src/Components/Form/KeyValueListInput.tsx b/frontend/src/Components/Form/KeyValueListInput.tsx
new file mode 100644
index 000000000..f5c6ac19b
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInput.tsx
@@ -0,0 +1,104 @@
+import classNames from 'classnames';
+import React, { useCallback, useState } from 'react';
+import { InputOnChange } from 'typings/inputs';
+import KeyValueListInputItem from './KeyValueListInputItem';
+import styles from './KeyValueListInput.css';
+
+interface KeyValue {
+ key: string;
+ value: string;
+}
+
+export interface KeyValueListInputProps {
+ className?: string;
+ name: string;
+ value: KeyValue[];
+ hasError?: boolean;
+ hasWarning?: boolean;
+ keyPlaceholder?: string;
+ valuePlaceholder?: string;
+ onChange: InputOnChange;
+}
+
+function KeyValueListInput({
+ className = styles.inputContainer,
+ name,
+ value = [],
+ hasError = false,
+ hasWarning = false,
+ keyPlaceholder,
+ valuePlaceholder,
+ onChange,
+}: KeyValueListInputProps): JSX.Element {
+ const [isFocused, setIsFocused] = useState(false);
+
+ const handleItemChange = useCallback(
+ (index: number | null, itemValue: KeyValue) => {
+ const newValue = [...value];
+
+ if (index === null) {
+ newValue.push(itemValue);
+ } else {
+ newValue.splice(index, 1, itemValue);
+ }
+
+ onChange({ name, value: newValue });
+ },
+ [value, name, onChange]
+ );
+
+ const handleRemoveItem = useCallback(
+ (index: number) => {
+ const newValue = [...value];
+ newValue.splice(index, 1);
+ onChange({ name, value: newValue });
+ },
+ [value, name, onChange]
+ );
+
+ const onFocus = useCallback(() => setIsFocused(true), []);
+
+ const onBlur = useCallback(() => {
+ setIsFocused(false);
+
+ const newValue = value.reduce((acc: KeyValue[], v) => {
+ if (v.key || v.value) {
+ acc.push(v);
+ }
+ return acc;
+ }, []);
+
+ if (newValue.length !== value.length) {
+ onChange({ name, value: newValue });
+ }
+ }, [value, name, onChange]);
+
+ return (
+
+ {[...value, { key: '', value: '' }].map((v, index) => (
+
+ ))}
+
+ );
+}
+
+export default KeyValueListInput;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css
index 75d37b74f..ed82db459 100644
--- a/frontend/src/Components/Form/KeyValueListInputItem.css
+++ b/frontend/src/Components/Form/KeyValueListInputItem.css
@@ -5,11 +5,12 @@
&:last-child {
margin-bottom: 0;
+ border-bottom: 0;
}
}
.keyInputWrapper {
- flex: 6 0 0;
+ flex: 1 0 0;
}
.valueInputWrapper {
@@ -25,4 +26,10 @@
.valueInput {
width: 100%;
border: none;
+ background-color: transparent;
+ color: var(--textColor);
+
+ &::placeholder {
+ color: var(--helpTextColor);
+ }
}
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js
deleted file mode 100644
index 9f5abce2f..000000000
--- a/frontend/src/Components/Form/KeyValueListInputItem.js
+++ /dev/null
@@ -1,124 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import IconButton from 'Components/Link/IconButton';
-import { icons } from 'Helpers/Props';
-import TextInput from './TextInput';
-import styles from './KeyValueListInputItem.css';
-
-class KeyValueListInputItem extends Component {
-
- //
- // Listeners
-
- onKeyChange = ({ value: keyValue }) => {
- const {
- index,
- value,
- onChange
- } = this.props;
-
- onChange(index, { key: keyValue, value });
- };
-
- onValueChange = ({ value }) => {
- // TODO: Validate here or validate at a lower level component
-
- const {
- index,
- keyValue,
- onChange
- } = this.props;
-
- onChange(index, { key: keyValue, value });
- };
-
- onRemovePress = () => {
- const {
- index,
- onRemove
- } = this.props;
-
- onRemove(index);
- };
-
- onFocus = () => {
- this.props.onFocus();
- };
-
- onBlur = () => {
- this.props.onBlur();
- };
-
- //
- // Render
-
- render() {
- const {
- keyValue,
- value,
- keyPlaceholder,
- valuePlaceholder,
- isNew
- } = this.props;
-
- return (
-
-
-
-
-
-
-
-
-
-
- {
- isNew ?
- null :
-
- }
-
-
- );
- }
-}
-
-KeyValueListInputItem.propTypes = {
- index: PropTypes.number,
- keyValue: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- keyPlaceholder: PropTypes.string.isRequired,
- valuePlaceholder: PropTypes.string.isRequired,
- isNew: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- onRemove: PropTypes.func.isRequired,
- onFocus: PropTypes.func.isRequired,
- onBlur: PropTypes.func.isRequired
-};
-
-KeyValueListInputItem.defaultProps = {
- keyPlaceholder: 'Key',
- valuePlaceholder: 'Value'
-};
-
-export default KeyValueListInputItem;
diff --git a/frontend/src/Components/Form/KeyValueListInputItem.tsx b/frontend/src/Components/Form/KeyValueListInputItem.tsx
new file mode 100644
index 000000000..c63ad50a9
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.tsx
@@ -0,0 +1,89 @@
+import React, { useCallback } from 'react';
+import IconButton from 'Components/Link/IconButton';
+import { icons } from 'Helpers/Props';
+import TextInput from './TextInput';
+import styles from './KeyValueListInputItem.css';
+
+interface KeyValueListInputItemProps {
+ index: number;
+ keyValue: string;
+ value: string;
+ keyPlaceholder?: string;
+ valuePlaceholder?: string;
+ isNew: boolean;
+ onChange: (index: number, itemValue: { key: string; value: string }) => void;
+ onRemove: (index: number) => void;
+ onFocus: () => void;
+ onBlur: () => void;
+}
+
+function KeyValueListInputItem({
+ index,
+ keyValue,
+ value,
+ keyPlaceholder = 'Key',
+ valuePlaceholder = 'Value',
+ isNew,
+ onChange,
+ onRemove,
+ onFocus,
+ onBlur,
+}: KeyValueListInputItemProps): JSX.Element {
+ const handleKeyChange = useCallback(
+ ({ value: keyValue }: { value: string }) => {
+ onChange(index, { key: keyValue, value });
+ },
+ [index, value, onChange]
+ );
+
+ const handleValueChange = useCallback(
+ ({ value }: { value: string }) => {
+ onChange(index, { key: keyValue, value });
+ },
+ [index, keyValue, onChange]
+ );
+
+ const handleRemovePress = useCallback(() => {
+ onRemove(index);
+ }, [index, onRemove]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {isNew ? null : (
+
+ )}
+
+
+ );
+}
+
+export default KeyValueListInputItem;
diff --git a/frontend/src/Components/Form/LanguageSelectInputConnector.js b/frontend/src/Components/Form/LanguageSelectInputConnector.js
deleted file mode 100644
index dd3a52017..000000000
--- a/frontend/src/Components/Form/LanguageSelectInputConnector.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import EnhancedSelectInput from './EnhancedSelectInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state, { values }) => values,
- ( languages ) => {
-
- const minId = languages.reduce((min, v) => (v.key < 1 ? v.key : min), languages[0].key);
-
- const values = languages.map(({ key, value }) => {
- return {
- key,
- value,
- dividerAfter: minId < 1 ? key === minId : false
- };
- });
-
- return {
- values
- };
- }
- );
-}
-
-class LanguageSelectInputConnector extends Component {
-
- //
- // Render
-
- render() {
-
- return (
-
- );
- }
-}
-
-LanguageSelectInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]).isRequired,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps)(LanguageSelectInputConnector);
diff --git a/frontend/src/Components/Form/MonitorEpisodesSelectInput.js b/frontend/src/Components/Form/MonitorEpisodesSelectInput.js
deleted file mode 100644
index 9b80cc587..000000000
--- a/frontend/src/Components/Form/MonitorEpisodesSelectInput.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import monitorOptions from 'Utilities/Series/monitorOptions';
-import translate from 'Utilities/String/translate';
-import SelectInput from './SelectInput';
-
-function MonitorEpisodesSelectInput(props) {
- const {
- includeNoChange,
- includeMixed,
- ...otherProps
- } = props;
-
- const values = [...monitorOptions];
-
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- get value() {
- return translate('NoChange');
- },
- disabled: true
- });
- }
-
- if (includeMixed) {
- values.unshift({
- key: 'mixed',
- get value() {
- return `(${translate('Mixed')})`;
- },
- disabled: true
- });
- }
-
- return (
-
- );
-}
-
-MonitorEpisodesSelectInput.propTypes = {
- includeNoChange: PropTypes.bool.isRequired,
- includeMixed: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-MonitorEpisodesSelectInput.defaultProps = {
- includeNoChange: false,
- includeMixed: false
-};
-
-export default MonitorEpisodesSelectInput;
diff --git a/frontend/src/Components/Form/MonitorNewItemsSelectInput.js b/frontend/src/Components/Form/MonitorNewItemsSelectInput.js
deleted file mode 100644
index c704e5c1f..000000000
--- a/frontend/src/Components/Form/MonitorNewItemsSelectInput.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions';
-import SelectInput from './SelectInput';
-
-function MonitorNewItemsSelectInput(props) {
- const {
- includeNoChange,
- includeMixed,
- ...otherProps
- } = props;
-
- const values = [...monitorNewItemsOptions];
-
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- value: 'No Change',
- disabled: true
- });
- }
-
- if (includeMixed) {
- values.unshift({
- key: 'mixed',
- value: '(Mixed)',
- disabled: true
- });
- }
-
- return (
-
- );
-}
-
-MonitorNewItemsSelectInput.propTypes = {
- includeNoChange: PropTypes.bool.isRequired,
- includeMixed: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-MonitorNewItemsSelectInput.defaultProps = {
- includeNoChange: false,
- includeMixed: false
-};
-
-export default MonitorNewItemsSelectInput;
diff --git a/frontend/src/Components/Form/NumberInput.js b/frontend/src/Components/Form/NumberInput.js
deleted file mode 100644
index cac274d95..000000000
--- a/frontend/src/Components/Form/NumberInput.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import TextInput from './TextInput';
-
-function parseValue(props, value) {
- const {
- isFloat,
- min,
- max
- } = props;
-
- if (value == null || value === '') {
- return null;
- }
-
- let newValue = isFloat ? parseFloat(value) : parseInt(value);
-
- if (min != null && newValue != null && newValue < min) {
- newValue = min;
- } else if (max != null && newValue != null && newValue > max) {
- newValue = max;
- }
-
- return newValue;
-}
-
-class NumberInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- value: props.value == null ? '' : props.value.toString(),
- isFocused: false
- };
- }
-
- componentDidUpdate(prevProps, prevState) {
- const { value } = this.props;
-
- if (!isNaN(value) && value !== prevProps.value && !this.state.isFocused) {
- this.setState({
- value: value == null ? '' : value.toString()
- });
- }
- }
-
- //
- // Listeners
-
- onChange = ({ name, value }) => {
- this.setState({ value });
-
- this.props.onChange({
- name,
- value: parseValue(this.props, value)
- });
-
- };
-
- onFocus = () => {
- this.setState({ isFocused: true });
- };
-
- onBlur = () => {
- const {
- name,
- onChange
- } = this.props;
-
- const { value } = this.state;
- const parsedValue = parseValue(this.props, value);
- const stringValue = parsedValue == null ? '' : parsedValue.toString();
-
- if (stringValue === value) {
- this.setState({ isFocused: false });
- } else {
- this.setState({
- value: stringValue,
- isFocused: false
- });
- }
-
- onChange({
- name,
- value: parsedValue
- });
- };
-
- //
- // Render
-
- render() {
- const value = this.state.value;
-
- return (
-
- );
- }
-}
-
-NumberInput.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.number,
- min: PropTypes.number,
- max: PropTypes.number,
- isFloat: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-NumberInput.defaultProps = {
- value: null,
- isFloat: false
-};
-
-export default NumberInput;
diff --git a/frontend/src/Components/Form/NumberInput.tsx b/frontend/src/Components/Form/NumberInput.tsx
new file mode 100644
index 000000000..a5e1fcb64
--- /dev/null
+++ b/frontend/src/Components/Form/NumberInput.tsx
@@ -0,0 +1,108 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import { InputChanged } from 'typings/inputs';
+import TextInput, { TextInputProps } from './TextInput';
+
+function parseValue(
+ value: string | null | undefined,
+ isFloat: boolean,
+ min: number | undefined,
+ max: number | undefined
+) {
+ if (value == null || value === '') {
+ return null;
+ }
+
+ let newValue = isFloat ? parseFloat(value) : parseInt(value);
+
+ if (min != null && newValue != null && newValue < min) {
+ newValue = min;
+ } else if (max != null && newValue != null && newValue > max) {
+ newValue = max;
+ }
+
+ return newValue;
+}
+
+interface NumberInputProps
+ extends Omit, 'value'> {
+ value?: number | null;
+ min?: number;
+ max?: number;
+ isFloat?: boolean;
+}
+
+function NumberInput({
+ name,
+ value: inputValue = null,
+ isFloat = false,
+ min,
+ max,
+ onChange,
+ ...otherProps
+}: NumberInputProps) {
+ const [value, setValue] = useState(
+ inputValue == null ? '' : inputValue.toString()
+ );
+ const isFocused = useRef(false);
+ const previousValue = usePrevious(inputValue);
+
+ const handleChange = useCallback(
+ ({ name, value: newValue }: InputChanged) => {
+ setValue(newValue);
+
+ onChange({
+ name,
+ value: parseValue(newValue, isFloat, min, max),
+ });
+ },
+ [isFloat, min, max, onChange, setValue]
+ );
+
+ const handleFocus = useCallback(() => {
+ isFocused.current = true;
+ }, []);
+
+ const handleBlur = useCallback(() => {
+ const parsedValue = parseValue(value, isFloat, min, max);
+ const stringValue = parsedValue == null ? '' : parsedValue.toString();
+
+ if (stringValue !== value) {
+ setValue(stringValue);
+ }
+
+ onChange({
+ name,
+ value: parsedValue,
+ });
+
+ isFocused.current = false;
+ }, [name, value, isFloat, min, max, onChange]);
+
+ useEffect(() => {
+ if (
+ // @ts-expect-error inputValue may be null
+ !isNaN(inputValue) &&
+ inputValue !== previousValue &&
+ !isFocused.current
+ ) {
+ setValue(inputValue == null ? '' : inputValue.toString());
+ }
+ }, [inputValue, previousValue, setValue]);
+
+ return (
+
+ );
+}
+
+export default NumberInput;
diff --git a/frontend/src/Components/Form/OAuthInput.js b/frontend/src/Components/Form/OAuthInput.js
deleted file mode 100644
index 4ecd625bc..000000000
--- a/frontend/src/Components/Form/OAuthInput.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
-import { kinds } from 'Helpers/Props';
-
-function OAuthInput(props) {
- const {
- label,
- authorizing,
- error,
- onPress
- } = props;
-
- return (
-
-
- {label}
-
-
- );
-}
-
-OAuthInput.propTypes = {
- label: PropTypes.string.isRequired,
- authorizing: PropTypes.bool.isRequired,
- error: PropTypes.object,
- onPress: PropTypes.func.isRequired
-};
-
-OAuthInput.defaultProps = {
- label: 'Start OAuth'
-};
-
-export default OAuthInput;
diff --git a/frontend/src/Components/Form/OAuthInput.tsx b/frontend/src/Components/Form/OAuthInput.tsx
new file mode 100644
index 000000000..04d2a0caf
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInput.tsx
@@ -0,0 +1,72 @@
+import React, { useCallback, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import AppState from 'App/State/AppState';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import { kinds } from 'Helpers/Props';
+import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions';
+import { InputOnChange } from 'typings/inputs';
+
+interface OAuthInputProps {
+ label?: string;
+ name: string;
+ provider: string;
+ providerData: object;
+ section: string;
+ onChange: InputOnChange;
+}
+
+function OAuthInput({
+ label = 'Start OAuth',
+ name,
+ provider,
+ providerData,
+ section,
+ onChange,
+}: OAuthInputProps) {
+ const dispatch = useDispatch();
+ const { authorizing, error, result } = useSelector(
+ (state: AppState) => state.oAuth
+ );
+
+ const handlePress = useCallback(() => {
+ dispatch(
+ startOAuth({
+ name,
+ provider,
+ providerData,
+ section,
+ })
+ );
+ }, [name, provider, providerData, section, dispatch]);
+
+ useEffect(() => {
+ if (!result) {
+ return;
+ }
+
+ Object.keys(result).forEach((key) => {
+ onChange({ name: key, value: result[key] });
+ });
+ }, [result, onChange]);
+
+ useEffect(() => {
+ return () => {
+ dispatch(resetOAuth());
+ };
+ }, [dispatch]);
+
+ return (
+
+
+ {label}
+
+
+ );
+}
+
+export default OAuthInput;
diff --git a/frontend/src/Components/Form/OAuthInputConnector.js b/frontend/src/Components/Form/OAuthInputConnector.js
deleted file mode 100644
index 1567c7e6c..000000000
--- a/frontend/src/Components/Form/OAuthInputConnector.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions';
-import OAuthInput from './OAuthInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.oAuth,
- (oAuth) => {
- return oAuth;
- }
- );
-}
-
-const mapDispatchToProps = {
- startOAuth,
- resetOAuth
-};
-
-class OAuthInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidUpdate(prevProps) {
- const {
- result,
- onChange
- } = this.props;
-
- if (!result || result === prevProps.result) {
- return;
- }
-
- Object.keys(result).forEach((key) => {
- onChange({ name: key, value: result[key] });
- });
- }
-
- componentWillUnmount = () => {
- this.props.resetOAuth();
- };
-
- //
- // Listeners
-
- onPress = () => {
- const {
- name,
- provider,
- providerData,
- section
- } = this.props;
-
- this.props.startOAuth({
- name,
- provider,
- providerData,
- section
- });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-OAuthInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- result: PropTypes.object,
- provider: PropTypes.string.isRequired,
- providerData: PropTypes.object.isRequired,
- section: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
- startOAuth: PropTypes.func.isRequired,
- resetOAuth: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector);
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
deleted file mode 100644
index fef54fd5a..000000000
--- a/frontend/src/Components/Form/PasswordInput.js
+++ /dev/null
@@ -1,29 +0,0 @@
-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) {
- e.preventDefault();
- e.nativeEvent.stopImmediatePropagation();
-}
-
-function PasswordInput(props) {
- return (
-
- );
-}
-
-PasswordInput.propTypes = {
- className: PropTypes.string.isRequired
-};
-
-PasswordInput.defaultProps = {
- className: styles.input
-};
-
-export default PasswordInput;
diff --git a/frontend/src/Components/Form/PasswordInput.tsx b/frontend/src/Components/Form/PasswordInput.tsx
new file mode 100644
index 000000000..776c2b913
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.tsx
@@ -0,0 +1,14 @@
+import React, { SyntheticEvent } from 'react';
+import TextInput, { TextInputProps } from './TextInput';
+
+// Prevent a user from copying (or cutting) the password from the input
+function onCopy(e: SyntheticEvent) {
+ e.preventDefault();
+ e.nativeEvent.stopImmediatePropagation();
+}
+
+function PasswordInput(props: TextInputProps) {
+ return ;
+}
+
+export default PasswordInput;
diff --git a/frontend/src/Components/Form/PathInput.css b/frontend/src/Components/Form/PathInput.css
index 3b32b16f0..327a85ef8 100644
--- a/frontend/src/Components/Form/PathInput.css
+++ b/frontend/src/Components/Form/PathInput.css
@@ -16,3 +16,7 @@
height: 35px;
}
+
+.fileBrowserMiddleButton {
+ composes: middleButton from '~./FormInputButton.css';
+}
diff --git a/frontend/src/Components/Form/PathInput.css.d.ts b/frontend/src/Components/Form/PathInput.css.d.ts
index d44c3dd56..82be3d1ff 100644
--- a/frontend/src/Components/Form/PathInput.css.d.ts
+++ b/frontend/src/Components/Form/PathInput.css.d.ts
@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'fileBrowserButton': string;
+ 'fileBrowserMiddleButton': string;
'hasFileBrowser': string;
'inputWrapper': string;
'pathMatch': string;
diff --git a/frontend/src/Components/Form/PathInput.js b/frontend/src/Components/Form/PathInput.js
deleted file mode 100644
index 972d8f99f..000000000
--- a/frontend/src/Components/Form/PathInput.js
+++ /dev/null
@@ -1,195 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
-import Icon from 'Components/Icon';
-import { icons } from 'Helpers/Props';
-import AutoSuggestInput from './AutoSuggestInput';
-import FormInputButton from './FormInputButton';
-import styles from './PathInput.css';
-
-class PathInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._node = document.getElementById('portal-root');
-
- this.state = {
- value: props.value,
- isFileBrowserModalOpen: false
- };
- }
-
- componentDidUpdate(prevProps) {
- const { value } = this.props;
-
- if (prevProps.value !== value) {
- this.setState({ value });
- }
- }
-
- //
- // Control
-
- getSuggestionValue({ path }) {
- return path;
- }
-
- renderSuggestion({ path }, { query }) {
- const lastSeparatorIndex = query.lastIndexOf('\\') || query.lastIndexOf('/');
-
- if (lastSeparatorIndex === -1) {
- return (
- {path}
- );
- }
-
- return (
-
-
- {path.substr(0, lastSeparatorIndex)}
-
- {path.substr(lastSeparatorIndex)}
-
- );
- }
-
- //
- // Listeners
-
- onInputChange = ({ value }) => {
- this.setState({ value });
- };
-
- onInputKeyDown = (event) => {
- if (event.key === 'Tab') {
- event.preventDefault();
- const path = this.props.paths[0];
-
- if (path) {
- this.props.onChange({
- name: this.props.name,
- value: path.path
- });
-
- if (path.type !== 'file') {
- this.props.onFetchPaths(path.path);
- }
- }
- }
- };
-
- onInputBlur = () => {
- this.props.onChange({
- name: this.props.name,
- value: this.state.value
- });
-
- this.props.onClearPaths();
- };
-
- onSuggestionsFetchRequested = ({ value }) => {
- this.props.onFetchPaths(value);
- };
-
- onSuggestionsClearRequested = () => {
- // Required because props aren't always rendered, but no-op
- // because we don't want to reset the paths after a path is selected.
- };
-
- onSuggestionSelected = (event, { suggestionValue }) => {
- this.props.onFetchPaths(suggestionValue);
- };
-
- onFileBrowserOpenPress = () => {
- this.setState({ isFileBrowserModalOpen: true });
- };
-
- onFileBrowserModalClose = () => {
- this.setState({ isFileBrowserModalOpen: false });
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- name,
- paths,
- includeFiles,
- hasFileBrowser,
- onChange,
- ...otherProps
- } = this.props;
-
- const {
- value,
- isFileBrowserModalOpen
- } = this.state;
-
- return (
-
-
-
- {
- hasFileBrowser &&
-
-
-
-
-
-
-
- }
-
- );
- }
-}
-
-PathInput.propTypes = {
- className: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- value: PropTypes.string,
- paths: PropTypes.array.isRequired,
- includeFiles: PropTypes.bool.isRequired,
- hasFileBrowser: PropTypes.bool,
- onChange: PropTypes.func.isRequired,
- onFetchPaths: PropTypes.func.isRequired,
- onClearPaths: PropTypes.func.isRequired
-};
-
-PathInput.defaultProps = {
- className: styles.inputWrapper,
- value: '',
- hasFileBrowser: true
-};
-
-export default PathInput;
diff --git a/frontend/src/Components/Form/PathInput.tsx b/frontend/src/Components/Form/PathInput.tsx
new file mode 100644
index 000000000..0caf66905
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.tsx
@@ -0,0 +1,258 @@
+import classNames from 'classnames';
+import React, {
+ KeyboardEvent,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useState,
+} from 'react';
+import {
+ ChangeEvent,
+ SuggestionsFetchRequestedParams,
+} from 'react-autosuggest';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { Path } from 'App/State/PathsAppState';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import Icon from 'Components/Icon';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import { icons } from 'Helpers/Props';
+import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
+import { InputChanged } from 'typings/inputs';
+import AutoSuggestInput from './AutoSuggestInput';
+import FormInputButton from './FormInputButton';
+import styles from './PathInput.css';
+
+interface PathInputProps {
+ className?: string;
+ name: string;
+ value?: string;
+ placeholder?: string;
+ includeFiles: boolean;
+ hasButton?: boolean;
+ hasFileBrowser?: boolean;
+ onChange: (change: InputChanged) => void;
+}
+
+interface PathInputInternalProps extends PathInputProps {
+ paths: Path[];
+ onFetchPaths: (path: string) => void;
+ onClearPaths: () => void;
+}
+
+function handleSuggestionsClearRequested() {
+ // Required because props aren't always rendered, but no-op
+ // because we don't want to reset the paths after a path is selected.
+}
+
+function createPathsSelector() {
+ return createSelector(
+ (state: AppState) => state.paths,
+ (paths) => {
+ const { currentPath, directories, files } = paths;
+
+ const filteredPaths = [...directories, ...files].filter(({ path }) => {
+ return path.toLowerCase().startsWith(currentPath.toLowerCase());
+ });
+
+ return filteredPaths;
+ }
+ );
+}
+
+function PathInput(props: PathInputProps) {
+ const { includeFiles } = props;
+
+ const dispatch = useDispatch();
+
+ const paths = useSelector(createPathsSelector());
+
+ const handleFetchPaths = useCallback(
+ (path: string) => {
+ dispatch(fetchPaths({ path, includeFiles }));
+ },
+ [includeFiles, dispatch]
+ );
+
+ const handleClearPaths = useCallback(() => {
+ dispatch(clearPaths);
+ }, [dispatch]);
+
+ return (
+
+ );
+}
+
+export default PathInput;
+
+export function PathInputInternal(props: PathInputInternalProps) {
+ const {
+ className = styles.inputWrapper,
+ name,
+ value: inputValue = '',
+ paths,
+ includeFiles,
+ hasButton,
+ hasFileBrowser = true,
+ onChange,
+ onFetchPaths,
+ onClearPaths,
+ ...otherProps
+ } = props;
+
+ const [value, setValue] = useState(inputValue);
+ const [isFileBrowserModalOpen, setIsFileBrowserModalOpen] = useState(false);
+ const previousInputValue = usePrevious(inputValue);
+ const dispatch = useDispatch();
+
+ const handleFetchPaths = useCallback(
+ (path: string) => {
+ dispatch(fetchPaths({ path, includeFiles }));
+ },
+ [includeFiles, dispatch]
+ );
+
+ const handleInputChange = useCallback(
+ (_event: SyntheticEvent, { newValue }: ChangeEvent) => {
+ setValue(newValue);
+ },
+ [setValue]
+ );
+
+ const handleInputKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key === 'Tab') {
+ event.preventDefault();
+ const path = paths[0];
+
+ if (path) {
+ onChange({
+ name,
+ value: path.path,
+ });
+
+ if (path.type !== 'file') {
+ handleFetchPaths(path.path);
+ }
+ }
+ }
+ },
+ [name, paths, handleFetchPaths, onChange]
+ );
+ const handleInputBlur = useCallback(() => {
+ onChange({
+ name,
+ value,
+ });
+
+ onClearPaths();
+ }, [name, value, onClearPaths, onChange]);
+
+ const handleSuggestionSelected = useCallback(
+ (_event: SyntheticEvent, { suggestion }: { suggestion: Path }) => {
+ handleFetchPaths(suggestion.path);
+ },
+ [handleFetchPaths]
+ );
+
+ const handleSuggestionsFetchRequested = useCallback(
+ ({ value: newValue }: SuggestionsFetchRequestedParams) => {
+ handleFetchPaths(newValue);
+ },
+ [handleFetchPaths]
+ );
+
+ const handleFileBrowserOpenPress = useCallback(() => {
+ setIsFileBrowserModalOpen(true);
+ }, [setIsFileBrowserModalOpen]);
+
+ const handleFileBrowserModalClose = useCallback(() => {
+ setIsFileBrowserModalOpen(false);
+ }, [setIsFileBrowserModalOpen]);
+
+ const handleChange = useCallback(
+ (change: InputChanged) => {
+ onChange({ name, value: change.value.path });
+ },
+ [name, onChange]
+ );
+
+ const getSuggestionValue = useCallback(({ path }: Path) => path, []);
+
+ const renderSuggestion = useCallback(
+ ({ path }: Path, { query }: { query: string }) => {
+ const lastSeparatorIndex =
+ query.lastIndexOf('\\') || query.lastIndexOf('/');
+
+ if (lastSeparatorIndex === -1) {
+ return {path};
+ }
+
+ return (
+
+
+ {path.substring(0, lastSeparatorIndex)}
+
+ {path.substring(lastSeparatorIndex)}
+
+ );
+ },
+ []
+ );
+
+ useEffect(() => {
+ if (inputValue !== previousInputValue) {
+ setValue(inputValue);
+ }
+ }, [inputValue, previousInputValue, setValue]);
+
+ return (
+
+
+
+ {hasFileBrowser ? (
+ <>
+
+
+
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/Components/Form/PathInputConnector.js b/frontend/src/Components/Form/PathInputConnector.js
deleted file mode 100644
index 563437f9a..000000000
--- a/frontend/src/Components/Form/PathInputConnector.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
-import PathInput from './PathInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.paths,
- (paths) => {
- const {
- currentPath,
- directories,
- files
- } = paths;
-
- const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
- return path.toLowerCase().startsWith(currentPath.toLowerCase());
- });
-
- return {
- paths: filteredPaths
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- dispatchFetchPaths: fetchPaths,
- dispatchClearPaths: clearPaths
-};
-
-class PathInputConnector extends Component {
-
- //
- // Listeners
-
- onFetchPaths = (path) => {
- const {
- includeFiles,
- dispatchFetchPaths
- } = this.props;
-
- dispatchFetchPaths({
- path,
- includeFiles
- });
- };
-
- onClearPaths = () => {
- this.props.dispatchClearPaths();
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-PathInputConnector.propTypes = {
- ...PathInput.props,
- includeFiles: PropTypes.bool.isRequired,
- dispatchFetchPaths: PropTypes.func.isRequired,
- dispatchClearPaths: PropTypes.func.isRequired
-};
-
-PathInputConnector.defaultProps = {
- includeFiles: false
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);
diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
index a184aa1ec..f081f5906 100644
--- a/frontend/src/Components/Form/ProviderFieldFormGroup.js
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -14,6 +14,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.CHECK;
case 'device':
return inputTypes.DEVICE;
+ case 'keyValueList':
+ return inputTypes.KEY_VALUE_LIST;
case 'password':
return inputTypes.PASSWORD;
case 'number':
@@ -27,6 +29,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.DYNAMIC_SELECT;
}
return inputTypes.SELECT;
+ case 'seriesTag':
+ return inputTypes.SERIES_TAG;
case 'tag':
return inputTypes.TEXT_TAG;
case 'tagSelect':
@@ -134,6 +138,8 @@ ProviderFieldFormGroup.propTypes = {
type: PropTypes.string.isRequired,
advanced: PropTypes.bool.isRequired,
hidden: PropTypes.string,
+ isDisabled: PropTypes.bool,
+ provider: PropTypes.string,
pending: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
diff --git a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
deleted file mode 100644
index cc8ffbdb8..000000000
--- a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
+++ /dev/null
@@ -1,105 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
-import sortByName from 'Utilities/Array/sortByName';
-import translate from 'Utilities/String/translate';
-import EnhancedSelectInput from './EnhancedSelectInput';
-
-function createMapStateToProps() {
- return createSelector(
- createSortedSectionSelector('settings.qualityProfiles', sortByName),
- (state, { includeNoChange }) => includeNoChange,
- (state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
- (state, { includeMixed }) => includeMixed,
- (qualityProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed) => {
- const values = _.map(qualityProfiles.items, (qualityProfile) => {
- return {
- key: qualityProfile.id,
- value: qualityProfile.name
- };
- });
-
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- get value() {
- return translate('NoChange');
- },
- disabled: includeNoChangeDisabled
- });
- }
-
- if (includeMixed) {
- values.unshift({
- key: 'mixed',
- get value() {
- return `(${translate('Mixed')})`;
- },
- disabled: true
- });
- }
-
- return {
- values
- };
- }
- );
-}
-
-class QualityProfileSelectInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount() {
- const {
- name,
- value,
- values
- } = this.props;
-
- if (!value || !values.some((option) => option.key === value || parseInt(option.key) === value)) {
- const firstValue = values.find((option) => !isNaN(parseInt(option.key)));
-
- if (firstValue) {
- this.onChange({ name, value: firstValue.key });
- }
- }
- }
-
- //
- // Listeners
-
- onChange = ({ name, value }) => {
- this.props.onChange({ name, value: value === 'noChange' ? value : parseInt(value) });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-QualityProfileSelectInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- includeNoChange: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-QualityProfileSelectInputConnector.defaultProps = {
- includeNoChange: false
-};
-
-export default connect(createMapStateToProps)(QualityProfileSelectInputConnector);
diff --git a/frontend/src/Components/Form/RootFolderSelectInput.js b/frontend/src/Components/Form/RootFolderSelectInput.js
deleted file mode 100644
index 1d76ad946..000000000
--- a/frontend/src/Components/Form/RootFolderSelectInput.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
-import EnhancedSelectInput from './EnhancedSelectInput';
-import RootFolderSelectInputOption from './RootFolderSelectInputOption';
-import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
-
-class RootFolderSelectInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- isAddNewRootFolderModalOpen: false,
- newRootFolderPath: ''
- };
- }
-
- componentDidUpdate(prevProps) {
- const {
- name,
- isSaving,
- saveError,
- onChange
- } = this.props;
-
- const newRootFolderPath = this.state.newRootFolderPath;
-
- if (
- prevProps.isSaving &&
- !isSaving &&
- !saveError &&
- newRootFolderPath
- ) {
- onChange({ name, value: newRootFolderPath });
- this.setState({ newRootFolderPath: '' });
- }
- }
-
- //
- // Listeners
-
- onChange = ({ name, value }) => {
- if (value === 'addNew') {
- this.setState({ isAddNewRootFolderModalOpen: true });
- } else {
- this.props.onChange({ name, value });
- }
- };
-
- onNewRootFolderSelect = ({ value }) => {
- this.setState({ newRootFolderPath: value }, () => {
- this.props.onNewRootFolderSelect(value);
- });
- };
-
- onAddRootFolderModalClose = () => {
- this.setState({ isAddNewRootFolderModalOpen: false });
- };
-
- //
- // Render
-
- render() {
- const {
- includeNoChange,
- onNewRootFolderSelect,
- ...otherProps
- } = this.props;
-
- return (
-
-
-
-
-
- );
- }
-}
-
-RootFolderSelectInput.propTypes = {
- name: PropTypes.string.isRequired,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- isSaving: PropTypes.bool.isRequired,
- saveError: PropTypes.object,
- includeNoChange: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- onNewRootFolderSelect: PropTypes.func.isRequired
-};
-
-RootFolderSelectInput.defaultProps = {
- includeNoChange: false
-};
-
-export default RootFolderSelectInput;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
deleted file mode 100644
index 43581835f..000000000
--- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { addRootFolder } from 'Store/Actions/rootFolderActions';
-import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
-import translate from 'Utilities/String/translate';
-import RootFolderSelectInput from './RootFolderSelectInput';
-
-const ADD_NEW_KEY = 'addNew';
-
-function createMapStateToProps() {
- return createSelector(
- createRootFoldersSelector(),
- (state, { value }) => value,
- (state, { includeMissingValue }) => includeMissingValue,
- (state, { includeNoChange }) => includeNoChange,
- (state, { includeNoChangeDisabled }) => includeNoChangeDisabled,
- (rootFolders, value, includeMissingValue, includeNoChange, includeNoChangeDisabled = true) => {
- const values = rootFolders.items.map((rootFolder) => {
- return {
- key: rootFolder.path,
- value: rootFolder.path,
- freeSpace: rootFolder.freeSpace,
- isMissing: false
- };
- });
-
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- get value() {
- return translate('NoChange');
- },
- isDisabled: includeNoChangeDisabled,
- isMissing: false
- });
- }
-
- if (!values.length) {
- values.push({
- key: '',
- value: '',
- isDisabled: true,
- isHidden: true
- });
- }
-
- if (includeMissingValue && !values.find((v) => v.key === value)) {
- values.push({
- key: value,
- value,
- isMissing: true,
- isDisabled: true
- });
- }
-
- values.push({
- key: ADD_NEW_KEY,
- value: translate('AddANewPath')
- });
-
- return {
- values,
- isSaving: rootFolders.isSaving,
- saveError: rootFolders.saveError
- };
- }
- );
-}
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- dispatchAddRootFolder(path) {
- dispatch(addRootFolder({ path }));
- }
- };
-}
-
-class RootFolderSelectInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentWillMount() {
- const {
- value,
- values,
- onChange
- } = this.props;
-
- if (value == null && values[0].key === '') {
- onChange({ name, value: '' });
- }
- }
-
- componentDidMount() {
- const {
- name,
- value,
- values,
- onChange
- } = this.props;
-
- if (!value || !values.some((v) => v.key === value) || value === ADD_NEW_KEY) {
- const defaultValue = values[0];
-
- if (defaultValue.key === ADD_NEW_KEY) {
- onChange({ name, value: '' });
- } else {
- onChange({ name, value: defaultValue.key });
- }
- }
- }
-
- componentDidUpdate(prevProps) {
- const {
- name,
- value,
- values,
- onChange
- } = this.props;
-
- if (prevProps.values === values) {
- return;
- }
-
- if (!value && values.length && values.some((v) => !!v.key && v.key !== ADD_NEW_KEY)) {
- const defaultValue = values[0];
-
- if (defaultValue.key !== ADD_NEW_KEY) {
- onChange({ name, value: defaultValue.key });
- }
- }
- }
-
- //
- // Listeners
-
- onNewRootFolderSelect = (path) => {
- this.props.dispatchAddRootFolder(path);
- };
-
- //
- // Render
-
- render() {
- const {
- dispatchAddRootFolder,
- ...otherProps
- } = this.props;
-
- return (
-
- );
- }
-}
-
-RootFolderSelectInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.string,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- includeNoChange: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- dispatchAddRootFolder: PropTypes.func.isRequired
-};
-
-RootFolderSelectInputConnector.defaultProps = {
- includeNoChange: false
-};
-
-export default connect(createMapStateToProps, createMapDispatchToProps)(RootFolderSelectInputConnector);
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js
deleted file mode 100644
index daac82f34..000000000
--- a/frontend/src/Components/Form/RootFolderSelectInputOption.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import formatBytes from 'Utilities/Number/formatBytes';
-import translate from 'Utilities/String/translate';
-import EnhancedSelectInputOption from './EnhancedSelectInputOption';
-import styles from './RootFolderSelectInputOption.css';
-
-function RootFolderSelectInputOption(props) {
- const {
- id,
- value,
- freeSpace,
- isMissing,
- seriesFolder,
- isMobile,
- isWindows,
- ...otherProps
- } = props;
-
- const slashCharacter = isWindows ? '\\' : '/';
-
- return (
-
-
-
- {value}
-
- {
- seriesFolder && id !== 'addNew' ?
-
- {slashCharacter}
- {seriesFolder}
-
:
- null
- }
-
-
- {
- freeSpace == null ?
- null :
-
- {translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
-
- }
-
- {
- isMissing ?
-
- {translate('Missing')}
-
:
- null
- }
-
-
- );
-}
-
-RootFolderSelectInputOption.propTypes = {
- id: PropTypes.string.isRequired,
- value: PropTypes.string.isRequired,
- freeSpace: PropTypes.number,
- isMissing: PropTypes.bool,
- seriesFolder: PropTypes.string,
- isMobile: PropTypes.bool.isRequired,
- isWindows: PropTypes.bool
-};
-
-export default RootFolderSelectInputOption;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
deleted file mode 100644
index 1c3a4fc9d..000000000
--- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import formatBytes from 'Utilities/Number/formatBytes';
-import translate from 'Utilities/String/translate';
-import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
-import styles from './RootFolderSelectInputSelectedValue.css';
-
-function RootFolderSelectInputSelectedValue(props) {
- const {
- value,
- freeSpace,
- seriesFolder,
- includeFreeSpace,
- isWindows,
- ...otherProps
- } = props;
-
- const slashCharacter = isWindows ? '\\' : '/';
-
- return (
-
-
-
- {value}
-
-
- {
- seriesFolder ?
-
- {slashCharacter}
- {seriesFolder}
-
:
- null
- }
-
-
- {
- freeSpace != null && includeFreeSpace &&
-
- {translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
-
- }
-
- );
-}
-
-RootFolderSelectInputSelectedValue.propTypes = {
- value: PropTypes.string,
- freeSpace: PropTypes.number,
- seriesFolder: PropTypes.string,
- isWindows: PropTypes.bool,
- includeFreeSpace: PropTypes.bool.isRequired
-};
-
-RootFolderSelectInputSelectedValue.defaultProps = {
- includeFreeSpace: true
-};
-
-export default RootFolderSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx b/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx
new file mode 100644
index 000000000..4ed3e0952
--- /dev/null
+++ b/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx
@@ -0,0 +1,88 @@
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { fetchDownloadClients } from 'Store/Actions/settingsActions';
+import { Protocol } from 'typings/DownloadClient';
+import { EnhancedSelectInputChanged } from 'typings/inputs';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+function createDownloadClientsSelector(
+ includeAny: boolean,
+ protocol: Protocol
+) {
+ return createSelector(
+ (state: AppState) => state.settings.downloadClients,
+ (downloadClients) => {
+ const { isFetching, isPopulated, error, items } = downloadClients;
+
+ const filteredItems = items.filter((item) => item.protocol === protocol);
+
+ const values = filteredItems
+ .sort(sortByProp('name'))
+ .map((downloadClient) => {
+ return {
+ key: downloadClient.id,
+ value: downloadClient.name,
+ hint: `(${downloadClient.id})`,
+ };
+ });
+
+ if (includeAny) {
+ values.unshift({
+ key: 0,
+ value: `(${translate('Any')})`,
+ hint: '',
+ });
+ }
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ values,
+ };
+ }
+ );
+}
+
+interface DownloadClientSelectInputProps
+ extends EnhancedSelectInputProps, number> {
+ name: string;
+ value: number;
+ includeAny?: boolean;
+ protocol?: Protocol;
+ onChange: (change: EnhancedSelectInputChanged) => void;
+}
+
+function DownloadClientSelectInput({
+ includeAny = false,
+ protocol = 'torrent',
+ ...otherProps
+}: DownloadClientSelectInputProps) {
+ const dispatch = useDispatch();
+ const { isFetching, isPopulated, values } = useSelector(
+ createDownloadClientsSelector(includeAny, protocol)
+ );
+
+ useEffect(() => {
+ if (!isPopulated) {
+ dispatch(fetchDownloadClients());
+ }
+ }, [isPopulated, dispatch]);
+
+ return (
+
+ );
+}
+
+export default DownloadClientSelectInput;
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/Select/EnhancedSelectInput.css
similarity index 92%
rename from frontend/src/Components/Form/EnhancedSelectInput.css
rename to frontend/src/Components/Form/Select/EnhancedSelectInput.css
index 56f5564b9..735d63573 100644
--- a/frontend/src/Components/Form/EnhancedSelectInput.css
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.css
@@ -19,7 +19,7 @@
.isDisabled {
opacity: 0.7;
- cursor: not-allowed;
+ cursor: not-allowed !important;
}
.dropdownArrowContainer {
@@ -73,6 +73,12 @@
padding: 10px 0;
}
+.optionsInnerModalBody {
+ composes: innerModalBody from '~Components/Modal/ModalBody.css';
+
+ padding: 0;
+}
+
.optionsModalScroller {
composes: scroller from '~Components/Scroller/Scroller.css';
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css.d.ts b/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts
similarity index 94%
rename from frontend/src/Components/Form/EnhancedSelectInput.css.d.ts
rename to frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts
index edcf0079b..98167a9b5 100644
--- a/frontend/src/Components/Form/EnhancedSelectInput.css.d.ts
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts
@@ -14,6 +14,7 @@ interface CssExports {
'mobileCloseButtonContainer': string;
'options': string;
'optionsContainer': string;
+ 'optionsInnerModalBody': string;
'optionsModal': string;
'optionsModalBody': string;
'optionsModalScroller': string;
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx
new file mode 100644
index 000000000..5ae175357
--- /dev/null
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx
@@ -0,0 +1,627 @@
+import classNames from 'classnames';
+import React, {
+ ElementType,
+ KeyboardEvent,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { Manager, Popper, Reference } from 'react-popper';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Measure from 'Components/Measure';
+import Modal from 'Components/Modal/Modal';
+import ModalBody from 'Components/Modal/ModalBody';
+import Portal from 'Components/Portal';
+import Scroller from 'Components/Scroller/Scroller';
+import { icons, scrollDirections, sizes } from 'Helpers/Props';
+import ArrayElement from 'typings/Helpers/ArrayElement';
+import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
+import { isMobile as isMobileUtil } from 'Utilities/browser';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import getUniqueElementId from 'Utilities/getUniqueElementId';
+import TextInput from '../TextInput';
+import HintedSelectInputOption from './HintedSelectInputOption';
+import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
+import styles from './EnhancedSelectInput.css';
+
+const MINIMUM_DISTANCE_FROM_EDGE = 10;
+
+function isArrowKey(keyCode: number) {
+ return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
+}
+
+function getSelectedOption, V>(
+ selectedIndex: number,
+ values: T[]
+) {
+ return values[selectedIndex];
+}
+
+function findIndex, V>(
+ startingIndex: number,
+ direction: 1 | -1,
+ values: T[]
+) {
+ let indexToTest = startingIndex + direction;
+
+ while (indexToTest !== startingIndex) {
+ if (indexToTest < 0) {
+ indexToTest = values.length - 1;
+ } else if (indexToTest >= values.length) {
+ indexToTest = 0;
+ }
+
+ if (getSelectedOption(indexToTest, values).isDisabled) {
+ indexToTest = indexToTest + direction;
+ } else {
+ return indexToTest;
+ }
+ }
+
+ return null;
+}
+
+function previousIndex, V>(
+ selectedIndex: number,
+ values: T[]
+) {
+ return findIndex(selectedIndex, -1, values);
+}
+
+function nextIndex, V>(
+ selectedIndex: number,
+ values: T[]
+) {
+ return findIndex(selectedIndex, 1, values);
+}
+
+function getSelectedIndex, V>(
+ value: V,
+ values: T[]
+) {
+ if (Array.isArray(value)) {
+ return values.findIndex((v) => {
+ return v.key === value[0];
+ });
+ }
+
+ return values.findIndex((v) => {
+ return v.key === value;
+ });
+}
+
+function isSelectedItem, V>(
+ index: number,
+ value: V,
+ values: T[]
+) {
+ if (Array.isArray(value)) {
+ return value.includes(values[index].key);
+ }
+
+ return values[index].key === value;
+}
+
+export interface EnhancedSelectInputValue {
+ key: ArrayElement;
+ value: string;
+ hint?: ReactNode;
+ isDisabled?: boolean;
+ isHidden?: boolean;
+ parentKey?: V;
+ additionalProperties?: object;
+}
+
+export interface EnhancedSelectInputProps<
+ T extends EnhancedSelectInputValue,
+ V
+> {
+ className?: string;
+ disabledClassName?: string;
+ name: string;
+ value: V;
+ values: T[];
+ isDisabled?: boolean;
+ isFetching?: boolean;
+ isEditable?: boolean;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ valueOptions?: object;
+ selectedValueOptions?: object;
+ selectedValueComponent?: string | ElementType;
+ optionComponent?: ElementType;
+ onOpen?: () => void;
+ onChange: (change: EnhancedSelectInputChanged) => void;
+}
+
+function EnhancedSelectInput, V>(
+ props: EnhancedSelectInputProps
+) {
+ const {
+ className = styles.enhancedSelect,
+ disabledClassName = styles.isDisabled,
+ name,
+ value,
+ values,
+ isDisabled = false,
+ isEditable,
+ isFetching,
+ hasError,
+ hasWarning,
+ valueOptions,
+ selectedValueOptions,
+ selectedValueComponent:
+ SelectedValueComponent = HintedSelectInputSelectedValue,
+ optionComponent: OptionComponent = HintedSelectInputOption,
+ onChange,
+ onOpen,
+ } = props;
+
+ const updater = useRef<(() => void) | null>(null);
+ const buttonId = useMemo(() => getUniqueElementId(), []);
+ const optionsId = useMemo(() => getUniqueElementId(), []);
+ const [selectedIndex, setSelectedIndex] = useState(
+ getSelectedIndex(value, values)
+ );
+ const [width, setWidth] = useState(0);
+ const [isOpen, setIsOpen] = useState(false);
+ const isMobile = useMemo(() => isMobileUtil(), []);
+
+ const isMultiSelect = Array.isArray(value);
+ const selectedOption = getSelectedOption(selectedIndex, values);
+
+ const selectedValue = useMemo(() => {
+ if (values.length) {
+ return value;
+ }
+
+ if (isMultiSelect) {
+ return [];
+ } else if (typeof value === 'number') {
+ return 0;
+ }
+
+ return '';
+ }, [value, values, isMultiSelect]);
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const handleComputeMaxHeight = useCallback((data: any) => {
+ const windowHeight = window.innerHeight;
+
+ data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
+
+ return data;
+ }, []);
+
+ const handleWindowClick = useCallback(
+ (event: MouseEvent) => {
+ const button = document.getElementById(buttonId);
+ const options = document.getElementById(optionsId);
+ const eventTarget = event.target as HTMLElement;
+
+ if (!button || !eventTarget.isConnected || isMobile) {
+ return;
+ }
+
+ if (
+ !button.contains(eventTarget) &&
+ options &&
+ !options.contains(eventTarget) &&
+ isOpen
+ ) {
+ setIsOpen(false);
+ window.removeEventListener('click', handleWindowClick);
+ }
+ },
+ [isMobile, isOpen, buttonId, optionsId, setIsOpen]
+ );
+
+ const addListener = useCallback(() => {
+ window.addEventListener('click', handleWindowClick);
+ }, [handleWindowClick]);
+
+ const removeListener = useCallback(() => {
+ window.removeEventListener('click', handleWindowClick);
+ }, [handleWindowClick]);
+
+ const handlePress = useCallback(() => {
+ if (!isOpen && onOpen) {
+ onOpen();
+ }
+
+ setIsOpen(!isOpen);
+ }, [isOpen, setIsOpen, onOpen]);
+
+ const handleSelect = useCallback(
+ (newValue: ArrayElement) => {
+ const additionalProperties = values.find(
+ (v) => v.key === newValue
+ )?.additionalProperties;
+
+ if (Array.isArray(value)) {
+ const index = value.indexOf(newValue);
+
+ if (index === -1) {
+ const arrayValue = values
+ .map((v) => v.key)
+ .filter((v) => v === newValue || value.includes(v));
+
+ onChange({
+ name,
+ value: arrayValue as V,
+ additionalProperties,
+ });
+ } else {
+ const arrayValue = [...value];
+ arrayValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: arrayValue as V,
+ additionalProperties,
+ });
+ }
+ } else {
+ setIsOpen(false);
+
+ onChange({
+ name,
+ value: newValue as V,
+ additionalProperties,
+ });
+ }
+ },
+ [name, value, values, onChange, setIsOpen]
+ );
+
+ const handleBlur = useCallback(() => {
+ if (!isEditable) {
+ // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox)
+ const origIndex = getSelectedIndex(value, values);
+
+ if (origIndex !== selectedIndex) {
+ setSelectedIndex(origIndex);
+ }
+ }
+ }, [value, values, isEditable, selectedIndex, setSelectedIndex]);
+
+ const handleFocus = useCallback(() => {
+ if (isOpen) {
+ removeListener();
+ setIsOpen(false);
+ }
+ }, [isOpen, setIsOpen, removeListener]);
+
+ const handleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ const keyCode = event.keyCode;
+ let nextIsOpen: boolean | null = null;
+ let nextSelectedIndex: number | null = null;
+
+ if (!isOpen) {
+ if (isArrowKey(keyCode)) {
+ event.preventDefault();
+ nextIsOpen = true;
+ }
+
+ if (
+ selectedIndex == null ||
+ selectedIndex === -1 ||
+ getSelectedOption(selectedIndex, values).isDisabled
+ ) {
+ if (keyCode === keyCodes.UP_ARROW) {
+ nextSelectedIndex = previousIndex(0, values);
+ } else if (keyCode === keyCodes.DOWN_ARROW) {
+ nextSelectedIndex = nextIndex(values.length - 1, values);
+ }
+ }
+
+ if (nextIsOpen !== null) {
+ setIsOpen(nextIsOpen);
+ }
+
+ if (nextSelectedIndex !== null) {
+ setSelectedIndex(nextSelectedIndex);
+ }
+ return;
+ }
+
+ if (keyCode === keyCodes.UP_ARROW) {
+ event.preventDefault();
+ nextSelectedIndex = previousIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.DOWN_ARROW) {
+ event.preventDefault();
+ nextSelectedIndex = nextIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.ENTER) {
+ event.preventDefault();
+ nextIsOpen = false;
+ handleSelect(values[selectedIndex].key);
+ }
+
+ if (keyCode === keyCodes.TAB) {
+ nextIsOpen = false;
+ handleSelect(values[selectedIndex].key);
+ }
+
+ if (keyCode === keyCodes.ESCAPE) {
+ event.preventDefault();
+ event.stopPropagation();
+ nextIsOpen = false;
+ nextSelectedIndex = getSelectedIndex(value, values);
+ }
+
+ if (nextIsOpen !== null) {
+ setIsOpen(nextIsOpen);
+ }
+
+ if (nextSelectedIndex !== null) {
+ setSelectedIndex(nextSelectedIndex);
+ }
+ },
+ [
+ value,
+ isOpen,
+ selectedIndex,
+ values,
+ setIsOpen,
+ setSelectedIndex,
+ handleSelect,
+ ]
+ );
+
+ const handleMeasure = useCallback(
+ ({ width: newWidth }: { width: number }) => {
+ setWidth(newWidth);
+ },
+ [setWidth]
+ );
+
+ const handleOptionsModalClose = useCallback(() => {
+ setIsOpen(false);
+ }, [setIsOpen]);
+
+ const handleEditChange = useCallback(
+ (change: InputChanged) => {
+ onChange(change as EnhancedSelectInputChanged);
+ },
+ [onChange]
+ );
+
+ useEffect(() => {
+ if (updater.current) {
+ updater.current();
+ }
+ });
+
+ useEffect(() => {
+ if (isOpen) {
+ addListener();
+ } else {
+ removeListener();
+ }
+
+ return removeListener;
+ }, [isOpen, addListener, removeListener]);
+
+ return (
+
+
+
+ {({ ref }) => (
+
+ )}
+
+
+
+ {({ ref, style, scheduleUpdate }) => {
+ updater.current = scheduleUpdate;
+
+ return (
+
+ {isOpen && !isMobile ? (
+
+ {values.map((v, index) => {
+ const hasParent = v.parentKey !== undefined;
+ const depth = hasParent ? 1 : 0;
+ const parentSelected =
+ v.parentKey !== undefined &&
+ Array.isArray(value) &&
+ value.includes(v.parentKey);
+
+ const { key, ...other } = v;
+
+ return (
+
+ {v.value}
+
+ );
+ })}
+
+ ) : null}
+
+ );
+ }}
+
+
+
+
+ {isMobile ? (
+
+
+
+
+
+
+
+
+
+ {values.map((v, index) => {
+ const hasParent = v.parentKey !== undefined;
+ const depth = hasParent ? 1 : 0;
+ const parentSelected =
+ v.parentKey !== undefined &&
+ isMultiSelect &&
+ value.includes(v.parentKey);
+
+ const { key, ...other } = v;
+
+ return (
+
+ {v.value}
+
+ );
+ })}
+
+
+
+ ) : null}
+
+ );
+}
+
+export default EnhancedSelectInput;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css
similarity index 87%
rename from frontend/src/Components/Form/EnhancedSelectInputOption.css
rename to frontend/src/Components/Form/Select/EnhancedSelectInputOption.css
index d7f0e861b..bfdaa9036 100644
--- a/frontend/src/Components/Form/EnhancedSelectInputOption.css
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css
@@ -16,13 +16,13 @@
}
.optionCheck {
- composes: container from '~./CheckInput.css';
+ composes: container from '~Components/Form/CheckInput.css';
flex: 0 0 0;
}
.optionCheckInput {
- composes: input from '~./CheckInput.css';
+ composes: input from '~Components/Form/CheckInput.css';
margin-top: 0;
}
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/EnhancedSelectInputOption.css.d.ts
rename to frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx
new file mode 100644
index 000000000..c866a5060
--- /dev/null
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx
@@ -0,0 +1,84 @@
+import classNames from 'classnames';
+import React, { SyntheticEvent, useCallback } from 'react';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import { icons } from 'Helpers/Props';
+import CheckInput from '../CheckInput';
+import styles from './EnhancedSelectInputOption.css';
+
+function handleCheckPress() {
+ // CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation.
+}
+
+export interface EnhancedSelectInputOptionProps {
+ className?: string;
+ id: string | number;
+ depth?: number;
+ isSelected: boolean;
+ isDisabled?: boolean;
+ isHidden?: boolean;
+ isMultiSelect?: boolean;
+ isMobile: boolean;
+ children: React.ReactNode;
+ onSelect: (...args: unknown[]) => unknown;
+}
+
+function EnhancedSelectInputOption({
+ className = styles.option,
+ id,
+ depth = 0,
+ isSelected,
+ isDisabled = false,
+ isHidden = false,
+ isMultiSelect = false,
+ isMobile,
+ children,
+ onSelect,
+}: EnhancedSelectInputOptionProps) {
+ const handlePress = useCallback(
+ (event: SyntheticEvent) => {
+ event.preventDefault();
+
+ onSelect(id);
+ },
+ [id, onSelect]
+ );
+
+ return (
+
+ {depth !== 0 && }
+
+ {isMultiSelect && (
+
+ )}
+
+ {children}
+
+ {isMobile && (
+
+
+
+ )}
+
+ );
+}
+
+export default EnhancedSelectInputOption;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css
similarity index 100%
rename from frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
rename to frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css.d.ts
rename to frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx
new file mode 100644
index 000000000..88afdb18a
--- /dev/null
+++ b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx
@@ -0,0 +1,23 @@
+import classNames from 'classnames';
+import React, { ReactNode } from 'react';
+import styles from './EnhancedSelectInputSelectedValue.css';
+
+interface EnhancedSelectInputSelectedValueProps {
+ className?: string;
+ children: ReactNode;
+ isDisabled?: boolean;
+}
+
+function EnhancedSelectInputSelectedValue({
+ className = styles.selectedValue,
+ children,
+ isDisabled = false,
+}: EnhancedSelectInputSelectedValueProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default EnhancedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/HintedSelectInputOption.css b/frontend/src/Components/Form/Select/HintedSelectInputOption.css
similarity index 100%
rename from frontend/src/Components/Form/HintedSelectInputOption.css
rename to frontend/src/Components/Form/Select/HintedSelectInputOption.css
diff --git a/frontend/src/Components/Form/HintedSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/HintedSelectInputOption.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/HintedSelectInputOption.css.d.ts
rename to frontend/src/Components/Form/Select/HintedSelectInputOption.css.d.ts
diff --git a/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx b/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx
new file mode 100644
index 000000000..faa9081c5
--- /dev/null
+++ b/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx
@@ -0,0 +1,52 @@
+import classNames from 'classnames';
+import React from 'react';
+import EnhancedSelectInputOption, {
+ EnhancedSelectInputOptionProps,
+} from './EnhancedSelectInputOption';
+import styles from './HintedSelectInputOption.css';
+
+interface HintedSelectInputOptionProps extends EnhancedSelectInputOptionProps {
+ value: string;
+ hint?: React.ReactNode;
+}
+
+function HintedSelectInputOption(props: HintedSelectInputOptionProps) {
+ const {
+ id,
+ value,
+ hint,
+ depth,
+ isSelected = false,
+ isDisabled,
+ isMobile,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
{value}
+
+ {hint != null &&
{hint}
}
+
+
+ );
+}
+
+HintedSelectInputOption.defaultProps = {
+ isDisabled: false,
+ isHidden: false,
+ isMultiSelect: false,
+};
+
+export default HintedSelectInputOption;
diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.css b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css
similarity index 100%
rename from frontend/src/Components/Form/HintedSelectInputSelectedValue.css
rename to frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css
diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/HintedSelectInputSelectedValue.css.d.ts
rename to frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css.d.ts
diff --git a/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx
new file mode 100644
index 000000000..7c4cba115
--- /dev/null
+++ b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx
@@ -0,0 +1,55 @@
+import React, { ReactNode, useMemo } from 'react';
+import Label from 'Components/Label';
+import ArrayElement from 'typings/Helpers/ArrayElement';
+import { EnhancedSelectInputValue } from './EnhancedSelectInput';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import styles from './HintedSelectInputSelectedValue.css';
+
+interface HintedSelectInputSelectedValueProps {
+ selectedValue: V;
+ values: T[];
+ hint?: ReactNode;
+ isMultiSelect?: boolean;
+ includeHint?: boolean;
+}
+
+function HintedSelectInputSelectedValue<
+ T extends EnhancedSelectInputValue,
+ V extends number | string
+>(props: HintedSelectInputSelectedValueProps) {
+ const {
+ selectedValue,
+ values,
+ hint,
+ isMultiSelect = false,
+ includeHint = true,
+ ...otherProps
+ } = props;
+
+ const valuesMap = useMemo(() => {
+ return new Map(values.map((v) => [v.key, v.value]));
+ }, [values]);
+
+ return (
+
+
+ {isMultiSelect && Array.isArray(selectedValue)
+ ? selectedValue.map((key) => {
+ const v = valuesMap.get(key);
+
+ return ;
+ })
+ : valuesMap.get(selectedValue as ArrayElement)}
+
+
+ {hint != null && includeHint ? (
+ {hint}
+ ) : null}
+
+ );
+}
+
+export default HintedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx b/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx
new file mode 100644
index 000000000..a43044156
--- /dev/null
+++ b/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx
@@ -0,0 +1,70 @@
+import React, { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { EnhancedSelectInputChanged } from 'typings/inputs';
+import EnhancedSelectInput from './EnhancedSelectInput';
+
+const selectIndexerFlagsValues = (selectedFlags: number) =>
+ createSelector(
+ (state: AppState) => state.settings.indexerFlags,
+ (indexerFlags) => {
+ const value = indexerFlags.items.reduce((acc: number[], { id }) => {
+ // eslint-disable-next-line no-bitwise
+ if ((selectedFlags & id) === id) {
+ acc.push(id);
+ }
+
+ return acc;
+ }, []);
+
+ const values = indexerFlags.items.map(({ id, name }) => ({
+ key: id,
+ value: name,
+ }));
+
+ return {
+ value,
+ values,
+ };
+ }
+ );
+
+interface IndexerFlagsSelectInputProps {
+ name: string;
+ indexerFlags: number;
+ onChange(payload: EnhancedSelectInputChanged): void;
+}
+
+function IndexerFlagsSelectInput({
+ name,
+ indexerFlags,
+ onChange,
+ ...otherProps
+}: IndexerFlagsSelectInputProps) {
+ const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags));
+
+ const handleChange = useCallback(
+ (change: EnhancedSelectInputChanged) => {
+ const indexerFlags = change.value.reduce(
+ (acc, flagId) => acc + flagId,
+ 0
+ );
+
+ onChange({ name, value: indexerFlags });
+ },
+ [name, onChange]
+ );
+
+ return (
+
+ );
+}
+
+export default IndexerFlagsSelectInput;
diff --git a/frontend/src/Components/Form/Select/IndexerSelectInput.tsx b/frontend/src/Components/Form/Select/IndexerSelectInput.tsx
new file mode 100644
index 000000000..4bb4ff787
--- /dev/null
+++ b/frontend/src/Components/Form/Select/IndexerSelectInput.tsx
@@ -0,0 +1,81 @@
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import { fetchIndexers } from 'Store/Actions/settingsActions';
+import { EnhancedSelectInputChanged } from 'typings/inputs';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput from './EnhancedSelectInput';
+
+function createIndexersSelector(includeAny: boolean) {
+ return createSelector(
+ (state: AppState) => state.settings.indexers,
+ (indexers) => {
+ const { isFetching, isPopulated, error, items } = indexers;
+
+ const values = items.sort(sortByProp('name')).map((indexer) => {
+ return {
+ key: indexer.id,
+ value: indexer.name,
+ };
+ });
+
+ if (includeAny) {
+ values.unshift({
+ key: 0,
+ value: `(${translate('Any')})`,
+ });
+ }
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ values,
+ };
+ }
+ );
+}
+
+interface IndexerSelectInputConnectorProps {
+ name: string;
+ value: number;
+ includeAny?: boolean;
+ values: object[];
+ onChange: (change: EnhancedSelectInputChanged) => void;
+}
+
+function IndexerSelectInput({
+ name,
+ value,
+ includeAny = false,
+ onChange,
+}: IndexerSelectInputConnectorProps) {
+ const dispatch = useDispatch();
+ const { isFetching, isPopulated, values } = useSelector(
+ createIndexersSelector(includeAny)
+ );
+
+ useEffect(() => {
+ if (!isPopulated) {
+ dispatch(fetchIndexers());
+ }
+ }, [isPopulated, dispatch]);
+
+ return (
+
+ );
+}
+
+IndexerSelectInput.defaultProps = {
+ includeAny: false,
+};
+
+export default IndexerSelectInput;
diff --git a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx
new file mode 100644
index 000000000..3c9bbc150
--- /dev/null
+++ b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx
@@ -0,0 +1,95 @@
+import React, { useCallback, useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import Language from 'Language/Language';
+import createFilteredLanguagesSelector from 'Store/Selectors/createFilteredLanguagesSelector';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+interface LanguageSelectInputOnChangeProps {
+ name: string;
+ value: number | string | Language;
+}
+
+interface LanguageSelectInputProps {
+ name: string;
+ value: number | string | Language;
+ includeNoChange: boolean;
+ includeNoChangeDisabled?: boolean;
+ includeMixed: boolean;
+ onChange: (payload: LanguageSelectInputOnChangeProps) => void;
+}
+
+export default function LanguageSelectInput({
+ value,
+ includeNoChange,
+ includeNoChangeDisabled,
+ includeMixed,
+ onChange,
+ ...otherProps
+}: LanguageSelectInputProps) {
+ const { items } = useSelector(createFilteredLanguagesSelector(true));
+
+ const values = useMemo(() => {
+ const result: EnhancedSelectInputValue[] = items.map(
+ (item) => {
+ return {
+ key: item.id,
+ value: item.name,
+ };
+ }
+ );
+
+ if (includeNoChange) {
+ result.unshift({
+ key: 'noChange',
+ value: translate('NoChange'),
+ isDisabled: includeNoChangeDisabled,
+ });
+ }
+
+ if (includeMixed) {
+ result.unshift({
+ key: 'mixed',
+ value: `(${translate('Mixed')})`,
+ isDisabled: true,
+ });
+ }
+
+ return result;
+ }, [includeNoChange, includeNoChangeDisabled, includeMixed, items]);
+
+ const selectValue =
+ typeof value === 'number' || typeof value === 'string' ? value : value.id;
+
+ const handleChange = useCallback(
+ (payload: LanguageSelectInputOnChangeProps) => {
+ if (typeof value === 'number') {
+ onChange(payload);
+ } else {
+ const language = items.find((i) => i.id === payload.value);
+
+ onChange({
+ ...payload,
+ value: language
+ ? {
+ id: language.id,
+ name: language.name,
+ }
+ : ({ id: payload.value } as Language),
+ });
+ }
+ },
+ [value, items, onChange]
+ );
+
+ return (
+
+ );
+}
diff --git a/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx b/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx
new file mode 100644
index 000000000..59fd08513
--- /dev/null
+++ b/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import monitorOptions from 'Utilities/Series/monitorOptions';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+interface MonitorEpisodesSelectInputProps
+ extends Omit<
+ EnhancedSelectInputProps, string>,
+ 'values'
+ > {
+ includeNoChange: boolean;
+ includeMixed: boolean;
+}
+
+function MonitorEpisodesSelectInput(props: MonitorEpisodesSelectInputProps) {
+ const {
+ includeNoChange = false,
+ includeMixed = false,
+ ...otherProps
+ } = props;
+
+ const values: EnhancedSelectInputValue[] = [...monitorOptions];
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ get value() {
+ return translate('NoChange');
+ },
+ isDisabled: true,
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ get value() {
+ return `(${translate('Mixed')})`;
+ },
+ isDisabled: true,
+ });
+ }
+
+ return ;
+}
+
+export default MonitorEpisodesSelectInput;
diff --git a/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx b/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx
new file mode 100644
index 000000000..ac11f1fca
--- /dev/null
+++ b/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+interface MonitorNewItemsSelectInputProps
+ extends Omit<
+ EnhancedSelectInputProps, string>,
+ 'values'
+ > {
+ includeNoChange?: boolean;
+ includeMixed?: boolean;
+ onChange: (...args: unknown[]) => unknown;
+}
+
+function MonitorNewItemsSelectInput(props: MonitorNewItemsSelectInputProps) {
+ const {
+ includeNoChange = false,
+ includeMixed = false,
+ ...otherProps
+ } = props;
+
+ const values: EnhancedSelectInputValue[] = [
+ ...monitorNewItemsOptions,
+ ];
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ isDisabled: true,
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ isDisabled: true,
+ });
+ }
+
+ return ;
+}
+
+export default MonitorNewItemsSelectInput;
diff --git a/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx b/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx
new file mode 100644
index 000000000..e4a8003eb
--- /dev/null
+++ b/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx
@@ -0,0 +1,164 @@
+import { isEqual } from 'lodash';
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import ProviderOptionsAppState, {
+ ProviderOptions,
+} from 'App/State/ProviderOptionsAppState';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import {
+ clearOptions,
+ fetchOptions,
+} from 'Store/Actions/providerOptionActions';
+import { FieldSelectOption } from 'typings/Field';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+const importantFieldNames = ['baseUrl', 'apiPath', 'apiKey', 'authToken'];
+
+function getProviderDataKey(providerData: ProviderOptions) {
+ if (!providerData || !providerData.fields) {
+ return null;
+ }
+
+ const fields = providerData.fields
+ .filter((f) => importantFieldNames.includes(f.name))
+ .map((f) => f.value);
+
+ return fields;
+}
+
+function getSelectOptions(items: FieldSelectOption[]) {
+ if (!items) {
+ return [];
+ }
+
+ return items.map((option) => {
+ return {
+ key: option.value,
+ value: option.name,
+ hint: option.hint,
+ parentKey: option.parentValue,
+ isDisabled: option.isDisabled,
+ additionalProperties: option.additionalProperties,
+ };
+ });
+}
+
+function createProviderOptionsSelector(
+ selectOptionsProviderAction: keyof Omit
+) {
+ return createSelector(
+ (state: AppState) => state.providerOptions[selectOptionsProviderAction],
+ (options) => {
+ if (!options) {
+ return {
+ isFetching: false,
+ values: [],
+ };
+ }
+
+ return {
+ isFetching: options.isFetching,
+ values: getSelectOptions(options.items),
+ };
+ }
+ );
+}
+
+interface ProviderOptionSelectInputProps
+ extends Omit<
+ EnhancedSelectInputProps, unknown>,
+ 'values'
+ > {
+ provider: string;
+ providerData: ProviderOptions;
+ name: string;
+ value: unknown;
+ selectOptionsProviderAction: keyof Omit;
+}
+
+function ProviderOptionSelectInput({
+ provider,
+ providerData,
+ selectOptionsProviderAction,
+ ...otherProps
+}: ProviderOptionSelectInputProps) {
+ const dispatch = useDispatch();
+ const [isRefetchRequired, setIsRefetchRequired] = useState(false);
+ const previousProviderData = usePrevious(providerData);
+ const { isFetching, values } = useSelector(
+ createProviderOptionsSelector(selectOptionsProviderAction)
+ );
+
+ const handleOpen = useCallback(() => {
+ if (isRefetchRequired && selectOptionsProviderAction) {
+ setIsRefetchRequired(false);
+
+ dispatch(
+ fetchOptions({
+ section: selectOptionsProviderAction,
+ action: selectOptionsProviderAction,
+ provider,
+ providerData,
+ })
+ );
+ }
+ }, [
+ isRefetchRequired,
+ provider,
+ providerData,
+ selectOptionsProviderAction,
+ dispatch,
+ ]);
+
+ useEffect(() => {
+ if (selectOptionsProviderAction) {
+ dispatch(
+ fetchOptions({
+ section: selectOptionsProviderAction,
+ action: selectOptionsProviderAction,
+ provider,
+ providerData,
+ })
+ );
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectOptionsProviderAction, dispatch]);
+
+ useEffect(() => {
+ if (!previousProviderData) {
+ return;
+ }
+
+ const prevKey = getProviderDataKey(previousProviderData);
+ const nextKey = getProviderDataKey(providerData);
+
+ if (!isEqual(prevKey, nextKey)) {
+ setIsRefetchRequired(true);
+ }
+ }, [providerData, previousProviderData, setIsRefetchRequired]);
+
+ useEffect(() => {
+ return () => {
+ if (selectOptionsProviderAction) {
+ dispatch(clearOptions({ section: selectOptionsProviderAction }));
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ );
+}
+
+export default ProviderOptionSelectInput;
diff --git a/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx b/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx
new file mode 100644
index 000000000..036f0f82c
--- /dev/null
+++ b/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx
@@ -0,0 +1,126 @@
+import React, { useCallback, useEffect } from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import { QualityProfilesAppState } from 'App/State/SettingsAppState';
+import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
+import { EnhancedSelectInputChanged } from 'typings/inputs';
+import QualityProfile from 'typings/QualityProfile';
+import sortByProp from 'Utilities/Array/sortByProp';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+
+function createQualityProfilesSelector(
+ includeNoChange: boolean,
+ includeNoChangeDisabled: boolean,
+ includeMixed: boolean
+) {
+ return createSelector(
+ createSortedSectionSelector(
+ 'settings.qualityProfiles',
+ sortByProp('name')
+ ),
+ (qualityProfiles: QualityProfilesAppState) => {
+ const values: EnhancedSelectInputValue[] =
+ qualityProfiles.items.map((qualityProfile) => {
+ return {
+ key: qualityProfile.id,
+ value: qualityProfile.name,
+ };
+ });
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ get value() {
+ return translate('NoChange');
+ },
+ isDisabled: includeNoChangeDisabled,
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ get value() {
+ return `(${translate('Mixed')})`;
+ },
+ isDisabled: true,
+ });
+ }
+
+ return values;
+ }
+ );
+}
+
+interface QualityProfileSelectInputConnectorProps
+ extends Omit<
+ EnhancedSelectInputProps<
+ EnhancedSelectInputValue,
+ number | string
+ >,
+ 'values'
+ > {
+ name: string;
+ includeNoChange?: boolean;
+ includeNoChangeDisabled?: boolean;
+ includeMixed?: boolean;
+}
+
+function QualityProfileSelectInput({
+ name,
+ value,
+ includeNoChange = false,
+ includeNoChangeDisabled = true,
+ includeMixed = false,
+ onChange,
+ ...otherProps
+}: QualityProfileSelectInputConnectorProps) {
+ const values = useSelector(
+ createQualityProfilesSelector(
+ includeNoChange,
+ includeNoChangeDisabled,
+ includeMixed
+ )
+ );
+
+ const handleChange = useCallback(
+ ({ value: newValue }: EnhancedSelectInputChanged) => {
+ onChange({
+ name,
+ value: newValue === 'noChange' ? value : newValue,
+ });
+ },
+ [name, value, onChange]
+ );
+
+ useEffect(() => {
+ if (
+ !value ||
+ !values.some((option) => option.key === value || option.key === value)
+ ) {
+ const firstValue = values.find(
+ (option) => typeof option.key === 'number'
+ );
+
+ if (firstValue) {
+ onChange({ name, value: firstValue.key });
+ }
+ }
+ }, [name, value, values, onChange]);
+
+ return (
+
+ );
+}
+
+export default QualityProfileSelectInput;
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx
new file mode 100644
index 000000000..8b278ded7
--- /dev/null
+++ b/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx
@@ -0,0 +1,222 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import usePrevious from 'Helpers/Hooks/usePrevious';
+import {
+ addRootFolder,
+ fetchRootFolders,
+} from 'Store/Actions/rootFolderActions';
+import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
+import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
+import RootFolderSelectInputOption from './RootFolderSelectInputOption';
+import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
+
+const ADD_NEW_KEY = 'addNew';
+
+export interface RootFolderSelectInputValue
+ extends EnhancedSelectInputValue {
+ isMissing?: boolean;
+}
+
+interface RootFolderSelectInputProps
+ extends Omit<
+ EnhancedSelectInputProps, string>,
+ 'value' | 'values'
+ > {
+ name: string;
+ value?: string;
+ isSaving: boolean;
+ saveError?: object;
+ includeNoChange: boolean;
+}
+
+function createRootFolderOptionsSelector(
+ value: string | undefined,
+ includeMissingValue: boolean,
+ includeNoChange: boolean,
+ includeNoChangeDisabled: boolean
+) {
+ return createSelector(
+ createRootFoldersSelector(),
+
+ (rootFolders) => {
+ const values: RootFolderSelectInputValue[] = rootFolders.items.map(
+ (rootFolder) => {
+ return {
+ key: rootFolder.path,
+ value: rootFolder.path,
+ freeSpace: rootFolder.freeSpace,
+ isMissing: false,
+ };
+ }
+ );
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ get value() {
+ return translate('NoChange');
+ },
+ isDisabled: includeNoChangeDisabled,
+ isMissing: false,
+ });
+ }
+
+ if (!values.length) {
+ values.push({
+ key: '',
+ value: '',
+ isDisabled: true,
+ isHidden: true,
+ });
+ }
+
+ if (
+ includeMissingValue &&
+ value &&
+ !values.find((v) => v.key === value)
+ ) {
+ values.push({
+ key: value,
+ value,
+ isMissing: true,
+ isDisabled: true,
+ });
+ }
+
+ values.push({
+ key: ADD_NEW_KEY,
+ value: translate('AddANewPath'),
+ });
+
+ return {
+ values,
+ isSaving: rootFolders.isSaving,
+ saveError: rootFolders.saveError,
+ };
+ }
+ );
+}
+
+function RootFolderSelectInput({
+ name,
+ value,
+ includeNoChange = false,
+ onChange,
+ ...otherProps
+}: RootFolderSelectInputProps) {
+ const dispatch = useDispatch();
+ const { values, isSaving, saveError } = useSelector(
+ createRootFolderOptionsSelector(value, true, includeNoChange, false)
+ );
+ const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
+ useState(false);
+ const [newRootFolderPath, setNewRootFolderPath] = useState('');
+ const previousIsSaving = usePrevious(isSaving);
+
+ const handleChange = useCallback(
+ ({ value: newValue }: EnhancedSelectInputChanged) => {
+ if (newValue === 'addNew') {
+ setIsAddNewRootFolderModalOpen(true);
+ } else {
+ onChange({ name, value: newValue });
+ }
+ },
+ [name, setIsAddNewRootFolderModalOpen, onChange]
+ );
+
+ const handleNewRootFolderSelect = useCallback(
+ ({ value: newValue }: InputChanged) => {
+ setNewRootFolderPath(newValue);
+ dispatch(addRootFolder({ path: newValue }));
+ },
+ [setNewRootFolderPath, dispatch]
+ );
+
+ const handleAddRootFolderModalClose = useCallback(() => {
+ setIsAddNewRootFolderModalOpen(false);
+ }, [setIsAddNewRootFolderModalOpen]);
+
+ useEffect(() => {
+ if (
+ !value &&
+ values.length &&
+ values.some((v) => !!v.key && v.key !== ADD_NEW_KEY)
+ ) {
+ const defaultValue = values[0];
+
+ if (defaultValue.key !== ADD_NEW_KEY) {
+ onChange({ name, value: defaultValue.key });
+ }
+ }
+
+ if (previousIsSaving && !isSaving && !saveError && newRootFolderPath) {
+ onChange({ name, value: newRootFolderPath });
+ setNewRootFolderPath('');
+ }
+ }, [
+ name,
+ value,
+ values,
+ isSaving,
+ saveError,
+ previousIsSaving,
+ newRootFolderPath,
+ onChange,
+ ]);
+
+ useEffect(() => {
+ if (value == null && values[0].key === '') {
+ onChange({ name, value: '' });
+ } else if (
+ !value ||
+ !values.some((v) => v.key === value) ||
+ value === ADD_NEW_KEY
+ ) {
+ const defaultValue = values[0];
+
+ if (defaultValue.key === ADD_NEW_KEY) {
+ onChange({ name, value: '' });
+ } else {
+ onChange({ name, value: defaultValue.key });
+ }
+ }
+
+ // Only run on mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ dispatch(fetchRootFolders());
+ }, [dispatch]);
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
+
+export default RootFolderSelectInput;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.css
similarity index 100%
rename from frontend/src/Components/Form/RootFolderSelectInputOption.css
rename to frontend/src/Components/Form/Select/RootFolderSelectInputOption.css
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts
rename to frontend/src/Components/Form/Select/RootFolderSelectInputOption.css.d.ts
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx
new file mode 100644
index 000000000..d71f0d638
--- /dev/null
+++ b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx
@@ -0,0 +1,67 @@
+import classNames from 'classnames';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInputOption, {
+ EnhancedSelectInputOptionProps,
+} from './EnhancedSelectInputOption';
+import styles from './RootFolderSelectInputOption.css';
+
+interface RootFolderSelectInputOptionProps
+ extends EnhancedSelectInputOptionProps {
+ id: string;
+ value: string;
+ freeSpace?: number;
+ isMissing?: boolean;
+ seriesFolder?: string;
+ isMobile: boolean;
+ isWindows?: boolean;
+}
+
+function RootFolderSelectInputOption(props: RootFolderSelectInputOptionProps) {
+ const {
+ id,
+ value,
+ freeSpace,
+ isMissing,
+ seriesFolder,
+ isMobile,
+ isWindows,
+ ...otherProps
+ } = props;
+
+ const slashCharacter = isWindows ? '\\' : '/';
+
+ return (
+
+
+
+ {value}
+
+ {seriesFolder && id !== 'addNew' ? (
+
+ {slashCharacter}
+ {seriesFolder}
+
+ ) : null}
+
+
+ {freeSpace == null ? null : (
+
+ {translate('RootFolderSelectFreeSpace', {
+ freeSpace: formatBytes(freeSpace),
+ })}
+
+ )}
+
+ {isMissing ? (
+
{translate('Missing')}
+ ) : null}
+
+
+ );
+}
+
+export default RootFolderSelectInputOption;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css
similarity index 100%
rename from frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
rename to frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts
rename to frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css.d.ts
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx
new file mode 100644
index 000000000..e06101f2a
--- /dev/null
+++ b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import { RootFolderSelectInputValue } from './RootFolderSelectInput';
+import styles from './RootFolderSelectInputSelectedValue.css';
+
+interface RootFolderSelectInputSelectedValueProps {
+ selectedValue: string;
+ values: RootFolderSelectInputValue[];
+ freeSpace?: number;
+ seriesFolder?: string;
+ isWindows?: boolean;
+ includeFreeSpace?: boolean;
+}
+
+function RootFolderSelectInputSelectedValue(
+ props: RootFolderSelectInputSelectedValueProps
+) {
+ const {
+ selectedValue,
+ values,
+ freeSpace,
+ seriesFolder,
+ includeFreeSpace = true,
+ isWindows,
+ ...otherProps
+ } = props;
+
+ const slashCharacter = isWindows ? '\\' : '/';
+ const value = values.find((v) => v.key === selectedValue)?.value;
+
+ return (
+
+
+
{value}
+
+ {seriesFolder ? (
+
+ {slashCharacter}
+ {seriesFolder}
+
+ ) : null}
+
+
+ {freeSpace != null && includeFreeSpace ? (
+
+ {translate('RootFolderSelectFreeSpace', {
+ freeSpace: formatBytes(freeSpace),
+ })}
+
+ ) : null}
+
+ );
+}
+
+export default RootFolderSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/SeriesTypeSelectInput.tsx b/frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx
similarity index 60%
rename from frontend/src/Components/Form/SeriesTypeSelectInput.tsx
rename to frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx
index 471d6592b..6a3bba650 100644
--- a/frontend/src/Components/Form/SeriesTypeSelectInput.tsx
+++ b/frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx
@@ -1,21 +1,25 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import * as seriesTypes from 'Utilities/Series/seriesTypes';
import translate from 'Utilities/String/translate';
-import EnhancedSelectInput from './EnhancedSelectInput';
+import EnhancedSelectInput, {
+ EnhancedSelectInputProps,
+ EnhancedSelectInputValue,
+} from './EnhancedSelectInput';
import SeriesTypeSelectInputOption from './SeriesTypeSelectInputOption';
import SeriesTypeSelectInputSelectedValue from './SeriesTypeSelectInputSelectedValue';
-interface SeriesTypeSelectInputProps {
+interface SeriesTypeSelectInputProps
+ extends EnhancedSelectInputProps, string> {
includeNoChange: boolean;
includeNoChangeDisabled?: boolean;
includeMixed: boolean;
}
-interface ISeriesTypeOption {
+export interface ISeriesTypeOption {
key: string;
value: string;
format?: string;
- disabled?: boolean;
+ isDisabled?: boolean;
}
const seriesTypeOptions: ISeriesTypeOption[] = [
@@ -43,29 +47,33 @@ const seriesTypeOptions: ISeriesTypeOption[] = [
];
function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
- const values = [...seriesTypeOptions];
-
const {
- includeNoChange,
+ includeNoChange = false,
includeNoChangeDisabled = true,
- includeMixed,
+ includeMixed = false,
} = props;
- if (includeNoChange) {
- values.unshift({
- key: 'noChange',
- value: translate('NoChange'),
- disabled: includeNoChangeDisabled,
- });
- }
+ const values = useMemo(() => {
+ const result = [...seriesTypeOptions];
- if (includeMixed) {
- values.unshift({
- key: 'mixed',
- value: `(${translate('Mixed')})`,
- disabled: true,
- });
- }
+ if (includeNoChange) {
+ result.unshift({
+ key: 'noChange',
+ value: translate('NoChange'),
+ isDisabled: includeNoChangeDisabled,
+ });
+ }
+
+ if (includeMixed) {
+ result.unshift({
+ key: 'mixed',
+ value: `(${translate('Mixed')})`,
+ isDisabled: true,
+ });
+ }
+
+ return result;
+ }, [includeNoChange, includeNoChangeDisabled, includeMixed]);
return (
+
diff --git a/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx
new file mode 100644
index 000000000..b6470f1a4
--- /dev/null
+++ b/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
+import { ISeriesTypeOption } from './SeriesTypeSelectInput';
+
+interface SeriesTypeSelectInputOptionProps {
+ selectedValue: string;
+ values: ISeriesTypeOption[];
+ format: string;
+}
+function SeriesTypeSelectInputSelectedValue(
+ props: SeriesTypeSelectInputOptionProps
+) {
+ const { selectedValue, values, ...otherProps } = props;
+ const format = values.find((v) => v.key === selectedValue)?.format;
+
+ return (
+
+ );
+}
+
+export default SeriesTypeSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/UMaskInput.css b/frontend/src/Components/Form/Select/UMaskInput.css
similarity index 93%
rename from frontend/src/Components/Form/UMaskInput.css
rename to frontend/src/Components/Form/Select/UMaskInput.css
index 91486687e..a777aaeef 100644
--- a/frontend/src/Components/Form/UMaskInput.css
+++ b/frontend/src/Components/Form/Select/UMaskInput.css
@@ -1,53 +1,53 @@
-.inputWrapper {
- display: flex;
-}
-
-.inputFolder {
- composes: input from '~Components/Form/Input.css';
-
- max-width: 100px;
-}
-
-.inputUnitWrapper {
- position: relative;
- width: 100%;
-}
-
-.inputUnit {
- composes: inputUnit from '~Components/Form/FormInputGroup.css';
-
- right: 40px;
- font-family: $monoSpaceFontFamily;
-}
-
-.unit {
- font-family: $monoSpaceFontFamily;
-}
-
-.details {
- margin-top: 5px;
- margin-left: 17px;
- line-height: 20px;
-
- > div {
- display: flex;
-
- label {
- flex: 0 0 50px;
- }
-
- .value {
- width: 50px;
- text-align: right;
- }
-
- .unit {
- width: 90px;
- text-align: right;
- }
- }
-}
-
-.readOnly {
- background-color: var(--inputReadOnlyBackgroundColor);
-}
+.inputWrapper {
+ display: flex;
+}
+
+.inputFolder {
+ composes: input from '~Components/Form/Input.css';
+
+ max-width: 100px;
+}
+
+.inputUnitWrapper {
+ position: relative;
+ width: 100%;
+}
+
+.inputUnit {
+ composes: inputUnit from '~Components/Form/FormInputGroup.css';
+
+ right: 40px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.unit {
+ font-family: $monoSpaceFontFamily;
+}
+
+.details {
+ margin-top: 5px;
+ margin-left: 17px;
+ line-height: 20px;
+
+ > div {
+ display: flex;
+
+ label {
+ flex: 0 0 50px;
+ }
+
+ .value {
+ width: 50px;
+ text-align: right;
+ }
+
+ .unit {
+ width: 90px;
+ text-align: right;
+ }
+ }
+}
+
+.readOnly {
+ background-color: var(--inputReadOnlyBackgroundColor);
+}
diff --git a/frontend/src/Components/Form/UMaskInput.css.d.ts b/frontend/src/Components/Form/Select/UMaskInput.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/UMaskInput.css.d.ts
rename to frontend/src/Components/Form/Select/UMaskInput.css.d.ts
diff --git a/frontend/src/Components/Form/Select/UMaskInput.tsx b/frontend/src/Components/Form/Select/UMaskInput.tsx
new file mode 100644
index 000000000..1f537f968
--- /dev/null
+++ b/frontend/src/Components/Form/Select/UMaskInput.tsx
@@ -0,0 +1,142 @@
+/* eslint-disable no-bitwise */
+import PropTypes from 'prop-types';
+import React, { SyntheticEvent } from 'react';
+import { InputChanged } from 'typings/inputs';
+import translate from 'Utilities/String/translate';
+import EnhancedSelectInput from './EnhancedSelectInput';
+import styles from './UMaskInput.css';
+
+const umaskOptions = [
+ {
+ key: '755',
+ get value() {
+ return translate('Umask755Description', { octal: '755' });
+ },
+ hint: 'drwxr-xr-x',
+ },
+ {
+ key: '775',
+ get value() {
+ return translate('Umask775Description', { octal: '775' });
+ },
+ hint: 'drwxrwxr-x',
+ },
+ {
+ key: '770',
+ get value() {
+ return translate('Umask770Description', { octal: '770' });
+ },
+ hint: 'drwxrwx---',
+ },
+ {
+ key: '750',
+ get value() {
+ return translate('Umask750Description', { octal: '750' });
+ },
+ hint: 'drwxr-x---',
+ },
+ {
+ key: '777',
+ get value() {
+ return translate('Umask777Description', { octal: '777' });
+ },
+ hint: 'drwxrwxrwx',
+ },
+];
+
+function formatPermissions(permissions: number) {
+ const hasSticky = permissions & 0o1000;
+ const hasSetGID = permissions & 0o2000;
+ const hasSetUID = permissions & 0o4000;
+
+ let result = '';
+
+ for (let i = 0; i < 9; i++) {
+ const bit = (permissions & (1 << i)) !== 0;
+ let digit = bit ? 'xwr'[i % 3] : '-';
+ if (i === 6 && hasSetUID) {
+ digit = bit ? 's' : 'S';
+ } else if (i === 3 && hasSetGID) {
+ digit = bit ? 's' : 'S';
+ } else if (i === 0 && hasSticky) {
+ digit = bit ? 't' : 'T';
+ }
+ result = digit + result;
+ }
+
+ return result;
+}
+
+interface UMaskInputProps {
+ name: string;
+ value: string;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ onChange: (change: InputChanged) => void;
+ onFocus?: (event: SyntheticEvent) => void;
+ onBlur?: (event: SyntheticEvent) => void;
+}
+
+function UMaskInput({ name, value, onChange }: UMaskInputProps) {
+ const valueNum = parseInt(value, 8);
+ const umaskNum = 0o777 & ~valueNum;
+ const umask = umaskNum.toString(8).padStart(4, '0');
+ const folderNum = 0o777 & ~umaskNum;
+ const folder = folderNum.toString(8).padStart(3, '0');
+ const fileNum = 0o666 & ~umaskNum;
+ const file = fileNum.toString(8).padStart(3, '0');
+ const unit = formatPermissions(folderNum);
+
+ const values = umaskOptions.map((v) => {
+ return { ...v, hint:
{v.hint} };
+ });
+
+ return (
+
+
+
+
+
+
+
{umask}
+
+
+
+
+
{folder}
+
d{formatPermissions(folderNum)}
+
+
+
+
+
{file}
+
{formatPermissions(fileNum)}
+
+
+
+ );
+}
+
+UMaskInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onFocus: PropTypes.func,
+ onBlur: PropTypes.func,
+};
+
+export default UMaskInput;
diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js
deleted file mode 100644
index 553501afc..000000000
--- a/frontend/src/Components/Form/SelectInput.js
+++ /dev/null
@@ -1,95 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import styles from './SelectInput.css';
-
-class SelectInput extends Component {
-
- //
- // Listeners
-
- onChange = (event) => {
- this.props.onChange({
- name: this.props.name,
- value: event.target.value
- });
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- disabledClassName,
- name,
- value,
- values,
- isDisabled,
- hasError,
- hasWarning,
- autoFocus,
- onBlur
- } = this.props;
-
- return (
-
- );
- }
-}
-
-SelectInput.propTypes = {
- className: PropTypes.string,
- disabledClassName: PropTypes.string,
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.func]).isRequired,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- isDisabled: PropTypes.bool,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- autoFocus: PropTypes.bool.isRequired,
- onChange: PropTypes.func.isRequired,
- onBlur: PropTypes.func
-};
-
-SelectInput.defaultProps = {
- className: styles.select,
- disabledClassName: styles.isDisabled,
- isDisabled: false,
- autoFocus: false
-};
-
-export default SelectInput;
diff --git a/frontend/src/Components/Form/SelectInput.tsx b/frontend/src/Components/Form/SelectInput.tsx
new file mode 100644
index 000000000..4716c2dfd
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.tsx
@@ -0,0 +1,76 @@
+import classNames from 'classnames';
+import React, { ChangeEvent, SyntheticEvent, useCallback } from 'react';
+import { InputChanged } from 'typings/inputs';
+import styles from './SelectInput.css';
+
+interface SelectInputOption {
+ key: string;
+ value: string | number | (() => string | number);
+}
+
+interface SelectInputProps
{
+ className?: string;
+ disabledClassName?: string;
+ name: string;
+ value: string | number;
+ values: SelectInputOption[];
+ isDisabled?: boolean;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ autoFocus?: boolean;
+ onChange: (change: InputChanged) => void;
+ onBlur?: (event: SyntheticEvent) => void;
+}
+
+function SelectInput({
+ className = styles.select,
+ disabledClassName = styles.isDisabled,
+ name,
+ value,
+ values,
+ isDisabled = false,
+ hasError,
+ hasWarning,
+ autoFocus = false,
+ onBlur,
+ onChange,
+}: SelectInputProps) {
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ onChange({
+ name,
+ value: event.target.value as T,
+ });
+ },
+ [name, onChange]
+ );
+
+ return (
+
+ );
+}
+
+export default SelectInput;
diff --git a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css
deleted file mode 100644
index c76b0a263..000000000
--- a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css
+++ /dev/null
@@ -1,20 +0,0 @@
-.selectedValue {
- composes: selectedValue from '~./EnhancedSelectInputSelectedValue.css';
-
- display: flex;
- align-items: center;
- justify-content: space-between;
- overflow: hidden;
-}
-
-.value {
- display: flex;
-}
-
-.format {
- flex: 0 0 auto;
- margin-left: 15px;
- color: var(--gray);
- text-align: right;
- font-size: $smallFontSize;
-}
diff --git a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.tsx b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.tsx
deleted file mode 100644
index 94d2b7157..000000000
--- a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import React from 'react';
-import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
-import styles from './SeriesTypeSelectInputSelectedValue.css';
-
-interface SeriesTypeSelectInputOptionProps {
- key: string;
- value: string;
- format: string;
-}
-function SeriesTypeSelectInputSelectedValue(
- props: SeriesTypeSelectInputOptionProps
-) {
- const { value, format, ...otherProps } = props;
-
- return (
-
- {value}
-
- {format == null ? null : {format}
}
-
- );
-}
-
-export default SeriesTypeSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/Tag/DeviceInput.css
similarity index 64%
rename from frontend/src/Components/Form/DeviceInput.css
rename to frontend/src/Components/Form/Tag/DeviceInput.css
index 7abe83db5..189cafc6b 100644
--- a/frontend/src/Components/Form/DeviceInput.css
+++ b/frontend/src/Components/Form/Tag/DeviceInput.css
@@ -3,6 +3,6 @@
}
.input {
- composes: input from '~./TagInput.css';
+ composes: input from '~Components/Form/Tag/TagInput.css';
composes: hasButton from '~Components/Form/Input.css';
}
diff --git a/frontend/src/Components/Form/DeviceInput.css.d.ts b/frontend/src/Components/Form/Tag/DeviceInput.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/DeviceInput.css.d.ts
rename to frontend/src/Components/Form/Tag/DeviceInput.css.d.ts
diff --git a/frontend/src/Components/Form/Tag/DeviceInput.tsx b/frontend/src/Components/Form/Tag/DeviceInput.tsx
new file mode 100644
index 000000000..3c483d1f2
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/DeviceInput.tsx
@@ -0,0 +1,149 @@
+import React, { useCallback, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import FormInputButton from 'Components/Form/FormInputButton';
+import Icon from 'Components/Icon';
+import { icons } from 'Helpers/Props';
+import {
+ clearOptions,
+ defaultState,
+ fetchOptions,
+} from 'Store/Actions/providerOptionActions';
+import { InputChanged } from 'typings/inputs';
+import TagInput, { TagInputProps } from './TagInput';
+import styles from './DeviceInput.css';
+
+interface DeviceTag {
+ id: string;
+ name: string;
+}
+
+interface DeviceInputProps extends TagInputProps {
+ className?: string;
+ name: string;
+ value: string[];
+ hasError?: boolean;
+ hasWarning?: boolean;
+ provider: string;
+ providerData: object;
+ onChange: (change: InputChanged) => unknown;
+}
+
+function createDeviceTagsSelector(value: string[]) {
+ return createSelector(
+ (state: AppState) => state.providerOptions.devices || defaultState,
+ (devices) => {
+ return {
+ ...devices,
+ selectedDevices: value.map((valueDevice) => {
+ const device = devices.items.find((d) => d.id === valueDevice);
+
+ if (device) {
+ return {
+ id: device.id,
+ name: `${device.name} (${device.id})`,
+ };
+ }
+
+ return {
+ id: valueDevice,
+ name: `Unknown (${valueDevice})`,
+ };
+ }),
+ };
+ }
+ );
+}
+
+function DeviceInput({
+ className = styles.deviceInputWrapper,
+ name,
+ value,
+ hasError,
+ hasWarning,
+ provider,
+ providerData,
+ onChange,
+}: DeviceInputProps) {
+ const dispatch = useDispatch();
+ const { items, selectedDevices, isFetching } = useSelector(
+ createDeviceTagsSelector(value)
+ );
+
+ const handleRefreshPress = useCallback(() => {
+ dispatch(
+ fetchOptions({
+ section: 'devices',
+ action: 'getDevices',
+ provider,
+ providerData,
+ })
+ );
+ }, [provider, providerData, dispatch]);
+
+ const handleTagAdd = useCallback(
+ (device: DeviceTag) => {
+ // New tags won't have an ID, only a name.
+ const deviceId = device.id || device.name;
+
+ onChange({
+ name,
+ value: [...value, deviceId],
+ });
+ },
+ [name, value, onChange]
+ );
+
+ const handleTagDelete = useCallback(
+ ({ index }: { index: number }) => {
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue,
+ });
+ },
+ [name, value, onChange]
+ );
+
+ useEffect(() => {
+ dispatch(
+ fetchOptions({
+ section: 'devices',
+ action: 'getDevices',
+ provider,
+ providerData,
+ })
+ );
+
+ return () => {
+ dispatch(clearOptions({ section: 'devices' }));
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dispatch]);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export default DeviceInput;
diff --git a/frontend/src/Components/Form/Tag/SeriesTagInput.tsx b/frontend/src/Components/Form/Tag/SeriesTagInput.tsx
new file mode 100644
index 000000000..f72248cf5
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/SeriesTagInput.tsx
@@ -0,0 +1,145 @@
+import React, { useCallback, useMemo } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addTag } from 'Store/Actions/tagActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import { InputChanged } from 'typings/inputs';
+import sortByProp from 'Utilities/Array/sortByProp';
+import TagInput, { TagBase } from './TagInput';
+
+interface SeriesTag extends TagBase {
+ id: number;
+ name: string;
+}
+
+interface SeriesTagInputProps {
+ name: string;
+ value: number | number[];
+ onChange: (change: InputChanged) => void;
+}
+
+const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i');
+
+function isValidTag(tagName: string) {
+ try {
+ return !VALID_TAG_REGEX.test(tagName);
+ } catch {
+ return false;
+ }
+}
+
+function createSeriesTagsSelector(tags: number[]) {
+ return createSelector(createTagsSelector(), (tagList) => {
+ const sortedTags = tagList.sort(sortByProp('label'));
+ const filteredTagList = sortedTags.filter((tag) => !tags.includes(tag.id));
+
+ return {
+ tags: tags.reduce((acc: SeriesTag[], tag) => {
+ const matchingTag = tagList.find((t) => t.id === tag);
+
+ if (matchingTag) {
+ acc.push({
+ id: tag,
+ name: matchingTag.label,
+ });
+ }
+
+ return acc;
+ }, []),
+
+ tagList: filteredTagList.map(({ id, label: name }) => {
+ return {
+ id,
+ name,
+ };
+ }),
+
+ allTags: sortedTags,
+ };
+ });
+}
+
+export default function SeriesTagInput({
+ name,
+ value,
+ onChange,
+}: SeriesTagInputProps) {
+ const dispatch = useDispatch();
+ const isArray = Array.isArray(value);
+
+ const arrayValue = useMemo(() => {
+ if (isArray) {
+ return value;
+ }
+
+ return value === 0 ? [] : [value];
+ }, [isArray, value]);
+
+ const { tags, tagList, allTags } = useSelector(
+ createSeriesTagsSelector(arrayValue)
+ );
+
+ const handleTagCreated = useCallback(
+ (tag: SeriesTag) => {
+ if (isArray) {
+ onChange({ name, value: [...value, tag.id] });
+ } else {
+ onChange({
+ name,
+ value: tag.id,
+ });
+ }
+ },
+ [name, value, isArray, onChange]
+ );
+
+ const handleTagAdd = useCallback(
+ (newTag: SeriesTag) => {
+ if (newTag.id) {
+ if (isArray) {
+ onChange({ name, value: [...value, newTag.id] });
+ } else {
+ onChange({ name, value: newTag.id });
+ }
+
+ return;
+ }
+
+ const existingTag = allTags.some((t) => t.label === newTag.name);
+
+ if (isValidTag(newTag.name) && !existingTag) {
+ dispatch(
+ addTag({
+ tag: { label: newTag.name },
+ onTagCreated: handleTagCreated,
+ })
+ );
+ }
+ },
+ [name, value, isArray, allTags, handleTagCreated, onChange, dispatch]
+ );
+
+ const handleTagDelete = useCallback(
+ ({ index }: { index: number }) => {
+ if (isArray) {
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ onChange({ name, value: newValue });
+ } else {
+ onChange({ name, value: 0 });
+ }
+ },
+ [name, value, isArray, onChange]
+ );
+
+ return (
+
+ );
+}
diff --git a/frontend/src/Components/Form/TagInput.css b/frontend/src/Components/Form/Tag/TagInput.css
similarity index 74%
rename from frontend/src/Components/Form/TagInput.css
rename to frontend/src/Components/Form/Tag/TagInput.css
index eeddab5b4..2ca02825e 100644
--- a/frontend/src/Components/Form/TagInput.css
+++ b/frontend/src/Components/Form/Tag/TagInput.css
@@ -1,5 +1,5 @@
.input {
- composes: input from '~./AutoSuggestInput.css';
+ composes: input from '~Components/Form/AutoSuggestInput.css';
padding: 0;
min-height: 35px;
@@ -8,7 +8,8 @@
&.isFocused {
outline: 0;
border-color: var(--inputFocusBorderColor);
- box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor);
+ box-shadow: inset 0 1px 1px var(--inputBoxShadowColor),
+ 0 0 8px var(--inputFocusBoxShadowColor);
}
}
diff --git a/frontend/src/Components/Form/TagInput.css.d.ts b/frontend/src/Components/Form/Tag/TagInput.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/TagInput.css.d.ts
rename to frontend/src/Components/Form/Tag/TagInput.css.d.ts
diff --git a/frontend/src/Components/Form/Tag/TagInput.tsx b/frontend/src/Components/Form/Tag/TagInput.tsx
new file mode 100644
index 000000000..bde24f369
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInput.tsx
@@ -0,0 +1,371 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, {
+ KeyboardEvent,
+ Ref,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import {
+ ChangeEvent,
+ RenderInputComponentProps,
+ RenderSuggestion,
+ SuggestionsFetchRequestedParams,
+} from 'react-autosuggest';
+import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
+import { kinds } from 'Helpers/Props';
+import { Kind } from 'Helpers/Props/kinds';
+import tagShape from 'Helpers/Props/Shapes/tagShape';
+import { InputChanged } from 'typings/inputs';
+import AutoSuggestInput from '../AutoSuggestInput';
+import TagInputInput from './TagInputInput';
+import TagInputTag, { EditedTag, TagInputTagProps } from './TagInputTag';
+import styles from './TagInput.css';
+
+export interface TagBase {
+ id: boolean | number | string | null;
+ name: string | number;
+}
+
+function getTag(
+ value: string,
+ selectedIndex: number,
+ suggestions: T[],
+ allowNew: boolean
+) {
+ if (selectedIndex == null && value) {
+ const existingTag = suggestions.find(
+ (suggestion) => suggestion.name === value
+ );
+
+ if (existingTag) {
+ return existingTag;
+ } else if (allowNew) {
+ return { name: value } as T;
+ }
+ } else if (selectedIndex != null) {
+ return suggestions[selectedIndex];
+ }
+
+ return null;
+}
+
+function handleSuggestionsClearRequested() {
+ // Required because props aren't always rendered, but no-op
+ // because we don't want to reset the paths after a path is selected.
+}
+
+export interface ReplacementTag {
+ index: number;
+ id: T['id'];
+}
+
+export interface TagInputProps {
+ className?: string;
+ inputContainerClassName?: string;
+ name: string;
+ tags: T[];
+ tagList: T[];
+ allowNew?: boolean;
+ kind?: Kind;
+ placeholder?: string;
+ delimiters?: string[];
+ minQueryLength?: number;
+ canEdit?: boolean;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ tagComponent?: React.ElementType;
+ onChange?: (change: InputChanged) => void;
+ onTagAdd: (newTag: T) => void;
+ onTagDelete: TagInputTagProps['onDelete'];
+ onTagReplace?: (
+ tagToReplace: ReplacementTag,
+ newTagName: T['name']
+ ) => void;
+}
+
+function TagInput({
+ className = styles.internalInput,
+ inputContainerClassName = styles.input,
+ name,
+ tags,
+ tagList,
+ allowNew = true,
+ kind = 'info',
+ placeholder = '',
+ delimiters = ['Tab', 'Enter', ' ', ','],
+ minQueryLength = 1,
+ canEdit = false,
+ tagComponent = TagInputTag,
+ hasError,
+ hasWarning,
+ onChange,
+ onTagAdd,
+ onTagDelete,
+ onTagReplace,
+ ...otherProps
+}: TagInputProps) {
+ const [value, setValue] = useState('');
+ const [suggestions, setSuggestions] = useState([]);
+ const [isFocused, setIsFocused] = useState(false);
+ const autoSuggestRef = useRef(null);
+
+ const addTag = useDebouncedCallback(
+ (tag: T | null) => {
+ if (!tag) {
+ return;
+ }
+
+ onTagAdd(tag);
+
+ setValue('');
+ setSuggestions([]);
+ },
+ 250,
+ {
+ leading: true,
+ trailing: false,
+ }
+ );
+
+ const handleEditTag = useCallback(
+ ({ value: newValue, ...otherProps }: EditedTag) => {
+ if (value && onTagReplace) {
+ onTagReplace(otherProps, value);
+ } else {
+ onTagDelete(otherProps);
+ }
+
+ setValue(String(newValue));
+ },
+ [value, setValue, onTagDelete, onTagReplace]
+ );
+
+ const handleInputContainerPress = useCallback(() => {
+ // @ts-expect-error Ref isn't typed yet
+ autoSuggestRef?.current?.input.focus();
+ }, []);
+
+ const handleInputChange = useCallback(
+ (_event: SyntheticEvent, { newValue, method }: ChangeEvent) => {
+ const finalValue =
+ // @ts-expect-error newValue may be an object?
+ typeof newValue === 'object' ? newValue.name : newValue;
+
+ if (method === 'type') {
+ setValue(finalValue);
+ }
+ },
+ [setValue]
+ );
+
+ const handleSuggestionsFetchRequested = useCallback(
+ ({ value: newValue }: SuggestionsFetchRequestedParams) => {
+ const lowerCaseValue = newValue.toLowerCase();
+
+ const suggestions = tagList.filter((tag) => {
+ return (
+ String(tag.name).toLowerCase().includes(lowerCaseValue) &&
+ !tags.some((t) => t.id === tag.id)
+ );
+ });
+
+ setSuggestions(suggestions);
+ },
+ [tags, tagList, setSuggestions]
+ );
+
+ const handleInputKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ const key = event.key;
+
+ if (!autoSuggestRef.current) {
+ return;
+ }
+
+ if (key === 'Backspace' && !value.length) {
+ const index = tags.length - 1;
+
+ if (index >= 0) {
+ onTagDelete({ index, id: tags[index].id });
+ }
+
+ setTimeout(() => {
+ handleSuggestionsFetchRequested({
+ value: '',
+ reason: 'input-changed',
+ });
+ });
+
+ event.preventDefault();
+ }
+
+ if (delimiters.includes(key)) {
+ // @ts-expect-error Ref isn't typed yet
+ const selectedIndex = autoSuggestRef.current.highlightedSuggestionIndex;
+ const tag = getTag(value, selectedIndex, suggestions, allowNew);
+
+ if (tag) {
+ addTag(tag);
+ event.preventDefault();
+ }
+ }
+ },
+ [
+ tags,
+ allowNew,
+ delimiters,
+ onTagDelete,
+ value,
+ suggestions,
+ addTag,
+ handleSuggestionsFetchRequested,
+ ]
+ );
+
+ const handleInputFocus = useCallback(() => {
+ setIsFocused(true);
+ }, [setIsFocused]);
+
+ const handleInputBlur = useCallback(() => {
+ setIsFocused(false);
+
+ if (!autoSuggestRef.current) {
+ return;
+ }
+
+ // @ts-expect-error Ref isn't typed yet
+ const selectedIndex = autoSuggestRef.current.highlightedSuggestionIndex;
+ const tag = getTag(value, selectedIndex, suggestions, allowNew);
+
+ if (tag) {
+ addTag(tag);
+ }
+ }, [allowNew, value, suggestions, autoSuggestRef, addTag, setIsFocused]);
+
+ const handleSuggestionSelected = useCallback(
+ (_event: SyntheticEvent, { suggestion }: { suggestion: T }) => {
+ addTag(suggestion);
+ },
+ [addTag]
+ );
+
+ const getSuggestionValue = useCallback(({ name }: T): string => {
+ return String(name);
+ }, []);
+
+ const shouldRenderSuggestions = useCallback(
+ (v: string) => {
+ return v.length >= minQueryLength;
+ },
+ [minQueryLength]
+ );
+
+ const renderSuggestion: RenderSuggestion = useCallback(({ name }: T) => {
+ return name;
+ }, []);
+
+ const renderInputComponent = useCallback(
+ (
+ inputProps: RenderInputComponentProps,
+ forwardedRef: Ref
+ ) => {
+ return (
+
+ );
+ },
+ [
+ tags,
+ kind,
+ canEdit,
+ isFocused,
+ tagComponent,
+ handleInputContainerPress,
+ handleEditTag,
+ onTagDelete,
+ ]
+ );
+
+ useEffect(() => {
+ return () => {
+ addTag.cancel();
+ };
+ }, [addTag]);
+
+ return (
+
+ );
+}
+
+TagInput.propTypes = {
+ className: PropTypes.string,
+ inputContainerClassName: PropTypes.string,
+ tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ allowNew: PropTypes.bool,
+ kind: PropTypes.oneOf(kinds.all),
+ placeholder: PropTypes.string,
+ delimiters: PropTypes.arrayOf(PropTypes.string),
+ minQueryLength: PropTypes.number,
+ canEdit: PropTypes.bool,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ tagComponent: PropTypes.elementType,
+ onTagAdd: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired,
+ onTagReplace: PropTypes.func,
+};
+
+TagInput.defaultProps = {
+ className: styles.internalInput,
+ inputContainerClassName: styles.input,
+ allowNew: true,
+ kind: kinds.INFO,
+ placeholder: '',
+ delimiters: ['Tab', 'Enter', ' ', ','],
+ minQueryLength: 1,
+ canEdit: false,
+ tagComponent: TagInputTag,
+};
+
+export default TagInput;
diff --git a/frontend/src/Components/Form/TagInputInput.css b/frontend/src/Components/Form/Tag/TagInputInput.css
similarity index 100%
rename from frontend/src/Components/Form/TagInputInput.css
rename to frontend/src/Components/Form/Tag/TagInputInput.css
diff --git a/frontend/src/Components/Form/TagInputInput.css.d.ts b/frontend/src/Components/Form/Tag/TagInputInput.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/TagInputInput.css.d.ts
rename to frontend/src/Components/Form/Tag/TagInputInput.css.d.ts
diff --git a/frontend/src/Components/Form/Tag/TagInputInput.tsx b/frontend/src/Components/Form/Tag/TagInputInput.tsx
new file mode 100644
index 000000000..d181136b8
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInputInput.tsx
@@ -0,0 +1,71 @@
+import React, { MouseEvent, Ref, useCallback } from 'react';
+import { Kind } from 'Helpers/Props/kinds';
+import { TagBase } from './TagInput';
+import { TagInputTagProps } from './TagInputTag';
+import styles from './TagInputInput.css';
+
+interface TagInputInputProps {
+ forwardedRef?: Ref;
+ className?: string;
+ tags: TagBase[];
+ inputProps: object;
+ kind: Kind;
+ isFocused: boolean;
+ canEdit: boolean;
+ tagComponent: React.ElementType;
+ onTagDelete: TagInputTagProps['onDelete'];
+ onTagEdit: TagInputTagProps['onEdit'];
+ onInputContainerPress: () => void;
+}
+
+function TagInputInput(props: TagInputInputProps) {
+ const {
+ forwardedRef,
+ className = styles.inputContainer,
+ tags,
+ inputProps,
+ kind,
+ isFocused,
+ canEdit,
+ tagComponent: TagComponent,
+ onTagDelete,
+ onTagEdit,
+ onInputContainerPress,
+ } = props;
+
+ const handleMouseDown = useCallback(
+ (event: MouseEvent) => {
+ event.preventDefault();
+
+ if (isFocused) {
+ return;
+ }
+
+ onInputContainerPress();
+ },
+ [isFocused, onInputContainerPress]
+ );
+
+ return (
+
+ {tags.map((tag, index) => {
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+export default TagInputInput;
diff --git a/frontend/src/Components/Form/TagInputTag.css b/frontend/src/Components/Form/Tag/TagInputTag.css
similarity index 96%
rename from frontend/src/Components/Form/TagInputTag.css
rename to frontend/src/Components/Form/Tag/TagInputTag.css
index 7e66a4d12..1a8ff45d6 100644
--- a/frontend/src/Components/Form/TagInputTag.css
+++ b/frontend/src/Components/Form/Tag/TagInputTag.css
@@ -30,5 +30,6 @@
.label {
composes: label from '~Components/Label.css';
+ display: flex;
max-width: 100%;
}
diff --git a/frontend/src/Components/Form/TagInputTag.css.d.ts b/frontend/src/Components/Form/Tag/TagInputTag.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/TagInputTag.css.d.ts
rename to frontend/src/Components/Form/Tag/TagInputTag.css.d.ts
diff --git a/frontend/src/Components/Form/Tag/TagInputTag.tsx b/frontend/src/Components/Form/Tag/TagInputTag.tsx
new file mode 100644
index 000000000..7b549767c
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagInputTag.tsx
@@ -0,0 +1,79 @@
+import React, { useCallback } from 'react';
+import Label, { LabelProps } from 'Components/Label';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import MiddleTruncate from 'Components/MiddleTruncate';
+import { icons } from 'Helpers/Props';
+import { TagBase } from './TagInput';
+import styles from './TagInputTag.css';
+
+export interface DeletedTag {
+ index: number;
+ id: T['id'];
+}
+
+export interface EditedTag {
+ index: number;
+ id: T['id'];
+ value: T['name'];
+}
+
+export interface TagInputTagProps {
+ index: number;
+ tag: T;
+ kind: LabelProps['kind'];
+ canEdit: boolean;
+ onDelete: (deletedTag: DeletedTag) => void;
+ onEdit: (editedTag: EditedTag) => void;
+}
+
+function TagInputTag({
+ tag,
+ kind,
+ index,
+ canEdit,
+ onDelete,
+ onEdit,
+}: TagInputTagProps) {
+ const handleDelete = useCallback(() => {
+ onDelete({
+ index,
+ id: tag.id,
+ });
+ }, [index, tag, onDelete]);
+
+ const handleEdit = useCallback(() => {
+ onEdit({
+ index,
+ id: tag.id,
+ value: tag.name,
+ });
+ }, [index, tag, onEdit]);
+
+ return (
+
+
+
+ );
+}
+
+export default TagInputTag;
diff --git a/frontend/src/Components/Form/Tag/TagSelectInput.tsx b/frontend/src/Components/Form/Tag/TagSelectInput.tsx
new file mode 100644
index 000000000..21fde893c
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TagSelectInput.tsx
@@ -0,0 +1,97 @@
+import React, { useCallback, useMemo } from 'react';
+import { InputChanged } from 'typings/inputs';
+import TagInput, { TagBase, TagInputProps } from './TagInput';
+
+interface SelectTag extends TagBase {
+ id: number;
+ name: string;
+}
+
+interface TagSelectValue {
+ value: string;
+ key: number;
+ order: number;
+}
+
+interface TagSelectInputProps extends TagInputProps {
+ name: string;
+ value: number[];
+ values: TagSelectValue[];
+ onChange: (change: InputChanged) => unknown;
+}
+
+function TagSelectInput({
+ name,
+ value,
+ values,
+ onChange,
+ ...otherProps
+}: TagSelectInputProps) {
+ const { tags, tagList, allTags } = useMemo(() => {
+ const sortedTags = values.sort((a, b) => a.key - b.key);
+
+ return {
+ tags: value.reduce((acc: SelectTag[], tag) => {
+ const matchingTag = values.find((t) => t.key === tag);
+
+ if (matchingTag) {
+ acc.push({
+ id: tag,
+ name: matchingTag.value,
+ });
+ }
+
+ return acc;
+ }, []),
+
+ tagList: sortedTags.map((sorted) => {
+ return {
+ id: sorted.key,
+ name: sorted.value,
+ };
+ }),
+
+ allTags: sortedTags,
+ };
+ }, [value, values]);
+
+ const handleTagAdd = useCallback(
+ (newTag: SelectTag) => {
+ const existingTag = allTags.some((tag) => tag.key === newTag.id);
+ const newValue = value.slice();
+
+ if (existingTag) {
+ newValue.push(newTag.id);
+ }
+
+ onChange({ name, value: newValue });
+ },
+ [name, value, allTags, onChange]
+ );
+
+ const handleTagDelete = useCallback(
+ ({ index }: { index: number }) => {
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue,
+ });
+ },
+ [name, value, onChange]
+ );
+
+ return (
+
+ );
+}
+
+export default TagSelectInput;
diff --git a/frontend/src/Components/Form/Tag/TextTagInput.tsx b/frontend/src/Components/Form/Tag/TextTagInput.tsx
new file mode 100644
index 000000000..6e2082c50
--- /dev/null
+++ b/frontend/src/Components/Form/Tag/TextTagInput.tsx
@@ -0,0 +1,109 @@
+import React, { useCallback, useMemo } from 'react';
+import { InputChanged } from 'typings/inputs';
+import split from 'Utilities/String/split';
+import TagInput, { ReplacementTag, TagBase, TagInputProps } from './TagInput';
+
+interface TextTag extends TagBase {
+ id: string;
+ name: string;
+}
+
+interface TextTagInputProps extends TagInputProps {
+ name: string;
+ value: string | string[];
+ onChange: (change: InputChanged) => unknown;
+}
+
+function TextTagInput({
+ name,
+ value,
+ onChange,
+ ...otherProps
+}: TextTagInputProps) {
+ const { tags, tagList, valueArray } = useMemo(() => {
+ const tagsArray = Array.isArray(value) ? value : split(value);
+
+ return {
+ tags: tagsArray.reduce((result: TextTag[], tag) => {
+ if (tag) {
+ result.push({
+ id: tag,
+ name: tag,
+ });
+ }
+
+ return result;
+ }, []),
+ tagList: [],
+ valueArray: tagsArray,
+ };
+ }, [value]);
+
+ const handleTagAdd = useCallback(
+ (newTag: TextTag) => {
+ // Split and trim tags before adding them to the list, this will
+ // cleanse tags pasted in that had commas and spaces which leads
+ // to oddities with restrictions (as an example).
+
+ const newValue = [...valueArray];
+ const newTags = newTag.name.startsWith('/')
+ ? [newTag.name]
+ : split(newTag.name);
+
+ newTags.forEach((newTag) => {
+ const newTagValue = newTag.trim();
+
+ if (newTagValue) {
+ newValue.push(newTagValue);
+ }
+ });
+
+ onChange({ name, value: newValue });
+ },
+ [name, valueArray, onChange]
+ );
+
+ const handleTagDelete = useCallback(
+ ({ index }: { index: number }) => {
+ const newValue = [...valueArray];
+ newValue.splice(index, 1);
+
+ onChange({
+ name,
+ value: newValue,
+ });
+ },
+ [name, valueArray, onChange]
+ );
+
+ const handleTagReplace = useCallback(
+ (tagToReplace: ReplacementTag, newTagName: string) => {
+ const newValue = [...valueArray];
+ newValue.splice(tagToReplace.index, 1);
+
+ const newTagValue = newTagName.trim();
+
+ if (newTagValue) {
+ newValue.push(newTagValue);
+ }
+
+ onChange({ name, value: newValue });
+ },
+ [name, valueArray, onChange]
+ );
+
+ return (
+
+ );
+}
+
+export default TextTagInput;
diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js
deleted file mode 100644
index 840d627f8..000000000
--- a/frontend/src/Components/Form/TagInput.js
+++ /dev/null
@@ -1,301 +0,0 @@
-import classNames from 'classnames';
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { kinds } from 'Helpers/Props';
-import tagShape from 'Helpers/Props/Shapes/tagShape';
-import AutoSuggestInput from './AutoSuggestInput';
-import TagInputInput from './TagInputInput';
-import TagInputTag from './TagInputTag';
-import styles from './TagInput.css';
-
-function getTag(value, selectedIndex, suggestions, allowNew) {
- if (selectedIndex == null && value) {
- const existingTag = suggestions.find((suggestion) => suggestion.name === value);
-
- if (existingTag) {
- return existingTag;
- } else if (allowNew) {
- return { name: value };
- }
- } else if (selectedIndex != null) {
- return suggestions[selectedIndex];
- }
-}
-
-class TagInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this.state = {
- value: '',
- suggestions: [],
- isFocused: false
- };
-
- this._autosuggestRef = null;
- }
-
- componentWillUnmount() {
- this.addTag.cancel();
- }
-
- //
- // Control
-
- _setAutosuggestRef = (ref) => {
- this._autosuggestRef = ref;
- };
-
- getSuggestionValue({ name }) {
- return name;
- }
-
- shouldRenderSuggestions = (value) => {
- return value.length >= this.props.minQueryLength;
- };
-
- renderSuggestion({ name }) {
- return name;
- }
-
- addTag = _.debounce((tag) => {
- this.props.onTagAdd(tag);
-
- this.setState({
- value: '',
- suggestions: []
- });
- }, 250, { leading: true, trailing: false });
-
- //
- // 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 = () => {
- this._autosuggestRef.input.focus();
- };
-
- onInputChange = (event, { newValue, method }) => {
- const value = _.isObject(newValue) ? newValue.name : newValue;
-
- if (method === 'type') {
- this.setState({ value });
- }
- };
-
- onInputKeyDown = (event) => {
- const {
- tags,
- allowNew,
- delimiters,
- onTagDelete
- } = this.props;
-
- const {
- value,
- suggestions
- } = this.state;
-
- const key = event.key;
-
- if (key === 'Backspace' && !value.length) {
- const index = tags.length - 1;
-
- if (index >= 0) {
- onTagDelete({ index, id: tags[index].id });
- }
-
- setTimeout(() => {
- this.onSuggestionsFetchRequested({ value: '' });
- });
-
- event.preventDefault();
- }
-
- if (delimiters.includes(key)) {
- const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
- const tag = getTag(value, selectedIndex, suggestions, allowNew);
-
- if (tag) {
- this.addTag(tag);
- event.preventDefault();
- }
- }
- };
-
- onInputFocus = () => {
- this.setState({ isFocused: true });
- };
-
- onInputBlur = () => {
- this.setState({ isFocused: false });
-
- if (!this._autosuggestRef) {
- return;
- }
-
- const {
- allowNew
- } = this.props;
-
- const {
- value,
- suggestions
- } = this.state;
-
- const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
- const tag = getTag(value, selectedIndex, suggestions, allowNew);
-
- if (tag) {
- this.addTag(tag);
- }
- };
-
- onSuggestionsFetchRequested = ({ value }) => {
- const lowerCaseValue = value.toLowerCase();
-
- const {
- tags,
- tagList
- } = this.props;
-
- const suggestions = tagList.filter((tag) => {
- return (
- tag.name.toLowerCase().includes(lowerCaseValue) &&
- !tags.some((t) => t.id === tag.id));
- });
-
- this.setState({ suggestions });
- };
-
- onSuggestionsClearRequested = () => {
- // Required because props aren't always rendered, but no-op
- // because we don't want to reset the paths after a path is selected.
- };
-
- onSuggestionSelected = (event, { suggestion }) => {
- this.addTag(suggestion);
- };
-
- //
- // Render
-
- renderInputComponent = (inputProps, forwardedRef) => {
- const {
- tags,
- kind,
- canEdit,
- tagComponent,
- onTagDelete
- } = this.props;
-
- return (
-
- );
- };
-
- render() {
- const {
- className,
- inputContainerClassName,
- hasError,
- hasWarning,
- ...otherProps
- } = this.props;
-
- const {
- value,
- suggestions,
- isFocused
- } = this.state;
-
- return (
-
- );
- }
-}
-
-TagInput.propTypes = {
- className: PropTypes.string.isRequired,
- inputContainerClassName: PropTypes.string.isRequired,
- tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
- tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
- allowNew: PropTypes.bool.isRequired,
- kind: PropTypes.oneOf(kinds.all).isRequired,
- placeholder: PropTypes.string.isRequired,
- delimiters: PropTypes.arrayOf(PropTypes.string).isRequired,
- minQueryLength: PropTypes.number.isRequired,
- canEdit: PropTypes.bool,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- tagComponent: PropTypes.elementType.isRequired,
- onTagAdd: PropTypes.func.isRequired,
- onTagDelete: PropTypes.func.isRequired,
- onTagReplace: PropTypes.func
-};
-
-TagInput.defaultProps = {
- className: styles.internalInput,
- inputContainerClassName: styles.input,
- allowNew: true,
- kind: kinds.INFO,
- placeholder: '',
- delimiters: ['Tab', 'Enter', ' ', ','],
- minQueryLength: 1,
- canEdit: false,
- tagComponent: TagInputTag
-};
-
-export default TagInput;
diff --git a/frontend/src/Components/Form/TagInputConnector.js b/frontend/src/Components/Form/TagInputConnector.js
deleted file mode 100644
index 8d0782fa5..000000000
--- a/frontend/src/Components/Form/TagInputConnector.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { addTag } from 'Store/Actions/tagActions';
-import createTagsSelector from 'Store/Selectors/createTagsSelector';
-import TagInput from './TagInput';
-
-const validTagRegex = new RegExp('[^-_a-z0-9]', 'i');
-
-function isValidTag(tagName) {
- try {
- return !validTagRegex.test(tagName);
- } catch (e) {
- return false;
- }
-}
-
-function createMapStateToProps() {
- return createSelector(
- (state, { value }) => value,
- createTagsSelector(),
- (tags, tagList) => {
- const sortedTags = _.sortBy(tagList, 'label');
- const filteredTagList = _.filter(sortedTags, (tag) => _.indexOf(tags, tag.id) === -1);
-
- return {
- tags: tags.reduce((acc, tag) => {
- const matchingTag = _.find(tagList, { id: tag });
-
- if (matchingTag) {
- acc.push({
- id: tag,
- name: matchingTag.label
- });
- }
-
- return acc;
- }, []),
-
- tagList: filteredTagList.map(({ id, label: name }) => {
- return {
- id,
- name
- };
- }),
-
- allTags: sortedTags
- };
- }
- );
-}
-
-const mapDispatchToProps = {
- addTag
-};
-
-class TagInputConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount() {
- const {
- name,
- value,
- tags,
- onChange
- } = this.props;
-
- if (value.length !== tags.length) {
- onChange({ name, value: tags.map((tag) => tag.id) });
- }
- }
-
- //
- // Listeners
-
- onTagAdd = (tag) => {
- const {
- name,
- value,
- allTags
- } = this.props;
-
- if (!tag.id) {
- const existingTag =_.some(allTags, { label: tag.name });
-
- if (isValidTag(tag.name) && !existingTag) {
- this.props.addTag({
- tag: { label: tag.name },
- onTagCreated: this.onTagCreated
- });
- }
-
- return;
- }
-
- const newValue = value.slice();
- newValue.push(tag.id);
-
- this.props.onChange({ name, value: newValue });
- };
-
- onTagDelete = ({ index }) => {
- const {
- name,
- value
- } = this.props;
-
- const newValue = value.slice();
- newValue.splice(index, 1);
-
- this.props.onChange({
- name,
- value: newValue
- });
- };
-
- onTagCreated = (tag) => {
- const {
- name,
- value
- } = this.props;
-
- const newValue = value.slice();
- newValue.push(tag.id);
-
- this.props.onChange({ name, value: newValue });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-TagInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.number).isRequired,
- tags: PropTypes.arrayOf(PropTypes.object).isRequired,
- allTags: PropTypes.arrayOf(PropTypes.object).isRequired,
- onChange: PropTypes.func.isRequired,
- addTag: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, mapDispatchToProps)(TagInputConnector);
diff --git a/frontend/src/Components/Form/TagInputInput.js b/frontend/src/Components/Form/TagInputInput.js
deleted file mode 100644
index 86628b134..000000000
--- a/frontend/src/Components/Form/TagInputInput.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { kinds } from 'Helpers/Props';
-import tagShape from 'Helpers/Props/Shapes/tagShape';
-import styles from './TagInputInput.css';
-
-class TagInputInput extends Component {
-
- onMouseDown = (event) => {
- event.preventDefault();
-
- const {
- isFocused,
- onInputContainerPress
- } = this.props;
-
- if (isFocused) {
- return;
- }
-
- onInputContainerPress();
- };
-
- render() {
- const {
- forwardedRef,
- className,
- tags,
- inputProps,
- kind,
- canEdit,
- tagComponent: TagComponent,
- onTagDelete,
- onTagEdit
- } = this.props;
-
- return (
-
- {
- tags.map((tag, index) => {
- return (
-
- );
- })
- }
-
-
-
- );
- }
-}
-
-TagInputInput.propTypes = {
- forwardedRef: PropTypes.func,
- className: PropTypes.string.isRequired,
- tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
- inputProps: PropTypes.object.isRequired,
- kind: PropTypes.oneOf(kinds.all).isRequired,
- isFocused: PropTypes.bool.isRequired,
- canEdit: PropTypes.bool.isRequired,
- tagComponent: PropTypes.elementType.isRequired,
- onTagDelete: PropTypes.func.isRequired,
- onTagEdit: PropTypes.func.isRequired,
- onInputContainerPress: PropTypes.func.isRequired
-};
-
-TagInputInput.defaultProps = {
- className: styles.inputContainer
-};
-
-export default TagInputInput;
diff --git a/frontend/src/Components/Form/TagInputTag.js b/frontend/src/Components/Form/TagInputTag.js
deleted file mode 100644
index 05a780442..000000000
--- a/frontend/src/Components/Form/TagInputTag.js
+++ /dev/null
@@ -1,101 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import MiddleTruncate from 'react-middle-truncate';
-import Label from 'Components/Label';
-import IconButton from 'Components/Link/IconButton';
-import Link from 'Components/Link/Link';
-import { icons, kinds } from 'Helpers/Props';
-import tagShape from 'Helpers/Props/Shapes/tagShape';
-import styles from './TagInputTag.css';
-
-class TagInputTag extends Component {
-
- //
- // Listeners
-
- onDelete = () => {
- const {
- index,
- tag,
- onDelete
- } = this.props;
-
- onDelete({
- index,
- id: tag.id
- });
- };
-
- onEdit = () => {
- const {
- index,
- tag,
- onEdit
- } = this.props;
-
- onEdit({
- index,
- id: tag.id,
- value: tag.name
- });
- };
-
- //
- // Render
-
- render() {
- const {
- tag,
- kind,
- canEdit
- } = this.props;
-
- return (
-
-
-
- );
- }
-}
-
-TagInputTag.propTypes = {
- index: PropTypes.number.isRequired,
- tag: PropTypes.shape(tagShape),
- kind: PropTypes.oneOf(kinds.all).isRequired,
- canEdit: PropTypes.bool.isRequired,
- onDelete: PropTypes.func.isRequired,
- onEdit: PropTypes.func.isRequired
-};
-
-export default TagInputTag;
diff --git a/frontend/src/Components/Form/TagSelectInputConnector.js b/frontend/src/Components/Form/TagSelectInputConnector.js
deleted file mode 100644
index 23afe6da1..000000000
--- a/frontend/src/Components/Form/TagSelectInputConnector.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import _ from 'lodash';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import TagInput from './TagInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state, { value }) => value,
- (state, { values }) => values,
- (tags, tagList) => {
- const sortedTags = _.sortBy(tagList, 'value');
-
- return {
- tags: tags.reduce((acc, tag) => {
- const matchingTag = _.find(tagList, { key: tag });
-
- if (matchingTag) {
- acc.push({
- id: tag,
- name: matchingTag.value
- });
- }
-
- return acc;
- }, []),
-
- tagList: sortedTags.map(({ key: id, value: name }) => {
- return {
- id,
- name
- };
- }),
-
- allTags: sortedTags
- };
- }
- );
-}
-
-class TagSelectInputConnector extends Component {
-
- //
- // Listeners
-
- onTagAdd = (tag) => {
- const {
- name,
- value,
- allTags
- } = this.props;
-
- const existingTag =_.some(allTags, { key: tag.id });
-
- const newValue = value.slice();
-
- if (existingTag) {
- newValue.push(tag.id);
- }
-
- this.props.onChange({ name, value: newValue });
- };
-
- onTagDelete = ({ index }) => {
- const {
- name,
- value
- } = this.props;
-
- const newValue = value.slice();
- newValue.splice(index, 1);
-
- this.props.onChange({
- name,
- value: newValue
- });
- };
-
- //
- // Render
-
- render() {
- return (
-
- );
- }
-}
-
-TagSelectInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.arrayOf(PropTypes.number).isRequired,
- values: PropTypes.arrayOf(PropTypes.object).isRequired,
- allTags: PropTypes.arrayOf(PropTypes.object).isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps)(TagSelectInputConnector);
diff --git a/frontend/src/Components/Form/TextArea.js b/frontend/src/Components/Form/TextArea.js
deleted file mode 100644
index 44fd3a249..000000000
--- a/frontend/src/Components/Form/TextArea.js
+++ /dev/null
@@ -1,172 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import styles from './TextArea.css';
-
-class TextArea extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._input = null;
- this._selectionStart = null;
- this._selectionEnd = null;
- this._selectionTimeout = null;
- this._isMouseTarget = false;
- }
-
- componentDidMount() {
- window.addEventListener('mouseup', this.onDocumentMouseUp);
- }
-
- componentWillUnmount() {
- window.removeEventListener('mouseup', this.onDocumentMouseUp);
-
- if (this._selectionTimeout) {
- this._selectionTimeout = clearTimeout(this._selectionTimeout);
- }
- }
-
- //
- // Control
-
- setInputRef = (ref) => {
- this._input = ref;
- };
-
- selectionChange() {
- if (this._selectionTimeout) {
- this._selectionTimeout = clearTimeout(this._selectionTimeout);
- }
-
- this._selectionTimeout = setTimeout(() => {
- const selectionStart = this._input.selectionStart;
- const selectionEnd = this._input.selectionEnd;
-
- const selectionChanged = (
- this._selectionStart !== selectionStart ||
- this._selectionEnd !== selectionEnd
- );
-
- this._selectionStart = selectionStart;
- this._selectionEnd = selectionEnd;
-
- if (this.props.onSelectionChange && selectionChanged) {
- this.props.onSelectionChange(selectionStart, selectionEnd);
- }
- }, 10);
- }
-
- //
- // Listeners
-
- onChange = (event) => {
- const {
- name,
- onChange
- } = this.props;
-
- const payload = {
- name,
- value: event.target.value
- };
-
- onChange(payload);
- };
-
- onFocus = (event) => {
- if (this.props.onFocus) {
- this.props.onFocus(event);
- }
-
- this.selectionChange();
- };
-
- onKeyUp = () => {
- this.selectionChange();
- };
-
- onMouseDown = () => {
- this._isMouseTarget = true;
- };
-
- onMouseUp = () => {
- this.selectionChange();
- };
-
- onDocumentMouseUp = () => {
- if (this._isMouseTarget) {
- this.selectionChange();
- }
-
- this._isMouseTarget = false;
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- readOnly,
- autoFocus,
- placeholder,
- name,
- value,
- hasError,
- hasWarning,
- onBlur
- } = this.props;
-
- return (
-
- );
- }
-}
-
-TextArea.propTypes = {
- className: PropTypes.string.isRequired,
- readOnly: PropTypes.bool,
- autoFocus: PropTypes.bool,
- placeholder: PropTypes.string,
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- onChange: PropTypes.func.isRequired,
- onFocus: PropTypes.func,
- onBlur: PropTypes.func,
- onSelectionChange: PropTypes.func
-};
-
-TextArea.defaultProps = {
- className: styles.input,
- type: 'text',
- readOnly: false,
- autoFocus: false,
- value: ''
-};
-
-export default TextArea;
diff --git a/frontend/src/Components/Form/TextArea.tsx b/frontend/src/Components/Form/TextArea.tsx
new file mode 100644
index 000000000..f37d5cb5f
--- /dev/null
+++ b/frontend/src/Components/Form/TextArea.tsx
@@ -0,0 +1,143 @@
+import classNames from 'classnames';
+import React, {
+ ChangeEvent,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useRef,
+} from 'react';
+import { InputChanged } from 'typings/inputs';
+import styles from './TextArea.css';
+
+interface TextAreaProps {
+ className?: string;
+ readOnly?: boolean;
+ autoFocus?: boolean;
+ placeholder?: string;
+ name: string;
+ value?: string;
+ hasError?: boolean;
+ hasWarning?: boolean;
+ onChange: (change: InputChanged) => void;
+ onFocus?: (event: SyntheticEvent) => void;
+ onBlur?: (event: SyntheticEvent) => void;
+ onSelectionChange?: (start: number | null, end: number | null) => void;
+}
+
+function TextArea({
+ className = styles.input,
+ readOnly = false,
+ autoFocus = false,
+ placeholder,
+ name,
+ value = '',
+ hasError,
+ hasWarning,
+ onBlur,
+ onFocus,
+ onChange,
+ onSelectionChange,
+}: TextAreaProps) {
+ const inputRef = useRef(null);
+ const selectionTimeout = useRef>();
+ const selectionStart = useRef();
+ const selectionEnd = useRef();
+ const isMouseTarget = useRef(false);
+
+ const selectionChanged = useCallback(() => {
+ if (selectionTimeout.current) {
+ clearTimeout(selectionTimeout.current);
+ }
+
+ selectionTimeout.current = setTimeout(() => {
+ if (!inputRef.current) {
+ return;
+ }
+
+ const start = inputRef.current.selectionStart;
+ const end = inputRef.current.selectionEnd;
+
+ const selectionChanged =
+ selectionStart.current !== start || selectionEnd.current !== end;
+
+ selectionStart.current = start;
+ selectionEnd.current = end;
+
+ if (selectionChanged) {
+ onSelectionChange?.(start, end);
+ }
+ }, 10);
+ }, [onSelectionChange]);
+
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ onChange({
+ name,
+ value: event.target.value,
+ });
+ },
+ [name, onChange]
+ );
+
+ const handleFocus = useCallback(
+ (event: SyntheticEvent) => {
+ onFocus?.(event);
+
+ selectionChanged();
+ },
+ [selectionChanged, onFocus]
+ );
+
+ const handleKeyUp = useCallback(() => {
+ selectionChanged();
+ }, [selectionChanged]);
+
+ const handleMouseDown = useCallback(() => {
+ isMouseTarget.current = true;
+ }, []);
+
+ const handleMouseUp = useCallback(() => {
+ selectionChanged();
+ }, [selectionChanged]);
+
+ const handleDocumentMouseUp = useCallback(() => {
+ if (isMouseTarget.current) {
+ selectionChanged();
+ }
+
+ isMouseTarget.current = false;
+ }, [selectionChanged]);
+
+ useEffect(() => {
+ window.addEventListener('mouseup', handleDocumentMouseUp);
+
+ return () => {
+ window.removeEventListener('mouseup', handleDocumentMouseUp);
+ };
+ }, [handleDocumentMouseUp]);
+
+ return (
+
+ );
+}
+
+export default TextArea;
diff --git a/frontend/src/Components/Form/TextInput.js b/frontend/src/Components/Form/TextInput.js
deleted file mode 100644
index e018dd5a3..000000000
--- a/frontend/src/Components/Form/TextInput.js
+++ /dev/null
@@ -1,205 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import styles from './TextInput.css';
-
-class TextInput extends Component {
-
- //
- // Lifecycle
-
- constructor(props, context) {
- super(props, context);
-
- this._input = null;
- this._selectionStart = null;
- this._selectionEnd = null;
- this._selectionTimeout = null;
- this._isMouseTarget = false;
- }
-
- componentDidMount() {
- window.addEventListener('mouseup', this.onDocumentMouseUp);
- }
-
- componentWillUnmount() {
- window.removeEventListener('mouseup', this.onDocumentMouseUp);
-
- if (this._selectionTimeout) {
- this._selectionTimeout = clearTimeout(this._selectionTimeout);
- }
- }
-
- //
- // Control
-
- setInputRef = (ref) => {
- this._input = ref;
- };
-
- selectionChange() {
- if (this._selectionTimeout) {
- this._selectionTimeout = clearTimeout(this._selectionTimeout);
- }
-
- this._selectionTimeout = setTimeout(() => {
- const selectionStart = this._input.selectionStart;
- const selectionEnd = this._input.selectionEnd;
-
- const selectionChanged = (
- this._selectionStart !== selectionStart ||
- this._selectionEnd !== selectionEnd
- );
-
- this._selectionStart = selectionStart;
- this._selectionEnd = selectionEnd;
-
- if (this.props.onSelectionChange && selectionChanged) {
- this.props.onSelectionChange(selectionStart, selectionEnd);
- }
- }, 10);
- }
-
- //
- // Listeners
-
- onChange = (event) => {
- const {
- name,
- type,
- onChange
- } = this.props;
-
- const payload = {
- name,
- value: event.target.value
- };
-
- // Also return the files for a file input type.
-
- if (type === 'file') {
- payload.files = event.target.files;
- }
-
- onChange(payload);
- };
-
- onFocus = (event) => {
- if (this.props.onFocus) {
- this.props.onFocus(event);
- }
-
- this.selectionChange();
- };
-
- onKeyUp = () => {
- this.selectionChange();
- };
-
- onMouseDown = () => {
- this._isMouseTarget = true;
- };
-
- onMouseUp = () => {
- this.selectionChange();
- };
-
- onDocumentMouseUp = () => {
- if (this._isMouseTarget) {
- this.selectionChange();
- }
-
- this._isMouseTarget = false;
- };
-
- onWheel = () => {
- if (this.props.type === 'number') {
- this._input.blur();
- }
- };
-
- //
- // Render
-
- render() {
- const {
- className,
- type,
- readOnly,
- autoFocus,
- placeholder,
- name,
- value,
- hasError,
- hasWarning,
- hasButton,
- step,
- min,
- max,
- onBlur,
- onCopy
- } = this.props;
-
- return (
-
- );
- }
-}
-
-TextInput.propTypes = {
- className: PropTypes.string.isRequired,
- type: PropTypes.string.isRequired,
- readOnly: PropTypes.bool,
- autoFocus: PropTypes.bool,
- placeholder: PropTypes.string,
- name: PropTypes.string.isRequired,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- hasButton: PropTypes.bool,
- step: PropTypes.number,
- min: PropTypes.number,
- max: PropTypes.number,
- onChange: PropTypes.func.isRequired,
- onFocus: PropTypes.func,
- onBlur: PropTypes.func,
- onCopy: PropTypes.func,
- onSelectionChange: PropTypes.func
-};
-
-TextInput.defaultProps = {
- className: styles.input,
- type: 'text',
- readOnly: false,
- autoFocus: false,
- value: ''
-};
-
-export default TextInput;
diff --git a/frontend/src/Components/Form/TextInput.tsx b/frontend/src/Components/Form/TextInput.tsx
new file mode 100644
index 000000000..647b9f2ac
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.tsx
@@ -0,0 +1,178 @@
+import classNames from 'classnames';
+import React, {
+ ChangeEvent,
+ FocusEvent,
+ SyntheticEvent,
+ useCallback,
+ useEffect,
+ useRef,
+} from 'react';
+import { InputType } from 'Helpers/Props/inputTypes';
+import { FileInputChanged, InputChanged } from 'typings/inputs';
+import styles from './TextInput.css';
+
+export interface TextInputProps {
+ className?: string;
+ type?: InputType;
+ readOnly?: boolean;
+ autoFocus?: boolean;
+ placeholder?: string;
+ name: string;
+ value: string | number | string[];
+ hasError?: boolean;
+ hasWarning?: boolean;
+ hasButton?: boolean;
+ step?: number;
+ min?: number;
+ max?: number;
+ onChange: (change: InputChanged | FileInputChanged) => void;
+ onFocus?: (event: FocusEvent) => void;
+ onBlur?: (event: SyntheticEvent) => void;
+ onCopy?: (event: SyntheticEvent) => void;
+ onSelectionChange?: (start: number | null, end: number | null) => void;
+}
+
+function TextInput({
+ className = styles.input,
+ type = 'text',
+ readOnly = false,
+ autoFocus = false,
+ placeholder,
+ name,
+ value = '',
+ hasError,
+ hasWarning,
+ hasButton,
+ step,
+ min,
+ max,
+ onBlur,
+ onFocus,
+ onCopy,
+ onChange,
+ onSelectionChange,
+}: TextInputProps) {
+ const inputRef = useRef(null);
+ const selectionTimeout = useRef>();
+ const selectionStart = useRef();
+ const selectionEnd = useRef();
+ const isMouseTarget = useRef(false);
+
+ const selectionChanged = useCallback(() => {
+ if (selectionTimeout.current) {
+ clearTimeout(selectionTimeout.current);
+ }
+
+ selectionTimeout.current = setTimeout(() => {
+ if (!inputRef.current) {
+ return;
+ }
+
+ const start = inputRef.current.selectionStart;
+ const end = inputRef.current.selectionEnd;
+
+ const selectionChanged =
+ selectionStart.current !== start || selectionEnd.current !== end;
+
+ selectionStart.current = start;
+ selectionEnd.current = end;
+
+ if (selectionChanged) {
+ onSelectionChange?.(start, end);
+ }
+ }, 10);
+ }, [onSelectionChange]);
+
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ onChange({
+ name,
+ value: event.target.value,
+ files: type === 'file' ? event.target.files : undefined,
+ });
+ },
+ [name, type, onChange]
+ );
+
+ const handleFocus = useCallback(
+ (event: FocusEvent) => {
+ onFocus?.(event);
+
+ selectionChanged();
+ },
+ [selectionChanged, onFocus]
+ );
+
+ const handleKeyUp = useCallback(() => {
+ selectionChanged();
+ }, [selectionChanged]);
+
+ const handleMouseDown = useCallback(() => {
+ isMouseTarget.current = true;
+ }, []);
+
+ const handleMouseUp = useCallback(() => {
+ selectionChanged();
+ }, [selectionChanged]);
+
+ const handleWheel = useCallback(() => {
+ if (type === 'number') {
+ inputRef.current?.blur();
+ }
+ }, [type]);
+
+ const handleDocumentMouseUp = useCallback(() => {
+ if (isMouseTarget.current) {
+ selectionChanged();
+ }
+
+ isMouseTarget.current = false;
+ }, [selectionChanged]);
+
+ useEffect(() => {
+ window.addEventListener('mouseup', handleDocumentMouseUp);
+
+ return () => {
+ window.removeEventListener('mouseup', handleDocumentMouseUp);
+ };
+ }, [handleDocumentMouseUp]);
+
+ useEffect(() => {
+ return () => {
+ clearTimeout(selectionTimeout.current);
+ };
+ }, []);
+
+ return (
+
+ );
+}
+
+export default TextInput;
diff --git a/frontend/src/Components/Form/TextTagInputConnector.js b/frontend/src/Components/Form/TextTagInputConnector.js
deleted file mode 100644
index aef065cfa..000000000
--- a/frontend/src/Components/Form/TextTagInputConnector.js
+++ /dev/null
@@ -1,110 +0,0 @@
-
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import split from 'Utilities/String/split';
-import TagInput from './TagInput';
-
-function createMapStateToProps() {
- return createSelector(
- (state, { value }) => value,
- (tags) => {
- const tagsArray = Array.isArray(tags) ? tags : split(tags);
-
- return {
- tags: tagsArray.reduce((result, tag) => {
- if (tag) {
- result.push({
- id: tag,
- name: tag
- });
- }
-
- return result;
- }, []),
- valueArray: tagsArray
- };
- }
- );
-}
-
-class TextTagInputConnector extends Component {
-
- //
- // Listeners
-
- onTagAdd = (tag) => {
- const {
- name,
- valueArray,
- onChange
- } = this.props;
-
- // Split and trim tags before adding them to the list, this will
- // cleanse tags pasted in that had commas and spaces which leads
- // to oddities with restrictions (as an example).
-
- const newValue = [...valueArray];
- const newTags = tag.name.startsWith('/') ? [tag.name] : split(tag.name);
-
- newTags.forEach((newTag) => {
- newValue.push(newTag.trim());
- });
-
- onChange({ name, value: newValue });
- };
-
- onTagDelete = ({ index }) => {
- const {
- name,
- valueArray,
- onChange
- } = this.props;
-
- const newValue = [...valueArray];
- newValue.splice(index, 1);
-
- onChange({
- name,
- value: newValue
- });
- };
-
- 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() {
- return (
-
- );
- }
-}
-
-TextTagInputConnector.propTypes = {
- name: PropTypes.string.isRequired,
- valueArray: PropTypes.arrayOf(PropTypes.string).isRequired,
- onChange: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, null)(TextTagInputConnector);
diff --git a/frontend/src/Components/Form/UMaskInput.js b/frontend/src/Components/Form/UMaskInput.js
deleted file mode 100644
index 544865197..000000000
--- a/frontend/src/Components/Form/UMaskInput.js
+++ /dev/null
@@ -1,144 +0,0 @@
-/* eslint-disable no-bitwise */
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import translate from 'Utilities/String/translate';
-import EnhancedSelectInput from './EnhancedSelectInput';
-import styles from './UMaskInput.css';
-
-const umaskOptions = [
- {
- key: '755',
- get value() {
- return translate('Umask755Description', { octal: '755' });
- },
- hint: 'drwxr-xr-x'
- },
- {
- key: '775',
- get value() {
- return translate('Umask775Description', { octal: '775' });
- },
- hint: 'drwxrwxr-x'
- },
- {
- key: '770',
- get value() {
- return translate('Umask770Description', { octal: '770' });
- },
- hint: 'drwxrwx---'
- },
- {
- key: '750',
- get value() {
- return translate('Umask750Description', { octal: '750' });
- },
- hint: 'drwxr-x---'
- },
- {
- key: '777',
- get value() {
- return translate('Umask777Description', { octal: '777' });
- },
- hint: 'drwxrwxrwx'
- }
-];
-
-function formatPermissions(permissions) {
-
- const hasSticky = permissions & 0o1000;
- const hasSetGID = permissions & 0o2000;
- const hasSetUID = permissions & 0o4000;
-
- let result = '';
-
- for (let i = 0; i < 9; i++) {
- const bit = (permissions & (1 << i)) !== 0;
- let digit = bit ? 'xwr'[i % 3] : '-';
- if (i === 6 && hasSetUID) {
- digit = bit ? 's' : 'S';
- } else if (i === 3 && hasSetGID) {
- digit = bit ? 's' : 'S';
- } else if (i === 0 && hasSticky) {
- digit = bit ? 't' : 'T';
- }
- result = digit + result;
- }
-
- return result;
-}
-
-class UMaskInput extends Component {
-
- //
- // Render
-
- render() {
- const {
- name,
- value,
- onChange
- } = this.props;
-
- const valueNum = parseInt(value, 8);
- const umaskNum = 0o777 & ~valueNum;
- const umask = umaskNum.toString(8).padStart(4, '0');
- const folderNum = 0o777 & ~umaskNum;
- const folder = folderNum.toString(8).padStart(3, '0');
- const fileNum = 0o666 & ~umaskNum;
- const file = fileNum.toString(8).padStart(3, '0');
-
- const unit = formatPermissions(folderNum);
-
- const values = umaskOptions.map((v) => {
- return { ...v, hint: {v.hint} };
- });
-
- return (
-
-
-
-
-
-
{umask}
-
-
-
-
{folder}
-
d{formatPermissions(folderNum)}
-
-
-
-
{file}
-
{formatPermissions(fileNum)}
-
-
-
- );
- }
-}
-
-UMaskInput.propTypes = {
- name: PropTypes.string.isRequired,
- value: PropTypes.string.isRequired,
- hasError: PropTypes.bool,
- hasWarning: PropTypes.bool,
- onChange: PropTypes.func.isRequired,
- onFocus: PropTypes.func,
- onBlur: PropTypes.func
-};
-
-export default UMaskInput;
diff --git a/frontend/src/Components/HeartRating.js b/frontend/src/Components/HeartRating.js
deleted file mode 100644
index fe53a4e5f..000000000
--- a/frontend/src/Components/HeartRating.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Icon from 'Components/Icon';
-import { icons } from 'Helpers/Props';
-import styles from './HeartRating.css';
-
-function HeartRating({ rating, iconSize }) {
- return (
-
-
-
- {rating * 10}%
-
- );
-}
-
-HeartRating.propTypes = {
- rating: PropTypes.number.isRequired,
- iconSize: PropTypes.number.isRequired
-};
-
-HeartRating.defaultProps = {
- iconSize: 14
-};
-
-export default HeartRating;
diff --git a/frontend/src/Components/HeartRating.tsx b/frontend/src/Components/HeartRating.tsx
new file mode 100644
index 000000000..774cb4239
--- /dev/null
+++ b/frontend/src/Components/HeartRating.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import Icon, { IconProps } from 'Components/Icon';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './HeartRating.css';
+
+interface HeartRatingProps {
+ rating: number;
+ votes?: number;
+ iconSize?: IconProps['size'];
+}
+
+function HeartRating({ rating, votes = 0, iconSize = 14 }: HeartRatingProps) {
+ return (
+
+
+ {rating * 10}%
+
+ }
+ tooltip={translate('CountVotes', { votes })}
+ kind={kinds.INVERSE}
+ position={tooltipPositions.TOP}
+ />
+ );
+}
+
+export default HeartRating;
diff --git a/frontend/src/Components/Icon.js b/frontend/src/Components/Icon.js
deleted file mode 100644
index d200b8c08..000000000
--- a/frontend/src/Components/Icon.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React, { PureComponent } from 'react';
-import { kinds } from 'Helpers/Props';
-import styles from './Icon.css';
-
-class Icon extends PureComponent {
-
- //
- // Render
-
- render() {
- const {
- containerClassName,
- className,
- name,
- kind,
- size,
- title,
- isSpinning,
- ...otherProps
- } = this.props;
-
- const icon = (
-
- );
-
- if (title) {
- return (
-
- {icon}
-
- );
- }
-
- return icon;
- }
-}
-
-Icon.propTypes = {
- containerClassName: PropTypes.string,
- className: PropTypes.string,
- name: PropTypes.object.isRequired,
- kind: PropTypes.string.isRequired,
- size: PropTypes.number.isRequired,
- title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
- isSpinning: PropTypes.bool.isRequired,
- fixedWidth: PropTypes.bool.isRequired
-};
-
-Icon.defaultProps = {
- kind: kinds.DEFAULT,
- size: 14,
- isSpinning: false,
- fixedWidth: false
-};
-
-export default Icon;
diff --git a/frontend/src/Components/Icon.tsx b/frontend/src/Components/Icon.tsx
new file mode 100644
index 000000000..a04463b51
--- /dev/null
+++ b/frontend/src/Components/Icon.tsx
@@ -0,0 +1,60 @@
+import {
+ FontAwesomeIcon,
+ FontAwesomeIconProps,
+} from '@fortawesome/react-fontawesome';
+import classNames from 'classnames';
+import React, { ComponentProps } from 'react';
+import { kinds } from 'Helpers/Props';
+import { Kind } from 'Helpers/Props/kinds';
+import styles from './Icon.css';
+
+export interface IconProps
+ extends Omit<
+ FontAwesomeIconProps,
+ 'icon' | 'spin' | 'name' | 'title' | 'size'
+ > {
+ containerClassName?: ComponentProps<'span'>['className'];
+ name: FontAwesomeIconProps['icon'];
+ kind?: Extract;
+ size?: number;
+ isSpinning?: FontAwesomeIconProps['spin'];
+ title?: string | (() => string) | null;
+}
+
+export default function Icon({
+ containerClassName,
+ className,
+ name,
+ kind = kinds.DEFAULT,
+ size = 14,
+ title,
+ isSpinning = false,
+ fixedWidth = false,
+ ...otherProps
+}: IconProps) {
+ const icon = (
+
+ );
+
+ if (title) {
+ return (
+
+ {icon}
+
+ );
+ }
+
+ return icon;
+}
diff --git a/frontend/src/Components/Label.css b/frontend/src/Components/Label.css
index f3ff83993..c7512987a 100644
--- a/frontend/src/Components/Label.css
+++ b/frontend/src/Components/Label.css
@@ -88,6 +88,15 @@
}
}
+.purple {
+ border-color: var(--purple);
+ background-color: var(--purple);
+
+ &.outline {
+ color: var(--purple);
+ }
+}
+
/** Sizes **/
.small {
diff --git a/frontend/src/Components/Label.css.d.ts b/frontend/src/Components/Label.css.d.ts
index 1a0b4d9e0..778ba6faf 100644
--- a/frontend/src/Components/Label.css.d.ts
+++ b/frontend/src/Components/Label.css.d.ts
@@ -11,6 +11,7 @@ interface CssExports {
'medium': string;
'outline': string;
'primary': string;
+ 'purple': string;
'small': string;
'success': string;
'warning': string;
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..610350a8d
--- /dev/null
+++ b/frontend/src/Components/Link/Button.tsx
@@ -0,0 +1,35 @@
+import classNames from 'classnames';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import { Align } from 'Helpers/Props/align';
+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;
+ 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/IconButton.js b/frontend/src/Components/Link/IconButton.js
deleted file mode 100644
index fffbe13e0..000000000
--- a/frontend/src/Components/Link/IconButton.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import Icon from 'Components/Icon';
-import translate from 'Utilities/String/translate';
-import Link from './Link';
-import styles from './IconButton.css';
-
-function IconButton(props) {
- const {
- className,
- iconClassName,
- name,
- kind,
- size,
- isSpinning,
- isDisabled,
- ...otherProps
- } = props;
-
- return (
-
-
-
- );
-}
-
-IconButton.propTypes = {
- ...Link.propTypes,
- className: PropTypes.string.isRequired,
- iconClassName: PropTypes.string,
- kind: PropTypes.string,
- name: PropTypes.object.isRequired,
- size: PropTypes.number,
- title: PropTypes.string,
- isSpinning: PropTypes.bool,
- isDisabled: PropTypes.bool
-};
-
-IconButton.defaultProps = {
- className: styles.button,
- size: 12
-};
-
-export default IconButton;
diff --git a/frontend/src/Components/Link/IconButton.tsx b/frontend/src/Components/Link/IconButton.tsx
new file mode 100644
index 000000000..b6951c00c
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.tsx
@@ -0,0 +1,41 @@
+import classNames from 'classnames';
+import React from 'react';
+import Icon, { IconProps } from 'Components/Icon';
+import translate from 'Utilities/String/translate';
+import Link, { LinkProps } from './Link';
+import styles from './IconButton.css';
+
+export interface IconButtonProps
+ extends Omit,
+ Pick {
+ iconClassName?: IconProps['className'];
+}
+
+export default function IconButton({
+ className = styles.button,
+ iconClassName,
+ name,
+ kind,
+ size = 12,
+ isSpinning,
+ ...otherProps
+}: IconButtonProps) {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/Components/Link/Link.tsx b/frontend/src/Components/Link/Link.tsx
index 5015a1fe3..80ee66e82 100644
--- a/frontend/src/Components/Link/Link.tsx
+++ b/frontend/src/Components/Link/Link.tsx
@@ -1,96 +1,93 @@
import classNames from 'classnames';
import React, {
- ComponentClass,
- FunctionComponent,
+ ComponentPropsWithoutRef,
+ ElementType,
SyntheticEvent,
useCallback,
} from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
-interface ReactRouterLinkProps {
- to?: string;
-}
+export type LinkProps =
+ ComponentPropsWithoutRef & {
+ component?: C;
+ to?: string;
+ target?: string;
+ isDisabled?: LinkProps['disabled'];
+ noRouter?: boolean;
+ onPress?(event: SyntheticEvent): void;
+ };
-export interface LinkProps extends React.HTMLProps {
- className?: string;
- component?:
- | string
- | FunctionComponent
- | ComponentClass;
- 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;
+export default function Link