diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index ddc7300fd..77b933a8f 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -210,7 +210,6 @@ module.exports = { 'no-undef-init': 'off', 'no-undefined': 'off', 'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }], - 'no-use-before-define': 'error', // Node.js and CommonJS diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 33638f91f..8dfecab9e 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,12 +1,15 @@ import BlocklistAppState from './BlocklistAppState'; import CalendarAppState from './CalendarAppState'; +import CaptchaAppState from './CaptchaAppState'; import CommandAppState from './CommandAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodesAppState from './EpisodesAppState'; import HistoryAppState from './HistoryAppState'; import InteractiveImportAppState from './InteractiveImportAppState'; +import OAuthAppState from './OAuthAppState'; import ParseAppState from './ParseAppState'; import PathsAppState from './PathsAppState'; +import ProviderOptionsAppState from './ProviderOptionsAppState'; import QueueAppState from './QueueAppState'; import ReleasesAppState from './ReleasesAppState'; import RootFolderAppState from './RootFolderAppState'; @@ -64,14 +67,17 @@ interface AppState { app: AppSectionState; blocklist: BlocklistAppState; calendar: CalendarAppState; + captcha: CaptchaAppState; commands: CommandAppState; episodeFiles: EpisodeFilesAppState; episodes: EpisodesAppState; episodesSelection: EpisodesAppState; history: HistoryAppState; interactiveImport: InteractiveImportAppState; + oAuth: OAuthAppState; parse: ParseAppState; paths: PathsAppState; + providerOptions: ProviderOptionsAppState; queue: QueueAppState; releases: ReleasesAppState; rootFolders: RootFolderAppState; diff --git a/frontend/src/App/State/CaptchaAppState.ts b/frontend/src/App/State/CaptchaAppState.ts new file mode 100644 index 000000000..7252937eb --- /dev/null +++ b/frontend/src/App/State/CaptchaAppState.ts @@ -0,0 +1,11 @@ +interface CaptchaAppState { + refreshing: false; + token: string; + siteKey: unknown; + secretToken: unknown; + ray: unknown; + stoken: unknown; + responseUrl: unknown; +} + +export default CaptchaAppState; diff --git a/frontend/src/App/State/OAuthAppState.ts b/frontend/src/App/State/OAuthAppState.ts new file mode 100644 index 000000000..619767929 --- /dev/null +++ b/frontend/src/App/State/OAuthAppState.ts @@ -0,0 +1,9 @@ +import { Error } from './AppSectionState'; + +interface OAuthAppState { + authorizing: boolean; + result: Record | null; + error: Error; +} + +export default OAuthAppState; diff --git a/frontend/src/App/State/ProviderOptionsAppState.ts b/frontend/src/App/State/ProviderOptionsAppState.ts new file mode 100644 index 000000000..7fb5df02b --- /dev/null +++ b/frontend/src/App/State/ProviderOptionsAppState.ts @@ -0,0 +1,22 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Field, { FieldSelectOption } from 'typings/Field'; + +export interface ProviderOptions { + fields?: Field[]; +} + +interface ProviderOptionsDevice { + id: string; + name: string; +} + +interface ProviderOptionsAppState { + devices: AppSectionState; + servers: AppSectionState>; + newznabCategories: AppSectionState>; + getProfiles: AppSectionState>; + getTags: AppSectionState>; + getRootFolders: AppSectionState>; +} + +export default ProviderOptionsAppState; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx index 53589551f..41338cb39 100644 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import Alert from 'Components/Alert'; -import PathInput from 'Components/Form/PathInput'; +import { PathInputInternal } from 'Components/Form/PathInput'; import Button from 'Components/Link/Button'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; @@ -151,7 +151,7 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) { ) : null} - { - this.props.onChange({ - name: this.props.name, - value: newValue - }); - }; - - onInputBlur = () => { - this.setState({ suggestions: [] }); - }; - - onSuggestionsFetchRequested = ({ value }) => { - const { values } = this.props; - const lowerCaseValue = jdu.replace(value).toLowerCase(); - - const filteredValues = values.filter((v) => { - return jdu.replace(v).toLowerCase().contains(lowerCaseValue); - }); - - this.setState({ suggestions: filteredValues }); - }; - - onSuggestionsClearRequested = () => { - this.setState({ suggestions: [] }); - }; - - // - // Render - - render() { - const { - name, - value, - ...otherProps - } = this.props; - - const { suggestions } = this.state; - - return ( - - ); - } -} - -AutoCompleteInput.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string, - values: PropTypes.arrayOf(PropTypes.string).isRequired, - onChange: PropTypes.func.isRequired -}; - -AutoCompleteInput.defaultProps = { - value: '' -}; - -export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/AutoCompleteInput.tsx b/frontend/src/Components/Form/AutoCompleteInput.tsx new file mode 100644 index 000000000..7ba114125 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.tsx @@ -0,0 +1,81 @@ +import jdu from 'jdu'; +import React, { SyntheticEvent, useCallback, useState } from 'react'; +import { + ChangeEvent, + SuggestionsFetchRequestedParams, +} from 'react-autosuggest'; +import { InputChanged } from 'typings/inputs'; +import AutoSuggestInput from './AutoSuggestInput'; + +interface AutoCompleteInputProps { + name: string; + value?: string; + values: string[]; + onChange: (change: InputChanged) => unknown; +} + +function AutoCompleteInput({ + name, + value = '', + values, + onChange, + ...otherProps +}: AutoCompleteInputProps) { + const [suggestions, setSuggestions] = useState([]); + + const getSuggestionValue = useCallback((item: string) => { + return item; + }, []); + + const renderSuggestion = useCallback((item: string) => { + return item; + }, []); + + const handleInputChange = useCallback( + (_event: SyntheticEvent, { newValue }: ChangeEvent) => { + onChange({ + name, + value: newValue, + }); + }, + [name, onChange] + ); + + const handleInputBlur = useCallback(() => { + setSuggestions([]); + }, [setSuggestions]); + + const handleSuggestionsFetchRequested = useCallback( + ({ value: newValue }: SuggestionsFetchRequestedParams) => { + const lowerCaseValue = jdu.replace(newValue).toLowerCase(); + + const filteredValues = values.filter((v) => { + return jdu.replace(v).toLowerCase().includes(lowerCaseValue); + }); + + setSuggestions(filteredValues); + }, + [values, setSuggestions] + ); + + const handleSuggestionsClearRequested = useCallback(() => { + setSuggestions([]); + }, [setSuggestions]); + + return ( + + ); +} + +export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/AutoSuggestInput.js b/frontend/src/Components/Form/AutoSuggestInput.js deleted file mode 100644 index 34ec7530b..000000000 --- a/frontend/src/Components/Form/AutoSuggestInput.js +++ /dev/null @@ -1,257 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Autosuggest from 'react-autosuggest'; -import { Manager, Popper, Reference } from 'react-popper'; -import Portal from 'Components/Portal'; -import styles from './AutoSuggestInput.css'; - -class AutoSuggestInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scheduleUpdate = null; - } - - componentDidUpdate(prevProps) { - if ( - this._scheduleUpdate && - prevProps.suggestions !== this.props.suggestions - ) { - this._scheduleUpdate(); - } - } - - // - // Control - - renderInputComponent = (inputProps) => { - const { renderInputComponent } = this.props; - - return ( - - {({ ref }) => { - if (renderInputComponent) { - return renderInputComponent(inputProps, ref); - } - - return ( -
- -
- ); - }} -
- ); - }; - - renderSuggestionsContainer = ({ containerProps, children }) => { - return ( - - - {({ ref: popperRef, style, scheduleUpdate }) => { - this._scheduleUpdate = scheduleUpdate; - - return ( -
-
- {children} -
-
- ); - }} -
-
- ); - }; - - // - // Listeners - - onComputeMaxHeight = (data) => { - const { - top, - bottom, - width - } = data.offsets.reference; - - const windowHeight = window.innerHeight; - - if ((/^botton/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom; - } else { - data.styles.maxHeight = top; - } - - data.styles.width = width; - - return data; - }; - - onInputChange = (event, { newValue }) => { - this.props.onChange({ - name: this.props.name, - value: newValue - }); - }; - - onInputKeyDown = (event) => { - const { - name, - value, - suggestions, - onChange - } = this.props; - - if ( - event.key === 'Tab' && - suggestions.length && - suggestions[0] !== this.props.value - ) { - event.preventDefault(); - - if (value) { - onChange({ - name, - value: suggestions[0] - }); - } - } - }; - - // - // Render - - render() { - const { - forwardedRef, - className, - inputContainerClassName, - name, - value, - placeholder, - suggestions, - hasError, - hasWarning, - getSuggestionValue, - renderSuggestion, - onInputChange, - onInputKeyDown, - onInputFocus, - onInputBlur, - onSuggestionsFetchRequested, - onSuggestionsClearRequested, - onSuggestionSelected, - ...otherProps - } = this.props; - - const inputProps = { - className: classNames( - className, - hasError && styles.hasError, - hasWarning && styles.hasWarning - ), - name, - value, - placeholder, - autoComplete: 'off', - spellCheck: false, - onChange: onInputChange || this.onInputChange, - onKeyDown: onInputKeyDown || this.onInputKeyDown, - onFocus: onInputFocus, - onBlur: onInputBlur - }; - - const theme = { - container: inputContainerClassName, - containerOpen: styles.suggestionsContainerOpen, - suggestionsContainer: styles.suggestionsContainer, - suggestionsList: styles.suggestionsList, - suggestion: styles.suggestion, - suggestionHighlighted: styles.suggestionHighlighted - }; - - return ( - - - - ); - } -} - -AutoSuggestInput.propTypes = { - forwardedRef: PropTypes.func, - className: PropTypes.string.isRequired, - inputContainerClassName: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - placeholder: PropTypes.string, - suggestions: PropTypes.array.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - enforceMaxHeight: PropTypes.bool.isRequired, - minHeight: PropTypes.number.isRequired, - maxHeight: PropTypes.number.isRequired, - getSuggestionValue: PropTypes.func.isRequired, - renderInputComponent: PropTypes.elementType, - renderSuggestion: PropTypes.func.isRequired, - onInputChange: PropTypes.func, - onInputKeyDown: PropTypes.func, - onInputFocus: PropTypes.func, - onInputBlur: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionSelected: PropTypes.func, - onChange: PropTypes.func.isRequired -}; - -AutoSuggestInput.defaultProps = { - className: styles.input, - inputContainerClassName: styles.inputContainer, - enforceMaxHeight: true, - minHeight: 50, - maxHeight: 200 -}; - -export default AutoSuggestInput; diff --git a/frontend/src/Components/Form/AutoSuggestInput.tsx b/frontend/src/Components/Form/AutoSuggestInput.tsx new file mode 100644 index 000000000..b3a7c31b0 --- /dev/null +++ b/frontend/src/Components/Form/AutoSuggestInput.tsx @@ -0,0 +1,259 @@ +import classNames from 'classnames'; +import React, { + FocusEvent, + FormEvent, + KeyboardEvent, + KeyboardEventHandler, + MutableRefObject, + ReactNode, + Ref, + SyntheticEvent, + useCallback, + useEffect, + useRef, +} from 'react'; +import Autosuggest, { + AutosuggestPropsBase, + BlurEvent, + ChangeEvent, + RenderInputComponentProps, + RenderSuggestionsContainerParams, +} from 'react-autosuggest'; +import { Manager, Popper, Reference } from 'react-popper'; +import Portal from 'Components/Portal'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { InputChanged } from 'typings/inputs'; +import styles from './AutoSuggestInput.css'; + +interface AutoSuggestInputProps + extends Omit, 'renderInputComponent' | 'inputProps'> { + forwardedRef?: MutableRefObject | null>; + className?: string; + inputContainerClassName?: string; + name: string; + value?: string; + placeholder?: string; + suggestions: T[]; + hasError?: boolean; + hasWarning?: boolean; + enforceMaxHeight?: boolean; + minHeight?: number; + maxHeight?: number; + renderInputComponent?: ( + inputProps: RenderInputComponentProps, + ref: Ref + ) => ReactNode; + onInputChange: ( + event: FormEvent, + params: ChangeEvent + ) => unknown; + onInputKeyDown?: KeyboardEventHandler; + onInputFocus?: (event: SyntheticEvent) => unknown; + onInputBlur: ( + event: FocusEvent, + params?: BlurEvent + ) => unknown; + onChange?: (change: InputChanged) => unknown; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function AutoSuggestInput(props: AutoSuggestInputProps) { + const { + // TODO: forwaredRef should be replaces with React.forwardRef + forwardedRef, + className = styles.input, + inputContainerClassName = styles.inputContainer, + name, + value = '', + placeholder, + suggestions, + enforceMaxHeight = true, + hasError, + hasWarning, + minHeight = 50, + maxHeight = 200, + getSuggestionValue, + renderSuggestion, + renderInputComponent, + onInputChange, + onInputKeyDown, + onInputFocus, + onInputBlur, + onSuggestionsFetchRequested, + onSuggestionsClearRequested, + onSuggestionSelected, + onChange, + ...otherProps + } = props; + + const updater = useRef<(() => void) | null>(null); + const previousSuggestions = usePrevious(suggestions); + + const handleComputeMaxHeight = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data: any) => { + const { top, bottom, width } = data.offsets.reference; + + if (enforceMaxHeight) { + data.styles.maxHeight = maxHeight; + } else { + const windowHeight = window.innerHeight; + + if (/^botton/.test(data.placement)) { + data.styles.maxHeight = windowHeight - bottom; + } else { + data.styles.maxHeight = top; + } + } + + data.styles.width = width; + + return data; + }, + [enforceMaxHeight, maxHeight] + ); + + const createRenderInputComponent = useCallback( + (inputProps: RenderInputComponentProps) => { + return ( + + {({ ref }) => { + if (renderInputComponent) { + return renderInputComponent(inputProps, ref); + } + + return ( +
+ +
+ ); + }} +
+ ); + }, + [renderInputComponent] + ); + + const renderSuggestionsContainer = useCallback( + ({ containerProps, children }: RenderSuggestionsContainerParams) => { + return ( + + + {({ ref: popperRef, style, scheduleUpdate }) => { + updater.current = scheduleUpdate; + + return ( +
+
+ {children} +
+
+ ); + }} +
+
+ ); + }, + [minHeight, handleComputeMaxHeight] + ); + + const handleInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if ( + event.key === 'Tab' && + suggestions.length && + suggestions[0] !== value + ) { + event.preventDefault(); + + if (value) { + onSuggestionSelected?.(event, { + suggestion: suggestions[0], + suggestionValue: value, + suggestionIndex: 0, + sectionIndex: null, + method: 'enter', + }); + } + } + }, + [value, suggestions, onSuggestionSelected] + ); + + const inputProps = { + className: classNames( + className, + hasError && styles.hasError, + hasWarning && styles.hasWarning + ), + name, + value, + placeholder, + autoComplete: 'off', + spellCheck: false, + onChange: onInputChange, + onKeyDown: onInputKeyDown || handleInputKeyDown, + onFocus: onInputFocus, + onBlur: onInputBlur, + }; + + const theme = { + container: inputContainerClassName, + containerOpen: styles.suggestionsContainerOpen, + suggestionsContainer: styles.suggestionsContainer, + suggestionsList: styles.suggestionsList, + suggestion: styles.suggestion, + suggestionHighlighted: styles.suggestionHighlighted, + }; + + useEffect(() => { + if (updater.current && suggestions !== previousSuggestions) { + updater.current(); + } + }, [suggestions, previousSuggestions]); + + return ( + + + + ); +} + +export default AutoSuggestInput; diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js deleted file mode 100644 index b422198b5..000000000 --- a/frontend/src/Components/Form/CaptchaInput.js +++ /dev/null @@ -1,84 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ReCAPTCHA from 'react-google-recaptcha'; -import Icon from 'Components/Icon'; -import { icons } from 'Helpers/Props'; -import FormInputButton from './FormInputButton'; -import TextInput from './TextInput'; -import styles from './CaptchaInput.css'; - -function CaptchaInput(props) { - const { - className, - name, - value, - hasError, - hasWarning, - refreshing, - siteKey, - secretToken, - onChange, - onRefreshPress, - onCaptchaChange - } = props; - - return ( -
-
- - - - - -
- - { - !!siteKey && !!secretToken && -
- -
- } -
- ); -} - -CaptchaInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - refreshing: PropTypes.bool.isRequired, - siteKey: PropTypes.string, - secretToken: PropTypes.string, - onChange: PropTypes.func.isRequired, - onRefreshPress: PropTypes.func.isRequired, - onCaptchaChange: PropTypes.func.isRequired -}; - -CaptchaInput.defaultProps = { - className: styles.input, - value: '' -}; - -export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInput.tsx b/frontend/src/Components/Form/CaptchaInput.tsx new file mode 100644 index 000000000..d5a3f11f7 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.tsx @@ -0,0 +1,118 @@ +import classNames from 'classnames'; +import React, { useCallback, useEffect } from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Icon from 'Components/Icon'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons } from 'Helpers/Props'; +import { + getCaptchaCookie, + refreshCaptcha, + resetCaptcha, +} from 'Store/Actions/captchaActions'; +import { InputChanged } from 'typings/inputs'; +import FormInputButton from './FormInputButton'; +import TextInput from './TextInput'; +import styles from './CaptchaInput.css'; + +interface CaptchaInputProps { + className?: string; + name: string; + value?: string; + provider: string; + providerData: object; + hasError?: boolean; + hasWarning?: boolean; + refreshing: boolean; + siteKey?: string; + secretToken?: string; + onChange: (change: InputChanged) => unknown; +} + +function CaptchaInput({ + className = styles.input, + name, + value = '', + provider, + providerData, + hasError, + hasWarning, + refreshing, + siteKey, + secretToken, + onChange, +}: CaptchaInputProps) { + const { token } = useSelector((state: AppState) => state.captcha); + const dispatch = useDispatch(); + const previousToken = usePrevious(token); + + const handleCaptchaChange = useCallback( + (token: string | null) => { + // If the captcha has expired `captchaResponse` will be null. + // In the event it's null don't try to get the captchaCookie. + // TODO: Should we clear the cookie? or reset the captcha? + + if (!token) { + return; + } + + dispatch( + getCaptchaCookie({ + provider, + providerData, + captchaResponse: token, + }) + ); + }, + [provider, providerData, dispatch] + ); + + const handleRefreshPress = useCallback(() => { + dispatch(refreshCaptcha({ provider, providerData })); + }, [provider, providerData, dispatch]); + + useEffect(() => { + if (token && token !== previousToken) { + onChange({ name, value: token }); + } + }, [name, token, previousToken, onChange]); + + useEffect(() => { + dispatch(resetCaptcha()); + }, [dispatch]); + + return ( +
+
+ + + + + +
+ + {siteKey && secretToken ? ( +
+ +
+ ) : null} +
+ ); +} + +export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js deleted file mode 100644 index ad83bf02f..000000000 --- a/frontend/src/Components/Form/CaptchaInputConnector.js +++ /dev/null @@ -1,98 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions'; -import CaptchaInput from './CaptchaInput'; - -function createMapStateToProps() { - return createSelector( - (state) => state.captcha, - (captcha) => { - return captcha; - } - ); -} - -const mapDispatchToProps = { - refreshCaptcha, - getCaptchaCookie, - resetCaptcha -}; - -class CaptchaInputConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps) { - const { - name, - token, - onChange - } = this.props; - - if (token && token !== prevProps.token) { - onChange({ name, value: token }); - } - } - - componentWillUnmount = () => { - this.props.resetCaptcha(); - }; - - // - // Listeners - - onRefreshPress = () => { - const { - provider, - providerData - } = this.props; - - this.props.refreshCaptcha({ provider, providerData }); - }; - - onCaptchaChange = (captchaResponse) => { - // If the captcha has expired `captchaResponse` will be null. - // In the event it's null don't try to get the captchaCookie. - // TODO: Should we clear the cookie? or reset the captcha? - - if (!captchaResponse) { - return; - } - - const { - provider, - providerData - } = this.props; - - this.props.getCaptchaCookie({ provider, providerData, captchaResponse }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CaptchaInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - token: PropTypes.string, - onChange: PropTypes.func.isRequired, - refreshCaptcha: PropTypes.func.isRequired, - getCaptchaCookie: PropTypes.func.isRequired, - resetCaptcha: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector); diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js deleted file mode 100644 index 26d915880..000000000 --- a/frontend/src/Components/Form/CheckInput.js +++ /dev/null @@ -1,191 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import { icons, kinds } from 'Helpers/Props'; -import FormInputHelpText from './FormInputHelpText'; -import styles from './CheckInput.css'; - -class CheckInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._checkbox = null; - } - - componentDidMount() { - this.setIndeterminate(); - } - - componentDidUpdate() { - this.setIndeterminate(); - } - - // - // Control - - setIndeterminate() { - if (!this._checkbox) { - return; - } - - const { - value, - uncheckedValue, - checkedValue - } = this.props; - - this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue; - } - - toggleChecked = (checked, shiftKey) => { - const { - name, - value, - checkedValue, - uncheckedValue - } = this.props; - - const newValue = checked ? checkedValue : uncheckedValue; - - if (value !== newValue) { - this.props.onChange({ - name, - value: newValue, - shiftKey - }); - } - }; - - // - // Listeners - - setRef = (ref) => { - this._checkbox = ref; - }; - - onClick = (event) => { - if (this.props.isDisabled) { - return; - } - - const shiftKey = event.nativeEvent.shiftKey; - const checked = !this._checkbox.checked; - - event.preventDefault(); - this.toggleChecked(checked, shiftKey); - }; - - onChange = (event) => { - const checked = event.target.checked; - const shiftKey = event.nativeEvent.shiftKey; - - this.toggleChecked(checked, shiftKey); - }; - - // - // Render - - render() { - const { - className, - containerClassName, - name, - value, - checkedValue, - uncheckedValue, - helpText, - helpTextWarning, - isDisabled, - kind - } = this.props; - - const isChecked = value === checkedValue; - const isUnchecked = value === uncheckedValue; - const isIndeterminate = !isChecked && !isUnchecked; - const isCheckClass = `${kind}IsChecked`; - - return ( -
- -
- ); - } -} - -CheckInput.propTypes = { - className: PropTypes.string.isRequired, - containerClassName: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - checkedValue: PropTypes.bool, - uncheckedValue: PropTypes.bool, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - helpText: PropTypes.string, - helpTextWarning: PropTypes.string, - isDisabled: PropTypes.bool, - kind: PropTypes.oneOf(kinds.all).isRequired, - onChange: PropTypes.func.isRequired -}; - -CheckInput.defaultProps = { - className: styles.input, - containerClassName: styles.container, - checkedValue: true, - uncheckedValue: false, - kind: kinds.PRIMARY -}; - -export default CheckInput; diff --git a/frontend/src/Components/Form/CheckInput.tsx b/frontend/src/Components/Form/CheckInput.tsx new file mode 100644 index 000000000..b7080cfdd --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.tsx @@ -0,0 +1,141 @@ +import classNames from 'classnames'; +import React, { SyntheticEvent, useCallback, useEffect, useRef } from 'react'; +import Icon from 'Components/Icon'; +import { icons } from 'Helpers/Props'; +import { Kind } from 'Helpers/Props/kinds'; +import { CheckInputChanged } from 'typings/inputs'; +import FormInputHelpText from './FormInputHelpText'; +import styles from './CheckInput.css'; + +interface ChangeEvent extends SyntheticEvent { + target: EventTarget & T; +} + +interface CheckInputProps { + className?: string; + containerClassName?: string; + name: string; + checkedValue?: boolean; + uncheckedValue?: boolean; + value?: string | boolean; + helpText?: string; + helpTextWarning?: string; + isDisabled?: boolean; + kind?: Extract; + onChange: (changes: CheckInputChanged) => void; +} + +function CheckInput(props: CheckInputProps) { + const { + className = styles.input, + containerClassName = styles.container, + name, + value, + checkedValue = true, + uncheckedValue = false, + helpText, + helpTextWarning, + isDisabled, + kind = 'primary', + onChange, + } = props; + + const inputRef = useRef(null); + + const isChecked = value === checkedValue; + const isUnchecked = value === uncheckedValue; + const isIndeterminate = !isChecked && !isUnchecked; + const isCheckClass: keyof typeof styles = `${kind}IsChecked`; + + const toggleChecked = useCallback( + (checked: boolean, shiftKey: boolean) => { + const newValue = checked ? checkedValue : uncheckedValue; + + if (value !== newValue) { + onChange({ + name, + value: newValue, + shiftKey, + }); + } + }, + [name, value, checkedValue, uncheckedValue, onChange] + ); + + const handleClick = useCallback( + (event: SyntheticEvent) => { + if (isDisabled) { + return; + } + + const shiftKey = event.nativeEvent.shiftKey; + const checked = !(inputRef.current?.checked ?? false); + + event.preventDefault(); + toggleChecked(checked, shiftKey); + }, + [isDisabled, toggleChecked] + ); + + const handleChange = useCallback( + (event: ChangeEvent) => { + const checked = event.target.checked; + const shiftKey = event.nativeEvent.shiftKey; + + toggleChecked(checked, shiftKey); + }, + [toggleChecked] + ); + + useEffect(() => { + if (!inputRef.current) { + return; + } + + inputRef.current.indeterminate = + value !== uncheckedValue && value !== checkedValue; + }, [value, uncheckedValue, checkedValue]); + + return ( +
+ +
+ ); +} + +export default CheckInput; diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js deleted file mode 100644 index 55c239cb8..000000000 --- a/frontend/src/Components/Form/DeviceInput.js +++ /dev/null @@ -1,106 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import { icons } from 'Helpers/Props'; -import tagShape from 'Helpers/Props/Shapes/tagShape'; -import FormInputButton from './FormInputButton'; -import TagInput from './TagInput'; -import styles from './DeviceInput.css'; - -class DeviceInput extends Component { - - onTagAdd = (device) => { - const { - name, - value, - onChange - } = this.props; - - // New tags won't have an ID, only a name. - const deviceId = device.id || device.name; - - onChange({ - name, - value: [...value, deviceId] - }); - }; - - onTagDelete = ({ index }) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = value.slice(); - newValue.splice(index, 1); - - onChange({ - name, - value: newValue - }); - }; - - // - // Render - - render() { - const { - className, - name, - items, - selectedDevices, - hasError, - hasWarning, - isFetching, - onRefreshPress - } = this.props; - - return ( -
- - - - - -
- ); - } -} - -DeviceInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, - items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onRefreshPress: PropTypes.func.isRequired -}; - -DeviceInput.defaultProps = { - className: styles.deviceInputWrapper, - inputClassName: styles.input -}; - -export default DeviceInput; diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js deleted file mode 100644 index 2af9a79f6..000000000 --- a/frontend/src/Components/Form/DeviceInputConnector.js +++ /dev/null @@ -1,104 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; -import DeviceInput from './DeviceInput'; - -function createMapStateToProps() { - return createSelector( - (state, { value }) => value, - (state) => state.providerOptions.devices || defaultState, - (value, devices) => { - - return { - ...devices, - selectedDevices: value.map((valueDevice) => { - // Disable equality ESLint rule so we don't need to worry about - // a type mismatch between the value items and the device ID. - // eslint-disable-next-line eqeqeq - const device = devices.items.find((d) => d.id == valueDevice); - - if (device) { - return { - id: device.id, - name: `${device.name} (${device.id})` - }; - } - - return { - id: valueDevice, - name: `Unknown (${valueDevice})` - }; - }) - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchOptions: fetchOptions, - dispatchClearOptions: clearOptions -}; - -class DeviceInputConnector extends Component { - - // - // Lifecycle - - componentDidMount = () => { - this._populate(); - }; - - componentWillUnmount = () => { - this.props.dispatchClearOptions({ section: 'devices' }); - }; - - // - // Control - - _populate() { - const { - provider, - providerData, - dispatchFetchOptions - } = this.props; - - dispatchFetchOptions({ - section: 'devices', - action: 'getDevices', - provider, - providerData - }); - } - - // - // Listeners - - onRefreshPress = () => { - this._populate(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -DeviceInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - dispatchFetchOptions: PropTypes.func.isRequired, - dispatchClearOptions: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector); diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js deleted file mode 100644 index c21f0ded6..000000000 --- a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js +++ /dev/null @@ -1,102 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchDownloadClients } from 'Store/Actions/settingsActions'; -import sortByProp from 'Utilities/Array/sortByProp'; -import translate from 'Utilities/String/translate'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.downloadClients, - (state, { includeAny }) => includeAny, - (state, { protocol }) => protocol, - (downloadClients, includeAny, protocolFilter) => { - const { - isFetching, - isPopulated, - error, - items - } = downloadClients; - - const filteredItems = items.filter((item) => item.protocol === protocolFilter); - - const values = _.map(filteredItems.sort(sortByProp('name')), (downloadClient) => { - return { - key: downloadClient.id, - value: downloadClient.name, - hint: `(${downloadClient.id})` - }; - }); - - if (includeAny) { - values.unshift({ - key: 0, - value: `(${translate('Any')})` - }); - } - - return { - isFetching, - isPopulated, - error, - values - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchDownloadClients: fetchDownloadClients -}; - -class DownloadClientSelectInputConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - if (!this.props.isPopulated) { - this.props.dispatchFetchDownloadClients(); - } - } - - // - // Listeners - - onChange = ({ name, value }) => { - this.props.onChange({ name, value: parseInt(value) }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -DownloadClientSelectInputConnector.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - includeAny: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - dispatchFetchDownloadClients: PropTypes.func.isRequired -}; - -DownloadClientSelectInputConnector.defaultProps = { - includeAny: false, - protocol: 'torrent' -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js deleted file mode 100644 index 38b5e6ab5..000000000 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ /dev/null @@ -1,614 +0,0 @@ -import classNames from 'classnames'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Manager, Popper, Reference } from 'react-popper'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Measure from 'Components/Measure'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import Portal from 'Components/Portal'; -import Scroller from 'Components/Scroller/Scroller'; -import { icons, scrollDirections, sizes } from 'Helpers/Props'; -import { isMobile as isMobileUtil } from 'Utilities/browser'; -import * as keyCodes from 'Utilities/Constants/keyCodes'; -import getUniqueElememtId from 'Utilities/getUniqueElementId'; -import HintedSelectInputOption from './HintedSelectInputOption'; -import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; -import TextInput from './TextInput'; -import styles from './EnhancedSelectInput.css'; - -function isArrowKey(keyCode) { - return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; -} - -function getSelectedOption(selectedIndex, values) { - return values[selectedIndex]; -} - -function findIndex(startingIndex, direction, values) { - let indexToTest = startingIndex + direction; - - while (indexToTest !== startingIndex) { - if (indexToTest < 0) { - indexToTest = values.length - 1; - } else if (indexToTest >= values.length) { - indexToTest = 0; - } - - if (getSelectedOption(indexToTest, values).isDisabled) { - indexToTest = indexToTest + direction; - } else { - return indexToTest; - } - } -} - -function previousIndex(selectedIndex, values) { - return findIndex(selectedIndex, -1, values); -} - -function nextIndex(selectedIndex, values) { - return findIndex(selectedIndex, 1, values); -} - -function getSelectedIndex(props) { - const { - value, - values - } = props; - - if (Array.isArray(value)) { - return values.findIndex((v) => { - return value.size && v.key === value[0]; - }); - } - - return values.findIndex((v) => { - return v.key === value; - }); -} - -function isSelectedItem(index, props) { - const { - value, - values - } = props; - - if (Array.isArray(value)) { - return value.includes(values[index].key); - } - - return values[index].key === value; -} - -function getKey(selectedIndex, values) { - return values[selectedIndex].key; -} - -class EnhancedSelectInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scheduleUpdate = null; - this._buttonId = getUniqueElememtId(); - this._optionsId = getUniqueElememtId(); - - this.state = { - isOpen: false, - selectedIndex: getSelectedIndex(props), - width: 0, - isMobile: isMobileUtil() - }; - } - - componentDidUpdate(prevProps) { - if (this._scheduleUpdate) { - this._scheduleUpdate(); - } - - if (!Array.isArray(this.props.value)) { - if (prevProps.value !== this.props.value || prevProps.values !== this.props.values) { - this.setState({ - selectedIndex: getSelectedIndex(this.props) - }); - } - } - } - - // - // Control - - _addListener() { - window.addEventListener('click', this.onWindowClick); - } - - _removeListener() { - window.removeEventListener('click', this.onWindowClick); - } - - // - // Listeners - - onComputeMaxHeight = (data) => { - const { - top, - bottom - } = data.offsets.reference; - - const windowHeight = window.innerHeight; - - if ((/^botton/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom; - } else { - data.styles.maxHeight = top; - } - - return data; - }; - - onWindowClick = (event) => { - const button = document.getElementById(this._buttonId); - const options = document.getElementById(this._optionsId); - - if (!button || !event.target.isConnected || this.state.isMobile) { - return; - } - - if ( - !button.contains(event.target) && - options && - !options.contains(event.target) && - this.state.isOpen - ) { - this.setState({ isOpen: false }); - this._removeListener(); - } - }; - - onFocus = () => { - if (this.state.isOpen) { - this._removeListener(); - this.setState({ isOpen: false }); - } - }; - - onBlur = () => { - if (!this.props.isEditable) { - // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) - const origIndex = getSelectedIndex(this.props); - - if (origIndex !== this.state.selectedIndex) { - this.setState({ selectedIndex: origIndex }); - } - } - }; - - onKeyDown = (event) => { - const { - values - } = this.props; - - const { - isOpen, - selectedIndex - } = this.state; - - const keyCode = event.keyCode; - const newState = {}; - - if (!isOpen) { - if (isArrowKey(keyCode)) { - event.preventDefault(); - newState.isOpen = true; - } - - if ( - selectedIndex == null || selectedIndex === -1 || - getSelectedOption(selectedIndex, values).isDisabled - ) { - if (keyCode === keyCodes.UP_ARROW) { - newState.selectedIndex = previousIndex(0, values); - } else if (keyCode === keyCodes.DOWN_ARROW) { - newState.selectedIndex = nextIndex(values.length - 1, values); - } - } - - this.setState(newState); - return; - } - - if (keyCode === keyCodes.UP_ARROW) { - event.preventDefault(); - newState.selectedIndex = previousIndex(selectedIndex, values); - } - - if (keyCode === keyCodes.DOWN_ARROW) { - event.preventDefault(); - newState.selectedIndex = nextIndex(selectedIndex, values); - } - - if (keyCode === keyCodes.ENTER) { - event.preventDefault(); - newState.isOpen = false; - this.onSelect(getKey(selectedIndex, values)); - } - - if (keyCode === keyCodes.TAB) { - newState.isOpen = false; - this.onSelect(getKey(selectedIndex, values)); - } - - if (keyCode === keyCodes.ESCAPE) { - event.preventDefault(); - event.stopPropagation(); - newState.isOpen = false; - newState.selectedIndex = getSelectedIndex(this.props); - } - - if (!_.isEmpty(newState)) { - this.setState(newState); - } - }; - - onPress = () => { - if (this.state.isOpen) { - this._removeListener(); - } else { - this._addListener(); - } - - if (!this.state.isOpen && this.props.onOpen) { - this.props.onOpen(); - } - - this.setState({ isOpen: !this.state.isOpen }); - }; - - onSelect = (newValue) => { - const { name, value, values, onChange } = this.props; - const additionalProperties = values.find((v) => v.key === newValue)?.additionalProperties; - - if (Array.isArray(value)) { - let arrayValue = null; - const index = value.indexOf(newValue); - - if (index === -1) { - arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v)); - } else { - arrayValue = [...value]; - arrayValue.splice(index, 1); - } - onChange({ - name, - value: arrayValue, - additionalProperties - }); - } else { - this.setState({ isOpen: false }); - - onChange({ - name, - value: newValue, - additionalProperties - }); - } - }; - - 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 }) => ( -
- - { - isEditable ? -
- - - { - isFetching ? - : - null - } - - { - isFetching ? - null : - - } - -
: - - - {selectedOption ? selectedOption.value : null} - - -
- - { - isFetching ? - : - null - } - - { - isFetching ? - null : - - } -
- - } -
-
- )} -
- - - {({ 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 && Array.isArray(value) && value.includes(v.parentKey); - return ( - - {v.value} - - ); - }) - } - : - null - } -
- ); - } - } -
-
-
- - { - isMobile ? - - - -
- - - -
- - { - values.map((v, index) => { - const hasParent = v.parentKey !== undefined; - const depth = hasParent ? 1 : 0; - const parentSelected = hasParent && value.includes(v.parentKey); - return ( - - {v.value} - - ); - }) - } -
-
-
: - null - } -
- ); - } -} - -EnhancedSelectInput.propTypes = { - className: PropTypes.string, - disabledClassName: PropTypes.string, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isEditable: PropTypes.bool.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - valueOptions: PropTypes.object.isRequired, - selectedValueOptions: PropTypes.object.isRequired, - selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, - optionComponent: PropTypes.elementType, - onOpen: PropTypes.func, - onChange: PropTypes.func.isRequired -}; - -EnhancedSelectInput.defaultProps = { - className: styles.enhancedSelect, - disabledClassName: styles.isDisabled, - isDisabled: false, - isFetching: false, - isEditable: false, - valueOptions: {}, - selectedValueOptions: {}, - selectedValueComponent: HintedSelectInputSelectedValue, - optionComponent: HintedSelectInputOption -}; - -export default EnhancedSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js deleted file mode 100644 index cfbe9484f..000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputConnector.js +++ /dev/null @@ -1,162 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -const importantFieldNames = [ - 'baseUrl', - 'apiPath', - 'apiKey', - 'authToken' -]; - -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, - isDisabled: option.isDisabled, - additionalProperties: option.additionalProperties - }; - }); -} - -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.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.number)]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - selectOptionsProviderAction: PropTypes.string, - onChange: PropTypes.func.isRequired, - isFetching: PropTypes.bool.isRequired, - dispatchFetchOptions: PropTypes.func.isRequired, - dispatchClearOptions: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js deleted file mode 100644 index b2783dbaa..000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.js +++ /dev/null @@ -1,113 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons } from 'Helpers/Props'; -import CheckInput from './CheckInput'; -import styles from './EnhancedSelectInputOption.css'; - -class EnhancedSelectInputOption extends Component { - - // - // Listeners - - onPress = (e) => { - e.preventDefault(); - - const { - id, - onSelect - } = this.props; - - onSelect(id); - }; - - onCheckPress = () => { - // CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation. - }; - - // - // Render - - render() { - const { - className, - id, - depth, - isSelected, - isDisabled, - isHidden, - isMultiSelect, - isMobile, - children - } = this.props; - - return ( - - - { - depth !== 0 && -
- } - - { - isMultiSelect && - - } - - {children} - - { - isMobile && -
- -
- } - - ); - } -} - -EnhancedSelectInputOption.propTypes = { - className: PropTypes.string.isRequired, - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - depth: PropTypes.number.isRequired, - isSelected: PropTypes.bool.isRequired, - isDisabled: PropTypes.bool.isRequired, - isHidden: PropTypes.bool.isRequired, - isMultiSelect: PropTypes.bool.isRequired, - isMobile: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, - onSelect: PropTypes.func.isRequired -}; - -EnhancedSelectInputOption.defaultProps = { - className: styles.option, - depth: 0, - isDisabled: false, - isHidden: false, - isMultiSelect: false -}; - -export default EnhancedSelectInputOption; diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js deleted file mode 100644 index 21ddebb02..000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js +++ /dev/null @@ -1,35 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './EnhancedSelectInputSelectedValue.css'; - -function EnhancedSelectInputSelectedValue(props) { - const { - className, - children, - isDisabled - } = props; - - return ( -
- {children} -
- ); -} - -EnhancedSelectInputSelectedValue.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node, - isDisabled: PropTypes.bool.isRequired -}; - -EnhancedSelectInputSelectedValue.defaultProps = { - className: styles.selectedValue, - isDisabled: false -}; - -export default EnhancedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js deleted file mode 100644 index 79ad3fe8a..000000000 --- a/frontend/src/Components/Form/Form.js +++ /dev/null @@ -1,66 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Alert from 'Components/Alert'; -import { kinds } from 'Helpers/Props'; -import styles from './Form.css'; - -function Form(props) { - const { - children, - validationErrors, - validationWarnings, - // eslint-disable-next-line no-unused-vars - ...otherProps - } = props; - - return ( -
- { - validationErrors.length || validationWarnings.length ? -
- { - validationErrors.map((error, index) => { - return ( - - {error.errorMessage} - - ); - }) - } - - { - validationWarnings.map((warning, index) => { - return ( - - {warning.errorMessage} - - ); - }) - } -
: - null - } - - {children} -
- ); -} - -Form.propTypes = { - children: PropTypes.node.isRequired, - validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired, - validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -Form.defaultProps = { - validationErrors: [], - validationWarnings: [] -}; - -export default Form; diff --git a/frontend/src/Components/Form/Form.tsx b/frontend/src/Components/Form/Form.tsx new file mode 100644 index 000000000..d522019e7 --- /dev/null +++ b/frontend/src/Components/Form/Form.tsx @@ -0,0 +1,45 @@ +import React, { ReactNode } from 'react'; +import Alert from 'Components/Alert'; +import { kinds } from 'Helpers/Props'; +import { ValidationError, ValidationWarning } from 'typings/pending'; +import styles from './Form.css'; + +export interface FormProps { + children: ReactNode; + validationErrors?: ValidationError[]; + validationWarnings?: ValidationWarning[]; +} + +function Form({ + children, + validationErrors = [], + validationWarnings = [], +}: FormProps) { + return ( +
+ {validationErrors.length || validationWarnings.length ? ( +
+ {validationErrors.map((error, index) => { + return ( + + {error.errorMessage} + + ); + })} + + {validationWarnings.map((warning, index) => { + return ( + + {warning.errorMessage} + + ); + })} +
+ ) : null} + + {children} +
+ ); +} + +export default Form; diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js deleted file mode 100644 index f538daa2f..000000000 --- a/frontend/src/Components/Form/FormGroup.js +++ /dev/null @@ -1,56 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { map } from 'Helpers/elementChildren'; -import { sizes } from 'Helpers/Props'; -import styles from './FormGroup.css'; - -function FormGroup(props) { - const { - className, - children, - size, - advancedSettings, - isAdvanced, - ...otherProps - } = props; - - if (!advancedSettings && isAdvanced) { - return null; - } - - const childProps = isAdvanced ? { isAdvanced } : {}; - - return ( -
- { - map(children, (child) => { - return React.cloneElement(child, childProps); - }) - } -
- ); -} - -FormGroup.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, - size: PropTypes.oneOf(sizes.all).isRequired, - advancedSettings: PropTypes.bool.isRequired, - isAdvanced: PropTypes.bool.isRequired -}; - -FormGroup.defaultProps = { - className: styles.group, - size: sizes.SMALL, - advancedSettings: false, - isAdvanced: false -}; - -export default FormGroup; diff --git a/frontend/src/Components/Form/FormGroup.tsx b/frontend/src/Components/Form/FormGroup.tsx new file mode 100644 index 000000000..1dd879897 --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames'; +import React, { Children, ComponentPropsWithoutRef, ReactNode } from 'react'; +import { Size } from 'Helpers/Props/sizes'; +import styles from './FormGroup.css'; + +interface FormGroupProps extends ComponentPropsWithoutRef<'div'> { + className?: string; + children: ReactNode; + size?: Extract; + advancedSettings?: boolean; + isAdvanced?: boolean; +} + +function FormGroup(props: FormGroupProps) { + const { + className = styles.group, + children, + size = 'small', + advancedSettings = false, + isAdvanced = false, + ...otherProps + } = props; + + if (!advancedSettings && isAdvanced) { + return null; + } + + const childProps = isAdvanced ? { isAdvanced } : {}; + + return ( +
+ {Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + return React.cloneElement(child, childProps); + })} +
+ ); +} + +export default FormGroup; diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js deleted file mode 100644 index e3bccaf7c..000000000 --- a/frontend/src/Components/Form/FormInputGroup.js +++ /dev/null @@ -1,311 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Link from 'Components/Link/Link'; -import { inputTypes, kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import AutoCompleteInput from './AutoCompleteInput'; -import CaptchaInputConnector from './CaptchaInputConnector'; -import CheckInput from './CheckInput'; -import DeviceInputConnector from './DeviceInputConnector'; -import DownloadClientSelectInputConnector from './DownloadClientSelectInputConnector'; -import EnhancedSelectInput from './EnhancedSelectInput'; -import EnhancedSelectInputConnector from './EnhancedSelectInputConnector'; -import FormInputHelpText from './FormInputHelpText'; -import IndexerFlagsSelectInput from './IndexerFlagsSelectInput'; -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 SeriesTagInput from './SeriesTagInput'; -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.INDEXER_FLAGS_SELECT: - return IndexerFlagsSelectInput; - - 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_TAG: - return SeriesTagInput; - - 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), - placeholder: PropTypes.string, - delimiters: PropTypes.arrayOf(PropTypes.string), - isDisabled: PropTypes.bool, - type: PropTypes.string.isRequired, - kind: PropTypes.oneOf(kinds.all), - min: PropTypes.number, - max: PropTypes.number, - unit: PropTypes.string, - buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), - helpText: PropTypes.string, - helpTexts: PropTypes.arrayOf(PropTypes.string), - helpTextWarning: PropTypes.string, - helpLink: PropTypes.string, - autoFocus: PropTypes.bool, - canEdit: PropTypes.bool, - includeNoChange: PropTypes.bool, - includeNoChangeDisabled: PropTypes.bool, - includeAny: PropTypes.bool, - selectedValueOptions: PropTypes.object, - indexerFlags: PropTypes.number, - pending: PropTypes.bool, - errors: PropTypes.arrayOf(PropTypes.object), - warnings: PropTypes.arrayOf(PropTypes.object), - onChange: PropTypes.func.isRequired -}; - -FormInputGroup.defaultProps = { - className: styles.inputGroup, - containerClassName: styles.inputGroupContainer, - type: inputTypes.TEXT, - buttons: [], - helpTexts: [], - errors: [], - warnings: [] -}; - -export default FormInputGroup; diff --git a/frontend/src/Components/Form/FormInputGroup.tsx b/frontend/src/Components/Form/FormInputGroup.tsx new file mode 100644 index 000000000..897f19bbd --- /dev/null +++ b/frontend/src/Components/Form/FormInputGroup.tsx @@ -0,0 +1,292 @@ +import React, { 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 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 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.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; + selectedValueOptions?: object; + indexerFlags?: number; + pending?: boolean; + canEdit?: boolean; + includeAny?: boolean; + delimiters?: string[]; + errors?: (ValidationMessage | ValidationError)[]; + warnings?: (ValidationMessage | ValidationWarning)[]; + onChange: (args: T) => 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 'message' in error ? ( + + ) : ( + + ); + })} + + {warnings.map((warning, index) => { + return 'message' in warning ? ( + + ) : ( + + ); + })} +
+ ); +} + +export default FormInputGroup; diff --git a/frontend/src/Components/Form/FormInputHelpText.js b/frontend/src/Components/Form/FormInputHelpText.js deleted file mode 100644 index 00024684e..000000000 --- a/frontend/src/Components/Form/FormInputHelpText.js +++ /dev/null @@ -1,74 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons } from 'Helpers/Props'; -import styles from './FormInputHelpText.css'; - -function FormInputHelpText(props) { - const { - className, - text, - link, - tooltip, - isError, - isWarning, - isCheckInput - } = props; - - return ( -
- {text} - - { - link ? - - - : - null - } - - { - !link && tooltip ? - : - null - } -
- ); -} - -FormInputHelpText.propTypes = { - className: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - link: PropTypes.string, - tooltip: PropTypes.string, - isError: PropTypes.bool, - isWarning: PropTypes.bool, - isCheckInput: PropTypes.bool -}; - -FormInputHelpText.defaultProps = { - className: styles.helpText, - isError: false, - isWarning: false, - isCheckInput: false -}; - -export default FormInputHelpText; diff --git a/frontend/src/Components/Form/FormInputHelpText.tsx b/frontend/src/Components/Form/FormInputHelpText.tsx new file mode 100644 index 000000000..1531d9585 --- /dev/null +++ b/frontend/src/Components/Form/FormInputHelpText.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; +import React from 'react'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import { icons } from 'Helpers/Props'; +import styles from './FormInputHelpText.css'; + +interface FormInputHelpTextProps { + className?: string; + text: string; + link?: string; + tooltip?: string; + isError?: boolean; + isWarning?: boolean; + isCheckInput?: boolean; +} + +function FormInputHelpText({ + className = styles.helpText, + text, + link, + tooltip, + isError = false, + isWarning = false, + isCheckInput = false, +}: FormInputHelpTextProps) { + return ( +
+ {text} + + {link ? ( + + + + ) : null} + + {!link && tooltip ? ( + + ) : null} +
+ ); +} + +export default FormInputHelpText; diff --git a/frontend/src/Components/Form/FormLabel.js b/frontend/src/Components/Form/FormLabel.js deleted file mode 100644 index d4a4bcffc..000000000 --- a/frontend/src/Components/Form/FormLabel.js +++ /dev/null @@ -1,52 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { sizes } from 'Helpers/Props'; -import styles from './FormLabel.css'; - -function FormLabel(props) { - const { - children, - className, - errorClassName, - size, - name, - hasError, - isAdvanced, - ...otherProps - } = props; - - return ( - - ); -} - -FormLabel.propTypes = { - children: PropTypes.oneOfType([PropTypes.node, PropTypes.string]).isRequired, - className: PropTypes.string, - errorClassName: PropTypes.string, - size: PropTypes.oneOf(sizes.all), - name: PropTypes.string, - hasError: PropTypes.bool, - isAdvanced: PropTypes.bool -}; - -FormLabel.defaultProps = { - className: styles.label, - errorClassName: styles.hasError, - isAdvanced: false, - size: sizes.LARGE -}; - -export default FormLabel; diff --git a/frontend/src/Components/Form/FormLabel.tsx b/frontend/src/Components/Form/FormLabel.tsx new file mode 100644 index 000000000..4f29e6ac6 --- /dev/null +++ b/frontend/src/Components/Form/FormLabel.tsx @@ -0,0 +1,42 @@ +import classNames from 'classnames'; +import React, { ReactNode } from 'react'; +import { Size } from 'Helpers/Props/sizes'; +import styles from './FormLabel.css'; + +interface FormLabelProps { + children: ReactNode; + className?: string; + errorClassName?: string; + size?: Extract; + name?: string; + hasError?: boolean; + isAdvanced?: boolean; +} + +function FormLabel(props: FormLabelProps) { + const { + children, + className = styles.label, + errorClassName = styles.hasError, + size = 'large', + name, + hasError, + isAdvanced = false, + } = props; + + return ( + + ); +} + +export default FormLabel; diff --git a/frontend/src/Components/Form/HintedSelectInputOption.js b/frontend/src/Components/Form/HintedSelectInputOption.js deleted file mode 100644 index 4957ece2a..000000000 --- a/frontend/src/Components/Form/HintedSelectInputOption.js +++ /dev/null @@ -1,66 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import EnhancedSelectInputOption from './EnhancedSelectInputOption'; -import styles from './HintedSelectInputOption.css'; - -function HintedSelectInputOption(props) { - const { - id, - value, - hint, - depth, - isSelected, - isDisabled, - isMultiSelect, - isMobile, - ...otherProps - } = props; - - return ( - -
-
{value}
- - { - hint != null && -
- {hint} -
- } -
-
- ); -} - -HintedSelectInputOption.propTypes = { - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - value: PropTypes.string.isRequired, - hint: PropTypes.node, - depth: PropTypes.number, - isSelected: PropTypes.bool.isRequired, - isDisabled: PropTypes.bool.isRequired, - isMultiSelect: PropTypes.bool.isRequired, - isMobile: PropTypes.bool.isRequired -}; - -HintedSelectInputOption.defaultProps = { - isDisabled: false, - isHidden: false, - isMultiSelect: false -}; - -export default HintedSelectInputOption; diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js b/frontend/src/Components/Form/HintedSelectInputSelectedValue.js deleted file mode 100644 index a3fecf324..000000000 --- a/frontend/src/Components/Form/HintedSelectInputSelectedValue.js +++ /dev/null @@ -1,68 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Label from 'Components/Label'; -import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue'; -import styles from './HintedSelectInputSelectedValue.css'; - -function HintedSelectInputSelectedValue(props) { - const { - value, - values, - hint, - isMultiSelect, - includeHint, - ...otherProps - } = props; - - const valuesMap = isMultiSelect && _.keyBy(values, 'key'); - - return ( - -
- { - isMultiSelect ? - value.map((key, index) => { - const v = valuesMap[key]; - return ( - - ); - }) : - null - } - - { - isMultiSelect ? null : value - } -
- - { - hint != null && includeHint ? -
- {hint} -
: - null - } -
- ); -} - -HintedSelectInputSelectedValue.propTypes = { - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number]))]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - hint: PropTypes.string, - isMultiSelect: PropTypes.bool.isRequired, - includeHint: PropTypes.bool.isRequired -}; - -HintedSelectInputSelectedValue.defaultProps = { - isMultiSelect: false, - includeHint: true -}; - -export default HintedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/IndexerSelectInputConnector.js b/frontend/src/Components/Form/IndexerSelectInputConnector.js deleted file mode 100644 index 5f62becbb..000000000 --- a/frontend/src/Components/Form/IndexerSelectInputConnector.js +++ /dev/null @@ -1,97 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchIndexers } from 'Store/Actions/settingsActions'; -import sortByProp from 'Utilities/Array/sortByProp'; -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(sortByProp('name')), (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.css b/frontend/src/Components/Form/KeyValueListInput.css deleted file mode 100644 index d86e6a512..000000000 --- a/frontend/src/Components/Form/KeyValueListInput.css +++ /dev/null @@ -1,21 +0,0 @@ -.inputContainer { - composes: input from '~Components/Form/Input.css'; - - position: relative; - min-height: 35px; - height: auto; - - &.isFocused { - outline: 0; - border-color: var(--inputFocusBorderColor); - box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor); - } -} - -.hasError { - composes: hasError from '~Components/Form/Input.css'; -} - -.hasWarning { - composes: hasWarning from '~Components/Form/Input.css'; -} diff --git a/frontend/src/Components/Form/KeyValueListInput.css.d.ts b/frontend/src/Components/Form/KeyValueListInput.css.d.ts deleted file mode 100644 index 972f108c9..000000000 --- a/frontend/src/Components/Form/KeyValueListInput.css.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file is automatically generated. -// Please do not change this file! -interface CssExports { - 'hasError': string; - 'hasWarning': string; - 'inputContainer': string; - 'isFocused': string; -} -export const cssExports: CssExports; -export default cssExports; diff --git a/frontend/src/Components/Form/KeyValueListInput.js b/frontend/src/Components/Form/KeyValueListInput.js deleted file mode 100644 index 3e73d74f3..000000000 --- a/frontend/src/Components/Form/KeyValueListInput.js +++ /dev/null @@ -1,156 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import KeyValueListInputItem from './KeyValueListInputItem'; -import styles from './KeyValueListInput.css'; - -class KeyValueListInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isFocused: false - }; - } - - // - // Listeners - - onItemChange = (index, itemValue) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = [...value]; - - if (index == null) { - newValue.push(itemValue); - } else { - newValue.splice(index, 1, itemValue); - } - - onChange({ - name, - value: newValue - }); - }; - - onRemoveItem = (index) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = [...value]; - newValue.splice(index, 1); - - onChange({ - name, - value: newValue - }); - }; - - onFocus = () => { - this.setState({ - isFocused: true - }); - }; - - onBlur = () => { - this.setState({ - isFocused: false - }); - - const { - name, - value, - onChange - } = this.props; - - const newValue = value.reduce((acc, v) => { - if (v.key || v.value) { - acc.push(v); - } - - return acc; - }, []); - - if (newValue.length !== value.length) { - onChange({ - name, - value: newValue - }); - } - }; - - // - // Render - - render() { - const { - className, - value, - keyPlaceholder, - valuePlaceholder, - hasError, - hasWarning - } = this.props; - - const { isFocused } = this.state; - - return ( -
- { - [...value, { key: '', value: '' }].map((v, index) => { - return ( - - ); - }) - } -
- ); - } -} - -KeyValueListInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.object).isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - keyPlaceholder: PropTypes.string, - valuePlaceholder: PropTypes.string, - onChange: PropTypes.func.isRequired -}; - -KeyValueListInput.defaultProps = { - className: styles.inputContainer, - value: [] -}; - -export default KeyValueListInput; diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css deleted file mode 100644 index 75d37b74f..000000000 --- a/frontend/src/Components/Form/KeyValueListInputItem.css +++ /dev/null @@ -1,28 +0,0 @@ -.itemContainer { - display: flex; - margin-bottom: 3px; - border-bottom: 1px solid var(--inputBorderColor); - - &:last-child { - margin-bottom: 0; - } -} - -.keyInputWrapper { - flex: 6 0 0; -} - -.valueInputWrapper { - flex: 1 0 0; - min-width: 40px; -} - -.buttonWrapper { - flex: 0 0 22px; -} - -.keyInput, -.valueInput { - width: 100%; - border: none; -} diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts deleted file mode 100644 index aa0c1be13..000000000 --- a/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file is automatically generated. -// Please do not change this file! -interface CssExports { - 'buttonWrapper': string; - 'itemContainer': string; - 'keyInput': string; - 'keyInputWrapper': string; - 'valueInput': string; - 'valueInputWrapper': string; -} -export const cssExports: CssExports; -export default cssExports; diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js deleted file mode 100644 index 9f5abce2f..000000000 --- a/frontend/src/Components/Form/KeyValueListInputItem.js +++ /dev/null @@ -1,124 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import { icons } from 'Helpers/Props'; -import TextInput from './TextInput'; -import styles from './KeyValueListInputItem.css'; - -class KeyValueListInputItem extends Component { - - // - // Listeners - - onKeyChange = ({ value: keyValue }) => { - const { - index, - value, - onChange - } = this.props; - - onChange(index, { key: keyValue, value }); - }; - - onValueChange = ({ value }) => { - // TODO: Validate here or validate at a lower level component - - const { - index, - keyValue, - onChange - } = this.props; - - onChange(index, { key: keyValue, value }); - }; - - onRemovePress = () => { - const { - index, - onRemove - } = this.props; - - onRemove(index); - }; - - onFocus = () => { - this.props.onFocus(); - }; - - onBlur = () => { - this.props.onBlur(); - }; - - // - // Render - - render() { - const { - keyValue, - value, - keyPlaceholder, - valuePlaceholder, - isNew - } = this.props; - - return ( -
-
- -
- -
- -
- -
- { - isNew ? - null : - - } -
-
- ); - } -} - -KeyValueListInputItem.propTypes = { - index: PropTypes.number, - keyValue: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - keyPlaceholder: PropTypes.string.isRequired, - valuePlaceholder: PropTypes.string.isRequired, - isNew: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onRemove: PropTypes.func.isRequired, - onFocus: PropTypes.func.isRequired, - onBlur: PropTypes.func.isRequired -}; - -KeyValueListInputItem.defaultProps = { - keyPlaceholder: 'Key', - valuePlaceholder: 'Value' -}; - -export default KeyValueListInputItem; diff --git a/frontend/src/Components/Form/LanguageSelectInputConnector.js b/frontend/src/Components/Form/LanguageSelectInputConnector.js deleted file mode 100644 index dd3a52017..000000000 --- a/frontend/src/Components/Form/LanguageSelectInputConnector.js +++ /dev/null @@ -1,52 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -function createMapStateToProps() { - return createSelector( - (state, { values }) => values, - ( languages ) => { - - const minId = languages.reduce((min, v) => (v.key < 1 ? v.key : min), languages[0].key); - - const values = languages.map(({ key, value }) => { - return { - key, - value, - dividerAfter: minId < 1 ? key === minId : false - }; - }); - - return { - values - }; - } - ); -} - -class LanguageSelectInputConnector extends Component { - - // - // Render - - render() { - - return ( - - ); - } -} - -LanguageSelectInputConnector.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - onChange: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps)(LanguageSelectInputConnector); diff --git a/frontend/src/Components/Form/MonitorEpisodesSelectInput.js b/frontend/src/Components/Form/MonitorEpisodesSelectInput.js deleted file mode 100644 index a4ee4fd85..000000000 --- a/frontend/src/Components/Form/MonitorEpisodesSelectInput.js +++ /dev/null @@ -1,55 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import monitorOptions from 'Utilities/Series/monitorOptions'; -import translate from 'Utilities/String/translate'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -function MonitorEpisodesSelectInput(props) { - const { - includeNoChange, - includeMixed, - ...otherProps - } = props; - - const values = [...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 ( - - ); -} - -MonitorEpisodesSelectInput.propTypes = { - includeNoChange: PropTypes.bool.isRequired, - includeMixed: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired -}; - -MonitorEpisodesSelectInput.defaultProps = { - includeNoChange: false, - includeMixed: false -}; - -export default MonitorEpisodesSelectInput; diff --git a/frontend/src/Components/Form/MonitorNewItemsSelectInput.js b/frontend/src/Components/Form/MonitorNewItemsSelectInput.js deleted file mode 100644 index be179c3e5..000000000 --- a/frontend/src/Components/Form/MonitorNewItemsSelectInput.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -function MonitorNewItemsSelectInput(props) { - const { - includeNoChange, - includeMixed, - ...otherProps - } = props; - - const values = [...monitorNewItemsOptions]; - - if (includeNoChange) { - values.unshift({ - key: 'noChange', - value: 'No Change', - isDisabled: true - }); - } - - if (includeMixed) { - values.unshift({ - key: 'mixed', - value: '(Mixed)', - isDisabled: true - }); - } - - return ( - - ); -} - -MonitorNewItemsSelectInput.propTypes = { - includeNoChange: PropTypes.bool.isRequired, - includeMixed: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired -}; - -MonitorNewItemsSelectInput.defaultProps = { - includeNoChange: false, - includeMixed: false -}; - -export default MonitorNewItemsSelectInput; diff --git a/frontend/src/Components/Form/NumberInput.js b/frontend/src/Components/Form/NumberInput.js deleted file mode 100644 index cac274d95..000000000 --- a/frontend/src/Components/Form/NumberInput.js +++ /dev/null @@ -1,126 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextInput from './TextInput'; - -function parseValue(props, value) { - const { - isFloat, - min, - max - } = props; - - if (value == null || value === '') { - return null; - } - - let newValue = isFloat ? parseFloat(value) : parseInt(value); - - if (min != null && newValue != null && newValue < min) { - newValue = min; - } else if (max != null && newValue != null && newValue > max) { - newValue = max; - } - - return newValue; -} - -class NumberInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - value: props.value == null ? '' : props.value.toString(), - isFocused: false - }; - } - - componentDidUpdate(prevProps, prevState) { - const { value } = this.props; - - if (!isNaN(value) && value !== prevProps.value && !this.state.isFocused) { - this.setState({ - value: value == null ? '' : value.toString() - }); - } - } - - // - // Listeners - - onChange = ({ name, value }) => { - this.setState({ value }); - - this.props.onChange({ - name, - value: parseValue(this.props, value) - }); - - }; - - onFocus = () => { - this.setState({ isFocused: true }); - }; - - onBlur = () => { - const { - name, - onChange - } = this.props; - - const { value } = this.state; - const parsedValue = parseValue(this.props, value); - const stringValue = parsedValue == null ? '' : parsedValue.toString(); - - if (stringValue === value) { - this.setState({ isFocused: false }); - } else { - this.setState({ - value: stringValue, - isFocused: false - }); - } - - onChange({ - name, - value: parsedValue - }); - }; - - // - // Render - - render() { - const value = this.state.value; - - return ( - - ); - } -} - -NumberInput.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.number, - min: PropTypes.number, - max: PropTypes.number, - isFloat: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired -}; - -NumberInput.defaultProps = { - value: null, - isFloat: false -}; - -export default NumberInput; diff --git a/frontend/src/Components/Form/NumberInput.tsx b/frontend/src/Components/Form/NumberInput.tsx new file mode 100644 index 000000000..a5e1fcb64 --- /dev/null +++ b/frontend/src/Components/Form/NumberInput.tsx @@ -0,0 +1,108 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { InputChanged } from 'typings/inputs'; +import TextInput, { TextInputProps } from './TextInput'; + +function parseValue( + value: string | null | undefined, + isFloat: boolean, + min: number | undefined, + max: number | undefined +) { + if (value == null || value === '') { + return null; + } + + let newValue = isFloat ? parseFloat(value) : parseInt(value); + + if (min != null && newValue != null && newValue < min) { + newValue = min; + } else if (max != null && newValue != null && newValue > max) { + newValue = max; + } + + return newValue; +} + +interface NumberInputProps + extends Omit, 'value'> { + value?: number | null; + min?: number; + max?: number; + isFloat?: boolean; +} + +function NumberInput({ + name, + value: inputValue = null, + isFloat = false, + min, + max, + onChange, + ...otherProps +}: NumberInputProps) { + const [value, setValue] = useState( + inputValue == null ? '' : inputValue.toString() + ); + const isFocused = useRef(false); + const previousValue = usePrevious(inputValue); + + const handleChange = useCallback( + ({ name, value: newValue }: InputChanged) => { + setValue(newValue); + + onChange({ + name, + value: parseValue(newValue, isFloat, min, max), + }); + }, + [isFloat, min, max, onChange, setValue] + ); + + const handleFocus = useCallback(() => { + isFocused.current = true; + }, []); + + const handleBlur = useCallback(() => { + const parsedValue = parseValue(value, isFloat, min, max); + const stringValue = parsedValue == null ? '' : parsedValue.toString(); + + if (stringValue !== value) { + setValue(stringValue); + } + + onChange({ + name, + value: parsedValue, + }); + + isFocused.current = false; + }, [name, value, isFloat, min, max, onChange]); + + useEffect(() => { + if ( + // @ts-expect-error inputValue may be null + !isNaN(inputValue) && + inputValue !== previousValue && + !isFocused.current + ) { + setValue(inputValue == null ? '' : inputValue.toString()); + } + }, [inputValue, previousValue, setValue]); + + return ( + + ); +} + +export default NumberInput; diff --git a/frontend/src/Components/Form/OAuthInput.js b/frontend/src/Components/Form/OAuthInput.js deleted file mode 100644 index 4ecd625bc..000000000 --- a/frontend/src/Components/Form/OAuthInput.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; -import { kinds } from 'Helpers/Props'; - -function OAuthInput(props) { - const { - label, - authorizing, - error, - onPress - } = props; - - return ( -
- - {label} - -
- ); -} - -OAuthInput.propTypes = { - label: PropTypes.string.isRequired, - authorizing: PropTypes.bool.isRequired, - error: PropTypes.object, - onPress: PropTypes.func.isRequired -}; - -OAuthInput.defaultProps = { - label: 'Start OAuth' -}; - -export default OAuthInput; diff --git a/frontend/src/Components/Form/OAuthInput.tsx b/frontend/src/Components/Form/OAuthInput.tsx new file mode 100644 index 000000000..04d2a0caf --- /dev/null +++ b/frontend/src/Components/Form/OAuthInput.tsx @@ -0,0 +1,72 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import { kinds } from 'Helpers/Props'; +import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions'; +import { InputOnChange } from 'typings/inputs'; + +interface OAuthInputProps { + label?: string; + name: string; + provider: string; + providerData: object; + section: string; + onChange: InputOnChange; +} + +function OAuthInput({ + label = 'Start OAuth', + name, + provider, + providerData, + section, + onChange, +}: OAuthInputProps) { + const dispatch = useDispatch(); + const { authorizing, error, result } = useSelector( + (state: AppState) => state.oAuth + ); + + const handlePress = useCallback(() => { + dispatch( + startOAuth({ + name, + provider, + providerData, + section, + }) + ); + }, [name, provider, providerData, section, dispatch]); + + useEffect(() => { + if (!result) { + return; + } + + Object.keys(result).forEach((key) => { + onChange({ name: key, value: result[key] }); + }); + }, [result, onChange]); + + useEffect(() => { + return () => { + dispatch(resetOAuth()); + }; + }, [dispatch]); + + return ( +
+ + {label} + +
+ ); +} + +export default OAuthInput; diff --git a/frontend/src/Components/Form/OAuthInputConnector.js b/frontend/src/Components/Form/OAuthInputConnector.js deleted file mode 100644 index 1567c7e6c..000000000 --- a/frontend/src/Components/Form/OAuthInputConnector.js +++ /dev/null @@ -1,89 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { resetOAuth, startOAuth } from 'Store/Actions/oAuthActions'; -import OAuthInput from './OAuthInput'; - -function createMapStateToProps() { - return createSelector( - (state) => state.oAuth, - (oAuth) => { - return oAuth; - } - ); -} - -const mapDispatchToProps = { - startOAuth, - resetOAuth -}; - -class OAuthInputConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps) { - const { - result, - onChange - } = this.props; - - if (!result || result === prevProps.result) { - return; - } - - Object.keys(result).forEach((key) => { - onChange({ name: key, value: result[key] }); - }); - } - - componentWillUnmount = () => { - this.props.resetOAuth(); - }; - - // - // Listeners - - onPress = () => { - const { - name, - provider, - providerData, - section - } = this.props; - - this.props.startOAuth({ - name, - provider, - providerData, - section - }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -OAuthInputConnector.propTypes = { - name: PropTypes.string.isRequired, - result: PropTypes.object, - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - section: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - startOAuth: PropTypes.func.isRequired, - resetOAuth: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector); diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js deleted file mode 100644 index dbc4cfdb4..000000000 --- a/frontend/src/Components/Form/PasswordInput.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import TextInput from './TextInput'; - -// 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 = { - ...TextInput.props -}; - -export default PasswordInput; diff --git a/frontend/src/Components/Form/PasswordInput.tsx b/frontend/src/Components/Form/PasswordInput.tsx new file mode 100644 index 000000000..776c2b913 --- /dev/null +++ b/frontend/src/Components/Form/PasswordInput.tsx @@ -0,0 +1,14 @@ +import React, { SyntheticEvent } from 'react'; +import TextInput, { TextInputProps } from './TextInput'; + +// Prevent a user from copying (or cutting) the password from the input +function onCopy(e: SyntheticEvent) { + e.preventDefault(); + e.nativeEvent.stopImmediatePropagation(); +} + +function PasswordInput(props: TextInputProps) { + return ; +} + +export default PasswordInput; diff --git a/frontend/src/Components/Form/PathInput.js b/frontend/src/Components/Form/PathInput.js deleted file mode 100644 index 972d8f99f..000000000 --- a/frontend/src/Components/Form/PathInput.js +++ /dev/null @@ -1,195 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; -import Icon from 'Components/Icon'; -import { icons } from 'Helpers/Props'; -import AutoSuggestInput from './AutoSuggestInput'; -import FormInputButton from './FormInputButton'; -import styles from './PathInput.css'; - -class PathInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._node = document.getElementById('portal-root'); - - this.state = { - value: props.value, - isFileBrowserModalOpen: false - }; - } - - componentDidUpdate(prevProps) { - const { value } = this.props; - - if (prevProps.value !== value) { - this.setState({ value }); - } - } - - // - // Control - - getSuggestionValue({ path }) { - return path; - } - - renderSuggestion({ path }, { query }) { - const lastSeparatorIndex = query.lastIndexOf('\\') || query.lastIndexOf('/'); - - if (lastSeparatorIndex === -1) { - return ( - {path} - ); - } - - return ( - - - {path.substr(0, lastSeparatorIndex)} - - {path.substr(lastSeparatorIndex)} - - ); - } - - // - // Listeners - - onInputChange = ({ value }) => { - this.setState({ value }); - }; - - onInputKeyDown = (event) => { - if (event.key === 'Tab') { - event.preventDefault(); - const path = this.props.paths[0]; - - if (path) { - this.props.onChange({ - name: this.props.name, - value: path.path - }); - - if (path.type !== 'file') { - this.props.onFetchPaths(path.path); - } - } - } - }; - - onInputBlur = () => { - this.props.onChange({ - name: this.props.name, - value: this.state.value - }); - - this.props.onClearPaths(); - }; - - onSuggestionsFetchRequested = ({ value }) => { - this.props.onFetchPaths(value); - }; - - onSuggestionsClearRequested = () => { - // Required because props aren't always rendered, but no-op - // because we don't want to reset the paths after a path is selected. - }; - - onSuggestionSelected = (event, { suggestionValue }) => { - this.props.onFetchPaths(suggestionValue); - }; - - onFileBrowserOpenPress = () => { - this.setState({ isFileBrowserModalOpen: true }); - }; - - onFileBrowserModalClose = () => { - this.setState({ isFileBrowserModalOpen: false }); - }; - - // - // Render - - render() { - const { - className, - name, - paths, - includeFiles, - hasFileBrowser, - onChange, - ...otherProps - } = this.props; - - const { - value, - isFileBrowserModalOpen - } = this.state; - - return ( -
- - - { - hasFileBrowser && -
- - - - - -
- } -
- ); - } -} - -PathInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.string, - paths: PropTypes.array.isRequired, - includeFiles: PropTypes.bool.isRequired, - hasFileBrowser: PropTypes.bool, - onChange: PropTypes.func.isRequired, - onFetchPaths: PropTypes.func.isRequired, - onClearPaths: PropTypes.func.isRequired -}; - -PathInput.defaultProps = { - className: styles.inputWrapper, - value: '', - hasFileBrowser: true -}; - -export default PathInput; diff --git a/frontend/src/Components/Form/PathInput.tsx b/frontend/src/Components/Form/PathInput.tsx new file mode 100644 index 000000000..f353f1be4 --- /dev/null +++ b/frontend/src/Components/Form/PathInput.tsx @@ -0,0 +1,252 @@ +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; + 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, + hasFileBrowser = true, + onChange, + onFetchPaths, + onClearPaths, + ...otherProps + } = props; + + const [value, setValue] = useState(inputValue); + const [isFileBrowserModalOpen, setIsFileBrowserModalOpen] = useState(false); + const previousInputValue = usePrevious(inputValue); + const dispatch = useDispatch(); + + const handleFetchPaths = useCallback( + (path: string) => { + dispatch(fetchPaths({ path, includeFiles })); + }, + [includeFiles, dispatch] + ); + + const handleInputChange = useCallback( + (_event: SyntheticEvent, { newValue }: ChangeEvent) => { + setValue(newValue); + }, + [setValue] + ); + + const handleInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Tab') { + event.preventDefault(); + const path = paths[0]; + + if (path) { + onChange({ + name, + value: path.path, + }); + + if (path.type !== 'file') { + handleFetchPaths(path.path); + } + } + } + }, + [name, paths, handleFetchPaths, onChange] + ); + const handleInputBlur = useCallback(() => { + onChange({ + name, + value, + }); + + onClearPaths(); + }, [name, value, onClearPaths, onChange]); + + const handleSuggestionSelected = useCallback( + (_event: SyntheticEvent, { suggestion }: { suggestion: Path }) => { + handleFetchPaths(suggestion.path); + }, + [handleFetchPaths] + ); + + const handleSuggestionsFetchRequested = useCallback( + ({ value: newValue }: SuggestionsFetchRequestedParams) => { + handleFetchPaths(newValue); + }, + [handleFetchPaths] + ); + + const handleFileBrowserOpenPress = useCallback(() => { + setIsFileBrowserModalOpen(true); + }, [setIsFileBrowserModalOpen]); + + const handleFileBrowserModalClose = useCallback(() => { + setIsFileBrowserModalOpen(false); + }, [setIsFileBrowserModalOpen]); + + const handleChange = useCallback( + (change: InputChanged) => { + onChange({ name, value: change.value.path }); + }, + [name, onChange] + ); + + const getSuggestionValue = useCallback(({ path }: Path) => path, []); + + const renderSuggestion = useCallback( + ({ path }: Path, { query }: { query: string }) => { + const lastSeparatorIndex = + query.lastIndexOf('\\') || query.lastIndexOf('/'); + + if (lastSeparatorIndex === -1) { + return {path}; + } + + return ( + + + {path.substring(0, lastSeparatorIndex)} + + {path.substring(lastSeparatorIndex)} + + ); + }, + [] + ); + + useEffect(() => { + if (inputValue !== previousInputValue) { + setValue(inputValue); + } + }, [inputValue, previousInputValue, setValue]); + + return ( +
+ + + {hasFileBrowser ? ( +
+ + + + + +
+ ) : null} +
+ ); +} diff --git a/frontend/src/Components/Form/PathInputConnector.js b/frontend/src/Components/Form/PathInputConnector.js deleted file mode 100644 index 563437f9a..000000000 --- a/frontend/src/Components/Form/PathInputConnector.js +++ /dev/null @@ -1,81 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearPaths, fetchPaths } from 'Store/Actions/pathActions'; -import PathInput from './PathInput'; - -function createMapStateToProps() { - return createSelector( - (state) => state.paths, - (paths) => { - const { - currentPath, - directories, - files - } = paths; - - const filteredPaths = _.filter([...directories, ...files], ({ path }) => { - return path.toLowerCase().startsWith(currentPath.toLowerCase()); - }); - - return { - paths: filteredPaths - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchPaths: fetchPaths, - dispatchClearPaths: clearPaths -}; - -class PathInputConnector extends Component { - - // - // Listeners - - onFetchPaths = (path) => { - const { - includeFiles, - dispatchFetchPaths - } = this.props; - - dispatchFetchPaths({ - path, - includeFiles - }); - }; - - onClearPaths = () => { - this.props.dispatchClearPaths(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -PathInputConnector.propTypes = { - ...PathInput.props, - includeFiles: PropTypes.bool.isRequired, - dispatchFetchPaths: PropTypes.func.isRequired, - dispatchClearPaths: PropTypes.func.isRequired -}; - -PathInputConnector.defaultProps = { - includeFiles: false -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector); diff --git a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js deleted file mode 100644 index 055180f12..000000000 --- a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js +++ /dev/null @@ -1,105 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByProp from 'Utilities/Array/sortByProp'; -import translate from 'Utilities/String/translate'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -function createMapStateToProps() { - return createSelector( - createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')), - (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'); - }, - isDisabled: includeNoChangeDisabled - }); - } - - if (includeMixed) { - values.unshift({ - key: 'mixed', - get value() { - return `(${translate('Mixed')})`; - }, - isDisabled: true - }); - } - - return { - values - }; - } - ); -} - -class QualityProfileSelectInputConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - name, - value, - values - } = this.props; - - if (!value || !values.some((option) => option.key === value || parseInt(option.key) === value)) { - const firstValue = values.find((option) => !isNaN(parseInt(option.key))); - - if (firstValue) { - this.onChange({ name, value: firstValue.key }); - } - } - } - - // - // Listeners - - onChange = ({ name, value }) => { - this.props.onChange({ name, value: value === 'noChange' ? value : parseInt(value) }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -QualityProfileSelectInputConnector.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - values: PropTypes.arrayOf(PropTypes.object).isRequired, - includeNoChange: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired -}; - -QualityProfileSelectInputConnector.defaultProps = { - includeNoChange: false -}; - -export default connect(createMapStateToProps)(QualityProfileSelectInputConnector); diff --git a/frontend/src/Components/Form/RootFolderSelectInput.js b/frontend/src/Components/Form/RootFolderSelectInput.js deleted file mode 100644 index 1d76ad946..000000000 --- a/frontend/src/Components/Form/RootFolderSelectInput.js +++ /dev/null @@ -1,109 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; -import EnhancedSelectInput from './EnhancedSelectInput'; -import RootFolderSelectInputOption from './RootFolderSelectInputOption'; -import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue'; - -class RootFolderSelectInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isAddNewRootFolderModalOpen: false, - newRootFolderPath: '' - }; - } - - componentDidUpdate(prevProps) { - const { - name, - isSaving, - saveError, - onChange - } = this.props; - - const newRootFolderPath = this.state.newRootFolderPath; - - if ( - prevProps.isSaving && - !isSaving && - !saveError && - newRootFolderPath - ) { - onChange({ name, value: newRootFolderPath }); - this.setState({ newRootFolderPath: '' }); - } - } - - // - // Listeners - - onChange = ({ name, value }) => { - if (value === 'addNew') { - this.setState({ isAddNewRootFolderModalOpen: true }); - } else { - this.props.onChange({ name, value }); - } - }; - - onNewRootFolderSelect = ({ value }) => { - this.setState({ newRootFolderPath: value }, () => { - this.props.onNewRootFolderSelect(value); - }); - }; - - onAddRootFolderModalClose = () => { - this.setState({ isAddNewRootFolderModalOpen: false }); - }; - - // - // Render - - render() { - const { - includeNoChange, - onNewRootFolderSelect, - ...otherProps - } = this.props; - - return ( -
- - - -
- ); - } -} - -RootFolderSelectInput.propTypes = { - name: PropTypes.string.isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - includeNoChange: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onNewRootFolderSelect: PropTypes.func.isRequired -}; - -RootFolderSelectInput.defaultProps = { - includeNoChange: false -}; - -export default RootFolderSelectInput; diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js deleted file mode 100644 index 43581835f..000000000 --- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js +++ /dev/null @@ -1,175 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { addRootFolder } from 'Store/Actions/rootFolderActions'; -import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector'; -import translate from 'Utilities/String/translate'; -import RootFolderSelectInput from './RootFolderSelectInput'; - -const ADD_NEW_KEY = 'addNew'; - -function createMapStateToProps() { - return createSelector( - createRootFoldersSelector(), - (state, { value }) => value, - (state, { includeMissingValue }) => includeMissingValue, - (state, { includeNoChange }) => includeNoChange, - (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, - (rootFolders, value, includeMissingValue, includeNoChange, includeNoChangeDisabled = true) => { - const values = rootFolders.items.map((rootFolder) => { - return { - key: rootFolder.path, - value: rootFolder.path, - freeSpace: rootFolder.freeSpace, - isMissing: false - }; - }); - - if (includeNoChange) { - values.unshift({ - key: 'noChange', - get value() { - return translate('NoChange'); - }, - isDisabled: includeNoChangeDisabled, - isMissing: false - }); - } - - if (!values.length) { - values.push({ - key: '', - value: '', - isDisabled: true, - isHidden: true - }); - } - - if (includeMissingValue && !values.find((v) => v.key === value)) { - values.push({ - key: value, - value, - isMissing: true, - isDisabled: true - }); - } - - values.push({ - key: ADD_NEW_KEY, - value: translate('AddANewPath') - }); - - return { - values, - isSaving: rootFolders.isSaving, - saveError: rootFolders.saveError - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchAddRootFolder(path) { - dispatch(addRootFolder({ path })); - } - }; -} - -class RootFolderSelectInputConnector extends Component { - - // - // Lifecycle - - componentWillMount() { - const { - value, - values, - onChange - } = this.props; - - if (value == null && values[0].key === '') { - onChange({ name, value: '' }); - } - } - - componentDidMount() { - const { - name, - value, - values, - onChange - } = this.props; - - if (!value || !values.some((v) => v.key === value) || value === ADD_NEW_KEY) { - const defaultValue = values[0]; - - if (defaultValue.key === ADD_NEW_KEY) { - onChange({ name, value: '' }); - } else { - onChange({ name, value: defaultValue.key }); - } - } - } - - componentDidUpdate(prevProps) { - const { - name, - value, - values, - onChange - } = this.props; - - if (prevProps.values === values) { - return; - } - - if (!value && values.length && values.some((v) => !!v.key && v.key !== ADD_NEW_KEY)) { - const defaultValue = values[0]; - - if (defaultValue.key !== ADD_NEW_KEY) { - onChange({ name, value: defaultValue.key }); - } - } - } - - // - // Listeners - - onNewRootFolderSelect = (path) => { - this.props.dispatchAddRootFolder(path); - }; - - // - // Render - - render() { - const { - dispatchAddRootFolder, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -RootFolderSelectInputConnector.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - includeNoChange: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - dispatchAddRootFolder: PropTypes.func.isRequired -}; - -RootFolderSelectInputConnector.defaultProps = { - includeNoChange: false -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(RootFolderSelectInputConnector); diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js deleted file mode 100644 index daac82f34..000000000 --- a/frontend/src/Components/Form/RootFolderSelectInputOption.js +++ /dev/null @@ -1,77 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import EnhancedSelectInputOption from './EnhancedSelectInputOption'; -import styles from './RootFolderSelectInputOption.css'; - -function RootFolderSelectInputOption(props) { - const { - id, - value, - freeSpace, - isMissing, - seriesFolder, - isMobile, - isWindows, - ...otherProps - } = props; - - const slashCharacter = isWindows ? '\\' : '/'; - - return ( - -
-
- {value} - - { - seriesFolder && id !== 'addNew' ? -
- {slashCharacter} - {seriesFolder} -
: - null - } -
- - { - freeSpace == null ? - null : -
- {translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })} -
- } - - { - isMissing ? -
- {translate('Missing')} -
: - null - } -
-
- ); -} - -RootFolderSelectInputOption.propTypes = { - id: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - freeSpace: PropTypes.number, - isMissing: PropTypes.bool, - seriesFolder: PropTypes.string, - isMobile: PropTypes.bool.isRequired, - isWindows: PropTypes.bool -}; - -export default RootFolderSelectInputOption; diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js deleted file mode 100644 index 1c3a4fc9d..000000000 --- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js +++ /dev/null @@ -1,62 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue'; -import styles from './RootFolderSelectInputSelectedValue.css'; - -function RootFolderSelectInputSelectedValue(props) { - const { - value, - freeSpace, - seriesFolder, - includeFreeSpace, - isWindows, - ...otherProps - } = props; - - const slashCharacter = isWindows ? '\\' : '/'; - - return ( - -
-
- {value} -
- - { - seriesFolder ? -
- {slashCharacter} - {seriesFolder} -
: - null - } -
- - { - freeSpace != null && includeFreeSpace && -
- {translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })} -
- } -
- ); -} - -RootFolderSelectInputSelectedValue.propTypes = { - value: PropTypes.string, - freeSpace: PropTypes.number, - seriesFolder: PropTypes.string, - isWindows: PropTypes.bool, - includeFreeSpace: PropTypes.bool.isRequired -}; - -RootFolderSelectInputSelectedValue.defaultProps = { - includeFreeSpace: true -}; - -export default RootFolderSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx b/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx new file mode 100644 index 000000000..4ed3e0952 --- /dev/null +++ b/frontend/src/Components/Form/Select/DownloadClientSelectInput.tsx @@ -0,0 +1,88 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { fetchDownloadClients } from 'Store/Actions/settingsActions'; +import { Protocol } from 'typings/DownloadClient'; +import { EnhancedSelectInputChanged } from 'typings/inputs'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; +import EnhancedSelectInput, { + EnhancedSelectInputProps, + EnhancedSelectInputValue, +} from './EnhancedSelectInput'; + +function createDownloadClientsSelector( + includeAny: boolean, + protocol: Protocol +) { + return createSelector( + (state: AppState) => state.settings.downloadClients, + (downloadClients) => { + const { isFetching, isPopulated, error, items } = downloadClients; + + const filteredItems = items.filter((item) => item.protocol === protocol); + + const values = filteredItems + .sort(sortByProp('name')) + .map((downloadClient) => { + return { + key: downloadClient.id, + value: downloadClient.name, + hint: `(${downloadClient.id})`, + }; + }); + + if (includeAny) { + values.unshift({ + key: 0, + value: `(${translate('Any')})`, + hint: '', + }); + } + + return { + isFetching, + isPopulated, + error, + values, + }; + } + ); +} + +interface DownloadClientSelectInputProps + extends EnhancedSelectInputProps, number> { + name: string; + value: number; + includeAny?: boolean; + protocol?: Protocol; + onChange: (change: EnhancedSelectInputChanged) => void; +} + +function DownloadClientSelectInput({ + includeAny = false, + protocol = 'torrent', + ...otherProps +}: DownloadClientSelectInputProps) { + const dispatch = useDispatch(); + const { isFetching, isPopulated, values } = useSelector( + createDownloadClientsSelector(includeAny, protocol) + ); + + useEffect(() => { + if (!isPopulated) { + dispatch(fetchDownloadClients()); + } + }, [isPopulated, dispatch]); + + return ( + + ); +} + +export default DownloadClientSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/Select/EnhancedSelectInput.css similarity index 94% rename from frontend/src/Components/Form/EnhancedSelectInput.css rename to frontend/src/Components/Form/Select/EnhancedSelectInput.css index defefb18e..735d63573 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.css +++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.css @@ -73,6 +73,12 @@ padding: 10px 0; } +.optionsInnerModalBody { + composes: innerModalBody from '~Components/Modal/ModalBody.css'; + + padding: 0; +} + .optionsModalScroller { composes: scroller from '~Components/Scroller/Scroller.css'; diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css.d.ts b/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts similarity index 94% rename from frontend/src/Components/Form/EnhancedSelectInput.css.d.ts rename to frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts index edcf0079b..98167a9b5 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.css.d.ts +++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.css.d.ts @@ -14,6 +14,7 @@ interface CssExports { 'mobileCloseButtonContainer': string; 'options': string; 'optionsContainer': string; + 'optionsInnerModalBody': string; 'optionsModal': string; 'optionsModalBody': string; 'optionsModalScroller': string; diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx new file mode 100644 index 000000000..b47f8da3d --- /dev/null +++ b/frontend/src/Components/Form/Select/EnhancedSelectInput.tsx @@ -0,0 +1,622 @@ +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'; + +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 { 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; + }, []); + + 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) { + removeListener(); + } else { + addListener(); + } + + if (!isOpen && onOpen) { + onOpen(); + } + + setIsOpen(!isOpen); + }, [isOpen, setIsOpen, addListener, removeListener, 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(); + } + }); + + return ( +
+ + + {({ ref }) => ( +
+ + {isEditable && typeof value === 'string' ? ( +
+ + + {isFetching ? ( + + ) : null} + + {isFetching ? null : } + +
+ ) : ( + + + {selectedOption ? selectedOption.value : selectedValue} + + +
+ {isFetching ? ( + + ) : null} + + {isFetching ? null : } +
+ + )} +
+
+ )} +
+ + + {({ ref, style, scheduleUpdate }) => { + updater.current = scheduleUpdate; + + return ( +
+ {isOpen && !isMobile ? ( + + {values.map((v, index) => { + const hasParent = v.parentKey !== undefined; + const depth = hasParent ? 1 : 0; + const parentSelected = + v.parentKey !== undefined && + Array.isArray(value) && + value.includes(v.parentKey); + + const { key, ...other } = v; + + return ( + + {v.value} + + ); + })} + + ) : null} +
+ ); + }} +
+
+
+ + {isMobile ? ( + + + +
+ + + +
+ + {values.map((v, index) => { + const hasParent = v.parentKey !== undefined; + const depth = hasParent ? 1 : 0; + const parentSelected = + v.parentKey !== undefined && + isMultiSelect && + value.includes(v.parentKey); + + const { key, ...other } = v; + + return ( + + {v.value} + + ); + })} +
+
+
+ ) : null} +
+ ); +} + +export default EnhancedSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css similarity index 87% rename from frontend/src/Components/Form/EnhancedSelectInputOption.css rename to frontend/src/Components/Form/Select/EnhancedSelectInputOption.css index d7f0e861b..bfdaa9036 100644 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.css +++ b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css @@ -16,13 +16,13 @@ } .optionCheck { - composes: container from '~./CheckInput.css'; + composes: container from '~Components/Form/CheckInput.css'; flex: 0 0 0; } .optionCheckInput { - composes: input from '~./CheckInput.css'; + composes: input from '~Components/Form/CheckInput.css'; margin-top: 0; } diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts similarity index 100% rename from frontend/src/Components/Form/EnhancedSelectInputOption.css.d.ts rename to frontend/src/Components/Form/Select/EnhancedSelectInputOption.css.d.ts diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx new file mode 100644 index 000000000..c866a5060 --- /dev/null +++ b/frontend/src/Components/Form/Select/EnhancedSelectInputOption.tsx @@ -0,0 +1,84 @@ +import classNames from 'classnames'; +import React, { SyntheticEvent, useCallback } from 'react'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import { icons } from 'Helpers/Props'; +import CheckInput from '../CheckInput'; +import styles from './EnhancedSelectInputOption.css'; + +function handleCheckPress() { + // CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation. +} + +export interface EnhancedSelectInputOptionProps { + className?: string; + id: string | number; + depth?: number; + isSelected: boolean; + isDisabled?: boolean; + isHidden?: boolean; + isMultiSelect?: boolean; + isMobile: boolean; + children: React.ReactNode; + onSelect: (...args: unknown[]) => unknown; +} + +function EnhancedSelectInputOption({ + className = styles.option, + id, + depth = 0, + isSelected, + isDisabled = false, + isHidden = false, + isMultiSelect = false, + isMobile, + children, + onSelect, +}: EnhancedSelectInputOptionProps) { + const handlePress = useCallback( + (event: SyntheticEvent) => { + event.preventDefault(); + + onSelect(id); + }, + [id, onSelect] + ); + + return ( + + {depth !== 0 &&
} + + {isMultiSelect && ( + + )} + + {children} + + {isMobile && ( +
+ +
+ )} + + ); +} + +export default EnhancedSelectInputOption; diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css similarity index 100% rename from frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css rename to frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts similarity index 100% rename from frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css.d.ts rename to frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.css.d.ts diff --git a/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx new file mode 100644 index 000000000..88afdb18a --- /dev/null +++ b/frontend/src/Components/Form/Select/EnhancedSelectInputSelectedValue.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames'; +import React, { ReactNode } from 'react'; +import styles from './EnhancedSelectInputSelectedValue.css'; + +interface EnhancedSelectInputSelectedValueProps { + className?: string; + children: ReactNode; + isDisabled?: boolean; +} + +function EnhancedSelectInputSelectedValue({ + className = styles.selectedValue, + children, + isDisabled = false, +}: EnhancedSelectInputSelectedValueProps) { + return ( +
+ {children} +
+ ); +} + +export default EnhancedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/HintedSelectInputOption.css b/frontend/src/Components/Form/Select/HintedSelectInputOption.css similarity index 100% rename from frontend/src/Components/Form/HintedSelectInputOption.css rename to frontend/src/Components/Form/Select/HintedSelectInputOption.css diff --git a/frontend/src/Components/Form/HintedSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/HintedSelectInputOption.css.d.ts similarity index 100% rename from frontend/src/Components/Form/HintedSelectInputOption.css.d.ts rename to frontend/src/Components/Form/Select/HintedSelectInputOption.css.d.ts diff --git a/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx b/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx new file mode 100644 index 000000000..faa9081c5 --- /dev/null +++ b/frontend/src/Components/Form/Select/HintedSelectInputOption.tsx @@ -0,0 +1,52 @@ +import classNames from 'classnames'; +import React from 'react'; +import EnhancedSelectInputOption, { + EnhancedSelectInputOptionProps, +} from './EnhancedSelectInputOption'; +import styles from './HintedSelectInputOption.css'; + +interface HintedSelectInputOptionProps extends EnhancedSelectInputOptionProps { + value: string; + hint?: React.ReactNode; +} + +function HintedSelectInputOption(props: HintedSelectInputOptionProps) { + const { + id, + value, + hint, + depth, + isSelected = false, + isDisabled, + isMobile, + ...otherProps + } = props; + + return ( + +
+
{value}
+ + {hint != null &&
{hint}
} +
+
+ ); +} + +HintedSelectInputOption.defaultProps = { + isDisabled: false, + isHidden: false, + isMultiSelect: false, +}; + +export default HintedSelectInputOption; diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.css b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css similarity index 100% rename from frontend/src/Components/Form/HintedSelectInputSelectedValue.css rename to frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css diff --git a/frontend/src/Components/Form/HintedSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css.d.ts similarity index 100% rename from frontend/src/Components/Form/HintedSelectInputSelectedValue.css.d.ts rename to frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.css.d.ts diff --git a/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx new file mode 100644 index 000000000..7c4cba115 --- /dev/null +++ b/frontend/src/Components/Form/Select/HintedSelectInputSelectedValue.tsx @@ -0,0 +1,55 @@ +import React, { ReactNode, useMemo } from 'react'; +import Label from 'Components/Label'; +import ArrayElement from 'typings/Helpers/ArrayElement'; +import { EnhancedSelectInputValue } from './EnhancedSelectInput'; +import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue'; +import styles from './HintedSelectInputSelectedValue.css'; + +interface HintedSelectInputSelectedValueProps { + selectedValue: V; + values: T[]; + hint?: ReactNode; + isMultiSelect?: boolean; + includeHint?: boolean; +} + +function HintedSelectInputSelectedValue< + T extends EnhancedSelectInputValue, + V extends number | string +>(props: HintedSelectInputSelectedValueProps) { + const { + selectedValue, + values, + hint, + isMultiSelect = false, + includeHint = true, + ...otherProps + } = props; + + const valuesMap = useMemo(() => { + return new Map(values.map((v) => [v.key, v.value])); + }, [values]); + + return ( + +
+ {isMultiSelect && Array.isArray(selectedValue) + ? selectedValue.map((key) => { + const v = valuesMap.get(key); + + return ; + }) + : valuesMap.get(selectedValue as ArrayElement)} +
+ + {hint != null && includeHint ? ( +
{hint}
+ ) : null} +
+ ); +} + +export default HintedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx b/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx similarity index 68% rename from frontend/src/Components/Form/IndexerFlagsSelectInput.tsx rename to frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx index 8dbd27a70..a43044156 100644 --- a/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx +++ b/frontend/src/Components/Form/Select/IndexerFlagsSelectInput.tsx @@ -2,6 +2,7 @@ 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) => @@ -32,29 +33,36 @@ const selectIndexerFlagsValues = (selectedFlags: number) => interface IndexerFlagsSelectInputProps { name: string; indexerFlags: number; - onChange(payload: object): void; + onChange(payload: EnhancedSelectInputChanged): void; } -function IndexerFlagsSelectInput(props: IndexerFlagsSelectInputProps) { - const { indexerFlags, onChange } = props; - +function IndexerFlagsSelectInput({ + name, + indexerFlags, + onChange, + ...otherProps +}: IndexerFlagsSelectInputProps) { const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags)); - const onChangeWrapper = useCallback( - ({ name, value }: { name: string; value: number[] }) => { - const indexerFlags = value.reduce((acc, flagId) => acc + flagId, 0); + const handleChange = useCallback( + (change: EnhancedSelectInputChanged) => { + const indexerFlags = change.value.reduce( + (acc, flagId) => acc + flagId, + 0 + ); onChange({ name, value: indexerFlags }); }, - [onChange] + [name, onChange] ); return ( ); } diff --git a/frontend/src/Components/Form/Select/IndexerSelectInput.tsx b/frontend/src/Components/Form/Select/IndexerSelectInput.tsx new file mode 100644 index 000000000..4bb4ff787 --- /dev/null +++ b/frontend/src/Components/Form/Select/IndexerSelectInput.tsx @@ -0,0 +1,81 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { fetchIndexers } from 'Store/Actions/settingsActions'; +import { EnhancedSelectInputChanged } from 'typings/inputs'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +function createIndexersSelector(includeAny: boolean) { + return createSelector( + (state: AppState) => state.settings.indexers, + (indexers) => { + const { isFetching, isPopulated, error, items } = indexers; + + const values = items.sort(sortByProp('name')).map((indexer) => { + return { + key: indexer.id, + value: indexer.name, + }; + }); + + if (includeAny) { + values.unshift({ + key: 0, + value: `(${translate('Any')})`, + }); + } + + return { + isFetching, + isPopulated, + error, + values, + }; + } + ); +} + +interface IndexerSelectInputConnectorProps { + name: string; + value: number; + includeAny?: boolean; + values: object[]; + onChange: (change: EnhancedSelectInputChanged) => void; +} + +function IndexerSelectInput({ + name, + value, + includeAny = false, + onChange, +}: IndexerSelectInputConnectorProps) { + const dispatch = useDispatch(); + const { isFetching, isPopulated, values } = useSelector( + createIndexersSelector(includeAny) + ); + + useEffect(() => { + if (!isPopulated) { + dispatch(fetchIndexers()); + } + }, [isPopulated, dispatch]); + + return ( + + ); +} + +IndexerSelectInput.defaultProps = { + includeAny: false, +}; + +export default IndexerSelectInput; diff --git a/frontend/src/Components/Form/Select/LanguageSelectInput.tsx b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx new file mode 100644 index 000000000..80efde065 --- /dev/null +++ b/frontend/src/Components/Form/Select/LanguageSelectInput.tsx @@ -0,0 +1,43 @@ +import React, { useMemo } from 'react'; +import { EnhancedSelectInputChanged } from 'typings/inputs'; +import EnhancedSelectInput, { + EnhancedSelectInputValue, +} from './EnhancedSelectInput'; + +interface LanguageSelectInputProps { + name: string; + value: number; + values: EnhancedSelectInputValue[]; + onChange: (change: EnhancedSelectInputChanged) => void; +} + +function LanguageSelectInput({ + values, + onChange, + ...otherProps +}: LanguageSelectInputProps) { + const mappedValues = useMemo(() => { + const minId = values.reduce( + (min: number, v) => (v.key < 1 ? v.key : min), + values[0].key + ); + + return values.map(({ key, value }) => { + return { + key, + value, + dividerAfter: minId < 1 ? key === minId : false, + }; + }); + }, [values]); + + return ( + + ); +} + +export default LanguageSelectInput; diff --git a/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx b/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx new file mode 100644 index 000000000..59fd08513 --- /dev/null +++ b/frontend/src/Components/Form/Select/MonitorEpisodesSelectInput.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import monitorOptions from 'Utilities/Series/monitorOptions'; +import translate from 'Utilities/String/translate'; +import EnhancedSelectInput, { + EnhancedSelectInputProps, + EnhancedSelectInputValue, +} from './EnhancedSelectInput'; + +interface MonitorEpisodesSelectInputProps + extends Omit< + EnhancedSelectInputProps, string>, + 'values' + > { + includeNoChange: boolean; + includeMixed: boolean; +} + +function MonitorEpisodesSelectInput(props: MonitorEpisodesSelectInputProps) { + const { + includeNoChange = false, + includeMixed = false, + ...otherProps + } = props; + + const values: EnhancedSelectInputValue[] = [...monitorOptions]; + + if (includeNoChange) { + values.unshift({ + key: 'noChange', + get value() { + return translate('NoChange'); + }, + isDisabled: true, + }); + } + + if (includeMixed) { + values.unshift({ + key: 'mixed', + get value() { + return `(${translate('Mixed')})`; + }, + isDisabled: true, + }); + } + + return ; +} + +export default MonitorEpisodesSelectInput; diff --git a/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx b/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx new file mode 100644 index 000000000..ac11f1fca --- /dev/null +++ b/frontend/src/Components/Form/Select/MonitorNewItemsSelectInput.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import monitorNewItemsOptions from 'Utilities/Series/monitorNewItemsOptions'; +import EnhancedSelectInput, { + EnhancedSelectInputProps, + EnhancedSelectInputValue, +} from './EnhancedSelectInput'; + +interface MonitorNewItemsSelectInputProps + extends Omit< + EnhancedSelectInputProps, string>, + 'values' + > { + includeNoChange?: boolean; + includeMixed?: boolean; + onChange: (...args: unknown[]) => unknown; +} + +function MonitorNewItemsSelectInput(props: MonitorNewItemsSelectInputProps) { + const { + includeNoChange = false, + includeMixed = false, + ...otherProps + } = props; + + const values: EnhancedSelectInputValue[] = [ + ...monitorNewItemsOptions, + ]; + + if (includeNoChange) { + values.unshift({ + key: 'noChange', + value: 'No Change', + isDisabled: true, + }); + } + + if (includeMixed) { + values.unshift({ + key: 'mixed', + value: '(Mixed)', + isDisabled: true, + }); + } + + return ; +} + +export default MonitorNewItemsSelectInput; diff --git a/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx b/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx new file mode 100644 index 000000000..e4a8003eb --- /dev/null +++ b/frontend/src/Components/Form/Select/ProviderOptionSelectInput.tsx @@ -0,0 +1,164 @@ +import { isEqual } from 'lodash'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import ProviderOptionsAppState, { + ProviderOptions, +} from 'App/State/ProviderOptionsAppState'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { + clearOptions, + fetchOptions, +} from 'Store/Actions/providerOptionActions'; +import { FieldSelectOption } from 'typings/Field'; +import EnhancedSelectInput, { + EnhancedSelectInputProps, + EnhancedSelectInputValue, +} from './EnhancedSelectInput'; + +const importantFieldNames = ['baseUrl', 'apiPath', 'apiKey', 'authToken']; + +function getProviderDataKey(providerData: ProviderOptions) { + if (!providerData || !providerData.fields) { + return null; + } + + const fields = providerData.fields + .filter((f) => importantFieldNames.includes(f.name)) + .map((f) => f.value); + + return fields; +} + +function getSelectOptions(items: FieldSelectOption[]) { + if (!items) { + return []; + } + + return items.map((option) => { + return { + key: option.value, + value: option.name, + hint: option.hint, + parentKey: option.parentValue, + isDisabled: option.isDisabled, + additionalProperties: option.additionalProperties, + }; + }); +} + +function createProviderOptionsSelector( + selectOptionsProviderAction: keyof Omit +) { + return createSelector( + (state: AppState) => state.providerOptions[selectOptionsProviderAction], + (options) => { + if (!options) { + return { + isFetching: false, + values: [], + }; + } + + return { + isFetching: options.isFetching, + values: getSelectOptions(options.items), + }; + } + ); +} + +interface ProviderOptionSelectInputProps + extends Omit< + EnhancedSelectInputProps, unknown>, + 'values' + > { + provider: string; + providerData: ProviderOptions; + name: string; + value: unknown; + selectOptionsProviderAction: keyof Omit; +} + +function ProviderOptionSelectInput({ + provider, + providerData, + selectOptionsProviderAction, + ...otherProps +}: ProviderOptionSelectInputProps) { + const dispatch = useDispatch(); + const [isRefetchRequired, setIsRefetchRequired] = useState(false); + const previousProviderData = usePrevious(providerData); + const { isFetching, values } = useSelector( + createProviderOptionsSelector(selectOptionsProviderAction) + ); + + const handleOpen = useCallback(() => { + if (isRefetchRequired && selectOptionsProviderAction) { + setIsRefetchRequired(false); + + dispatch( + fetchOptions({ + section: selectOptionsProviderAction, + action: selectOptionsProviderAction, + provider, + providerData, + }) + ); + } + }, [ + isRefetchRequired, + provider, + providerData, + selectOptionsProviderAction, + dispatch, + ]); + + useEffect(() => { + if (selectOptionsProviderAction) { + dispatch( + fetchOptions({ + section: selectOptionsProviderAction, + action: selectOptionsProviderAction, + provider, + providerData, + }) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectOptionsProviderAction, dispatch]); + + useEffect(() => { + if (!previousProviderData) { + return; + } + + const prevKey = getProviderDataKey(previousProviderData); + const nextKey = getProviderDataKey(providerData); + + if (!isEqual(prevKey, nextKey)) { + setIsRefetchRequired(true); + } + }, [providerData, previousProviderData, setIsRefetchRequired]); + + useEffect(() => { + return () => { + if (selectOptionsProviderAction) { + dispatch(clearOptions({ section: selectOptionsProviderAction })); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + ); +} + +export default ProviderOptionSelectInput; diff --git a/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx b/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx new file mode 100644 index 000000000..036f0f82c --- /dev/null +++ b/frontend/src/Components/Form/Select/QualityProfileSelectInput.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { QualityProfilesAppState } from 'App/State/SettingsAppState'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import { EnhancedSelectInputChanged } from 'typings/inputs'; +import QualityProfile from 'typings/QualityProfile'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; +import EnhancedSelectInput, { + EnhancedSelectInputProps, + EnhancedSelectInputValue, +} from './EnhancedSelectInput'; + +function createQualityProfilesSelector( + includeNoChange: boolean, + includeNoChangeDisabled: boolean, + includeMixed: boolean +) { + return createSelector( + createSortedSectionSelector( + 'settings.qualityProfiles', + sortByProp('name') + ), + (qualityProfiles: QualityProfilesAppState) => { + const values: EnhancedSelectInputValue[] = + qualityProfiles.items.map((qualityProfile) => { + return { + key: qualityProfile.id, + value: qualityProfile.name, + }; + }); + + if (includeNoChange) { + values.unshift({ + key: 'noChange', + get value() { + return translate('NoChange'); + }, + isDisabled: includeNoChangeDisabled, + }); + } + + if (includeMixed) { + values.unshift({ + key: 'mixed', + get value() { + return `(${translate('Mixed')})`; + }, + isDisabled: true, + }); + } + + return values; + } + ); +} + +interface QualityProfileSelectInputConnectorProps + extends Omit< + EnhancedSelectInputProps< + EnhancedSelectInputValue, + number | string + >, + 'values' + > { + name: string; + includeNoChange?: boolean; + includeNoChangeDisabled?: boolean; + includeMixed?: boolean; +} + +function QualityProfileSelectInput({ + name, + value, + includeNoChange = false, + includeNoChangeDisabled = true, + includeMixed = false, + onChange, + ...otherProps +}: QualityProfileSelectInputConnectorProps) { + const values = useSelector( + createQualityProfilesSelector( + includeNoChange, + includeNoChangeDisabled, + includeMixed + ) + ); + + const handleChange = useCallback( + ({ value: newValue }: EnhancedSelectInputChanged) => { + onChange({ + name, + value: newValue === 'noChange' ? value : newValue, + }); + }, + [name, value, onChange] + ); + + useEffect(() => { + if ( + !value || + !values.some((option) => option.key === value || option.key === value) + ) { + const firstValue = values.find( + (option) => typeof option.key === 'number' + ); + + if (firstValue) { + onChange({ name, value: firstValue.key }); + } + } + }, [name, value, values, onChange]); + + return ( + + ); +} + +export default QualityProfileSelectInput; diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx new file mode 100644 index 000000000..4704a3cd4 --- /dev/null +++ b/frontend/src/Components/Form/Select/RootFolderSelectInput.tsx @@ -0,0 +1,215 @@ +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 } 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(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 + }, []); + + return ( + <> + + + + + ); +} + +export default RootFolderSelectInput; diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.css similarity index 100% rename from frontend/src/Components/Form/RootFolderSelectInputOption.css rename to frontend/src/Components/Form/Select/RootFolderSelectInputOption.css diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.css.d.ts similarity index 100% rename from frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts rename to frontend/src/Components/Form/Select/RootFolderSelectInputOption.css.d.ts diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx new file mode 100644 index 000000000..d71f0d638 --- /dev/null +++ b/frontend/src/Components/Form/Select/RootFolderSelectInputOption.tsx @@ -0,0 +1,67 @@ +import classNames from 'classnames'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import EnhancedSelectInputOption, { + EnhancedSelectInputOptionProps, +} from './EnhancedSelectInputOption'; +import styles from './RootFolderSelectInputOption.css'; + +interface RootFolderSelectInputOptionProps + extends EnhancedSelectInputOptionProps { + id: string; + value: string; + freeSpace?: number; + isMissing?: boolean; + seriesFolder?: string; + isMobile: boolean; + isWindows?: boolean; +} + +function RootFolderSelectInputOption(props: RootFolderSelectInputOptionProps) { + const { + id, + value, + freeSpace, + isMissing, + seriesFolder, + isMobile, + isWindows, + ...otherProps + } = props; + + const slashCharacter = isWindows ? '\\' : '/'; + + return ( + +
+
+ {value} + + {seriesFolder && id !== 'addNew' ? ( +
+ {slashCharacter} + {seriesFolder} +
+ ) : null} +
+ + {freeSpace == null ? null : ( +
+ {translate('RootFolderSelectFreeSpace', { + freeSpace: formatBytes(freeSpace), + })} +
+ )} + + {isMissing ? ( +
{translate('Missing')}
+ ) : null} +
+
+ ); +} + +export default RootFolderSelectInputOption; diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css similarity index 100% rename from frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css rename to frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css.d.ts similarity index 100% rename from frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts rename to frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.css.d.ts diff --git a/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx new file mode 100644 index 000000000..e06101f2a --- /dev/null +++ b/frontend/src/Components/Form/Select/RootFolderSelectInputSelectedValue.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue'; +import { RootFolderSelectInputValue } from './RootFolderSelectInput'; +import styles from './RootFolderSelectInputSelectedValue.css'; + +interface RootFolderSelectInputSelectedValueProps { + selectedValue: string; + values: RootFolderSelectInputValue[]; + freeSpace?: number; + seriesFolder?: string; + isWindows?: boolean; + includeFreeSpace?: boolean; +} + +function RootFolderSelectInputSelectedValue( + props: RootFolderSelectInputSelectedValueProps +) { + const { + selectedValue, + values, + freeSpace, + seriesFolder, + includeFreeSpace = true, + isWindows, + ...otherProps + } = props; + + const slashCharacter = isWindows ? '\\' : '/'; + const value = values.find((v) => v.key === selectedValue)?.value; + + return ( + +
+
{value}
+ + {seriesFolder ? ( +
+ {slashCharacter} + {seriesFolder} +
+ ) : null} +
+ + {freeSpace != null && includeFreeSpace ? ( +
+ {translate('RootFolderSelectFreeSpace', { + freeSpace: formatBytes(freeSpace), + })} +
+ ) : null} +
+ ); +} + +export default RootFolderSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/SeriesTypeSelectInput.tsx b/frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx similarity index 64% rename from frontend/src/Components/Form/SeriesTypeSelectInput.tsx rename to frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx index 17082c75c..6a3bba650 100644 --- a/frontend/src/Components/Form/SeriesTypeSelectInput.tsx +++ b/frontend/src/Components/Form/Select/SeriesTypeSelectInput.tsx @@ -1,17 +1,21 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import * as seriesTypes from 'Utilities/Series/seriesTypes'; import translate from 'Utilities/String/translate'; -import EnhancedSelectInput from './EnhancedSelectInput'; +import EnhancedSelectInput, { + EnhancedSelectInputProps, + EnhancedSelectInputValue, +} from './EnhancedSelectInput'; import SeriesTypeSelectInputOption from './SeriesTypeSelectInputOption'; import SeriesTypeSelectInputSelectedValue from './SeriesTypeSelectInputSelectedValue'; -interface SeriesTypeSelectInputProps { +interface SeriesTypeSelectInputProps + extends EnhancedSelectInputProps, string> { includeNoChange: boolean; includeNoChangeDisabled?: boolean; includeMixed: boolean; } -interface ISeriesTypeOption { +export interface ISeriesTypeOption { key: string; value: string; format?: string; @@ -43,29 +47,33 @@ const seriesTypeOptions: ISeriesTypeOption[] = [ ]; function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) { - const values = [...seriesTypeOptions]; - const { includeNoChange = false, includeNoChangeDisabled = true, includeMixed = false, } = props; - if (includeNoChange) { - values.unshift({ - key: 'noChange', - value: translate('NoChange'), - isDisabled: includeNoChangeDisabled, - }); - } + const values = useMemo(() => { + const result = [...seriesTypeOptions]; - if (includeMixed) { - values.unshift({ - key: 'mixed', - value: `(${translate('Mixed')})`, - isDisabled: true, - }); - } + if (includeNoChange) { + result.unshift({ + key: 'noChange', + value: translate('NoChange'), + isDisabled: includeNoChangeDisabled, + }); + } + + if (includeMixed) { + result.unshift({ + key: 'mixed', + value: `(${translate('Mixed')})`, + isDisabled: true, + }); + } + + return result; + }, [includeNoChange, includeNoChangeDisabled, includeMixed]); return ( +
diff --git a/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx b/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx new file mode 100644 index 000000000..b6470f1a4 --- /dev/null +++ b/frontend/src/Components/Form/Select/SeriesTypeSelectInputSelectedValue.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; +import { ISeriesTypeOption } from './SeriesTypeSelectInput'; + +interface SeriesTypeSelectInputOptionProps { + selectedValue: string; + values: ISeriesTypeOption[]; + format: string; +} +function SeriesTypeSelectInputSelectedValue( + props: SeriesTypeSelectInputOptionProps +) { + const { selectedValue, values, ...otherProps } = props; + const format = values.find((v) => v.key === selectedValue)?.format; + + return ( + + ); +} + +export default SeriesTypeSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/UMaskInput.css b/frontend/src/Components/Form/Select/UMaskInput.css similarity index 93% rename from frontend/src/Components/Form/UMaskInput.css rename to frontend/src/Components/Form/Select/UMaskInput.css index 91486687e..a777aaeef 100644 --- a/frontend/src/Components/Form/UMaskInput.css +++ b/frontend/src/Components/Form/Select/UMaskInput.css @@ -1,53 +1,53 @@ -.inputWrapper { - display: flex; -} - -.inputFolder { - composes: input from '~Components/Form/Input.css'; - - max-width: 100px; -} - -.inputUnitWrapper { - position: relative; - width: 100%; -} - -.inputUnit { - composes: inputUnit from '~Components/Form/FormInputGroup.css'; - - right: 40px; - font-family: $monoSpaceFontFamily; -} - -.unit { - font-family: $monoSpaceFontFamily; -} - -.details { - margin-top: 5px; - margin-left: 17px; - line-height: 20px; - - > div { - display: flex; - - label { - flex: 0 0 50px; - } - - .value { - width: 50px; - text-align: right; - } - - .unit { - width: 90px; - text-align: right; - } - } -} - -.readOnly { - background-color: var(--inputReadOnlyBackgroundColor); -} +.inputWrapper { + display: flex; +} + +.inputFolder { + composes: input from '~Components/Form/Input.css'; + + max-width: 100px; +} + +.inputUnitWrapper { + position: relative; + width: 100%; +} + +.inputUnit { + composes: inputUnit from '~Components/Form/FormInputGroup.css'; + + right: 40px; + font-family: $monoSpaceFontFamily; +} + +.unit { + font-family: $monoSpaceFontFamily; +} + +.details { + margin-top: 5px; + margin-left: 17px; + line-height: 20px; + + > div { + display: flex; + + label { + flex: 0 0 50px; + } + + .value { + width: 50px; + text-align: right; + } + + .unit { + width: 90px; + text-align: right; + } + } +} + +.readOnly { + background-color: var(--inputReadOnlyBackgroundColor); +} diff --git a/frontend/src/Components/Form/UMaskInput.css.d.ts b/frontend/src/Components/Form/Select/UMaskInput.css.d.ts similarity index 100% rename from frontend/src/Components/Form/UMaskInput.css.d.ts rename to frontend/src/Components/Form/Select/UMaskInput.css.d.ts diff --git a/frontend/src/Components/Form/Select/UMaskInput.tsx b/frontend/src/Components/Form/Select/UMaskInput.tsx new file mode 100644 index 000000000..1f537f968 --- /dev/null +++ b/frontend/src/Components/Form/Select/UMaskInput.tsx @@ -0,0 +1,142 @@ +/* eslint-disable no-bitwise */ +import PropTypes from 'prop-types'; +import React, { SyntheticEvent } from 'react'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import EnhancedSelectInput from './EnhancedSelectInput'; +import styles from './UMaskInput.css'; + +const umaskOptions = [ + { + key: '755', + get value() { + return translate('Umask755Description', { octal: '755' }); + }, + hint: 'drwxr-xr-x', + }, + { + key: '775', + get value() { + return translate('Umask775Description', { octal: '775' }); + }, + hint: 'drwxrwxr-x', + }, + { + key: '770', + get value() { + return translate('Umask770Description', { octal: '770' }); + }, + hint: 'drwxrwx---', + }, + { + key: '750', + get value() { + return translate('Umask750Description', { octal: '750' }); + }, + hint: 'drwxr-x---', + }, + { + key: '777', + get value() { + return translate('Umask777Description', { octal: '777' }); + }, + hint: 'drwxrwxrwx', + }, +]; + +function formatPermissions(permissions: number) { + const hasSticky = permissions & 0o1000; + const hasSetGID = permissions & 0o2000; + const hasSetUID = permissions & 0o4000; + + let result = ''; + + for (let i = 0; i < 9; i++) { + const bit = (permissions & (1 << i)) !== 0; + let digit = bit ? 'xwr'[i % 3] : '-'; + if (i === 6 && hasSetUID) { + digit = bit ? 's' : 'S'; + } else if (i === 3 && hasSetGID) { + digit = bit ? 's' : 'S'; + } else if (i === 0 && hasSticky) { + digit = bit ? 't' : 'T'; + } + result = digit + result; + } + + return result; +} + +interface UMaskInputProps { + name: string; + value: string; + hasError?: boolean; + hasWarning?: boolean; + onChange: (change: InputChanged) => void; + onFocus?: (event: SyntheticEvent) => void; + onBlur?: (event: SyntheticEvent) => void; +} + +function UMaskInput({ name, value, onChange }: UMaskInputProps) { + const valueNum = parseInt(value, 8); + const umaskNum = 0o777 & ~valueNum; + const umask = umaskNum.toString(8).padStart(4, '0'); + const folderNum = 0o777 & ~umaskNum; + const folder = folderNum.toString(8).padStart(3, '0'); + const fileNum = 0o666 & ~umaskNum; + const file = fileNum.toString(8).padStart(3, '0'); + const unit = formatPermissions(folderNum); + + const values = umaskOptions.map((v) => { + return { ...v, hint: {v.hint} }; + }); + + return ( +
+
+
+ + +
d{unit}
+
+
+ +
+
+ +
{umask}
+
+ +
+ +
{folder}
+
d{formatPermissions(folderNum)}
+
+ +
+ +
{file}
+
{formatPermissions(fileNum)}
+
+
+
+ ); +} + +UMaskInput.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onFocus: PropTypes.func, + onBlur: PropTypes.func, +}; + +export default UMaskInput; diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js deleted file mode 100644 index 553501afc..000000000 --- a/frontend/src/Components/Form/SelectInput.js +++ /dev/null @@ -1,95 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import styles from './SelectInput.css'; - -class SelectInput extends Component { - - // - // Listeners - - onChange = (event) => { - this.props.onChange({ - name: this.props.name, - value: event.target.value - }); - }; - - // - // Render - - render() { - const { - className, - disabledClassName, - name, - value, - values, - isDisabled, - hasError, - hasWarning, - autoFocus, - onBlur - } = this.props; - - return ( - - ); - } -} - -SelectInput.propTypes = { - className: PropTypes.string, - disabledClassName: PropTypes.string, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.func]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - autoFocus: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onBlur: PropTypes.func -}; - -SelectInput.defaultProps = { - className: styles.select, - disabledClassName: styles.isDisabled, - isDisabled: false, - autoFocus: false -}; - -export default SelectInput; diff --git a/frontend/src/Components/Form/SelectInput.tsx b/frontend/src/Components/Form/SelectInput.tsx new file mode 100644 index 000000000..4716c2dfd --- /dev/null +++ b/frontend/src/Components/Form/SelectInput.tsx @@ -0,0 +1,76 @@ +import classNames from 'classnames'; +import React, { ChangeEvent, SyntheticEvent, useCallback } from 'react'; +import { InputChanged } from 'typings/inputs'; +import styles from './SelectInput.css'; + +interface SelectInputOption { + key: string; + value: string | number | (() => string | number); +} + +interface SelectInputProps { + className?: string; + disabledClassName?: string; + name: string; + value: string | number; + values: SelectInputOption[]; + isDisabled?: boolean; + hasError?: boolean; + hasWarning?: boolean; + autoFocus?: boolean; + onChange: (change: InputChanged) => void; + onBlur?: (event: SyntheticEvent) => void; +} + +function SelectInput({ + className = styles.select, + disabledClassName = styles.isDisabled, + name, + value, + values, + isDisabled = false, + hasError, + hasWarning, + autoFocus = false, + onBlur, + onChange, +}: SelectInputProps) { + const handleChange = useCallback( + (event: ChangeEvent) => { + onChange({ + name, + value: event.target.value as T, + }); + }, + [name, onChange] + ); + + return ( + + ); +} + +export default SelectInput; diff --git a/frontend/src/Components/Form/SeriesTagInput.tsx b/frontend/src/Components/Form/SeriesTagInput.tsx deleted file mode 100644 index 3d8279aa6..000000000 --- a/frontend/src/Components/Form/SeriesTagInput.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React, { useCallback } from 'react'; -import TagInputConnector from './TagInputConnector'; - -interface SeriesTageInputProps { - name: string; - value: number | number[]; - onChange: ({ - name, - value, - }: { - name: string; - value: number | number[]; - }) => void; -} - -export default function SeriesTagInput(props: SeriesTageInputProps) { - const { value, onChange, ...otherProps } = props; - const isArray = Array.isArray(value); - - const handleChange = useCallback( - ({ name, value: newValue }: { name: string; value: number[] }) => { - if (isArray) { - onChange({ name, value: newValue }); - } else { - onChange({ - name, - value: newValue.length ? newValue[newValue.length - 1] : 0, - }); - } - }, - [isArray, onChange] - ); - - let finalValue: number[] = []; - - if (isArray) { - finalValue = value; - } else if (value === 0) { - finalValue = []; - } else { - finalValue = [value]; - } - - return ( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore 2786 'TagInputConnector' isn't typed yet - - ); -} diff --git a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css deleted file mode 100644 index c76b0a263..000000000 --- a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css +++ /dev/null @@ -1,20 +0,0 @@ -.selectedValue { - composes: selectedValue from '~./EnhancedSelectInputSelectedValue.css'; - - display: flex; - align-items: center; - justify-content: space-between; - overflow: hidden; -} - -.value { - display: flex; -} - -.format { - flex: 0 0 auto; - margin-left: 15px; - color: var(--gray); - text-align: right; - font-size: $smallFontSize; -} diff --git a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css.d.ts deleted file mode 100644 index f6e19e481..000000000 --- a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.css.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file is automatically generated. -// Please do not change this file! -interface CssExports { - '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 deleted file mode 100644 index 94d2b7157..000000000 --- a/frontend/src/Components/Form/SeriesTypeSelectInputSelectedValue.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue'; -import styles from './SeriesTypeSelectInputSelectedValue.css'; - -interface SeriesTypeSelectInputOptionProps { - key: string; - value: string; - format: string; -} -function SeriesTypeSelectInputSelectedValue( - props: SeriesTypeSelectInputOptionProps -) { - const { value, format, ...otherProps } = props; - - return ( - -
{value}
- - {format == null ? null :
{format}
} -
- ); -} - -export default SeriesTypeSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/Tag/DeviceInput.css similarity index 64% rename from frontend/src/Components/Form/DeviceInput.css rename to frontend/src/Components/Form/Tag/DeviceInput.css index 7abe83db5..189cafc6b 100644 --- a/frontend/src/Components/Form/DeviceInput.css +++ b/frontend/src/Components/Form/Tag/DeviceInput.css @@ -3,6 +3,6 @@ } .input { - composes: input from '~./TagInput.css'; + composes: input from '~Components/Form/Tag/TagInput.css'; composes: hasButton from '~Components/Form/Input.css'; } diff --git a/frontend/src/Components/Form/DeviceInput.css.d.ts b/frontend/src/Components/Form/Tag/DeviceInput.css.d.ts similarity index 100% rename from frontend/src/Components/Form/DeviceInput.css.d.ts rename to frontend/src/Components/Form/Tag/DeviceInput.css.d.ts diff --git a/frontend/src/Components/Form/Tag/DeviceInput.tsx b/frontend/src/Components/Form/Tag/DeviceInput.tsx new file mode 100644 index 000000000..3c483d1f2 --- /dev/null +++ b/frontend/src/Components/Form/Tag/DeviceInput.tsx @@ -0,0 +1,149 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FormInputButton from 'Components/Form/FormInputButton'; +import Icon from 'Components/Icon'; +import { icons } from 'Helpers/Props'; +import { + clearOptions, + defaultState, + fetchOptions, +} from 'Store/Actions/providerOptionActions'; +import { InputChanged } from 'typings/inputs'; +import TagInput, { TagInputProps } from './TagInput'; +import styles from './DeviceInput.css'; + +interface DeviceTag { + id: string; + name: string; +} + +interface DeviceInputProps extends TagInputProps { + className?: string; + name: string; + value: string[]; + hasError?: boolean; + hasWarning?: boolean; + provider: string; + providerData: object; + onChange: (change: InputChanged) => unknown; +} + +function createDeviceTagsSelector(value: string[]) { + return createSelector( + (state: AppState) => state.providerOptions.devices || defaultState, + (devices) => { + return { + ...devices, + selectedDevices: value.map((valueDevice) => { + const device = devices.items.find((d) => d.id === valueDevice); + + if (device) { + return { + id: device.id, + name: `${device.name} (${device.id})`, + }; + } + + return { + id: valueDevice, + name: `Unknown (${valueDevice})`, + }; + }), + }; + } + ); +} + +function DeviceInput({ + className = styles.deviceInputWrapper, + name, + value, + hasError, + hasWarning, + provider, + providerData, + onChange, +}: DeviceInputProps) { + const dispatch = useDispatch(); + const { items, selectedDevices, isFetching } = useSelector( + createDeviceTagsSelector(value) + ); + + const handleRefreshPress = useCallback(() => { + dispatch( + fetchOptions({ + section: 'devices', + action: 'getDevices', + provider, + providerData, + }) + ); + }, [provider, providerData, dispatch]); + + const handleTagAdd = useCallback( + (device: DeviceTag) => { + // New tags won't have an ID, only a name. + const deviceId = device.id || device.name; + + onChange({ + name, + value: [...value, deviceId], + }); + }, + [name, value, onChange] + ); + + const handleTagDelete = useCallback( + ({ index }: { index: number }) => { + const newValue = value.slice(); + newValue.splice(index, 1); + + onChange({ + name, + value: newValue, + }); + }, + [name, value, onChange] + ); + + useEffect(() => { + dispatch( + fetchOptions({ + section: 'devices', + action: 'getDevices', + provider, + providerData, + }) + ); + + return () => { + dispatch(clearOptions({ section: 'devices' })); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch]); + + return ( +
+ + + + + +
+ ); +} + +export default DeviceInput; diff --git a/frontend/src/Components/Form/Tag/SeriesTagInput.tsx b/frontend/src/Components/Form/Tag/SeriesTagInput.tsx new file mode 100644 index 000000000..6d4beb20a --- /dev/null +++ b/frontend/src/Components/Form/Tag/SeriesTagInput.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { addTag } from 'Store/Actions/tagActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import { InputChanged } from 'typings/inputs'; +import sortByProp from 'Utilities/Array/sortByProp'; +import TagInput, { TagBase } from './TagInput'; + +interface SeriesTag extends TagBase { + id: number; + name: string; +} + +interface SeriesTagInputProps { + name: string; + value: number | number[]; + onChange: (change: InputChanged) => void; +} + +const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i'); + +function isValidTag(tagName: string) { + try { + return !VALID_TAG_REGEX.test(tagName); + } catch (e) { + return false; + } +} + +function createSeriesTagsSelector(tags: number[]) { + return createSelector(createTagsSelector(), (tagList) => { + const sortedTags = tagList.sort(sortByProp('label')); + const filteredTagList = sortedTags.filter((tag) => !tags.includes(tag.id)); + + return { + tags: tags.reduce((acc: SeriesTag[], tag) => { + const matchingTag = tagList.find((t) => t.id === tag); + + if (matchingTag) { + acc.push({ + id: tag, + name: matchingTag.label, + }); + } + + return acc; + }, []), + + tagList: filteredTagList.map(({ id, label: name }) => { + return { + id, + name, + }; + }), + + allTags: sortedTags, + }; + }); +} + +export default function SeriesTagInput({ + name, + value, + onChange, +}: SeriesTagInputProps) { + const dispatch = useDispatch(); + const isArray = Array.isArray(value); + + const arrayValue = useMemo(() => { + if (isArray) { + return value; + } + + return value === 0 ? [] : [value]; + }, [isArray, value]); + + const { tags, tagList, allTags } = useSelector( + createSeriesTagsSelector(arrayValue) + ); + + const handleTagCreated = useCallback( + (tag: SeriesTag) => { + if (isArray) { + onChange({ name, value: [...value, tag.id] }); + } else { + onChange({ + name, + value: tag.id, + }); + } + }, + [name, value, isArray, onChange] + ); + + const handleTagAdd = useCallback( + (newTag: SeriesTag) => { + if (newTag.id) { + if (isArray) { + onChange({ name, value: [...value, newTag.id] }); + } else { + onChange({ name, value: newTag.id }); + } + + return; + } + + const existingTag = allTags.some((t) => t.label === newTag.name); + + if (isValidTag(newTag.name) && !existingTag) { + dispatch( + addTag({ + tag: { label: newTag.name }, + onTagCreated: handleTagCreated, + }) + ); + } + }, + [name, value, isArray, allTags, handleTagCreated, onChange, dispatch] + ); + + const handleTagDelete = useCallback( + ({ index }: { index: number }) => { + if (isArray) { + const newValue = value.slice(); + newValue.splice(index, 1); + + onChange({ name, value: newValue }); + } else { + onChange({ name, value: 0 }); + } + }, + [name, value, isArray, onChange] + ); + + return ( + + ); +} diff --git a/frontend/src/Components/Form/TagInput.css b/frontend/src/Components/Form/Tag/TagInput.css similarity index 74% rename from frontend/src/Components/Form/TagInput.css rename to frontend/src/Components/Form/Tag/TagInput.css index eeddab5b4..2ca02825e 100644 --- a/frontend/src/Components/Form/TagInput.css +++ b/frontend/src/Components/Form/Tag/TagInput.css @@ -1,5 +1,5 @@ .input { - composes: input from '~./AutoSuggestInput.css'; + composes: input from '~Components/Form/AutoSuggestInput.css'; padding: 0; min-height: 35px; @@ -8,7 +8,8 @@ &.isFocused { outline: 0; border-color: var(--inputFocusBorderColor); - box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor); + box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), + 0 0 8px var(--inputFocusBoxShadowColor); } } diff --git a/frontend/src/Components/Form/TagInput.css.d.ts b/frontend/src/Components/Form/Tag/TagInput.css.d.ts similarity index 100% rename from frontend/src/Components/Form/TagInput.css.d.ts rename to frontend/src/Components/Form/Tag/TagInput.css.d.ts diff --git a/frontend/src/Components/Form/Tag/TagInput.tsx b/frontend/src/Components/Form/Tag/TagInput.tsx new file mode 100644 index 000000000..c113c06d3 --- /dev/null +++ b/frontend/src/Components/Form/Tag/TagInput.tsx @@ -0,0 +1,371 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React, { + KeyboardEvent, + Ref, + SyntheticEvent, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { + ChangeEvent, + RenderInputComponentProps, + RenderSuggestion, + SuggestionsFetchRequestedParams, +} from 'react-autosuggest'; +import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback'; +import { kinds } from 'Helpers/Props'; +import { Kind } from 'Helpers/Props/kinds'; +import tagShape from 'Helpers/Props/Shapes/tagShape'; +import { InputChanged } from 'typings/inputs'; +import AutoSuggestInput from '../AutoSuggestInput'; +import TagInputInput from './TagInputInput'; +import TagInputTag, { EditedTag, TagInputTagProps } from './TagInputTag'; +import styles from './TagInput.css'; + +export interface TagBase { + id: boolean | number | string; + 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 { id: 0, name: value } as T; + } + } else if (selectedIndex != null) { + return suggestions[selectedIndex]; + } + + return null; +} + +function handleSuggestionsClearRequested() { + // Required because props aren't always rendered, but no-op + // because we don't want to reset the paths after a path is selected. +} + +export interface ReplacementTag { + index: number; + id: T['id']; +} + +export interface TagInputProps { + className?: string; + inputContainerClassName?: string; + name: string; + tags: T[]; + tagList: T[]; + allowNew?: boolean; + kind?: Kind; + placeholder?: string; + delimiters?: string[]; + minQueryLength?: number; + canEdit?: boolean; + hasError?: boolean; + hasWarning?: boolean; + tagComponent?: React.ElementType; + onChange?: (change: InputChanged) => void; + onTagAdd: (newTag: T) => void; + onTagDelete: TagInputTagProps['onDelete']; + onTagReplace?: ( + tagToReplace: ReplacementTag, + newTagName: T['name'] + ) => void; +} + +function TagInput({ + className = styles.internalInput, + inputContainerClassName = styles.input, + name, + tags, + tagList, + allowNew = true, + kind = 'info', + placeholder = '', + delimiters = ['Tab', 'Enter', ' ', ','], + minQueryLength = 1, + canEdit = false, + tagComponent = TagInputTag, + hasError, + hasWarning, + onChange, + onTagAdd, + onTagDelete, + onTagReplace, + ...otherProps +}: TagInputProps) { + const [value, setValue] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [isFocused, setIsFocused] = useState(false); + const autoSuggestRef = useRef(null); + + const addTag = useDebouncedCallback( + (tag: T | null) => { + if (!tag) { + return; + } + + onTagAdd(tag); + + setValue(''); + setSuggestions([]); + }, + 250, + { + leading: true, + trailing: false, + } + ); + + const handleEditTag = useCallback( + ({ value: newValue, ...otherProps }: EditedTag) => { + if (value && onTagReplace) { + onTagReplace(otherProps, value); + } else { + onTagDelete(otherProps); + } + + setValue(String(newValue)); + }, + [value, setValue, onTagDelete, onTagReplace] + ); + + const handleInputContainerPress = useCallback(() => { + // @ts-expect-error Ref isn't typed yet + autoSuggestRef?.current?.input.focus(); + }, []); + + const handleInputChange = useCallback( + (_event: SyntheticEvent, { newValue, method }: ChangeEvent) => { + const finalValue = + // @ts-expect-error newValue may be an object? + typeof newValue === 'object' ? newValue.name : newValue; + + if (method === 'type') { + setValue(finalValue); + } + }, + [setValue] + ); + + const handleSuggestionsFetchRequested = useCallback( + ({ value: newValue }: SuggestionsFetchRequestedParams) => { + const lowerCaseValue = newValue.toLowerCase(); + + const suggestions = tagList.filter((tag) => { + return ( + String(tag.name).toLowerCase().includes(lowerCaseValue) && + !tags.some((t) => t.id === tag.id) + ); + }); + + setSuggestions(suggestions); + }, + [tags, tagList, setSuggestions] + ); + + const handleInputKeyDown = useCallback( + (event: KeyboardEvent) => { + const key = event.key; + + if (!autoSuggestRef.current) { + return; + } + + if (key === 'Backspace' && !value.length) { + const index = tags.length - 1; + + if (index >= 0) { + onTagDelete({ index, id: tags[index].id }); + } + + setTimeout(() => { + handleSuggestionsFetchRequested({ + value: '', + reason: 'input-changed', + }); + }); + + event.preventDefault(); + } + + if (delimiters.includes(key)) { + // @ts-expect-error Ref isn't typed yet + const selectedIndex = autoSuggestRef.current.highlightedSuggestionIndex; + const tag = getTag(value, selectedIndex, suggestions, allowNew); + + if (tag) { + addTag(tag); + event.preventDefault(); + } + } + }, + [ + tags, + allowNew, + delimiters, + onTagDelete, + value, + suggestions, + addTag, + handleSuggestionsFetchRequested, + ] + ); + + const handleInputFocus = useCallback(() => { + setIsFocused(true); + }, [setIsFocused]); + + const handleInputBlur = useCallback(() => { + setIsFocused(false); + + if (!autoSuggestRef.current) { + return; + } + + // @ts-expect-error Ref isn't typed yet + const selectedIndex = autoSuggestRef.current.highlightedSuggestionIndex; + const tag = getTag(value, selectedIndex, suggestions, allowNew); + + if (tag) { + addTag(tag); + } + }, [allowNew, value, suggestions, autoSuggestRef, addTag, setIsFocused]); + + const handleSuggestionSelected = useCallback( + (_event: SyntheticEvent, { suggestion }: { suggestion: T }) => { + addTag(suggestion); + }, + [addTag] + ); + + const getSuggestionValue = useCallback(({ name }: T): string => { + return String(name); + }, []); + + const shouldRenderSuggestions = useCallback( + (v: string) => { + return v.length >= minQueryLength; + }, + [minQueryLength] + ); + + const renderSuggestion: RenderSuggestion = useCallback(({ name }: T) => { + return name; + }, []); + + const renderInputComponent = useCallback( + ( + inputProps: RenderInputComponentProps, + forwardedRef: Ref + ) => { + return ( + + ); + }, + [ + tags, + kind, + canEdit, + isFocused, + tagComponent, + handleInputContainerPress, + handleEditTag, + onTagDelete, + ] + ); + + useEffect(() => { + return () => { + addTag.cancel(); + }; + }, [addTag]); + + return ( + + ); +} + +TagInput.propTypes = { + className: PropTypes.string, + inputContainerClassName: PropTypes.string, + tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + allowNew: PropTypes.bool, + kind: PropTypes.oneOf(kinds.all), + placeholder: PropTypes.string, + delimiters: PropTypes.arrayOf(PropTypes.string), + minQueryLength: PropTypes.number, + canEdit: PropTypes.bool, + hasError: PropTypes.bool, + hasWarning: PropTypes.bool, + tagComponent: PropTypes.elementType, + onTagAdd: PropTypes.func.isRequired, + onTagDelete: PropTypes.func.isRequired, + onTagReplace: PropTypes.func, +}; + +TagInput.defaultProps = { + className: styles.internalInput, + inputContainerClassName: styles.input, + allowNew: true, + kind: kinds.INFO, + placeholder: '', + delimiters: ['Tab', 'Enter', ' ', ','], + minQueryLength: 1, + canEdit: false, + tagComponent: TagInputTag, +}; + +export default TagInput; diff --git a/frontend/src/Components/Form/TagInputInput.css b/frontend/src/Components/Form/Tag/TagInputInput.css similarity index 100% rename from frontend/src/Components/Form/TagInputInput.css rename to frontend/src/Components/Form/Tag/TagInputInput.css diff --git a/frontend/src/Components/Form/TagInputInput.css.d.ts b/frontend/src/Components/Form/Tag/TagInputInput.css.d.ts similarity index 100% rename from frontend/src/Components/Form/TagInputInput.css.d.ts rename to frontend/src/Components/Form/Tag/TagInputInput.css.d.ts diff --git a/frontend/src/Components/Form/Tag/TagInputInput.tsx b/frontend/src/Components/Form/Tag/TagInputInput.tsx new file mode 100644 index 000000000..d181136b8 --- /dev/null +++ b/frontend/src/Components/Form/Tag/TagInputInput.tsx @@ -0,0 +1,71 @@ +import React, { MouseEvent, Ref, useCallback } from 'react'; +import { Kind } from 'Helpers/Props/kinds'; +import { TagBase } from './TagInput'; +import { TagInputTagProps } from './TagInputTag'; +import styles from './TagInputInput.css'; + +interface TagInputInputProps { + forwardedRef?: Ref; + className?: string; + tags: TagBase[]; + inputProps: object; + kind: Kind; + isFocused: boolean; + canEdit: boolean; + tagComponent: React.ElementType; + onTagDelete: TagInputTagProps['onDelete']; + onTagEdit: TagInputTagProps['onEdit']; + onInputContainerPress: () => void; +} + +function TagInputInput(props: TagInputInputProps) { + const { + forwardedRef, + className = styles.inputContainer, + tags, + inputProps, + kind, + isFocused, + canEdit, + tagComponent: TagComponent, + onTagDelete, + onTagEdit, + onInputContainerPress, + } = props; + + const handleMouseDown = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + + if (isFocused) { + return; + } + + onInputContainerPress(); + }, + [isFocused, onInputContainerPress] + ); + + return ( +
+ {tags.map((tag, index) => { + return ( + + ); + })} + + +
+ ); +} + +export default TagInputInput; diff --git a/frontend/src/Components/Form/TagInputTag.css b/frontend/src/Components/Form/Tag/TagInputTag.css similarity index 100% rename from frontend/src/Components/Form/TagInputTag.css rename to frontend/src/Components/Form/Tag/TagInputTag.css diff --git a/frontend/src/Components/Form/TagInputTag.css.d.ts b/frontend/src/Components/Form/Tag/TagInputTag.css.d.ts similarity index 100% rename from frontend/src/Components/Form/TagInputTag.css.d.ts rename to frontend/src/Components/Form/Tag/TagInputTag.css.d.ts diff --git a/frontend/src/Components/Form/Tag/TagInputTag.tsx b/frontend/src/Components/Form/Tag/TagInputTag.tsx new file mode 100644 index 000000000..484bf45e0 --- /dev/null +++ b/frontend/src/Components/Form/Tag/TagInputTag.tsx @@ -0,0 +1,79 @@ +import React, { useCallback } from 'react'; +import MiddleTruncate from 'react-middle-truncate'; +import Label, { LabelProps } from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import { icons } from 'Helpers/Props'; +import { TagBase } from './TagInput'; +import styles from './TagInputTag.css'; + +export interface DeletedTag { + index: number; + id: T['id']; +} + +export interface EditedTag { + index: number; + id: T['id']; + value: T['name']; +} + +export interface TagInputTagProps { + index: number; + tag: T; + kind: LabelProps['kind']; + canEdit: boolean; + onDelete: (deletedTag: DeletedTag) => void; + onEdit: (editedTag: EditedTag) => void; +} + +function TagInputTag({ + tag, + kind, + index, + canEdit, + onDelete, + onEdit, +}: TagInputTagProps) { + const handleDelete = useCallback(() => { + onDelete({ + index, + id: tag.id, + }); + }, [index, tag, onDelete]); + + const handleEdit = useCallback(() => { + onEdit({ + index, + id: tag.id, + value: tag.name, + }); + }, [index, tag, onEdit]); + + return ( +
+ +
+ ); +} + +export default TagInputTag; diff --git a/frontend/src/Components/Form/Tag/TagSelectInput.tsx b/frontend/src/Components/Form/Tag/TagSelectInput.tsx new file mode 100644 index 000000000..21fde893c --- /dev/null +++ b/frontend/src/Components/Form/Tag/TagSelectInput.tsx @@ -0,0 +1,97 @@ +import React, { useCallback, useMemo } from 'react'; +import { InputChanged } from 'typings/inputs'; +import TagInput, { TagBase, TagInputProps } from './TagInput'; + +interface SelectTag extends TagBase { + id: number; + name: string; +} + +interface TagSelectValue { + value: string; + key: number; + order: number; +} + +interface TagSelectInputProps extends TagInputProps { + name: string; + value: number[]; + values: TagSelectValue[]; + onChange: (change: InputChanged) => unknown; +} + +function TagSelectInput({ + name, + value, + values, + onChange, + ...otherProps +}: TagSelectInputProps) { + const { tags, tagList, allTags } = useMemo(() => { + const sortedTags = values.sort((a, b) => a.key - b.key); + + return { + tags: value.reduce((acc: SelectTag[], tag) => { + const matchingTag = values.find((t) => t.key === tag); + + if (matchingTag) { + acc.push({ + id: tag, + name: matchingTag.value, + }); + } + + return acc; + }, []), + + tagList: sortedTags.map((sorted) => { + return { + id: sorted.key, + name: sorted.value, + }; + }), + + allTags: sortedTags, + }; + }, [value, values]); + + const handleTagAdd = useCallback( + (newTag: SelectTag) => { + const existingTag = allTags.some((tag) => tag.key === newTag.id); + const newValue = value.slice(); + + if (existingTag) { + newValue.push(newTag.id); + } + + onChange({ name, value: newValue }); + }, + [name, value, allTags, onChange] + ); + + const handleTagDelete = useCallback( + ({ index }: { index: number }) => { + const newValue = value.slice(); + newValue.splice(index, 1); + + onChange({ + name, + value: newValue, + }); + }, + [name, value, onChange] + ); + + return ( + + ); +} + +export default TagSelectInput; diff --git a/frontend/src/Components/Form/Tag/TextTagInput.tsx b/frontend/src/Components/Form/Tag/TextTagInput.tsx new file mode 100644 index 000000000..6e2082c50 --- /dev/null +++ b/frontend/src/Components/Form/Tag/TextTagInput.tsx @@ -0,0 +1,109 @@ +import React, { useCallback, useMemo } from 'react'; +import { InputChanged } from 'typings/inputs'; +import split from 'Utilities/String/split'; +import TagInput, { ReplacementTag, TagBase, TagInputProps } from './TagInput'; + +interface TextTag extends TagBase { + id: string; + name: string; +} + +interface TextTagInputProps extends TagInputProps { + name: string; + value: string | string[]; + onChange: (change: InputChanged) => unknown; +} + +function TextTagInput({ + name, + value, + onChange, + ...otherProps +}: TextTagInputProps) { + const { tags, tagList, valueArray } = useMemo(() => { + const tagsArray = Array.isArray(value) ? value : split(value); + + return { + tags: tagsArray.reduce((result: TextTag[], tag) => { + if (tag) { + result.push({ + id: tag, + name: tag, + }); + } + + return result; + }, []), + tagList: [], + valueArray: tagsArray, + }; + }, [value]); + + const handleTagAdd = useCallback( + (newTag: TextTag) => { + // Split and trim tags before adding them to the list, this will + // cleanse tags pasted in that had commas and spaces which leads + // to oddities with restrictions (as an example). + + const newValue = [...valueArray]; + const newTags = newTag.name.startsWith('/') + ? [newTag.name] + : split(newTag.name); + + newTags.forEach((newTag) => { + const newTagValue = newTag.trim(); + + if (newTagValue) { + newValue.push(newTagValue); + } + }); + + onChange({ name, value: newValue }); + }, + [name, valueArray, onChange] + ); + + const handleTagDelete = useCallback( + ({ index }: { index: number }) => { + const newValue = [...valueArray]; + newValue.splice(index, 1); + + onChange({ + name, + value: newValue, + }); + }, + [name, valueArray, onChange] + ); + + const handleTagReplace = useCallback( + (tagToReplace: ReplacementTag, newTagName: string) => { + const newValue = [...valueArray]; + newValue.splice(tagToReplace.index, 1); + + const newTagValue = newTagName.trim(); + + if (newTagValue) { + newValue.push(newTagValue); + } + + onChange({ name, value: newValue }); + }, + [name, valueArray, onChange] + ); + + return ( + + ); +} + +export default TextTagInput; diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js deleted file mode 100644 index 840d627f8..000000000 --- a/frontend/src/Components/Form/TagInput.js +++ /dev/null @@ -1,301 +0,0 @@ -import classNames from 'classnames'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { kinds } from 'Helpers/Props'; -import tagShape from 'Helpers/Props/Shapes/tagShape'; -import AutoSuggestInput from './AutoSuggestInput'; -import TagInputInput from './TagInputInput'; -import TagInputTag from './TagInputTag'; -import styles from './TagInput.css'; - -function getTag(value, selectedIndex, suggestions, allowNew) { - if (selectedIndex == null && value) { - const existingTag = suggestions.find((suggestion) => suggestion.name === value); - - if (existingTag) { - return existingTag; - } else if (allowNew) { - return { name: value }; - } - } else if (selectedIndex != null) { - return suggestions[selectedIndex]; - } -} - -class TagInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - value: '', - suggestions: [], - isFocused: false - }; - - this._autosuggestRef = null; - } - - componentWillUnmount() { - this.addTag.cancel(); - } - - // - // Control - - _setAutosuggestRef = (ref) => { - this._autosuggestRef = ref; - }; - - getSuggestionValue({ name }) { - return name; - } - - shouldRenderSuggestions = (value) => { - return value.length >= this.props.minQueryLength; - }; - - renderSuggestion({ name }) { - return name; - } - - addTag = _.debounce((tag) => { - this.props.onTagAdd(tag); - - this.setState({ - value: '', - suggestions: [] - }); - }, 250, { leading: true, trailing: false }); - - // - // Listeners - - onTagEdit = ({ value, ...otherProps }) => { - const currentValue = this.state.value; - - if (currentValue && this.props.onTagReplace) { - this.props.onTagReplace(otherProps, { name: currentValue }); - } else { - this.props.onTagDelete(otherProps); - } - - this.setState({ value }); - }; - - onInputContainerPress = () => { - this._autosuggestRef.input.focus(); - }; - - onInputChange = (event, { newValue, method }) => { - const value = _.isObject(newValue) ? newValue.name : newValue; - - if (method === 'type') { - this.setState({ value }); - } - }; - - onInputKeyDown = (event) => { - const { - tags, - allowNew, - delimiters, - onTagDelete - } = this.props; - - const { - value, - suggestions - } = this.state; - - const key = event.key; - - if (key === 'Backspace' && !value.length) { - const index = tags.length - 1; - - if (index >= 0) { - onTagDelete({ index, id: tags[index].id }); - } - - setTimeout(() => { - this.onSuggestionsFetchRequested({ value: '' }); - }); - - event.preventDefault(); - } - - if (delimiters.includes(key)) { - const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex; - const tag = getTag(value, selectedIndex, suggestions, allowNew); - - if (tag) { - this.addTag(tag); - event.preventDefault(); - } - } - }; - - onInputFocus = () => { - this.setState({ isFocused: true }); - }; - - onInputBlur = () => { - this.setState({ isFocused: false }); - - if (!this._autosuggestRef) { - return; - } - - const { - allowNew - } = this.props; - - const { - value, - suggestions - } = this.state; - - const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex; - const tag = getTag(value, selectedIndex, suggestions, allowNew); - - if (tag) { - this.addTag(tag); - } - }; - - onSuggestionsFetchRequested = ({ value }) => { - const lowerCaseValue = value.toLowerCase(); - - const { - tags, - tagList - } = this.props; - - const suggestions = tagList.filter((tag) => { - return ( - tag.name.toLowerCase().includes(lowerCaseValue) && - !tags.some((t) => t.id === tag.id)); - }); - - this.setState({ suggestions }); - }; - - onSuggestionsClearRequested = () => { - // Required because props aren't always rendered, but no-op - // because we don't want to reset the paths after a path is selected. - }; - - onSuggestionSelected = (event, { suggestion }) => { - this.addTag(suggestion); - }; - - // - // Render - - renderInputComponent = (inputProps, forwardedRef) => { - const { - tags, - kind, - canEdit, - tagComponent, - onTagDelete - } = this.props; - - return ( - - ); - }; - - render() { - const { - className, - inputContainerClassName, - hasError, - hasWarning, - ...otherProps - } = this.props; - - const { - value, - suggestions, - isFocused - } = this.state; - - return ( - - ); - } -} - -TagInput.propTypes = { - className: PropTypes.string.isRequired, - inputContainerClassName: PropTypes.string.isRequired, - tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - allowNew: PropTypes.bool.isRequired, - kind: PropTypes.oneOf(kinds.all).isRequired, - placeholder: PropTypes.string.isRequired, - delimiters: PropTypes.arrayOf(PropTypes.string).isRequired, - minQueryLength: PropTypes.number.isRequired, - canEdit: PropTypes.bool, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - tagComponent: PropTypes.elementType.isRequired, - onTagAdd: PropTypes.func.isRequired, - onTagDelete: PropTypes.func.isRequired, - onTagReplace: PropTypes.func -}; - -TagInput.defaultProps = { - className: styles.internalInput, - inputContainerClassName: styles.input, - allowNew: true, - kind: kinds.INFO, - placeholder: '', - delimiters: ['Tab', 'Enter', ' ', ','], - minQueryLength: 1, - canEdit: false, - tagComponent: TagInputTag -}; - -export default TagInput; diff --git a/frontend/src/Components/Form/TagInputConnector.js b/frontend/src/Components/Form/TagInputConnector.js deleted file mode 100644 index 8d0782fa5..000000000 --- a/frontend/src/Components/Form/TagInputConnector.js +++ /dev/null @@ -1,157 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { addTag } from 'Store/Actions/tagActions'; -import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import TagInput from './TagInput'; - -const validTagRegex = new RegExp('[^-_a-z0-9]', 'i'); - -function isValidTag(tagName) { - try { - return !validTagRegex.test(tagName); - } catch (e) { - return false; - } -} - -function createMapStateToProps() { - return createSelector( - (state, { value }) => value, - createTagsSelector(), - (tags, tagList) => { - const sortedTags = _.sortBy(tagList, 'label'); - const filteredTagList = _.filter(sortedTags, (tag) => _.indexOf(tags, tag.id) === -1); - - return { - tags: tags.reduce((acc, tag) => { - const matchingTag = _.find(tagList, { id: tag }); - - if (matchingTag) { - acc.push({ - id: tag, - name: matchingTag.label - }); - } - - return acc; - }, []), - - tagList: filteredTagList.map(({ id, label: name }) => { - return { - id, - name - }; - }), - - allTags: sortedTags - }; - } - ); -} - -const mapDispatchToProps = { - addTag -}; - -class TagInputConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - name, - value, - tags, - onChange - } = this.props; - - if (value.length !== tags.length) { - onChange({ name, value: tags.map((tag) => tag.id) }); - } - } - - // - // Listeners - - onTagAdd = (tag) => { - const { - name, - value, - allTags - } = this.props; - - if (!tag.id) { - const existingTag =_.some(allTags, { label: tag.name }); - - if (isValidTag(tag.name) && !existingTag) { - this.props.addTag({ - tag: { label: tag.name }, - onTagCreated: this.onTagCreated - }); - } - - return; - } - - const newValue = value.slice(); - newValue.push(tag.id); - - this.props.onChange({ name, value: newValue }); - }; - - onTagDelete = ({ index }) => { - const { - name, - value - } = this.props; - - const newValue = value.slice(); - newValue.splice(index, 1); - - this.props.onChange({ - name, - value: newValue - }); - }; - - onTagCreated = (tag) => { - const { - name, - value - } = this.props; - - const newValue = value.slice(); - newValue.push(tag.id); - - this.props.onChange({ name, value: newValue }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -TagInputConnector.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.number).isRequired, - tags: PropTypes.arrayOf(PropTypes.object).isRequired, - allTags: PropTypes.arrayOf(PropTypes.object).isRequired, - onChange: PropTypes.func.isRequired, - addTag: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(TagInputConnector); diff --git a/frontend/src/Components/Form/TagInputInput.js b/frontend/src/Components/Form/TagInputInput.js deleted file mode 100644 index 86628b134..000000000 --- a/frontend/src/Components/Form/TagInputInput.js +++ /dev/null @@ -1,84 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { kinds } from 'Helpers/Props'; -import tagShape from 'Helpers/Props/Shapes/tagShape'; -import styles from './TagInputInput.css'; - -class TagInputInput extends Component { - - onMouseDown = (event) => { - event.preventDefault(); - - const { - isFocused, - onInputContainerPress - } = this.props; - - if (isFocused) { - return; - } - - onInputContainerPress(); - }; - - render() { - const { - forwardedRef, - className, - tags, - inputProps, - kind, - canEdit, - tagComponent: TagComponent, - onTagDelete, - onTagEdit - } = this.props; - - return ( -
- { - tags.map((tag, index) => { - return ( - - ); - }) - } - - -
- ); - } -} - -TagInputInput.propTypes = { - forwardedRef: PropTypes.func, - className: PropTypes.string.isRequired, - tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - inputProps: PropTypes.object.isRequired, - kind: PropTypes.oneOf(kinds.all).isRequired, - isFocused: PropTypes.bool.isRequired, - canEdit: PropTypes.bool.isRequired, - tagComponent: PropTypes.elementType.isRequired, - onTagDelete: PropTypes.func.isRequired, - onTagEdit: PropTypes.func.isRequired, - onInputContainerPress: PropTypes.func.isRequired -}; - -TagInputInput.defaultProps = { - className: styles.inputContainer -}; - -export default TagInputInput; diff --git a/frontend/src/Components/Form/TagInputTag.js b/frontend/src/Components/Form/TagInputTag.js deleted file mode 100644 index 05a780442..000000000 --- a/frontend/src/Components/Form/TagInputTag.js +++ /dev/null @@ -1,101 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import MiddleTruncate from 'react-middle-truncate'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import { icons, kinds } from 'Helpers/Props'; -import tagShape from 'Helpers/Props/Shapes/tagShape'; -import styles from './TagInputTag.css'; - -class TagInputTag extends Component { - - // - // Listeners - - onDelete = () => { - const { - index, - tag, - onDelete - } = this.props; - - onDelete({ - index, - id: tag.id - }); - }; - - onEdit = () => { - const { - index, - tag, - onEdit - } = this.props; - - onEdit({ - index, - id: tag.id, - value: tag.name - }); - }; - - // - // Render - - render() { - const { - tag, - kind, - canEdit - } = this.props; - - return ( -
- -
- ); - } -} - -TagInputTag.propTypes = { - index: PropTypes.number.isRequired, - tag: PropTypes.shape(tagShape), - kind: PropTypes.oneOf(kinds.all).isRequired, - canEdit: PropTypes.bool.isRequired, - onDelete: PropTypes.func.isRequired, - onEdit: PropTypes.func.isRequired -}; - -export default TagInputTag; diff --git a/frontend/src/Components/Form/TagSelectInputConnector.js b/frontend/src/Components/Form/TagSelectInputConnector.js deleted file mode 100644 index 23afe6da1..000000000 --- a/frontend/src/Components/Form/TagSelectInputConnector.js +++ /dev/null @@ -1,102 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import TagInput from './TagInput'; - -function createMapStateToProps() { - return createSelector( - (state, { value }) => value, - (state, { values }) => values, - (tags, tagList) => { - const sortedTags = _.sortBy(tagList, 'value'); - - return { - tags: tags.reduce((acc, tag) => { - const matchingTag = _.find(tagList, { key: tag }); - - if (matchingTag) { - acc.push({ - id: tag, - name: matchingTag.value - }); - } - - return acc; - }, []), - - tagList: sortedTags.map(({ key: id, value: name }) => { - return { - id, - name - }; - }), - - allTags: sortedTags - }; - } - ); -} - -class TagSelectInputConnector extends Component { - - // - // Listeners - - onTagAdd = (tag) => { - const { - name, - value, - allTags - } = this.props; - - const existingTag =_.some(allTags, { key: tag.id }); - - const newValue = value.slice(); - - if (existingTag) { - newValue.push(tag.id); - } - - this.props.onChange({ name, value: newValue }); - }; - - onTagDelete = ({ index }) => { - const { - name, - value - } = this.props; - - const newValue = value.slice(); - newValue.splice(index, 1); - - this.props.onChange({ - name, - value: newValue - }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -TagSelectInputConnector.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.number).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - allTags: PropTypes.arrayOf(PropTypes.object).isRequired, - onChange: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps)(TagSelectInputConnector); diff --git a/frontend/src/Components/Form/TextArea.js b/frontend/src/Components/Form/TextArea.js deleted file mode 100644 index 44fd3a249..000000000 --- a/frontend/src/Components/Form/TextArea.js +++ /dev/null @@ -1,172 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import styles from './TextArea.css'; - -class TextArea extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._input = null; - this._selectionStart = null; - this._selectionEnd = null; - this._selectionTimeout = null; - this._isMouseTarget = false; - } - - componentDidMount() { - window.addEventListener('mouseup', this.onDocumentMouseUp); - } - - componentWillUnmount() { - window.removeEventListener('mouseup', this.onDocumentMouseUp); - - if (this._selectionTimeout) { - this._selectionTimeout = clearTimeout(this._selectionTimeout); - } - } - - // - // Control - - setInputRef = (ref) => { - this._input = ref; - }; - - selectionChange() { - if (this._selectionTimeout) { - this._selectionTimeout = clearTimeout(this._selectionTimeout); - } - - this._selectionTimeout = setTimeout(() => { - const selectionStart = this._input.selectionStart; - const selectionEnd = this._input.selectionEnd; - - const selectionChanged = ( - this._selectionStart !== selectionStart || - this._selectionEnd !== selectionEnd - ); - - this._selectionStart = selectionStart; - this._selectionEnd = selectionEnd; - - if (this.props.onSelectionChange && selectionChanged) { - this.props.onSelectionChange(selectionStart, selectionEnd); - } - }, 10); - } - - // - // Listeners - - onChange = (event) => { - const { - name, - onChange - } = this.props; - - const payload = { - name, - value: event.target.value - }; - - onChange(payload); - }; - - onFocus = (event) => { - if (this.props.onFocus) { - this.props.onFocus(event); - } - - this.selectionChange(); - }; - - onKeyUp = () => { - this.selectionChange(); - }; - - onMouseDown = () => { - this._isMouseTarget = true; - }; - - onMouseUp = () => { - this.selectionChange(); - }; - - onDocumentMouseUp = () => { - if (this._isMouseTarget) { - this.selectionChange(); - } - - this._isMouseTarget = false; - }; - - // - // Render - - render() { - const { - className, - readOnly, - autoFocus, - placeholder, - name, - value, - hasError, - hasWarning, - onBlur - } = this.props; - - return ( -