mirror of
https://github.com/Sonarr/Sonarr.git
synced 2024-11-21 17:56:43 +02:00
Convert Form Components to TypeScript
This commit is contained in:
parent
c114e2ddb7
commit
682d2b4e1b
@ -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
|
||||
|
||||
|
@ -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;
|
||||
|
11
frontend/src/App/State/CaptchaAppState.ts
Normal file
11
frontend/src/App/State/CaptchaAppState.ts
Normal file
@ -0,0 +1,11 @@
|
||||
interface CaptchaAppState {
|
||||
refreshing: false;
|
||||
token: string;
|
||||
siteKey: unknown;
|
||||
secretToken: unknown;
|
||||
ray: unknown;
|
||||
stoken: unknown;
|
||||
responseUrl: unknown;
|
||||
}
|
||||
|
||||
export default CaptchaAppState;
|
9
frontend/src/App/State/OAuthAppState.ts
Normal file
9
frontend/src/App/State/OAuthAppState.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Error } from './AppSectionState';
|
||||
|
||||
interface OAuthAppState {
|
||||
authorizing: boolean;
|
||||
result: Record<string, unknown> | null;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export default OAuthAppState;
|
22
frontend/src/App/State/ProviderOptionsAppState.ts
Normal file
22
frontend/src/App/State/ProviderOptionsAppState.ts
Normal file
@ -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<ProviderOptionsDevice>;
|
||||
servers: AppSectionState<FieldSelectOption<unknown>>;
|
||||
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
|
||||
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
|
||||
getTags: AppSectionState<FieldSelectOption<unknown>>;
|
||||
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
|
||||
}
|
||||
|
||||
export default ProviderOptionsAppState;
|
@ -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) {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<PathInput
|
||||
<PathInputInternal
|
||||
className={styles.pathInput}
|
||||
placeholder={translate('FileBrowserPlaceholderText')}
|
||||
hasFileBrowser={false}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import TagInput from 'Components/Form/TagInput';
|
||||
import TagInput from 'Components/Form/Tag/TagInput';
|
||||
import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props';
|
||||
import tagShape from 'Helpers/Props/Shapes/tagShape';
|
||||
import convertToBytes from 'Utilities/Number/convertToBytes';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import TagInputTag from 'Components/Form/TagInputTag';
|
||||
import TagInputTag from 'Components/Form/Tag/TagInputTag';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './FilterBuilderRowValueTag.css';
|
||||
|
@ -1,98 +0,0 @@
|
||||
import jdu from 'jdu';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import AutoSuggestInput from './AutoSuggestInput';
|
||||
|
||||
class AutoCompleteInput extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
suggestions: []
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
getSuggestionValue(item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
renderSuggestion(item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = (event, { newValue }) => {
|
||||
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 (
|
||||
<AutoSuggestInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
value={value}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={this.getSuggestionValue}
|
||||
renderSuggestion={this.renderSuggestion}
|
||||
onInputBlur={this.onInputBlur}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
81
frontend/src/Components/Form/AutoCompleteInput.tsx
Normal file
81
frontend/src/Components/Form/AutoCompleteInput.tsx
Normal file
@ -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<string>) => unknown;
|
||||
}
|
||||
|
||||
function AutoCompleteInput({
|
||||
name,
|
||||
value = '',
|
||||
values,
|
||||
onChange,
|
||||
...otherProps
|
||||
}: AutoCompleteInputProps) {
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
|
||||
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 (
|
||||
<AutoSuggestInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
value={value}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderSuggestion={renderSuggestion}
|
||||
onInputChange={handleInputChange}
|
||||
onInputBlur={handleInputBlur}
|
||||
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={handleSuggestionsClearRequested}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutoCompleteInput;
|
@ -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 (
|
||||
<Reference>
|
||||
{({ ref }) => {
|
||||
if (renderInputComponent) {
|
||||
return renderInputComponent(inputProps, ref);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<input
|
||||
{...inputProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Reference>
|
||||
);
|
||||
};
|
||||
|
||||
renderSuggestionsContainer = ({ containerProps, children }) => {
|
||||
return (
|
||||
<Portal>
|
||||
<Popper
|
||||
placement='bottom-start'
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.onComputeMaxHeight
|
||||
},
|
||||
flip: {
|
||||
padding: this.props.minHeight
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref: popperRef, style, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popperRef}
|
||||
style={style}
|
||||
className={children ? styles.suggestionsContainerOpen : undefined}
|
||||
>
|
||||
<div
|
||||
{...containerProps}
|
||||
style={{
|
||||
maxHeight: style.maxHeight
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
//
|
||||
// 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 (
|
||||
<Manager>
|
||||
<Autosuggest
|
||||
{...otherProps}
|
||||
ref={forwardedRef}
|
||||
id={name}
|
||||
inputProps={inputProps}
|
||||
theme={theme}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderInputComponent={this.renderInputComponent}
|
||||
renderSuggestionsContainer={this.renderSuggestionsContainer}
|
||||
renderSuggestion={renderSuggestion}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
/>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
259
frontend/src/Components/Form/AutoSuggestInput.tsx
Normal file
259
frontend/src/Components/Form/AutoSuggestInput.tsx
Normal file
@ -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<T>
|
||||
extends Omit<AutosuggestPropsBase<T>, 'renderInputComponent' | 'inputProps'> {
|
||||
forwardedRef?: MutableRefObject<Autosuggest<T> | 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<HTMLDivElement>
|
||||
) => ReactNode;
|
||||
onInputChange: (
|
||||
event: FormEvent<HTMLElement>,
|
||||
params: ChangeEvent
|
||||
) => unknown;
|
||||
onInputKeyDown?: KeyboardEventHandler<HTMLElement>;
|
||||
onInputFocus?: (event: SyntheticEvent) => unknown;
|
||||
onInputBlur: (
|
||||
event: FocusEvent<HTMLElement>,
|
||||
params?: BlurEvent<T>
|
||||
) => unknown;
|
||||
onChange?: (change: InputChanged<T>) => unknown;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
|
||||
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 (
|
||||
<Reference>
|
||||
{({ ref }) => {
|
||||
if (renderInputComponent) {
|
||||
return renderInputComponent(inputProps, ref);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Reference>
|
||||
);
|
||||
},
|
||||
[renderInputComponent]
|
||||
);
|
||||
|
||||
const renderSuggestionsContainer = useCallback(
|
||||
({ containerProps, children }: RenderSuggestionsContainerParams) => {
|
||||
return (
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom-start"
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: handleComputeMaxHeight,
|
||||
},
|
||||
flip: {
|
||||
padding: minHeight,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ ref: popperRef, style, scheduleUpdate }) => {
|
||||
updater.current = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popperRef}
|
||||
style={style}
|
||||
className={
|
||||
children ? styles.suggestionsContainerOpen : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
{...containerProps}
|
||||
style={{
|
||||
maxHeight: style.maxHeight,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
);
|
||||
},
|
||||
[minHeight, handleComputeMaxHeight]
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLElement>) => {
|
||||
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 (
|
||||
<Manager>
|
||||
<Autosuggest
|
||||
{...otherProps}
|
||||
ref={forwardedRef}
|
||||
id={name}
|
||||
inputProps={inputProps}
|
||||
theme={theme}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderInputComponent={createRenderInputComponent}
|
||||
renderSuggestionsContainer={renderSuggestionsContainer}
|
||||
renderSuggestion={renderSuggestion}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
/>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
|
||||
export default AutoSuggestInput;
|
@ -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 (
|
||||
<div>
|
||||
<div className={styles.captchaInputWrapper}>
|
||||
<TextInput
|
||||
className={classNames(
|
||||
className,
|
||||
styles.hasButton,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning
|
||||
)}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
onPress={onRefreshPress}
|
||||
>
|
||||
<Icon
|
||||
name={icons.REFRESH}
|
||||
isSpinning={refreshing}
|
||||
/>
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
{
|
||||
!!siteKey && !!secretToken &&
|
||||
<div className={styles.recaptchaWrapper}>
|
||||
<ReCAPTCHA
|
||||
sitekey={siteKey}
|
||||
stoken={secretToken}
|
||||
onChange={onCaptchaChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
118
frontend/src/Components/Form/CaptchaInput.tsx
Normal file
118
frontend/src/Components/Form/CaptchaInput.tsx
Normal file
@ -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<string>) => 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 (
|
||||
<div>
|
||||
<div className={styles.captchaInputWrapper}>
|
||||
<TextInput
|
||||
className={classNames(
|
||||
className,
|
||||
styles.hasButton,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning
|
||||
)}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<FormInputButton onPress={handleRefreshPress}>
|
||||
<Icon name={icons.REFRESH} isSpinning={refreshing} />
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
{siteKey && secretToken ? (
|
||||
<div className={styles.recaptchaWrapper}>
|
||||
<ReCAPTCHA
|
||||
sitekey={siteKey}
|
||||
stoken={secretToken}
|
||||
onChange={handleCaptchaChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CaptchaInput;
|
@ -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 (
|
||||
<CaptchaInput
|
||||
{...this.props}
|
||||
onRefreshPress={this.onRefreshPress}
|
||||
onCaptchaChange={this.onCaptchaChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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 (
|
||||
<div className={containerClassName}>
|
||||
<label
|
||||
className={styles.label}
|
||||
onClick={this.onClick}
|
||||
>
|
||||
<input
|
||||
ref={this.setRef}
|
||||
className={styles.checkbox}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={isChecked}
|
||||
disabled={isDisabled}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
isChecked ? styles[isCheckClass] : styles.isNotChecked,
|
||||
isIndeterminate && styles.isIndeterminate,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
>
|
||||
{
|
||||
isChecked &&
|
||||
<Icon name={icons.CHECK} />
|
||||
}
|
||||
|
||||
{
|
||||
isIndeterminate &&
|
||||
<Icon name={icons.CHECK_INDETERMINATE} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
helpText &&
|
||||
<FormInputHelpText
|
||||
className={styles.helpText}
|
||||
text={helpText}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!helpText && helpTextWarning &&
|
||||
<FormInputHelpText
|
||||
className={styles.helpText}
|
||||
text={helpTextWarning}
|
||||
isWarning={true}
|
||||
/>
|
||||
}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
141
frontend/src/Components/Form/CheckInput.tsx
Normal file
141
frontend/src/Components/Form/CheckInput.tsx
Normal file
@ -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<T = Element> extends SyntheticEvent<T, MouseEvent> {
|
||||
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<Kind, keyof typeof styles>;
|
||||
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<HTMLInputElement>(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<HTMLElement, MouseEvent>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className={containerClassName}>
|
||||
<label className={styles.label} onClick={handleClick}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.checkbox}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={isChecked}
|
||||
disabled={isDisabled}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
isChecked ? styles[isCheckClass] : styles.isNotChecked,
|
||||
isIndeterminate && styles.isIndeterminate,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
>
|
||||
{isChecked ? <Icon name={icons.CHECK} /> : null}
|
||||
|
||||
{isIndeterminate ? <Icon name={icons.CHECK_INDETERMINATE} /> : null}
|
||||
</div>
|
||||
|
||||
{helpText ? (
|
||||
<FormInputHelpText className={styles.helpText} text={helpText} />
|
||||
) : null}
|
||||
|
||||
{!helpText && helpTextWarning ? (
|
||||
<FormInputHelpText
|
||||
className={styles.helpText}
|
||||
text={helpTextWarning}
|
||||
isWarning={true}
|
||||
/>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckInput;
|
@ -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 (
|
||||
<div className={className}>
|
||||
<TagInput
|
||||
inputContainerClassName={styles.input}
|
||||
name={name}
|
||||
tags={selectedDevices}
|
||||
tagList={items}
|
||||
allowNew={true}
|
||||
minQueryLength={0}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
onTagAdd={this.onTagAdd}
|
||||
onTagDelete={this.onTagDelete}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
onPress={onRefreshPress}
|
||||
>
|
||||
<Icon
|
||||
name={icons.REFRESH}
|
||||
isSpinning={isFetching}
|
||||
/>
|
||||
</FormInputButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
@ -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 (
|
||||
<DeviceInput
|
||||
{...this.props}
|
||||
onRefreshPress={this.onRefreshPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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 (
|
||||
<EnhancedSelectInput
|
||||
{...this.props}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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 (
|
||||
<div>
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._buttonId}
|
||||
>
|
||||
<Measure
|
||||
whitelist={['width']}
|
||||
onMeasure={this.onMeasure}
|
||||
>
|
||||
{
|
||||
isEditable ?
|
||||
<div
|
||||
className={styles.editableContainer}
|
||||
>
|
||||
<TextInput
|
||||
className={className}
|
||||
name={name}
|
||||
value={value}
|
||||
readOnly={isDisabled}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.dropdownArrowContainerEditable,
|
||||
isDisabled ?
|
||||
styles.dropdownArrowContainerDisabled :
|
||||
styles.dropdownArrowContainer)
|
||||
}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{
|
||||
isFetching ?
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isFetching ?
|
||||
null :
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
}
|
||||
</Link>
|
||||
</div> :
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onBlur={this.onBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
<SelectedValueComponent
|
||||
value={selectedValue}
|
||||
values={values}
|
||||
{...selectedValueOptions}
|
||||
{...selectedOption}
|
||||
isDisabled={isDisabled}
|
||||
isMultiSelect={isMultiSelect}
|
||||
>
|
||||
{selectedOption ? selectedOption.value : null}
|
||||
</SelectedValueComponent>
|
||||
|
||||
<div
|
||||
className={isDisabled ?
|
||||
styles.dropdownArrowContainerDisabled :
|
||||
styles.dropdownArrowContainer
|
||||
}
|
||||
>
|
||||
|
||||
{
|
||||
isFetching ?
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isFetching ?
|
||||
null :
|
||||
<Icon
|
||||
name={icons.CARET_DOWN}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</Link>
|
||||
}
|
||||
</Measure>
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom-start"
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.onComputeMaxHeight
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={this._optionsId}
|
||||
className={styles.optionsContainer}
|
||||
style={{
|
||||
...style,
|
||||
minWidth: width
|
||||
}}
|
||||
>
|
||||
{
|
||||
isOpen && !isMobile ?
|
||||
<Scroller
|
||||
className={styles.options}
|
||||
style={{
|
||||
maxHeight: style.maxHeight
|
||||
}}
|
||||
>
|
||||
{
|
||||
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 (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
depth={depth}
|
||||
isSelected={isSelectedItem(index, this.props)}
|
||||
isDisabled={parentSelected}
|
||||
isMultiSelect={isMultiSelect}
|
||||
{...valueOptions}
|
||||
{...v}
|
||||
isMobile={false}
|
||||
onSelect={this.onSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Scroller> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
|
||||
{
|
||||
isMobile ?
|
||||
<Modal
|
||||
className={styles.optionsModal}
|
||||
size={sizes.EXTRA_SMALL}
|
||||
isOpen={isOpen}
|
||||
onModalClose={this.onOptionsModalClose}
|
||||
>
|
||||
<ModalBody
|
||||
className={styles.optionsModalBody}
|
||||
innerClassName={styles.optionsInnerModalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
<Scroller className={styles.optionsModalScroller}>
|
||||
<div className={styles.mobileCloseButtonContainer}>
|
||||
<Link
|
||||
className={styles.mobileCloseButton}
|
||||
onPress={this.onOptionsModalClose}
|
||||
>
|
||||
<Icon
|
||||
name={icons.CLOSE}
|
||||
size={18}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{
|
||||
values.map((v, index) => {
|
||||
const hasParent = v.parentKey !== undefined;
|
||||
const depth = hasParent ? 1 : 0;
|
||||
const parentSelected = hasParent && value.includes(v.parentKey);
|
||||
return (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
depth={depth}
|
||||
isSelected={isSelectedItem(index, this.props)}
|
||||
isMultiSelect={isMultiSelect}
|
||||
isDisabled={parentSelected}
|
||||
{...valueOptions}
|
||||
{...v}
|
||||
isMobile={true}
|
||||
onSelect={this.onSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
</Modal> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
@ -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 (
|
||||
<EnhancedSelectInput
|
||||
{...this.props}
|
||||
onOpen={this.onOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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 (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
isSelected && !isMultiSelect && styles.isSelected,
|
||||
isDisabled && !isMultiSelect && styles.isDisabled,
|
||||
isHidden && styles.isHidden,
|
||||
isMobile && styles.isMobile
|
||||
)}
|
||||
component="div"
|
||||
isDisabled={isDisabled}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
|
||||
{
|
||||
depth !== 0 &&
|
||||
<div style={{ width: `${depth * 20}px` }} />
|
||||
}
|
||||
|
||||
{
|
||||
isMultiSelect &&
|
||||
<CheckInput
|
||||
className={styles.optionCheckInput}
|
||||
containerClassName={styles.optionCheck}
|
||||
name={`select-${id}`}
|
||||
value={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
onChange={this.onCheckPress}
|
||||
/>
|
||||
}
|
||||
|
||||
{children}
|
||||
|
||||
{
|
||||
isMobile &&
|
||||
<div className={styles.iconContainer}>
|
||||
<Icon
|
||||
name={isSelected ? icons.CHECK_CIRCLE : icons.CIRCLE_OUTLINE}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
@ -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 (
|
||||
<div className={classNames(
|
||||
className,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EnhancedSelectInputSelectedValue.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
isDisabled: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
EnhancedSelectInputSelectedValue.defaultProps = {
|
||||
className: styles.selectedValue,
|
||||
isDisabled: false
|
||||
};
|
||||
|
||||
export default EnhancedSelectInputSelectedValue;
|
@ -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 (
|
||||
<div>
|
||||
{
|
||||
validationErrors.length || validationWarnings.length ?
|
||||
<div className={styles.validationFailures}>
|
||||
{
|
||||
validationErrors.map((error, index) => {
|
||||
return (
|
||||
<Alert
|
||||
key={index}
|
||||
kind={kinds.DANGER}
|
||||
>
|
||||
{error.errorMessage}
|
||||
</Alert>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
validationWarnings.map((warning, index) => {
|
||||
return (
|
||||
<Alert
|
||||
key={index}
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
{warning.errorMessage}
|
||||
</Alert>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
45
frontend/src/Components/Form/Form.tsx
Normal file
45
frontend/src/Components/Form/Form.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
{validationErrors.length || validationWarnings.length ? (
|
||||
<div className={styles.validationFailures}>
|
||||
{validationErrors.map((error, index) => {
|
||||
return (
|
||||
<Alert key={index} kind={kinds.DANGER}>
|
||||
{error.errorMessage}
|
||||
</Alert>
|
||||
);
|
||||
})}
|
||||
|
||||
{validationWarnings.map((warning, index) => {
|
||||
return (
|
||||
<Alert key={index} kind={kinds.WARNING}>
|
||||
{warning.errorMessage}
|
||||
</Alert>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Form;
|
@ -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 (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[size]
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{
|
||||
map(children, (child) => {
|
||||
return React.cloneElement(child, childProps);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
43
frontend/src/Components/Form/FormGroup.tsx
Normal file
43
frontend/src/Components/Form/FormGroup.tsx
Normal file
@ -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<Size, keyof typeof styles>;
|
||||
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 (
|
||||
<div className={classNames(className, styles[size])} {...otherProps}>
|
||||
{Children.map(children, (child) => {
|
||||
if (!React.isValidElement(child)) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return React.cloneElement(child, childProps);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormGroup;
|
@ -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 (
|
||||
<div className={containerClassName}>
|
||||
<div className={className}>
|
||||
<div className={styles.inputContainer}>
|
||||
<InputComponent
|
||||
className={inputClassName}
|
||||
helpText={helpText}
|
||||
helpTextWarning={helpTextWarning}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
hasButton={hasButton}
|
||||
{...otherProps}
|
||||
/>
|
||||
|
||||
{
|
||||
unit &&
|
||||
<div
|
||||
className={
|
||||
type === inputTypes.NUMBER ?
|
||||
styles.inputUnitNumber :
|
||||
styles.inputUnit
|
||||
}
|
||||
>
|
||||
{unit}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
buttonsArray.map((button, index) => {
|
||||
return React.cloneElement(
|
||||
button,
|
||||
{
|
||||
isLastButton: index === lastButtonIndex
|
||||
}
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{/* <div className={styles.pendingChangesContainer}>
|
||||
{
|
||||
pending &&
|
||||
<Icon
|
||||
name={icons.UNSAVED_SETTING}
|
||||
className={styles.pendingChangesIcon}
|
||||
title="Change has not been saved yet"
|
||||
/>
|
||||
}
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{
|
||||
!checkInput && helpText &&
|
||||
<FormInputHelpText
|
||||
text={helpText}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!checkInput && helpTexts &&
|
||||
<div>
|
||||
{
|
||||
helpTexts.map((text, index) => {
|
||||
return (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={text}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
(!checkInput || helpText) && helpTextWarning &&
|
||||
<FormInputHelpText
|
||||
text={helpTextWarning}
|
||||
isWarning={true}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
helpLink &&
|
||||
<Link
|
||||
to={helpLink}
|
||||
>
|
||||
{translate('MoreInfo')}
|
||||
</Link>
|
||||
}
|
||||
|
||||
{
|
||||
errors.map((error, index) => {
|
||||
return (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={error.message}
|
||||
link={error.link}
|
||||
tooltip={error.detailedMessage}
|
||||
isError={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
warnings.map((warning, index) => {
|
||||
return (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={warning.message}
|
||||
link={warning.link}
|
||||
tooltip={warning.detailedMessage}
|
||||
isWarning={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
292
frontend/src/Components/Form/FormInputGroup.tsx
Normal file
292
frontend/src/Components/Form/FormInputGroup.tsx
Normal file
@ -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<T> {
|
||||
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<T>(props: FormInputGroupProps<T>) {
|
||||
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 (
|
||||
<div className={containerClassName}>
|
||||
<div className={className}>
|
||||
<div className={styles.inputContainer}>
|
||||
{/* @ts-expect-error - need to pass through all the expected options */}
|
||||
<InputComponent
|
||||
className={inputClassName}
|
||||
helpText={helpText}
|
||||
helpTextWarning={helpTextWarning}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
hasButton={hasButton}
|
||||
{...otherProps}
|
||||
/>
|
||||
|
||||
{unit && (
|
||||
<div
|
||||
className={
|
||||
type === inputTypes.NUMBER
|
||||
? styles.inputUnitNumber
|
||||
: styles.inputUnit
|
||||
}
|
||||
>
|
||||
{unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{buttonsArray.map((button, index) => {
|
||||
if (!React.isValidElement<FormInputButtonProps>(button)) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return React.cloneElement(button, {
|
||||
isLastButton: index === lastButtonIndex,
|
||||
});
|
||||
})}
|
||||
|
||||
{/* <div className={styles.pendingChangesContainer}>
|
||||
{
|
||||
pending &&
|
||||
<Icon
|
||||
name={icons.UNSAVED_SETTING}
|
||||
className={styles.pendingChangesIcon}
|
||||
title="Change has not been saved yet"
|
||||
/>
|
||||
}
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{!checkInput && helpText ? <FormInputHelpText text={helpText} /> : null}
|
||||
|
||||
{!checkInput && helpTexts ? (
|
||||
<div>
|
||||
{helpTexts.map((text, index) => {
|
||||
return (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={text}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(!checkInput || helpText) && helpTextWarning ? (
|
||||
<FormInputHelpText text={helpTextWarning} isWarning={true} />
|
||||
) : null}
|
||||
|
||||
{helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null}
|
||||
|
||||
{errors.map((error, index) => {
|
||||
return 'message' in error ? (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={error.message}
|
||||
isError={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
) : (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={error.errorMessage}
|
||||
link={error.infoLink}
|
||||
tooltip={error.detailedDescription}
|
||||
isError={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{warnings.map((warning, index) => {
|
||||
return 'message' in warning ? (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={warning.message}
|
||||
isWarning={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
) : (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={warning.errorMessage}
|
||||
link={warning.infoLink}
|
||||
tooltip={warning.detailedDescription}
|
||||
isWarning={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormInputGroup;
|
@ -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 (
|
||||
<div className={classNames(
|
||||
className,
|
||||
isError && styles.isError,
|
||||
isWarning && styles.isWarning,
|
||||
isCheckInput && styles.isCheckInput
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
|
||||
{
|
||||
link ?
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={link}
|
||||
title={tooltip}
|
||||
>
|
||||
<Icon
|
||||
name={icons.EXTERNAL_LINK}
|
||||
/>
|
||||
</Link> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!link && tooltip ?
|
||||
<Icon
|
||||
containerClassName={styles.details}
|
||||
name={icons.INFO}
|
||||
title={tooltip}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
55
frontend/src/Components/Form/FormInputHelpText.tsx
Normal file
55
frontend/src/Components/Form/FormInputHelpText.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
isError && styles.isError,
|
||||
isWarning && styles.isWarning,
|
||||
isCheckInput && styles.isCheckInput
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
|
||||
{link ? (
|
||||
<Link className={styles.link} to={link} title={tooltip}>
|
||||
<Icon name={icons.EXTERNAL_LINK} />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{!link && tooltip ? (
|
||||
<Icon
|
||||
containerClassName={styles.details}
|
||||
name={icons.INFO}
|
||||
title={tooltip}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormInputHelpText;
|
@ -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 (
|
||||
<label
|
||||
{...otherProps}
|
||||
className={classNames(
|
||||
className,
|
||||
styles[size],
|
||||
hasError && errorClassName,
|
||||
isAdvanced && styles.isAdvanced
|
||||
)}
|
||||
htmlFor={name}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
42
frontend/src/Components/Form/FormLabel.tsx
Normal file
42
frontend/src/Components/Form/FormLabel.tsx
Normal file
@ -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<Size, keyof typeof styles>;
|
||||
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 (
|
||||
<label
|
||||
className={classNames(
|
||||
className,
|
||||
styles[size],
|
||||
hasError && errorClassName,
|
||||
isAdvanced && styles.isAdvanced
|
||||
)}
|
||||
htmlFor={name}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormLabel;
|
@ -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 (
|
||||
<EnhancedSelectInputOption
|
||||
id={id}
|
||||
depth={depth}
|
||||
isSelected={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
isHidden={isDisabled}
|
||||
isMultiSelect={isMultiSelect}
|
||||
isMobile={isMobile}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={classNames(
|
||||
styles.optionText,
|
||||
isMobile && styles.isMobile
|
||||
)}
|
||||
>
|
||||
<div>{value}</div>
|
||||
|
||||
{
|
||||
hint != null &&
|
||||
<div className={styles.hintText}>
|
||||
{hint}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</EnhancedSelectInputOption>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
@ -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 (
|
||||
<EnhancedSelectInputSelectedValue
|
||||
className={styles.selectedValue}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.valueText}>
|
||||
{
|
||||
isMultiSelect ?
|
||||
value.map((key, index) => {
|
||||
const v = valuesMap[key];
|
||||
return (
|
||||
<Label key={key}>
|
||||
{v ? v.value : key}
|
||||
</Label>
|
||||
);
|
||||
}) :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
isMultiSelect ? null : value
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
hint != null && includeHint ?
|
||||
<div className={styles.hintText}>
|
||||
{hint}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
@ -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 (
|
||||
<EnhancedSelectInput
|
||||
{...this.props}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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';
|
||||
}
|
@ -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;
|
@ -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 (
|
||||
<div className={classNames(
|
||||
className,
|
||||
isFocused && styles.isFocused,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning
|
||||
)}
|
||||
>
|
||||
{
|
||||
[...value, { key: '', value: '' }].map((v, index) => {
|
||||
return (
|
||||
<KeyValueListInputItem
|
||||
key={index}
|
||||
index={index}
|
||||
keyValue={v.key}
|
||||
value={v.value}
|
||||
keyPlaceholder={keyPlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
isNew={index === value.length}
|
||||
onChange={this.onItemChange}
|
||||
onRemove={this.onRemoveItem}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
@ -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;
|
||||
}
|
@ -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;
|
@ -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 (
|
||||
<div className={styles.itemContainer}>
|
||||
<div className={styles.keyInputWrapper}>
|
||||
<TextInput
|
||||
className={styles.keyInput}
|
||||
name="key"
|
||||
value={keyValue}
|
||||
placeholder={keyPlaceholder}
|
||||
onChange={this.onKeyChange}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.valueInputWrapper}>
|
||||
<TextInput
|
||||
className={styles.valueInput}
|
||||
name="value"
|
||||
value={value}
|
||||
placeholder={valuePlaceholder}
|
||||
onChange={this.onValueChange}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonWrapper}>
|
||||
{
|
||||
isNew ?
|
||||
null :
|
||||
<IconButton
|
||||
name={icons.REMOVE}
|
||||
tabIndex={-1}
|
||||
onPress={this.onRemovePress}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
@ -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 (
|
||||
<EnhancedSelectInput
|
||||
{...this.props}
|
||||
onChange={this.props.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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 (
|
||||
<EnhancedSelectInput
|
||||
values={values}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
MonitorEpisodesSelectInput.propTypes = {
|
||||
includeNoChange: PropTypes.bool.isRequired,
|
||||
includeMixed: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
MonitorEpisodesSelectInput.defaultProps = {
|
||||
includeNoChange: false,
|
||||
includeMixed: false
|
||||
};
|
||||
|
||||
export default MonitorEpisodesSelectInput;
|
@ -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 (
|
||||
<EnhancedSelectInput
|
||||
values={values}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
MonitorNewItemsSelectInput.propTypes = {
|
||||
includeNoChange: PropTypes.bool.isRequired,
|
||||
includeMixed: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
MonitorNewItemsSelectInput.defaultProps = {
|
||||
includeNoChange: false,
|
||||
includeMixed: false
|
||||
};
|
||||
|
||||
export default MonitorNewItemsSelectInput;
|
@ -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 (
|
||||
<TextInput
|
||||
{...this.props}
|
||||
type="number"
|
||||
value={value == null ? '' : value}
|
||||
onChange={this.onChange}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
108
frontend/src/Components/Form/NumberInput.tsx
Normal file
108
frontend/src/Components/Form/NumberInput.tsx
Normal file
@ -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<TextInputProps<number | null>, '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<string>) => {
|
||||
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 (
|
||||
<TextInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
type="number"
|
||||
value={value == null ? '' : value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default NumberInput;
|
@ -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 (
|
||||
<div>
|
||||
<SpinnerErrorButton
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={authorizing}
|
||||
error={error}
|
||||
onPress={onPress}
|
||||
>
|
||||
{label}
|
||||
</SpinnerErrorButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
72
frontend/src/Components/Form/OAuthInput.tsx
Normal file
72
frontend/src/Components/Form/OAuthInput.tsx
Normal file
@ -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<unknown>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<SpinnerErrorButton
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={authorizing}
|
||||
error={error}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{label}
|
||||
</SpinnerErrorButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OAuthInput;
|
@ -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 (
|
||||
<OAuthInput
|
||||
{...this.props}
|
||||
onPress={this.onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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 (
|
||||
<TextInput
|
||||
{...props}
|
||||
type="password"
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
PasswordInput.propTypes = {
|
||||
...TextInput.props
|
||||
};
|
||||
|
||||
export default PasswordInput;
|
14
frontend/src/Components/Form/PasswordInput.tsx
Normal file
14
frontend/src/Components/Form/PasswordInput.tsx
Normal file
@ -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<string>) {
|
||||
return <TextInput {...props} type="password" onCopy={onCopy} />;
|
||||
}
|
||||
|
||||
export default PasswordInput;
|
@ -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 (
|
||||
<span>{path}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span className={styles.pathMatch}>
|
||||
{path.substr(0, lastSeparatorIndex)}
|
||||
</span>
|
||||
{path.substr(lastSeparatorIndex)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// 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 (
|
||||
<div className={className}>
|
||||
<AutoSuggestInput
|
||||
{...otherProps}
|
||||
className={hasFileBrowser ? styles.hasFileBrowser : undefined}
|
||||
name={name}
|
||||
value={value}
|
||||
suggestions={paths}
|
||||
getSuggestionValue={this.getSuggestionValue}
|
||||
renderSuggestion={this.renderSuggestion}
|
||||
onInputKeyDown={this.onInputKeyDown}
|
||||
onInputBlur={this.onInputBlur}
|
||||
onSuggestionSelected={this.onSuggestionSelected}
|
||||
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
|
||||
{
|
||||
hasFileBrowser &&
|
||||
<div>
|
||||
<FormInputButton
|
||||
className={styles.fileBrowserButton}
|
||||
onPress={this.onFileBrowserOpenPress}
|
||||
>
|
||||
<Icon name={icons.FOLDER_OPEN} />
|
||||
</FormInputButton>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={isFileBrowserModalOpen}
|
||||
name={name}
|
||||
value={value}
|
||||
includeFiles={includeFiles}
|
||||
onChange={onChange}
|
||||
onModalClose={this.onFileBrowserModalClose}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
252
frontend/src/Components/Form/PathInput.tsx
Normal file
252
frontend/src/Components/Form/PathInput.tsx
Normal file
@ -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<string>) => 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 (
|
||||
<PathInputInternal
|
||||
{...props}
|
||||
paths={paths}
|
||||
onFetchPaths={handleFetchPaths}
|
||||
onClearPaths={handleClearPaths}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLElement>) => {
|
||||
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<Path>) => {
|
||||
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 <span>{path}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span className={styles.pathMatch}>
|
||||
{path.substring(0, lastSeparatorIndex)}
|
||||
</span>
|
||||
{path.substring(lastSeparatorIndex)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputValue !== previousInputValue) {
|
||||
setValue(inputValue);
|
||||
}
|
||||
}, [inputValue, previousInputValue, setValue]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<AutoSuggestInput
|
||||
{...otherProps}
|
||||
className={hasFileBrowser ? styles.hasFileBrowser : undefined}
|
||||
name={name}
|
||||
value={value}
|
||||
suggestions={paths}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderSuggestion={renderSuggestion}
|
||||
onInputKeyDown={handleInputKeyDown}
|
||||
onInputChange={handleInputChange}
|
||||
onInputBlur={handleInputBlur}
|
||||
onSuggestionSelected={handleSuggestionSelected}
|
||||
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={handleSuggestionsClearRequested}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
{hasFileBrowser ? (
|
||||
<div>
|
||||
<FormInputButton
|
||||
className={styles.fileBrowserButton}
|
||||
onPress={handleFileBrowserOpenPress}
|
||||
>
|
||||
<Icon name={icons.FOLDER_OPEN} />
|
||||
</FormInputButton>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={isFileBrowserModalOpen}
|
||||
name={name}
|
||||
value={value}
|
||||
includeFiles={includeFiles}
|
||||
onChange={onChange}
|
||||
onModalClose={handleFileBrowserModalClose}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<PathInput
|
||||
onFetchPaths={this.onFetchPaths}
|
||||
onClearPaths={this.onClearPaths}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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 (
|
||||
<EnhancedSelectInput
|
||||
{...this.props}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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 (
|
||||
<div>
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
selectedValueComponent={RootFolderSelectInputSelectedValue}
|
||||
optionComponent={RootFolderSelectInputOption}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={this.state.isAddNewRootFolderModalOpen}
|
||||
name="rootFolderPath"
|
||||
value=""
|
||||
onChange={this.onNewRootFolderSelect}
|
||||
onModalClose={this.onAddRootFolderModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
@ -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 (
|
||||
<RootFolderSelectInput
|
||||
{...otherProps}
|
||||
onNewRootFolderSelect={this.onNewRootFolderSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
@ -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 (
|
||||
<EnhancedSelectInputOption
|
||||
id={id}
|
||||
isMobile={isMobile}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={classNames(
|
||||
styles.optionText,
|
||||
isMobile && styles.isMobile
|
||||
)}
|
||||
>
|
||||
<div className={styles.value}>
|
||||
{value}
|
||||
|
||||
{
|
||||
seriesFolder && id !== 'addNew' ?
|
||||
<div className={styles.seriesFolder}>
|
||||
{slashCharacter}
|
||||
{seriesFolder}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
freeSpace == null ?
|
||||
null :
|
||||
<div className={styles.freeSpace}>
|
||||
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
isMissing ?
|
||||
<div className={styles.isMissing}>
|
||||
{translate('Missing')}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</EnhancedSelectInputOption>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
@ -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 (
|
||||
<EnhancedSelectInputSelectedValue
|
||||
className={styles.selectedValue}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.pathContainer}>
|
||||
<div className={styles.path}>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{
|
||||
seriesFolder ?
|
||||
<div className={styles.seriesFolder}>
|
||||
{slashCharacter}
|
||||
{seriesFolder}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
freeSpace != null && includeFreeSpace &&
|
||||
<div className={styles.freeSpace}>
|
||||
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
|
||||
</div>
|
||||
}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
@ -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<EnhancedSelectInputValue<number>, number> {
|
||||
name: string;
|
||||
value: number;
|
||||
includeAny?: boolean;
|
||||
protocol?: Protocol;
|
||||
onChange: (change: EnhancedSelectInputChanged<number>) => 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 (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
isFetching={isFetching}
|
||||
values={values}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DownloadClientSelectInput;
|
@ -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';
|
||||
|
@ -14,6 +14,7 @@ interface CssExports {
|
||||
'mobileCloseButtonContainer': string;
|
||||
'options': string;
|
||||
'optionsContainer': string;
|
||||
'optionsInnerModalBody': string;
|
||||
'optionsModal': string;
|
||||
'optionsModalBody': string;
|
||||
'optionsModalScroller': string;
|
622
frontend/src/Components/Form/Select/EnhancedSelectInput.tsx
Normal file
622
frontend/src/Components/Form/Select/EnhancedSelectInput.tsx
Normal file
@ -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<T extends EnhancedSelectInputValue<V>, V>(
|
||||
selectedIndex: number,
|
||||
values: T[]
|
||||
) {
|
||||
return values[selectedIndex];
|
||||
}
|
||||
|
||||
function findIndex<T extends EnhancedSelectInputValue<V>, 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<T extends EnhancedSelectInputValue<V>, V>(
|
||||
selectedIndex: number,
|
||||
values: T[]
|
||||
) {
|
||||
return findIndex(selectedIndex, -1, values);
|
||||
}
|
||||
|
||||
function nextIndex<T extends EnhancedSelectInputValue<V>, V>(
|
||||
selectedIndex: number,
|
||||
values: T[]
|
||||
) {
|
||||
return findIndex(selectedIndex, 1, values);
|
||||
}
|
||||
|
||||
function getSelectedIndex<T extends EnhancedSelectInputValue<V>, 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<T extends EnhancedSelectInputValue<V>, 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<V> {
|
||||
key: ArrayElement<V>;
|
||||
value: string;
|
||||
hint?: ReactNode;
|
||||
isDisabled?: boolean;
|
||||
isHidden?: boolean;
|
||||
parentKey?: V;
|
||||
additionalProperties?: object;
|
||||
}
|
||||
|
||||
export interface EnhancedSelectInputProps<
|
||||
T extends EnhancedSelectInputValue<V>,
|
||||
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<V>) => void;
|
||||
}
|
||||
|
||||
function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
props: EnhancedSelectInputProps<T, V>
|
||||
) {
|
||||
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<V>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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<string>) => {
|
||||
onChange(change as EnhancedSelectInputChanged<V>);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (updater.current) {
|
||||
updater.current();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div ref={ref} id={buttonId}>
|
||||
<Measure whitelist={['width']} onMeasure={handleMeasure}>
|
||||
{isEditable && typeof value === 'string' ? (
|
||||
<div className={styles.editableContainer}>
|
||||
<TextInput
|
||||
className={className}
|
||||
name={name}
|
||||
value={value}
|
||||
readOnly={isDisabled}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.dropdownArrowContainerEditable,
|
||||
isDisabled
|
||||
? styles.dropdownArrowContainerDisabled
|
||||
: styles.dropdownArrowContainer
|
||||
)}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<SelectedValueComponent
|
||||
values={values}
|
||||
{...selectedValueOptions}
|
||||
selectedValue={selectedValue}
|
||||
isDisabled={isDisabled}
|
||||
isMultiSelect={isMultiSelect}
|
||||
>
|
||||
{selectedOption ? selectedOption.value : selectedValue}
|
||||
</SelectedValueComponent>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDisabled
|
||||
? styles.dropdownArrowContainerDisabled
|
||||
: styles.dropdownArrowContainer
|
||||
}
|
||||
>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</Measure>
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom-start"
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: handleComputeMaxHeight,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
updater.current = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={optionsId}
|
||||
className={styles.optionsContainer}
|
||||
style={{
|
||||
...style,
|
||||
minWidth: width,
|
||||
}}
|
||||
>
|
||||
{isOpen && !isMobile ? (
|
||||
<Scroller
|
||||
className={styles.options}
|
||||
style={{
|
||||
maxHeight: style.maxHeight,
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
depth={depth}
|
||||
isSelected={isSelectedItem(index, value, values)}
|
||||
isDisabled={parentSelected}
|
||||
isMultiSelect={isMultiSelect}
|
||||
{...valueOptions}
|
||||
{...other}
|
||||
isMobile={false}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})}
|
||||
</Scroller>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
|
||||
{isMobile ? (
|
||||
<Modal
|
||||
className={styles.optionsModal}
|
||||
size={sizes.EXTRA_SMALL}
|
||||
isOpen={isOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
>
|
||||
<ModalBody
|
||||
className={styles.optionsModalBody}
|
||||
innerClassName={styles.optionsInnerModalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
<Scroller className={styles.optionsModalScroller}>
|
||||
<div className={styles.mobileCloseButtonContainer}>
|
||||
<Link
|
||||
className={styles.mobileCloseButton}
|
||||
onPress={handleOptionsModalClose}
|
||||
>
|
||||
<Icon name={icons.CLOSE} size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<OptionComponent
|
||||
key={key}
|
||||
id={key}
|
||||
depth={depth}
|
||||
isSelected={isSelectedItem(index, value, values)}
|
||||
isMultiSelect={isMultiSelect}
|
||||
isDisabled={parentSelected}
|
||||
{...valueOptions}
|
||||
{...other}
|
||||
isMobile={true}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})}
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnhancedSelectInput;
|
@ -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;
|
||||
}
|
@ -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 (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
isSelected && !isMultiSelect && styles.isSelected,
|
||||
isDisabled && !isMultiSelect && styles.isDisabled,
|
||||
isHidden && styles.isHidden,
|
||||
isMobile && styles.isMobile
|
||||
)}
|
||||
component="div"
|
||||
isDisabled={isDisabled}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{depth !== 0 && <div style={{ width: `${depth * 20}px` }} />}
|
||||
|
||||
{isMultiSelect && (
|
||||
<CheckInput
|
||||
className={styles.optionCheckInput}
|
||||
containerClassName={styles.optionCheck}
|
||||
name={`select-${id}`}
|
||||
value={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
onChange={handleCheckPress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{isMobile && (
|
||||
<div className={styles.iconContainer}>
|
||||
<Icon name={isSelected ? icons.CHECK_CIRCLE : icons.CIRCLE_OUTLINE} />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnhancedSelectInputOption;
|
@ -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 (
|
||||
<div className={classNames(className, isDisabled && styles.isDisabled)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnhancedSelectInputSelectedValue;
|
@ -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 (
|
||||
<EnhancedSelectInputOption
|
||||
id={id}
|
||||
depth={depth}
|
||||
isSelected={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
isHidden={isDisabled}
|
||||
isMobile={isMobile}
|
||||
{...otherProps}
|
||||
>
|
||||
<div
|
||||
className={classNames(styles.optionText, isMobile && styles.isMobile)}
|
||||
>
|
||||
<div>{value}</div>
|
||||
|
||||
{hint != null && <div className={styles.hintText}>{hint}</div>}
|
||||
</div>
|
||||
</EnhancedSelectInputOption>
|
||||
);
|
||||
}
|
||||
|
||||
HintedSelectInputOption.defaultProps = {
|
||||
isDisabled: false,
|
||||
isHidden: false,
|
||||
isMultiSelect: false,
|
||||
};
|
||||
|
||||
export default HintedSelectInputOption;
|
@ -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<T, V> {
|
||||
selectedValue: V;
|
||||
values: T[];
|
||||
hint?: ReactNode;
|
||||
isMultiSelect?: boolean;
|
||||
includeHint?: boolean;
|
||||
}
|
||||
|
||||
function HintedSelectInputSelectedValue<
|
||||
T extends EnhancedSelectInputValue<V>,
|
||||
V extends number | string
|
||||
>(props: HintedSelectInputSelectedValueProps<T, V>) {
|
||||
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 (
|
||||
<EnhancedSelectInputSelectedValue
|
||||
className={styles.selectedValue}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.valueText}>
|
||||
{isMultiSelect && Array.isArray(selectedValue)
|
||||
? selectedValue.map((key) => {
|
||||
const v = valuesMap.get(key);
|
||||
|
||||
return <Label key={key}>{v ? v : key}</Label>;
|
||||
})
|
||||
: valuesMap.get(selectedValue as ArrayElement<V>)}
|
||||
</div>
|
||||
|
||||
{hint != null && includeHint ? (
|
||||
<div className={styles.hintText}>{hint}</div>
|
||||
) : null}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
);
|
||||
}
|
||||
|
||||
export default HintedSelectInputSelectedValue;
|
@ -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<number>): 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<number[]>) => {
|
||||
const indexerFlags = change.value.reduce(
|
||||
(acc, flagId) => acc + flagId,
|
||||
0
|
||||
);
|
||||
|
||||
onChange({ name, value: indexerFlags });
|
||||
},
|
||||
[onChange]
|
||||
[name, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...props}
|
||||
{...otherProps}
|
||||
name={name}
|
||||
value={value}
|
||||
values={values}
|
||||
onChange={onChangeWrapper}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
81
frontend/src/Components/Form/Select/IndexerSelectInput.tsx
Normal file
81
frontend/src/Components/Form/Select/IndexerSelectInput.tsx
Normal file
@ -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<number>) => 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 (
|
||||
<EnhancedSelectInput
|
||||
name={name}
|
||||
value={value}
|
||||
isFetching={isFetching}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
IndexerSelectInput.defaultProps = {
|
||||
includeAny: false,
|
||||
};
|
||||
|
||||
export default IndexerSelectInput;
|
43
frontend/src/Components/Form/Select/LanguageSelectInput.tsx
Normal file
43
frontend/src/Components/Form/Select/LanguageSelectInput.tsx
Normal file
@ -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<number>[];
|
||||
onChange: (change: EnhancedSelectInputChanged<number>) => 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 (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
values={mappedValues}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSelectInput;
|
@ -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<EnhancedSelectInputValue<string>, string>,
|
||||
'values'
|
||||
> {
|
||||
includeNoChange: boolean;
|
||||
includeMixed: boolean;
|
||||
}
|
||||
|
||||
function MonitorEpisodesSelectInput(props: MonitorEpisodesSelectInputProps) {
|
||||
const {
|
||||
includeNoChange = false,
|
||||
includeMixed = false,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const values: EnhancedSelectInputValue<string>[] = [...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 <EnhancedSelectInput {...otherProps} values={values} />;
|
||||
}
|
||||
|
||||
export default MonitorEpisodesSelectInput;
|
@ -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<EnhancedSelectInputValue<string>, string>,
|
||||
'values'
|
||||
> {
|
||||
includeNoChange?: boolean;
|
||||
includeMixed?: boolean;
|
||||
onChange: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function MonitorNewItemsSelectInput(props: MonitorNewItemsSelectInputProps) {
|
||||
const {
|
||||
includeNoChange = false,
|
||||
includeMixed = false,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const values: EnhancedSelectInputValue<string>[] = [
|
||||
...monitorNewItemsOptions,
|
||||
];
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
value: 'No Change',
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
values.unshift({
|
||||
key: 'mixed',
|
||||
value: '(Mixed)',
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return <EnhancedSelectInput {...otherProps} values={values} />;
|
||||
}
|
||||
|
||||
export default MonitorNewItemsSelectInput;
|
@ -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<unknown>[]) {
|
||||
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<ProviderOptionsAppState, 'devices'>
|
||||
) {
|
||||
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<EnhancedSelectInputValue<unknown>, unknown>,
|
||||
'values'
|
||||
> {
|
||||
provider: string;
|
||||
providerData: ProviderOptions;
|
||||
name: string;
|
||||
value: unknown;
|
||||
selectOptionsProviderAction: keyof Omit<ProviderOptionsAppState, 'devices'>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
isFetching={isFetching}
|
||||
values={values}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProviderOptionSelectInput;
|
@ -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<QualityProfile, 'name'>('name')
|
||||
),
|
||||
(qualityProfiles: QualityProfilesAppState) => {
|
||||
const values: EnhancedSelectInputValue<number | string>[] =
|
||||
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>,
|
||||
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<string | number>) => {
|
||||
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 (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
value={value}
|
||||
values={values}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QualityProfileSelectInput;
|
215
frontend/src/Components/Form/Select/RootFolderSelectInput.tsx
Normal file
215
frontend/src/Components/Form/Select/RootFolderSelectInput.tsx
Normal file
@ -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<string> {
|
||||
isMissing?: boolean;
|
||||
}
|
||||
|
||||
interface RootFolderSelectInputProps
|
||||
extends Omit<
|
||||
EnhancedSelectInputProps<EnhancedSelectInputValue<string>, 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<string>) => {
|
||||
if (newValue === 'addNew') {
|
||||
setIsAddNewRootFolderModalOpen(true);
|
||||
} else {
|
||||
onChange({ name, value: newValue });
|
||||
}
|
||||
},
|
||||
[name, setIsAddNewRootFolderModalOpen, onChange]
|
||||
);
|
||||
|
||||
const handleNewRootFolderSelect = useCallback(
|
||||
({ value: newValue }: InputChanged<string>) => {
|
||||
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 (
|
||||
<>
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
value={value ?? ''}
|
||||
values={values}
|
||||
selectedValueComponent={RootFolderSelectInputSelectedValue}
|
||||
optionComponent={RootFolderSelectInputOption}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<FileBrowserModal
|
||||
isOpen={isAddNewRootFolderModalOpen}
|
||||
name="rootFolderPath"
|
||||
value=""
|
||||
onChange={handleNewRootFolderSelect}
|
||||
onModalClose={handleAddRootFolderModalClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default RootFolderSelectInput;
|
@ -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 (
|
||||
<EnhancedSelectInputOption id={id} isMobile={isMobile} {...otherProps}>
|
||||
<div
|
||||
className={classNames(styles.optionText, isMobile && styles.isMobile)}
|
||||
>
|
||||
<div className={styles.value}>
|
||||
{value}
|
||||
|
||||
{seriesFolder && id !== 'addNew' ? (
|
||||
<div className={styles.seriesFolder}>
|
||||
{slashCharacter}
|
||||
{seriesFolder}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{freeSpace == null ? null : (
|
||||
<div className={styles.freeSpace}>
|
||||
{translate('RootFolderSelectFreeSpace', {
|
||||
freeSpace: formatBytes(freeSpace),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMissing ? (
|
||||
<div className={styles.isMissing}>{translate('Missing')}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</EnhancedSelectInputOption>
|
||||
);
|
||||
}
|
||||
|
||||
export default RootFolderSelectInputOption;
|
@ -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 (
|
||||
<EnhancedSelectInputSelectedValue
|
||||
className={styles.selectedValue}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.pathContainer}>
|
||||
<div className={styles.path}>{value}</div>
|
||||
|
||||
{seriesFolder ? (
|
||||
<div className={styles.seriesFolder}>
|
||||
{slashCharacter}
|
||||
{seriesFolder}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{freeSpace != null && includeFreeSpace ? (
|
||||
<div className={styles.freeSpace}>
|
||||
{translate('RootFolderSelectFreeSpace', {
|
||||
freeSpace: formatBytes(freeSpace),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
);
|
||||
}
|
||||
|
||||
export default RootFolderSelectInputSelectedValue;
|
@ -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<EnhancedSelectInputValue<string>, 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 (
|
||||
<EnhancedSelectInput
|
@ -1,20 +1,23 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
|
||||
import EnhancedSelectInputOption, {
|
||||
EnhancedSelectInputOptionProps,
|
||||
} from './EnhancedSelectInputOption';
|
||||
import styles from './SeriesTypeSelectInputOption.css';
|
||||
|
||||
interface SeriesTypeSelectInputOptionProps {
|
||||
key: string;
|
||||
interface SeriesTypeSelectInputOptionProps
|
||||
extends EnhancedSelectInputOptionProps {
|
||||
id: string;
|
||||
value: string;
|
||||
format: string;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
function SeriesTypeSelectInputOption(props: SeriesTypeSelectInputOptionProps) {
|
||||
const { key, value, format, isMobile, ...otherProps } = props;
|
||||
const { id, value, format, isMobile, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<EnhancedSelectInputOption id={key} isMobile={isMobile} {...otherProps}>
|
||||
<EnhancedSelectInputOption {...otherProps} id={id} isMobile={isMobile}>
|
||||
<div
|
||||
className={classNames(styles.optionText, isMobile && styles.isMobile)}
|
||||
>
|
@ -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 (
|
||||
<HintedSelectInputSelectedValue
|
||||
{...otherProps}
|
||||
selectedValue={selectedValue}
|
||||
values={values}
|
||||
hint={format}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesTypeSelectInputSelectedValue;
|
@ -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);
|
||||
}
|
142
frontend/src/Components/Form/Select/UMaskInput.tsx
Normal file
142
frontend/src/Components/Form/Select/UMaskInput.tsx
Normal file
@ -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: <span className={styles.unit}>{v.hint}</span> };
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.inputWrapper}>
|
||||
<div className={styles.inputUnitWrapper}>
|
||||
<EnhancedSelectInput
|
||||
name={name}
|
||||
value={value}
|
||||
values={values}
|
||||
isEditable={true}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<div className={styles.inputUnit}>d{unit}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
<label>{translate('Umask')}</label>
|
||||
<div className={styles.value}>{umask}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{translate('Folder')}</label>
|
||||
<div className={styles.value}>{folder}</div>
|
||||
<div className={styles.unit}>d{formatPermissions(folderNum)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>{translate('File')}</label>
|
||||
<div className={styles.value}>{file}</div>
|
||||
<div className={styles.unit}>{formatPermissions(fileNum)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
@ -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 (
|
||||
<select
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
name={name}
|
||||
value={value}
|
||||
autoFocus={autoFocus}
|
||||
onChange={this.onChange}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{
|
||||
values.map((option) => {
|
||||
const {
|
||||
key,
|
||||
value: optionValue,
|
||||
...otherOptionProps
|
||||
} = option;
|
||||
|
||||
return (
|
||||
<option
|
||||
key={key}
|
||||
value={key}
|
||||
{...otherOptionProps}
|
||||
>
|
||||
{typeof optionValue === 'function' ? optionValue() : optionValue}
|
||||
</option>
|
||||
);
|
||||
})
|
||||
}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user