{
@@ -196,7 +192,7 @@ class FilterBuilderModalContent extends Component {
- {translate('Save')}
+ Save
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
index 0b00c0f03..01c24b460 100644
--- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
+++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js
@@ -3,7 +3,6 @@ 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';
@@ -12,9 +11,7 @@ import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValu
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
-import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
-import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue';
-import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
+import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue';
@@ -79,13 +76,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return QualityFilterBuilderRowValueConnector;
case filterBuilderValueTypes.QUALITY_PROFILE:
- return QualityProfileFilterBuilderRowValue;
-
- case filterBuilderValueTypes.QUEUE_STATUS:
- return QueueStatusFilterBuilderRowValue;
-
- case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
- return SeasonsMonitoredStatusFilterBuilderRowValue;
+ return QualityProfileFilterBuilderRowValueConnector;
case filterBuilderValueTypes.SERIES:
return SeriesFilterBuilderRowValue;
@@ -233,7 +224,7 @@ class FilterBuilderRow extends Component {
key: name,
value: typeof label === 'function' ? label() : label
};
- }).sort(sortByProp('value'));
+ }).sort((a, b) => a.value.localeCompare(b.value));
const ValueComponent = getRowValueConnector(selectedFilterBuilderProp);
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js
index 217626c90..68fa5c557 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/Tag/TagInput';
+import TagInput from 'Components/Form/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 d1419327a..a7aed80b6 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 sortByProp from 'Utilities/Array/sortByProp';
+import sortByName from 'Utilities/Array/sortByName';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createTagListSelector() {
@@ -38,7 +38,7 @@ function createTagListSelector() {
}
return acc;
- }, []).sort(sortByProp('name'));
+ }, []).sort(sortByName);
}
return _.uniqBy(items, 'id');
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js
index 063a97346..7b6d6313a 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/Tag/TagInputTag';
+import TagInputTag from 'Components/Form/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
deleted file mode 100644
index 50036cb90..000000000
--- a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-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
new file mode 100644
index 000000000..4a8b82283
--- /dev/null
+++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js
@@ -0,0 +1,28 @@
+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
deleted file mode 100644
index 1127493a5..000000000
--- a/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-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
deleted file mode 100644
index b84260e3c..000000000
--- a/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js
+++ /dev/null
@@ -1,35 +0,0 @@
-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 88b34509a..2eae79c80 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 sortByProp from 'Utilities/Array/sortByProp';
+import sortByName from 'Utilities/Array/sortByName';
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(sortByProp('name'));
+ .sort(sortByName);
return
;
}
diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js
index e017f72e7..3464300f1 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 statusTagList = [
+const seriesStatusList = [
{
id: 'continuing',
get name() {
@@ -32,7 +32,7 @@ const statusTagList = [
function SeriesStatusFilterBuilderRowValue(props) {
return (
);
diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js
index 9f378d5a2..7407f729a 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 deletion was successful.
- // Moving this check to an ancestor would be more accurate, but would have
+ // Assume that delete and then unmounting means the delete was successful.
+ // Moving this check to a 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 99cb6ec5c..28eb91599 100644
--- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js
+++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js
@@ -5,7 +5,6 @@ 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';
@@ -32,7 +31,7 @@ function CustomFiltersModalContent(props) {
{
customFilters
- .sort((a, b) => sortByProp(a, b, 'label'))
+ .sort((a, b) => a.label.localeCompare(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
deleted file mode 100644
index 7ba114125..000000000
--- a/frontend/src/Components/Form/AutoCompleteInput.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-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
new file mode 100644
index 000000000..34ec7530b
--- /dev/null
+++ b/frontend/src/Components/Form/AutoSuggestInput.js
@@ -0,0 +1,257 @@
+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
deleted file mode 100644
index b3a7c31b0..000000000
--- a/frontend/src/Components/Form/AutoSuggestInput.tsx
+++ /dev/null
@@ -1,259 +0,0 @@
-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
new file mode 100644
index 000000000..b422198b5
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.js
@@ -0,0 +1,84 @@
+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
deleted file mode 100644
index d5a3f11f7..000000000
--- a/frontend/src/Components/Form/CaptchaInput.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-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
new file mode 100644
index 000000000..ad83bf02f
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInputConnector.js
@@ -0,0 +1,98 @@
+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
new file mode 100644
index 000000000..26d915880
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.js
@@ -0,0 +1,191 @@
+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
deleted file mode 100644
index b7080cfdd..000000000
--- a/frontend/src/Components/Form/CheckInput.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-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/Tag/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css
similarity index 64%
rename from frontend/src/Components/Form/Tag/DeviceInput.css
rename to frontend/src/Components/Form/DeviceInput.css
index 189cafc6b..7abe83db5 100644
--- a/frontend/src/Components/Form/Tag/DeviceInput.css
+++ b/frontend/src/Components/Form/DeviceInput.css
@@ -3,6 +3,6 @@
}
.input {
- composes: input from '~Components/Form/Tag/TagInput.css';
+ composes: input from '~./TagInput.css';
composes: hasButton from '~Components/Form/Input.css';
}
diff --git a/frontend/src/Components/Form/Tag/DeviceInput.css.d.ts b/frontend/src/Components/Form/DeviceInput.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Tag/DeviceInput.css.d.ts
rename to frontend/src/Components/Form/DeviceInput.css.d.ts
diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js
new file mode 100644
index 000000000..55c239cb8
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInput.js
@@ -0,0 +1,106 @@
+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
new file mode 100644
index 000000000..2af9a79f6
--- /dev/null
+++ b/frontend/src/Components/Form/DeviceInputConnector.js
@@ -0,0 +1,104 @@
+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
new file mode 100644
index 000000000..f0ebf534b
--- /dev/null
+++ b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js
@@ -0,0 +1,101 @@
+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/Select/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css
similarity index 92%
rename from frontend/src/Components/Form/Select/EnhancedSelectInput.css
rename to frontend/src/Components/Form/EnhancedSelectInput.css
index 735d63573..56f5564b9 100644
--- a/frontend/src/Components/Form/Select/EnhancedSelectInput.css
+++ b/frontend/src/Components/Form/EnhancedSelectInput.css
@@ -19,7 +19,7 @@
.isDisabled {
opacity: 0.7;
- cursor: not-allowed !important;
+ cursor: not-allowed;
}
.dropdownArrowContainer {
@@ -73,12 +73,6 @@
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/Select/EnhancedSelectInput.css.d.ts b/frontend/src/Components/Form/EnhancedSelectInput.css.d.ts
similarity index 94%
rename from frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts
rename to frontend/src/Components/Form/EnhancedSelectInput.css.d.ts
index 98167a9b5..edcf0079b 100644
--- a/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts
+++ b/frontend/src/Components/Form/EnhancedSelectInput.css.d.ts
@@ -14,7 +14,6 @@ interface CssExports {
'mobileCloseButtonContainer': string;
'options': string;
'optionsContainer': string;
- 'optionsInnerModalBody': string;
'optionsModal': string;
'optionsModalBody': string;
'optionsModalScroller': string;
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js
new file mode 100644
index 000000000..cc4215025
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.js
@@ -0,0 +1,608 @@
+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
new file mode 100644
index 000000000..f2af4a585
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputConnector.js
@@ -0,0 +1,159 @@
+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/Select/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css
similarity index 87%
rename from frontend/src/Components/Form/Select/EnhancedSelectInputOption.css
rename to frontend/src/Components/Form/EnhancedSelectInputOption.css
index bfdaa9036..d7f0e861b 100644
--- a/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css
@@ -16,13 +16,13 @@
}
.optionCheck {
- composes: container from '~Components/Form/CheckInput.css';
+ composes: container from '~./CheckInput.css';
flex: 0 0 0;
}
.optionCheckInput {
- composes: input from '~Components/Form/CheckInput.css';
+ composes: input from '~./CheckInput.css';
margin-top: 0;
}
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts b/frontend/src/Components/Form/EnhancedSelectInputOption.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts
rename to frontend/src/Components/Form/EnhancedSelectInputOption.css.d.ts
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js
new file mode 100644
index 000000000..b2783dbaa
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js
@@ -0,0 +1,113 @@
+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/Select/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
similarity index 100%
rename from frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css
rename to frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts
rename to frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css.d.ts
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
new file mode 100644
index 000000000..21ddebb02
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
@@ -0,0 +1,35 @@
+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
new file mode 100644
index 000000000..79ad3fe8a
--- /dev/null
+++ b/frontend/src/Components/Form/Form.js
@@ -0,0 +1,66 @@
+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
deleted file mode 100644
index d522019e7..000000000
--- a/frontend/src/Components/Form/Form.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-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
new file mode 100644
index 000000000..f538daa2f
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.js
@@ -0,0 +1,56 @@
+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
deleted file mode 100644
index 1dd879897..000000000
--- a/frontend/src/Components/Form/FormGroup.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-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
new file mode 100644
index 000000000..a7145363a
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.js
@@ -0,0 +1,54 @@
+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
deleted file mode 100644
index e5149ab14..000000000
--- a/frontend/src/Components/Form/FormInputButton.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-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
new file mode 100644
index 000000000..49f08c90b
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.js
@@ -0,0 +1,297 @@
+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
deleted file mode 100644
index 98c6e586a..000000000
--- a/frontend/src/Components/Form/FormInputGroup.tsx
+++ /dev/null
@@ -1,303 +0,0 @@
-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
new file mode 100644
index 000000000..00024684e
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.js
@@ -0,0 +1,74 @@
+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
deleted file mode 100644
index 1531d9585..000000000
--- a/frontend/src/Components/Form/FormInputHelpText.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-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
new file mode 100644
index 000000000..d4a4bcffc
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.js
@@ -0,0 +1,52 @@
+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
deleted file mode 100644
index 4f29e6ac6..000000000
--- a/frontend/src/Components/Form/FormLabel.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-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/Select/HintedSelectInputOption.css b/frontend/src/Components/Form/HintedSelectInputOption.css
similarity index 100%
rename from frontend/src/Components/Form/Select/HintedSelectInputOption.css
rename to frontend/src/Components/Form/HintedSelectInputOption.css
diff --git a/frontend/src/Components/Form/Select/HintedSelectInputOption.css.d.ts b/frontend/src/Components/Form/HintedSelectInputOption.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Select/HintedSelectInputOption.css.d.ts
rename to frontend/src/Components/Form/HintedSelectInputOption.css.d.ts
diff --git a/frontend/src/Components/Form/HintedSelectInputOption.js b/frontend/src/Components/Form/HintedSelectInputOption.js
new file mode 100644
index 000000000..4957ece2a
--- /dev/null
+++ b/frontend/src/Components/Form/HintedSelectInputOption.js
@@ -0,0 +1,66 @@
+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/Select/HintedSelectInputSelectedValue.css b/frontend/src/Components/Form/HintedSelectInputSelectedValue.css
similarity index 100%
rename from frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css
rename to frontend/src/Components/Form/HintedSelectInputSelectedValue.css
diff --git a/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/HintedSelectInputSelectedValue.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css.d.ts
rename to frontend/src/Components/Form/HintedSelectInputSelectedValue.css.d.ts
diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
new file mode 100644
index 000000000..a3fecf324
--- /dev/null
+++ b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js
@@ -0,0 +1,68 @@
+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
new file mode 100644
index 000000000..91c31198f
--- /dev/null
+++ b/frontend/src/Components/Form/IndexerSelectInputConnector.js
@@ -0,0 +1,97 @@
+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
new file mode 100644
index 000000000..3e73d74f3
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInput.js
@@ -0,0 +1,156 @@
+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
deleted file mode 100644
index f5c6ac19b..000000000
--- a/frontend/src/Components/Form/KeyValueListInput.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-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 ed82db459..75d37b74f 100644
--- a/frontend/src/Components/Form/KeyValueListInputItem.css
+++ b/frontend/src/Components/Form/KeyValueListInputItem.css
@@ -5,12 +5,11 @@
&:last-child {
margin-bottom: 0;
- border-bottom: 0;
}
}
.keyInputWrapper {
- flex: 1 0 0;
+ flex: 6 0 0;
}
.valueInputWrapper {
@@ -26,10 +25,4 @@
.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
new file mode 100644
index 000000000..9f5abce2f
--- /dev/null
+++ b/frontend/src/Components/Form/KeyValueListInputItem.js
@@ -0,0 +1,124 @@
+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
deleted file mode 100644
index c63ad50a9..000000000
--- a/frontend/src/Components/Form/KeyValueListInputItem.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-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
new file mode 100644
index 000000000..dd3a52017
--- /dev/null
+++ b/frontend/src/Components/Form/LanguageSelectInputConnector.js
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 000000000..9b80cc587
--- /dev/null
+++ b/frontend/src/Components/Form/MonitorEpisodesSelectInput.js
@@ -0,0 +1,55 @@
+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
new file mode 100644
index 000000000..c704e5c1f
--- /dev/null
+++ b/frontend/src/Components/Form/MonitorNewItemsSelectInput.js
@@ -0,0 +1,50 @@
+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
new file mode 100644
index 000000000..cac274d95
--- /dev/null
+++ b/frontend/src/Components/Form/NumberInput.js
@@ -0,0 +1,126 @@
+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
deleted file mode 100644
index a5e1fcb64..000000000
--- a/frontend/src/Components/Form/NumberInput.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-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
new file mode 100644
index 000000000..4ecd625bc
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInput.js
@@ -0,0 +1,39 @@
+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
deleted file mode 100644
index 04d2a0caf..000000000
--- a/frontend/src/Components/Form/OAuthInput.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-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
new file mode 100644
index 000000000..1567c7e6c
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInputConnector.js
@@ -0,0 +1,89 @@
+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
new file mode 100644
index 000000000..6cb162784
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.css
@@ -0,0 +1,5 @@
+.input {
+ composes: input from '~Components/Form/TextInput.css';
+
+ font-family: $passwordFamily;
+}
diff --git a/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.css.d.ts b/frontend/src/Components/Form/PasswordInput.css.d.ts
similarity index 88%
rename from frontend/src/InteractiveImport/Folder/FavoriteFolderRow.css.d.ts
rename to frontend/src/Components/Form/PasswordInput.css.d.ts
index d8ea83dc1..774807ef4 100644
--- a/frontend/src/InteractiveImport/Folder/FavoriteFolderRow.css.d.ts
+++ b/frontend/src/Components/Form/PasswordInput.css.d.ts
@@ -1,7 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
- 'actions': string;
+ 'input': string;
}
export const cssExports: CssExports;
export default cssExports;
diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js
new file mode 100644
index 000000000..fef54fd5a
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.js
@@ -0,0 +1,29 @@
+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
deleted file mode 100644
index 776c2b913..000000000
--- a/frontend/src/Components/Form/PasswordInput.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-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 327a85ef8..3b32b16f0 100644
--- a/frontend/src/Components/Form/PathInput.css
+++ b/frontend/src/Components/Form/PathInput.css
@@ -16,7 +16,3 @@
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 82be3d1ff..d44c3dd56 100644
--- a/frontend/src/Components/Form/PathInput.css.d.ts
+++ b/frontend/src/Components/Form/PathInput.css.d.ts
@@ -2,7 +2,6 @@
// 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
new file mode 100644
index 000000000..972d8f99f
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.js
@@ -0,0 +1,195 @@
+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
deleted file mode 100644
index 0caf66905..000000000
--- a/frontend/src/Components/Form/PathInput.tsx
+++ /dev/null
@@ -1,258 +0,0 @@
-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
new file mode 100644
index 000000000..563437f9a
--- /dev/null
+++ b/frontend/src/Components/Form/PathInputConnector.js
@@ -0,0 +1,81 @@
+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 f081f5906..a184aa1ec 100644
--- a/frontend/src/Components/Form/ProviderFieldFormGroup.js
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -14,8 +14,6 @@ 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':
@@ -29,8 +27,6 @@ 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':
@@ -138,8 +134,6 @@ 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
new file mode 100644
index 000000000..cc8ffbdb8
--- /dev/null
+++ b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
@@ -0,0 +1,105 @@
+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
new file mode 100644
index 000000000..1d76ad946
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInput.js
@@ -0,0 +1,109 @@
+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
new file mode 100644
index 000000000..43581835f
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
@@ -0,0 +1,175 @@
+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/Select/RootFolderSelectInputOption.css b/frontend/src/Components/Form/RootFolderSelectInputOption.css
similarity index 100%
rename from frontend/src/Components/Form/Select/RootFolderSelectInputOption.css
rename to frontend/src/Components/Form/RootFolderSelectInputOption.css
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputOption.css.d.ts b/frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Select/RootFolderSelectInputOption.css.d.ts
rename to frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js
new file mode 100644
index 000000000..daac82f34
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputOption.js
@@ -0,0 +1,77 @@
+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/Select/RootFolderSelectInputSelectedValue.css b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
similarity index 100%
rename from frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css
rename to frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css.d.ts
rename to frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
new file mode 100644
index 000000000..1c3a4fc9d
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
@@ -0,0 +1,62 @@
+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
deleted file mode 100644
index 4ed3e0952..000000000
--- a/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-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/Select/EnhancedSelectInput.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx
deleted file mode 100644
index 5ae175357..000000000
--- a/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx
+++ /dev/null
@@ -1,627 +0,0 @@
-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/Select/EnhancedSelectInputOption.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx
deleted file mode 100644
index c866a5060..000000000
--- a/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-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/Select/EnhancedSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx
deleted file mode 100644
index 88afdb18a..000000000
--- a/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-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/Select/HintedSelectInputOption.tsx b/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx
deleted file mode 100644
index faa9081c5..000000000
--- a/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-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/Select/HintedSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx
deleted file mode 100644
index 7c4cba115..000000000
--- a/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-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
deleted file mode 100644
index a43044156..000000000
--- a/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-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
deleted file mode 100644
index 4bb4ff787..000000000
--- a/frontend/src/Components/Form/Select/IndexerSelectInput.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-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
deleted file mode 100644
index 3c9bbc150..000000000
--- a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-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
deleted file mode 100644
index 59fd08513..000000000
--- a/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-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
deleted file mode 100644
index ac11f1fca..000000000
--- a/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-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
deleted file mode 100644
index e4a8003eb..000000000
--- a/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-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
deleted file mode 100644
index 036f0f82c..000000000
--- a/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-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
deleted file mode 100644
index 8b278ded7..000000000
--- a/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx
+++ /dev/null
@@ -1,222 +0,0 @@
-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/Select/RootFolderSelectInputOption.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx
deleted file mode 100644
index d71f0d638..000000000
--- a/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-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/Select/RootFolderSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx
deleted file mode 100644
index e06101f2a..000000000
--- a/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-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/Select/SeriesTypeSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx
deleted file mode 100644
index b6470f1a4..000000000
--- a/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-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/Select/UMaskInput.tsx b/frontend/src/Components/Form/Select/UMaskInput.tsx
deleted file mode 100644
index 1f537f968..000000000
--- a/frontend/src/Components/Form/Select/UMaskInput.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-/* 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
new file mode 100644
index 000000000..553501afc
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.js
@@ -0,0 +1,95 @@
+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
deleted file mode 100644
index 4716c2dfd..000000000
--- a/frontend/src/Components/Form/SelectInput.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-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/Select/SeriesTypeSelectInput.tsx b/frontend/src/Components/Form/SeriesTypeSelectInput.tsx
similarity index 60%
rename from frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx
rename to frontend/src/Components/Form/SeriesTypeSelectInput.tsx
index 6a3bba650..471d6592b 100644
--- a/frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx
+++ b/frontend/src/Components/Form/SeriesTypeSelectInput.tsx
@@ -1,25 +1,21 @@
-import React, { useMemo } from 'react';
+import React from 'react';
import * as seriesTypes from 'Utilities/Series/seriesTypes';
import translate from 'Utilities/String/translate';
-import EnhancedSelectInput, {
- EnhancedSelectInputProps,
- EnhancedSelectInputValue,
-} from './EnhancedSelectInput';
+import EnhancedSelectInput from './EnhancedSelectInput';
import SeriesTypeSelectInputOption from './SeriesTypeSelectInputOption';
import SeriesTypeSelectInputSelectedValue from './SeriesTypeSelectInputSelectedValue';
-interface SeriesTypeSelectInputProps
- extends EnhancedSelectInputProps, string> {
+interface SeriesTypeSelectInputProps {
includeNoChange: boolean;
includeNoChangeDisabled?: boolean;
includeMixed: boolean;
}
-export interface ISeriesTypeOption {
+interface ISeriesTypeOption {
key: string;
value: string;
format?: string;
- isDisabled?: boolean;
+ disabled?: boolean;
}
const seriesTypeOptions: ISeriesTypeOption[] = [
@@ -47,33 +43,29 @@ const seriesTypeOptions: ISeriesTypeOption[] = [
];
function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
+ const values = [...seriesTypeOptions];
+
const {
- includeNoChange = false,
+ includeNoChange,
includeNoChangeDisabled = true,
- includeMixed = false,
+ includeMixed,
} = props;
- const values = useMemo(() => {
- const result = [...seriesTypeOptions];
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: translate('NoChange'),
+ disabled: includeNoChangeDisabled,
+ });
+ }
- 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]);
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: `(${translate('Mixed')})`,
+ disabled: true,
+ });
+ }
return (
+
diff --git a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css
new file mode 100644
index 000000000..c76b0a263
--- /dev/null
+++ b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css
@@ -0,0 +1,20 @@
+.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/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css.d.ts
similarity index 71%
rename from frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts
rename to frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css.d.ts
index 3fc49a060..f6e19e481 100644
--- a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts
+++ b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css.d.ts
@@ -1,7 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
- 'modalBody': string;
+ 'format': string;
+ 'selectedValue': string;
+ 'value': string;
}
export const cssExports: CssExports;
export default cssExports;
diff --git a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.tsx b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.tsx
new file mode 100644
index 000000000..94d2b7157
--- /dev/null
+++ b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.tsx
@@ -0,0 +1,27 @@
+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/Tag/DeviceInput.tsx b/frontend/src/Components/Form/Tag/DeviceInput.tsx
deleted file mode 100644
index 3c483d1f2..000000000
--- a/frontend/src/Components/Form/Tag/DeviceInput.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-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
deleted file mode 100644
index f72248cf5..000000000
--- a/frontend/src/Components/Form/Tag/SeriesTagInput.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-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/Tag/TagInput.tsx b/frontend/src/Components/Form/Tag/TagInput.tsx
deleted file mode 100644
index bde24f369..000000000
--- a/frontend/src/Components/Form/Tag/TagInput.tsx
+++ /dev/null
@@ -1,371 +0,0 @@
-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/Tag/TagInputInput.tsx b/frontend/src/Components/Form/Tag/TagInputInput.tsx
deleted file mode 100644
index d181136b8..000000000
--- a/frontend/src/Components/Form/Tag/TagInputInput.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-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/Tag/TagInputTag.tsx b/frontend/src/Components/Form/Tag/TagInputTag.tsx
deleted file mode 100644
index 7b549767c..000000000
--- a/frontend/src/Components/Form/Tag/TagInputTag.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-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
deleted file mode 100644
index 21fde893c..000000000
--- a/frontend/src/Components/Form/Tag/TagSelectInput.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-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
deleted file mode 100644
index 6e2082c50..000000000
--- a/frontend/src/Components/Form/Tag/TextTagInput.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-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/Tag/TagInput.css b/frontend/src/Components/Form/TagInput.css
similarity index 74%
rename from frontend/src/Components/Form/Tag/TagInput.css
rename to frontend/src/Components/Form/TagInput.css
index 2ca02825e..eeddab5b4 100644
--- a/frontend/src/Components/Form/Tag/TagInput.css
+++ b/frontend/src/Components/Form/TagInput.css
@@ -1,5 +1,5 @@
.input {
- composes: input from '~Components/Form/AutoSuggestInput.css';
+ composes: input from '~./AutoSuggestInput.css';
padding: 0;
min-height: 35px;
@@ -8,8 +8,7 @@
&.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/Tag/TagInput.css.d.ts b/frontend/src/Components/Form/TagInput.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Tag/TagInput.css.d.ts
rename to frontend/src/Components/Form/TagInput.css.d.ts
diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js
new file mode 100644
index 000000000..840d627f8
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.js
@@ -0,0 +1,301 @@
+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
new file mode 100644
index 000000000..8d0782fa5
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputConnector.js
@@ -0,0 +1,157 @@
+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/Tag/TagInputInput.css b/frontend/src/Components/Form/TagInputInput.css
similarity index 100%
rename from frontend/src/Components/Form/Tag/TagInputInput.css
rename to frontend/src/Components/Form/TagInputInput.css
diff --git a/frontend/src/Components/Form/Tag/TagInputInput.css.d.ts b/frontend/src/Components/Form/TagInputInput.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Tag/TagInputInput.css.d.ts
rename to frontend/src/Components/Form/TagInputInput.css.d.ts
diff --git a/frontend/src/Components/Form/TagInputInput.js b/frontend/src/Components/Form/TagInputInput.js
new file mode 100644
index 000000000..86628b134
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputInput.js
@@ -0,0 +1,84 @@
+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/Tag/TagInputTag.css b/frontend/src/Components/Form/TagInputTag.css
similarity index 96%
rename from frontend/src/Components/Form/Tag/TagInputTag.css
rename to frontend/src/Components/Form/TagInputTag.css
index 1a8ff45d6..7e66a4d12 100644
--- a/frontend/src/Components/Form/Tag/TagInputTag.css
+++ b/frontend/src/Components/Form/TagInputTag.css
@@ -30,6 +30,5 @@
.label {
composes: label from '~Components/Label.css';
- display: flex;
max-width: 100%;
}
diff --git a/frontend/src/Components/Form/Tag/TagInputTag.css.d.ts b/frontend/src/Components/Form/TagInputTag.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Tag/TagInputTag.css.d.ts
rename to frontend/src/Components/Form/TagInputTag.css.d.ts
diff --git a/frontend/src/Components/Form/TagInputTag.js b/frontend/src/Components/Form/TagInputTag.js
new file mode 100644
index 000000000..05a780442
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputTag.js
@@ -0,0 +1,101 @@
+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
new file mode 100644
index 000000000..23afe6da1
--- /dev/null
+++ b/frontend/src/Components/Form/TagSelectInputConnector.js
@@ -0,0 +1,102 @@
+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
new file mode 100644
index 000000000..44fd3a249
--- /dev/null
+++ b/frontend/src/Components/Form/TextArea.js
@@ -0,0 +1,172 @@
+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
deleted file mode 100644
index f37d5cb5f..000000000
--- a/frontend/src/Components/Form/TextArea.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-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
new file mode 100644
index 000000000..e018dd5a3
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.js
@@ -0,0 +1,205 @@
+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
deleted file mode 100644
index 647b9f2ac..000000000
--- a/frontend/src/Components/Form/TextInput.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-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
new file mode 100644
index 000000000..aef065cfa
--- /dev/null
+++ b/frontend/src/Components/Form/TextTagInputConnector.js
@@ -0,0 +1,110 @@
+
+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/Select/UMaskInput.css b/frontend/src/Components/Form/UMaskInput.css
similarity index 93%
rename from frontend/src/Components/Form/Select/UMaskInput.css
rename to frontend/src/Components/Form/UMaskInput.css
index a777aaeef..91486687e 100644
--- a/frontend/src/Components/Form/Select/UMaskInput.css
+++ b/frontend/src/Components/Form/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/Select/UMaskInput.css.d.ts b/frontend/src/Components/Form/UMaskInput.css.d.ts
similarity index 100%
rename from frontend/src/Components/Form/Select/UMaskInput.css.d.ts
rename to frontend/src/Components/Form/UMaskInput.css.d.ts
diff --git a/frontend/src/Components/Form/UMaskInput.js b/frontend/src/Components/Form/UMaskInput.js
new file mode 100644
index 000000000..544865197
--- /dev/null
+++ b/frontend/src/Components/Form/UMaskInput.js
@@ -0,0 +1,144 @@
+/* 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
new file mode 100644
index 000000000..fe53a4e5f
--- /dev/null
+++ b/frontend/src/Components/HeartRating.js
@@ -0,0 +1,30 @@
+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
deleted file mode 100644
index 774cb4239..000000000
--- a/frontend/src/Components/HeartRating.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-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
new file mode 100644
index 000000000..d200b8c08
--- /dev/null
+++ b/frontend/src/Components/Icon.js
@@ -0,0 +1,73 @@
+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
deleted file mode 100644
index a04463b51..000000000
--- a/frontend/src/Components/Icon.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-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 c7512987a..f3ff83993 100644
--- a/frontend/src/Components/Label.css
+++ b/frontend/src/Components/Label.css
@@ -88,15 +88,6 @@
}
}
-.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 778ba6faf..1a0b4d9e0 100644
--- a/frontend/src/Components/Label.css.d.ts
+++ b/frontend/src/Components/Label.css.d.ts
@@ -11,7 +11,6 @@ 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
new file mode 100644
index 000000000..844da8165
--- /dev/null
+++ b/frontend/src/Components/Label.js
@@ -0,0 +1,48 @@
+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
deleted file mode 100644
index 9ab360f42..000000000
--- a/frontend/src/Components/Label.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-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
new file mode 100644
index 000000000..cbe4691d4
--- /dev/null
+++ b/frontend/src/Components/Link/Button.js
@@ -0,0 +1,54 @@
+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
deleted file mode 100644
index 610350a8d..000000000
--- a/frontend/src/Components/Link/Button.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-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
new file mode 100644
index 000000000..55843f05f
--- /dev/null
+++ b/frontend/src/Components/Link/ClipboardButton.js
@@ -0,0 +1,139 @@
+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
deleted file mode 100644
index dfce115ac..000000000
--- a/frontend/src/Components/Link/ClipboardButton.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-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
new file mode 100644
index 000000000..fffbe13e0
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.js
@@ -0,0 +1,59 @@
+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
deleted file mode 100644
index b6951c00c..000000000
--- a/frontend/src/Components/Link/IconButton.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-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 80ee66e82..5015a1fe3 100644
--- a/frontend/src/Components/Link/Link.tsx
+++ b/frontend/src/Components/Link/Link.tsx
@@ -1,93 +1,96 @@
import classNames from 'classnames';
import React, {
- ComponentPropsWithoutRef,
- ElementType,
+ ComponentClass,
+ FunctionComponent,
SyntheticEvent,
useCallback,
} from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
-export type LinkProps =
- ComponentPropsWithoutRef & {
- component?: C;
- to?: string;
- target?: string;
- isDisabled?: LinkProps['disabled'];
- noRouter?: boolean;
- onPress?(event: SyntheticEvent): void;
- };
+interface ReactRouterLinkProps {
+ to?: string;
+}
-export default function Link({
- className,
- component,
- to,
- target,
- type,
- isDisabled,
- noRouter,
- onPress,
- ...otherProps
-}: LinkProps) {
- const Component = component || 'button';
+export interface LinkProps extends React.HTMLProps {
+ className?: string;
+ component?:
+ | string
+ | FunctionComponent