1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-18 21:35:51 -04:00

New: Project Aphrodite

This commit is contained in:
Qstick
2018-11-23 02:04:42 -05:00
parent 65efa15551
commit 8430cb40ab
1080 changed files with 73015 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
.input {
composes: input from 'Components/Form/Input.css';
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.inputWrapper {
display: flex;
}
.inputContainer {
position: relative;
flex-grow: 1;
}
.container {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.inputContainerOpen {
.container {
position: absolute;
z-index: 1;
overflow-y: auto;
max-height: 200px;
width: 100%;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
}
.list {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.listItem {
padding: 0 16px;
}
.match {
font-weight: bold;
}
.highlighted {
background-color: $menuItemHoverBackgroundColor;
}

View File

@@ -0,0 +1,162 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import jdu from 'jdu';
import styles from './AutoCompleteInput.css';
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
});
}
onInputKeyDown = (event) => {
const {
name,
value,
onChange
} = this.props;
const { suggestions } = this.state;
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== this.props.value
) {
event.preventDefault();
if (value) {
onChange({
name,
value: suggestions[0]
});
}
}
}
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 {
className,
inputClassName,
name,
value,
placeholder,
hasError,
hasWarning
} = this.props;
const { suggestions } = this.state;
const inputProps = {
className: classNames(
inputClassName,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onBlur: this.onInputBlur
};
const theme = {
container: styles.inputContainer,
containerOpen: styles.inputContainerOpen,
suggestionsContainer: styles.container,
suggestionsList: styles.list,
suggestion: styles.listItem,
suggestionHighlighted: styles.highlighted
};
return (
<div className={className}>
<Autosuggest
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
</div>
);
}
}
AutoCompleteInput.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.string).isRequired,
placeholder: PropTypes.string,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
onChange: PropTypes.func.isRequired
};
AutoCompleteInput.defaultProps = {
className: styles.inputWrapper,
inputClassName: styles.input,
value: ''
};
export default AutoCompleteInput;

View File

@@ -0,0 +1,23 @@
.captchaInputWrapper {
display: flex;
}
.input {
composes: input from 'Components/Form/Input.css';
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.hasButton {
composes: hasButton from 'Components/Form/Input.css';
}
.recaptchaWrapper {
margin-top: 10px;
}

View File

@@ -0,0 +1,84 @@
import PropTypes from 'prop-types';
import React from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
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;

View File

@@ -0,0 +1,98 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { refreshCaptcha, getCaptchaCookie, 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);

View File

@@ -0,0 +1,105 @@
.container {
position: relative;
display: flex;
flex: 1 1 65%;
user-select: none;
}
.label {
display: flex;
margin-bottom: 0;
min-height: 21px;
font-weight: normal;
cursor: pointer;
}
.checkbox {
position: absolute;
opacity: 0;
cursor: pointer;
pointer-events: none;
&:global(.isDisabled) {
cursor: not-allowed;
}
}
.input {
flex: 1 0 auto;
margin-top: 7px;
margin-right: 5px;
width: 20px;
height: 20px;
border: 1px solid #ccc;
border-radius: 2px;
background-color: $white;
color: $white;
text-align: center;
line-height: 20px;
}
.checkbox:focus + .input {
outline: 0;
border-color: $inputFocusBorderColor;
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
}
.dangerIsChecked {
border-color: $dangerColor;
background-color: $dangerColor;
&.isDisabled {
opacity: 0.7;
}
}
.primaryIsChecked {
border-color: $primaryColor;
background-color: $primaryColor;
&.isDisabled {
opacity: 0.7;
}
}
.successIsChecked {
border-color: $successColor;
background-color: $successColor;
&.isDisabled {
opacity: 0.7;
}
}
.warningIsChecked {
border-color: $warningColor;
background-color: $warningColor;
&.isDisabled {
opacity: 0.7;
}
}
.isNotChecked {
&.isDisabled {
border-color: $disabledCheckInputColor;
background-color: $disabledCheckInputColor;
opacity: 0.7;
}
}
.isIndeterminate {
border-color: $gray;
background-color: $gray;
}
.helpText {
composes: helpText from 'Components/Form/FormInputHelpText.css';
margin-top: 8px;
margin-left: 5px;
}
.isDisabled {
cursor: not-allowed;
}

View File

@@ -0,0 +1,191 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
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;

View File

@@ -0,0 +1,8 @@
.deviceInputWrapper {
display: flex;
}
.inputContainer {
composes: inputContainer from './TagInput.css';
composes: hasButton from 'Components/Form/Input.css';
}

View File

@@ -0,0 +1,103 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import FormInputButton from './FormInputButton';
import TagInput, { tagShape } 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,
items,
selectedDevices,
hasError,
hasWarning,
isFetching,
onRefreshPress
} = this.props;
return (
<div className={className}>
<TagInput
className={styles.inputContainer}
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;

View File

@@ -0,0 +1,99 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchDevices, clearDevices } from 'Store/Actions/deviceActions';
import DeviceInput from './DeviceInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(state) => state.devices,
(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 = {
dispatchFetchDevices: fetchDevices,
dispatchClearDevices: clearDevices
};
class DeviceInputConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
this._populate();
}
componentWillUnmount = () => {
// this.props.dispatchClearDevices();
}
//
// Control
_populate() {
const {
provider,
providerData,
dispatchFetchDevices
} = this.props;
dispatchFetchDevices({ 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,
dispatchFetchDevices: PropTypes.func.isRequired,
dispatchClearDevices: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector);

View File

@@ -0,0 +1,79 @@
.tether {
z-index: 2000;
}
.enhancedSelect {
composes: input from 'Components/Form/Input.css';
composes: link from 'Components/Link/Link.css';
position: relative;
display: flex;
align-items: center;
padding: 6px 16px;
width: 100%;
height: 35px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
color: $black;
cursor: default;
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.isDisabled {
opacity: 0.7;
cursor: not-allowed;
}
.dropdownArrowContainer {
margin-left: 12px;
}
.dropdownArrowContainerDisabled {
composes: dropdownArrowContainer;
color: $disabledInputColor;
}
.optionsContainer {
width: auto;
}
.options {
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
}
.optionsModal {
display: flex;
justify-content: center;
max-width: 90%;
width: 350px !important;
height: auto !important;
}
.optionsModalBody {
composes: modalBody from 'Components/Modal/ModalBody.css';
display: flex;
justify-content: center;
flex-direction: column;
padding: 10px 0;
}
.optionsModalScroller {
composes: scroller from 'Components/Scroller/Scroller.css';
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
}

View File

@@ -0,0 +1,411 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import TetherComponent from 'react-tether';
import classNames from 'classnames';
import isMobileUtil from 'Utilities/isMobile';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import { icons, scrollDirections } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import Scroller from 'Components/Scroller/Scroller';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './EnhancedSelectInput.css';
const tetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
],
attachment: 'top left',
targetAttachment: 'bottom left'
};
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;
return values.findIndex((v) => {
return v.key === value;
});
}
function getKey(selectedIndex, values) {
return values[selectedIndex].key;
}
class EnhancedSelectInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOpen: false,
selectedIndex: getSelectedIndex(props),
width: 0,
isMobile: isMobileUtil()
};
}
componentDidUpdate(prevProps) {
if (prevProps.value !== this.props.value) {
this.setState({
selectedIndex: getSelectedIndex(this.props)
});
}
}
//
// Control
_setButtonRef = (ref) => {
this._buttonRef = ref;
}
_setOptionsRef = (ref) => {
this._optionsRef = ref;
}
_addListener() {
window.addEventListener('click', this.onWindowClick);
}
_removeListener() {
window.removeEventListener('click', this.onWindowClick);
}
//
// Listeners
onWindowClick = (event) => {
const button = ReactDOM.findDOMNode(this._buttonRef);
const options = ReactDOM.findDOMNode(this._optionsRef);
if (!button || this.state.isMobile) {
return;
}
if (
!button.contains(event.target) &&
options &&
!options.contains(event.target) &&
this.state.isOpen
) {
this.setState({ isOpen: false });
this._removeListener();
}
}
onBlur = () => {
this.setState({
selectedIndex: getSelectedIndex(this.props)
});
}
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 ||
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();
}
this.setState({ isOpen: !this.state.isOpen });
}
onSelect = (value) => {
this.setState({ isOpen: false });
this.props.onChange({
name: this.props.name,
value
});
}
onMeasure = ({ width }) => {
this.setState({ width });
}
onOptionsModalClose = () => {
this.setState({ isOpen: false });
}
//
// Render
render() {
const {
className,
disabledClassName,
values,
isDisabled,
hasError,
hasWarning,
selectedValueOptions,
selectedValueComponent: SelectedValueComponent,
optionComponent: OptionComponent
} = this.props;
const {
selectedIndex,
width,
isOpen,
isMobile
} = this.state;
const selectedOption = getSelectedOption(selectedIndex, values);
return (
<div>
<TetherComponent
classes={{
element: styles.tether
}}
{...tetherOptions}
>
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<Link
ref={this._setButtonRef}
className={classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
isDisabled && disabledClassName
)}
isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled}
>
{selectedOption ? selectedOption.value : null}
</SelectedValueComponent>
<div
className={isDisabled ?
styles.dropdownArrowContainerDisabled :
styles.dropdownArrowContainer
}
>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</Measure>
{
isOpen && !isMobile &&
<div
ref={this._setOptionsRef}
className={styles.optionsContainer}
style={{
minWidth: width
}}
>
<div className={styles.options}>
{
values.map((v, index) => {
return (
<OptionComponent
key={v.key}
id={v.key}
isSelected={index === selectedIndex}
{...v}
isMobile={false}
onSelect={this.onSelect}
>
{v.value}
</OptionComponent>
);
})
}
</div>
</div>
}
</TetherComponent>
{
isMobile &&
<Modal
className={styles.optionsModal}
isOpen={isOpen}
onModalClose={this.onOptionsModalClose}
>
<ModalBody
className={styles.optionsModalBody}
innerClassName={styles.optionsInnerModalBody}
scrollDirection={scrollDirections.NONE}
>
<Scroller className={styles.optionsModalScroller}>
{
values.map((v, index) => {
return (
<OptionComponent
key={v.key}
id={v.key}
isSelected={index === selectedIndex}
{...v}
isMobile={true}
onSelect={this.onSelect}
>
{v.value}
</OptionComponent>
);
})
}
</Scroller>
</ModalBody>
</Modal>
}
</div>
);
}
}
EnhancedSelectInput.propTypes = {
className: PropTypes.string,
disabledClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
selectedValueOptions: PropTypes.object.isRequired,
selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
optionComponent: PropTypes.func,
onChange: PropTypes.func.isRequired
};
EnhancedSelectInput.defaultProps = {
className: styles.enhancedSelect,
disabledClassName: styles.isDisabled,
isDisabled: false,
selectedValueOptions: {},
selectedValueComponent: EnhancedSelectInputSelectedValue,
optionComponent: EnhancedSelectInputOption
};
export default EnhancedSelectInput;

View File

@@ -0,0 +1,41 @@
.option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px;
width: 100%;
cursor: default;
&:hover {
background-color: #f9f9f9;
}
}
.isSelected {
background-color: #e2e2e2;
&.isMobile {
background-color: inherit;
.iconContainer {
color: $primaryColor;
}
}
}
.isDisabled {
background-color: #aaa;
}
.isHidden {
display: none;
}
.isMobile {
height: 50px;
border-bottom: 1px solid $borderColor;
&:last-child {
border: none;
}
}

View File

@@ -0,0 +1,81 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import styles from './EnhancedSelectInputOption.css';
class EnhancedSelectInputOption extends Component {
//
// Listeners
onPress = () => {
const {
id,
onSelect
} = this.props;
onSelect(id);
}
//
// Render
render() {
const {
className,
isSelected,
isDisabled,
isHidden,
isMobile,
children
} = this.props;
return (
<Link
className={classNames(
className,
isSelected && styles.isSelected,
isDisabled && styles.isDisabled,
isHidden && styles.isHidden,
isMobile && styles.isMobile
)}
component="div"
isDisabled={isDisabled}
onPress={this.onPress}
>
{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.string.isRequired,
isSelected: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool.isRequired,
isHidden: PropTypes.bool.isRequired,
isMobile: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
onSelect: PropTypes.func.isRequired
};
EnhancedSelectInputOption.defaultProps = {
className: styles.option,
isDisabled: false,
isHidden: false
};
export default EnhancedSelectInputOption;

View File

@@ -0,0 +1,7 @@
.selectedValue {
flex: 1 1 auto;
}
.isDisabled {
color: $disabledInputColor;
}

View File

@@ -0,0 +1,35 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
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;

View File

@@ -0,0 +1,3 @@
.validationFailures {
margin-bottom: 20px;
}

View File

@@ -0,0 +1,58 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import Alert from 'Components/Alert';
import styles from './Form.css';
function Form({ children, validationErrors, validationWarnings, ...otherProps }) {
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;

View File

@@ -0,0 +1,28 @@
.group {
display: flex;
margin-bottom: 20px;
}
/* Sizes */
.extraSmall {
max-width: $formGroupExtraSmallWidth;
}
.small {
max-width: $formGroupSmallWidth;
}
.medium {
max-width: $formGroupMediumWidth;
}
.large {
max-width: $formGroupLargeWidth;
}
@media only screen and (max-width: $breakpointLarge) {
.group {
display: block;
}
}

View File

@@ -0,0 +1,56 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
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;

View File

@@ -0,0 +1,12 @@
.button {
composes: button from 'Components/Link/Button.css';
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.middleButton {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

View File

@@ -0,0 +1,54 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css';
function FormInputButton(props) {
const {
className,
canSpin,
isLastButton,
...otherProps
} = props;
if (canSpin) {
return (
<SpinnerButton
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
FormInputButton.propTypes = {
className: PropTypes.string.isRequired,
isLastButton: PropTypes.bool.isRequired,
canSpin: PropTypes.bool.isRequired
};
FormInputButton.defaultProps = {
className: styles.button,
isLastButton: true,
canSpin: false
};
export default FormInputButton;

View File

@@ -0,0 +1,49 @@
.inputGroupContainer {
flex: 1 1 auto;
}
.inputGroup {
display: flex;
flex: 1 1 auto;
flex-wrap: wrap;
}
.inputContainer {
position: relative;
flex: 1 1 auto;
}
.inputUnit {
position: absolute;
top: 0;
right: 20px;
margin-top: 7px;
width: 75px;
color: #c6c6c6;
text-align: right;
pointer-events: none;
user-select: none;
}
.inputUnitNumber {
composes: inputUnit;
right: 40px;
}
.pendingChangesContainer {
display: flex;
justify-content: flex-end;
width: 30px;
}
.pendingChangesIcon {
color: $warningColor;
font-size: 20px;
line-height: 35px;
}
.helpLink {
margin-top: 5px;
line-height: 20px;
}

View File

@@ -0,0 +1,249 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes } from 'Helpers/Props';
import Link from 'Components/Link/Link';
import AutoCompleteInput from './AutoCompleteInput';
import CaptchaInputConnector from './CaptchaInputConnector';
import CheckInput from './CheckInput';
import DeviceInputConnector from './DeviceInputConnector';
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 MovieMonitoredSelectInput from './MovieMonitoredSelectInput';
import SelectInput from './SelectInput';
import TagInputConnector from './TagInputConnector';
import TextTagInputConnector from './TextTagInputConnector';
import TextInput from './TextInput';
import FormInputHelpText from './FormInputHelpText';
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.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.MOVIE_MONITORED_SELECT:
return MovieMonitoredSelectInput;
case inputTypes.ROOT_FOLDER_SELECT:
return RootFolderSelectInputConnector;
case inputTypes.SELECT:
return SelectInput;
case inputTypes.TAG:
return TagInputConnector;
case inputTypes.TEXT_TAG:
return TextTagInputConnector;
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 && helpTextWarning &&
<FormInputHelpText
text={helpTextWarning}
isWarning={true}
/>
}
{
helpLink &&
<Link
to={helpLink}
>
More Info
</Link>
}
{
errors.map((error, index) => {
return (
<FormInputHelpText
key={index}
text={error.message}
link={error.link}
linkTooltip={error.detailedMessage}
isError={true}
isCheckInput={checkInput}
/>
);
})
}
{
warnings.map((warning, index) => {
return (
<FormInputHelpText
key={index}
text={warning.message}
link={warning.link}
linkTooltip={warning.detailedMessage}
isWarning={true}
isCheckInput={checkInput}
/>
);
})
}
</div>
);
}
FormInputGroup.propTypes = {
className: PropTypes.string.isRequired,
containerClassName: PropTypes.string.isRequired,
inputClassName: PropTypes.string,
type: PropTypes.string.isRequired,
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,
pending: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.object),
warnings: PropTypes.arrayOf(PropTypes.object)
};
FormInputGroup.defaultProps = {
className: styles.inputGroup,
containerClassName: styles.inputGroupContainer,
type: inputTypes.TEXT,
buttons: [],
helpTexts: [],
errors: [],
warnings: []
};
export default FormInputGroup;

View File

@@ -0,0 +1,39 @@
.helpText {
margin-top: 5px;
color: $helpTextColor;
line-height: 20px;
}
.isError {
color: $dangerColor;
.link {
color: $dangerColor;
&:hover {
color: #e01313;
}
}
}
.isWarning {
color: $warningColor;
.link {
color: $warningColor;
&:hover {
color: #e36c00;
}
}
}
.isCheckInput {
padding-left: 30px;
}
.link {
composes: link from 'Components/Link/Link.css';
margin-left: 5px;
}

View File

@@ -0,0 +1,63 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import styles from './FormInputHelpText.css';
function FormInputHelpText(props) {
const {
className,
text,
link,
linkTooltip,
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={linkTooltip}
>
<Icon
name={icons.EXTERNAL_LINK}
/>
</Link>
}
</div>
);
}
FormInputHelpText.propTypes = {
className: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
link: PropTypes.string,
linkTooltip: 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;

View File

@@ -0,0 +1,29 @@
.label {
display: flex;
justify-content: flex-end;
margin-right: $formLabelRightMarginWidth;
font-weight: bold;
line-height: 35px;
}
.hasError {
color: $dangerColor;
}
.isAdvanced {
color: $advancedFormLabelColor;
}
@media only screen and (max-width: $breakpointLarge) {
.label {
justify-content: flex-start;
}
}
.small {
flex: 0 0 $formLabelSmallWidth;
}
.large {
flex: 0 0 $formLabelLargeWidth;
}

View File

@@ -0,0 +1,50 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import { sizes } from 'Helpers/Props';
import styles from './FormLabel.css';
function FormLabel({
children,
className,
errorClassName,
size,
name,
hasError,
isAdvanced,
...otherProps
}) {
return (
<label
{...otherProps}
className={classNames(
className,
styles[size],
hasError && errorClassName,
isAdvanced && styles.isAdvanced
)}
htmlFor={name}
>
{children}
</label>
);
}
FormLabel.propTypes = {
children: PropTypes.node.isRequired,
className: PropTypes.string,
errorClassName: PropTypes.string,
size: PropTypes.oneOf(sizes.all),
name: PropTypes.string,
hasError: PropTypes.bool,
isAdvanced: PropTypes.bool.isRequired
};
FormLabel.defaultProps = {
className: styles.label,
errorClassName: styles.hasError,
isAdvanced: false,
size: sizes.LARGE
};
export default FormLabel;

View File

@@ -0,0 +1,30 @@
.input {
padding: 6px 16px;
width: 100%;
height: 35px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
&:focus {
outline: 0;
border-color: $inputFocusBorderColor;
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
}
}
.hasError {
border-color: $inputErrorBorderColor;
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputErrorBoxShadowColor;
}
.hasWarning {
border-color: $inputWarningBorderColor;
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputWarningBoxShadowColor;
}
.hasButton {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}

View File

@@ -0,0 +1,52 @@
import PropTypes from 'prop-types';
import React from 'react';
import SelectInput from './SelectInput';
const monitorTypesOptions = [
{ key: 'true', value: 'True' },
{ key: 'false', value: 'False' }
];
function MovieMonitoredSelectInput(props) {
const values = [...monitorTypesOptions];
const {
includeNoChange,
includeMixed
} = props;
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: 'No Change',
disabled: true
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
disabled: true
});
}
return (
<SelectInput
{...props}
values={values}
/>
);
}
MovieMonitoredSelectInput.propTypes = {
includeNoChange: PropTypes.bool.isRequired,
includeMixed: PropTypes.bool.isRequired
};
MovieMonitoredSelectInput.defaultProps = {
includeNoChange: false,
includeMixed: false
};
export default MovieMonitoredSelectInput;

View File

@@ -0,0 +1,126 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextInput from './TextInput';
function parseValue(props, value) {
const {
isFloat,
min,
max
} = props;
if (value == null || value === '') {
return min;
}
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 (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;

View File

@@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
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;

View File

@@ -0,0 +1,89 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { startOAuth, resetOAuth } 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);

View File

@@ -0,0 +1,5 @@
.input {
composes: input from 'Components/Form/TextInput.css';
font-family: $passwordFamily;
}

View File

@@ -0,0 +1,22 @@
import PropTypes from 'prop-types';
import React from 'react';
import TextInput from './TextInput';
import styles from './PasswordInput.css';
function PasswordInput(props) {
return (
<TextInput
{...props}
/>
);
}
PasswordInput.propTypes = {
className: PropTypes.string.isRequired
};
PasswordInput.defaultProps = {
className: styles.input
};
export default PasswordInput;

View File

@@ -0,0 +1,68 @@
.path {
composes: input from 'Components/Form/Input.css';
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.hasFileBrowser {
composes: hasButton from 'Components/Form/Input.css';
}
.pathInputWrapper {
display: flex;
}
.pathInputContainer {
position: relative;
flex-grow: 1;
}
.pathContainer {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.pathInputContainerOpen {
.pathContainer {
position: absolute;
z-index: 1;
overflow-y: auto;
max-height: 200px;
width: 100%;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
}
.pathList {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.pathListItem {
padding: 0 16px;
}
.pathMatch {
font-weight: bold;
}
.pathHighlighted {
background-color: $menuItemHoverBackgroundColor;
}
.fileBrowserButton {
composes: button from './FormInputButton.css';
height: 35px;
}

View File

@@ -0,0 +1,206 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import FormInputButton from './FormInputButton';
import styles from './PathInput.css';
class PathInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isFileBrowserModalOpen: false
};
}
//
// 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 = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
}
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.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,
inputClassName,
name,
value,
placeholder,
paths,
hasError,
hasWarning,
hasFileBrowser,
onChange
} = this.props;
const inputProps = {
className: classNames(
inputClassName,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
hasFileBrowser && styles.hasFileBrowser
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onBlur: this.onInputBlur
};
const theme = {
container: styles.pathInputContainer,
containerOpen: styles.pathInputContainerOpen,
suggestionsContainer: styles.pathContainer,
suggestionsList: styles.pathList,
suggestion: styles.pathListItem,
suggestionHighlighted: styles.pathHighlighted
};
return (
<div className={className}>
<Autosuggest
id={name}
inputProps={inputProps}
theme={theme}
suggestions={paths}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
{
hasFileBrowser &&
<div>
<FormInputButton
className={styles.fileBrowserButton}
onPress={this.onFileBrowserOpenPress}
>
<Icon name={icons.FOLDER_OPEN} />
</FormInputButton>
<FileBrowserModal
isOpen={this.state.isFileBrowserModalOpen}
name={name}
value={value}
onChange={onChange}
onModalClose={this.onFileBrowserModalClose}
/>
</div>
}
</div>
);
}
}
PathInput.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string,
placeholder: PropTypes.string,
paths: PropTypes.array.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
hasFileBrowser: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onFetchPaths: PropTypes.func.isRequired,
onClearPaths: PropTypes.func.isRequired
};
PathInput.defaultProps = {
className: styles.pathInputWrapper,
inputClassName: styles.path,
value: '',
hasFileBrowser: true
};
export default PathInput;

View File

@@ -0,0 +1,67 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchPaths, clearPaths } 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 = {
fetchPaths,
clearPaths
};
class PathInputConnector extends Component {
//
// Listeners
onFetchPaths = (path) => {
this.props.fetchPaths({ path });
}
onClearPaths = () => {
this.props.clearPaths();
}
//
// Render
render() {
return (
<PathInput
onFetchPaths={this.onFetchPaths}
onClearPaths={this.onClearPaths}
{...this.props}
/>
);
}
}
PathInputConnector.propTypes = {
fetchPaths: PropTypes.func.isRequired,
clearPaths: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);

View File

@@ -0,0 +1,120 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes } from 'Helpers/Props';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
function getType(type) {
switch (type) {
case 'captcha':
return inputTypes.CAPTCHA;
case 'checkbox':
return inputTypes.CHECK;
case 'device':
return inputTypes.DEVICE;
case 'password':
return inputTypes.PASSWORD;
case 'number':
return inputTypes.NUMBER;
case 'path':
return inputTypes.PATH;
case 'select':
return inputTypes.SELECT;
case 'tag':
return inputTypes.TEXT_TAG;
case 'textbox':
return inputTypes.TEXT;
case 'oauth':
return inputTypes.OAUTH;
default:
return inputTypes.TEXT;
}
}
function getSelectValues(selectOptions) {
if (!selectOptions) {
return;
}
return _.reduce(selectOptions, (result, option) => {
result.push({
key: option.value,
value: option.name
});
return result;
}, []);
}
function ProviderFieldFormGroup(props) {
const {
advancedSettings,
name,
label,
helpText,
helpLink,
value,
type,
advanced,
pending,
errors,
warnings,
selectOptions,
onChange,
...otherProps
} = props;
return (
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={advanced}
>
<FormLabel>{label}</FormLabel>
<FormInputGroup
type={getType(type)}
name={name}
label={label}
helpText={helpText}
helpLink={helpLink}
value={value}
values={getSelectValues(selectOptions)}
errors={errors}
warnings={warnings}
pending={pending}
hasFileBrowser={false}
onChange={onChange}
{...otherProps}
/>
</FormGroup>
);
}
const selectOptionsShape = {
name: PropTypes.string.isRequired,
value: PropTypes.number.isRequired
};
ProviderFieldFormGroup.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
helpText: PropTypes.string,
helpLink: PropTypes.string,
value: PropTypes.any,
type: PropTypes.string.isRequired,
advanced: PropTypes.bool.isRequired,
pending: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)),
onChange: PropTypes.func.isRequired
};
ProviderFieldFormGroup.defaultProps = {
advancedSettings: false
};
export default ProviderFieldFormGroup;

View File

@@ -0,0 +1,98 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import sortByName from 'Utilities/Array/sortByName';
import SelectInput from './SelectInput';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(state, { includeNoChange }) => includeNoChange,
(state, { includeMixed }) => includeMixed,
(qualityProfiles, includeNoChange, includeMixed) => {
const values = _.map(qualityProfiles.items.sort(sortByName), (qualityProfile) => {
return {
key: qualityProfile.id,
value: qualityProfile.name
};
});
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: 'No Change',
disabled: true
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
disabled: true
});
}
return {
values
};
}
);
}
class QualityProfileSelectInputConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
name,
value,
values
} = this.props;
if (!value || !_.some(values, (option) => parseInt(option.key) === value)) {
const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key)));
if (firstValue) {
this.onChange({ name, value: firstValue.key });
}
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.props.onChange({ name, value: parseInt(value) });
}
//
// Render
render() {
return (
<SelectInput
{...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);

View File

@@ -0,0 +1,109 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import EnhancedSelectInput from './EnhancedSelectInput';
import RootFolderSelectInputOption from './RootFolderSelectInputOption';
import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
class RootFolderSelectInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddNewRootFolderModalOpen: false,
newRootFolderPath: ''
};
}
componentDidUpdate(prevProps) {
const {
name,
isSaving,
saveError,
onChange
} = this.props;
const newRootFolderPath = this.state.newRootFolderPath;
if (
prevProps.isSaving &&
!isSaving &&
!saveError &&
newRootFolderPath
) {
onChange({ name, value: newRootFolderPath });
this.setState({ newRootFolderPath: '' });
}
}
//
// Listeners
onChange = ({ name, value }) => {
if (value === 'addNew') {
this.setState({ isAddNewRootFolderModalOpen: true });
} else {
this.props.onChange({ name, value });
}
}
onNewRootFolderSelect = ({ value }) => {
this.setState({ newRootFolderPath: value }, () => {
this.props.onNewRootFolderSelect(value);
});
}
onAddRootFolderModalClose = () => {
this.setState({ isAddNewRootFolderModalOpen: false });
}
//
// Render
render() {
const {
includeNoChange,
onNewRootFolderSelect,
...otherProps
} = this.props;
return (
<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;

View File

@@ -0,0 +1,137 @@
import _ from 'lodash';
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 RootFolderSelectInput from './RootFolderSelectInput';
const ADD_NEW_KEY = 'addNew';
function createMapStateToProps() {
return createSelector(
(state) => state.rootFolders,
(state, { includeNoChange }) => includeNoChange,
(rootFolders, includeNoChange) => {
const values = _.map(rootFolders.items, (rootFolder) => {
return {
key: rootFolder.path,
value: rootFolder.path,
freeSpace: rootFolder.freeSpace
};
});
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: 'No Change',
isDisabled: true
});
}
if (!values.length) {
values.push({
key: '',
value: '',
isDisabled: true,
isHidden: true
});
}
values.push({
key: ADD_NEW_KEY,
value: 'Add a new path'
});
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 || !_.some(values, (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 });
}
}
}
//
// 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);

View File

@@ -0,0 +1,20 @@
.optionText {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1 0 0;
&.isMobile {
display: block;
.freeSpace {
margin-left: 0;
}
}
}
.freeSpace {
margin-left: 15px;
color: $darkGray;
font-size: $smallFontSize;
}

View File

@@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import formatBytes from 'Utilities/Number/formatBytes';
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './RootFolderSelectInputOption.css';
function RootFolderSelectInputOption(props) {
const {
value,
freeSpace,
isMobile,
...otherProps
} = props;
return (
<EnhancedSelectInputOption
isMobile={isMobile}
{...otherProps}
>
<div className={classNames(
styles.optionText,
isMobile && styles.isMobile
)}
>
<div>{value}</div>
{
freeSpace != null &&
<div className={styles.freeSpace}>
{formatBytes(freeSpace)} Free
</div>
}
</div>
</EnhancedSelectInputOption>
);
}
RootFolderSelectInputOption.propTypes = {
value: PropTypes.string.isRequired,
freeSpace: PropTypes.number,
isMobile: PropTypes.bool.isRequired
};
export default RootFolderSelectInputOption;

View File

@@ -0,0 +1,22 @@
.selectedValue {
composes: selectedValue from './EnhancedSelectInputSelectedValue.css';
display: flex;
align-items: center;
justify-content: space-between;
overflow: hidden;
}
.path {
@add-mixin truncate;
flex: 1 0 0;
}
.freeSpace {
flex: 0 0 auto;
margin-left: 15px;
color: $gray;
text-align: right;
font-size: $smallFontSize;
}

View File

@@ -0,0 +1,44 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import styles from './RootFolderSelectInputSelectedValue.css';
function RootFolderSelectInputSelectedValue(props) {
const {
value,
freeSpace,
includeFreeSpace,
...otherProps
} = props;
return (
<EnhancedSelectInputSelectedValue
className={styles.selectedValue}
{...otherProps}
>
<div className={styles.path}>
{value}
</div>
{
freeSpace != null && includeFreeSpace &&
<div className={styles.freeSpace}>
{formatBytes(freeSpace)} Free
</div>
}
</EnhancedSelectInputSelectedValue>
);
}
RootFolderSelectInputSelectedValue.propTypes = {
value: PropTypes.string,
freeSpace: PropTypes.number,
includeFreeSpace: PropTypes.bool.isRequired
};
RootFolderSelectInputSelectedValue.defaultProps = {
includeFreeSpace: true
};
export default RootFolderSelectInputSelectedValue;

View File

@@ -0,0 +1,18 @@
.select {
composes: input from 'Components/Form/Input.css';
padding: 0 11px;
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.isDisabled {
opacity: 0.7;
cursor: not-allowed;
}

View File

@@ -0,0 +1,95 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
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}
>
{optionValue}
</option>
);
})
}
</select>
);
}
}
SelectInput.propTypes = {
className: PropTypes.string,
disabledClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).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;

View File

@@ -0,0 +1,78 @@
.inputContainer {
composes: input from 'Components/Form/Input.css';
position: relative;
padding: 0;
min-height: 35px;
height: auto;
&.isFocused {
outline: 0;
border-color: $inputFocusBorderColor;
box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
}
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.tags {
flex: 0 0 auto;
max-width: 100%;
}
.input {
flex: 1 1 0%;
margin-left: 3px;
min-width: 20%;
max-width: 100%;
width: 0%;
height: 21px;
border: none;
}
.suggestionsContainer {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.containerOpen {
.suggestionsContainer {
position: absolute;
right: -1px;
left: -1px;
z-index: 1;
overflow-y: auto;
margin-top: 1px;
max-height: 110px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
}
.suggestionsList {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.suggestion {
padding: 0 16px;
cursor: default;
&:hover {
background-color: $menuItemHoverBackgroundColor;
}
}
.suggestionHighlighted {
background-color: $menuItemHoverBackgroundColor;
}

View File

@@ -0,0 +1,303 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import { kinds } from 'Helpers/Props';
import TagInputInput from './TagInputInput';
import TagInputTag from './TagInputTag';
import styles from './TagInput.css';
function getTag(value, selectedIndex, suggestions, allowNew) {
if (selectedIndex == null && value) {
const existingTag = _.find(suggestions, { name: value });
if (existingTag) {
return existingTag;
} else if (allowNew) {
return { name: value };
}
} else if (selectedIndex != null) {
return suggestions[selectedIndex];
}
}
class TagInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
value: '',
suggestions: [],
isFocused: false
};
this._autosuggestRef = null;
}
componentWillUnmount() {
this.addTag.cancel();
}
//
// Control
_setAutosuggestRef = (ref) => {
this._autosuggestRef = ref;
}
getSuggestionValue({ name }) {
return name;
}
shouldRenderSuggestions = (value) => {
return value.length >= this.props.minQueryLength;
}
renderSuggestion({ name }) {
return name;
}
addTag = _.debounce((tag) => {
this.props.onTagAdd(tag);
this.setState({
value: '',
suggestions: []
});
}, 250, { leading: true, trailing: false })
//
// Listeners
onInputContainerPress = () => {
this._autosuggestRef.input.focus();
}
onInputChange = (event, { newValue, method }) => {
const value = _.isObject(newValue) ? newValue.name : newValue;
if (method === 'type') {
this.setState({ value });
}
}
onInputKeyDown = (event) => {
const {
tags,
allowNew,
delimiters,
onTagDelete
} = this.props;
const {
value,
suggestions
} = this.state;
const keyCode = event.keyCode;
if (keyCode === 8 && !value.length) {
const index = tags.length - 1;
if (index >= 0) {
onTagDelete({ index, id: tags[index].id });
}
setTimeout(() => {
this.onSuggestionsFetchRequested({ value: '' });
});
event.preventDefault();
}
if (delimiters.includes(keyCode)) {
const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
const tag = getTag(value, selectedIndex, suggestions, allowNew);
if (tag) {
this.addTag(tag);
event.preventDefault();
}
}
}
onInputFocus = () => {
this.setState({ isFocused: true });
}
onInputBlur = () => {
this.setState({ isFocused: false });
if (!this._autosuggestRef) {
return;
}
const {
allowNew
} = this.props;
const {
value,
suggestions
} = this.state;
const selectedIndex = this._autosuggestRef.highlightedSuggestionIndex;
const tag = getTag(value, selectedIndex, suggestions, allowNew);
if (tag) {
this.addTag(tag);
}
}
onSuggestionsFetchRequested = ({ value }) => {
const lowerCaseValue = value.toLowerCase();
const {
tags,
tagList
} = this.props;
const suggestions = tagList.filter((tag) => {
return (
tag.name.toLowerCase().includes(lowerCaseValue) &&
!tags.some((t) => t.id === tag.id));
});
this.setState({ suggestions });
}
onSuggestionsClearRequested = () => {
// Required because props aren't always rendered, but no-op
// because we don't want to reset the paths after a path is selected.
}
onSuggestionSelected = (event, { suggestion }) => {
this.addTag(suggestion);
}
//
// Render
renderInputComponent = (inputProps) => {
const {
tags,
kind,
tagComponent,
onTagDelete
} = this.props;
return (
<TagInputInput
tags={tags}
kind={kind}
inputProps={inputProps}
isFocused={this.state.isFocused}
tagComponent={tagComponent}
onTagDelete={onTagDelete}
onInputContainerPress={this.onInputContainerPress}
/>
);
}
render() {
const {
className,
inputClassName,
placeholder,
hasError,
hasWarning
} = this.props;
const {
value,
suggestions,
isFocused
} = this.state;
const inputProps = {
className: inputClassName,
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onFocus: this.onInputFocus,
onBlur: this.onInputBlur
};
const theme = {
container: classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
containerOpen: styles.containerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted
};
return (
<Autosuggest
ref={this._setAutosuggestRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
shouldRenderSuggestions={this.shouldRenderSuggestions}
focusInputOnSuggestionClick={false}
renderSuggestion={this.renderSuggestion}
renderInputComponent={this.renderInputComponent}
onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
);
}
}
export const tagShape = {
id: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]).isRequired,
name: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired
};
TagInput.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
allowNew: PropTypes.bool.isRequired,
kind: PropTypes.oneOf(kinds.all).isRequired,
placeholder: PropTypes.string.isRequired,
delimiters: PropTypes.arrayOf(PropTypes.number).isRequired,
minQueryLength: PropTypes.number.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
tagComponent: PropTypes.func.isRequired,
onTagAdd: PropTypes.func.isRequired,
onTagDelete: PropTypes.func.isRequired
};
TagInput.defaultProps = {
className: styles.inputContainer,
inputClassName: styles.input,
allowNew: true,
kind: kinds.INFO,
placeholder: '',
// Tab, enter, space and comma
delimiters: [9, 13, 32, 188],
minQueryLength: 1,
tagComponent: TagInputTag
};
export default TagInput;

View File

@@ -0,0 +1,156 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addTag } from 'Store/Actions/tagActions';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import TagInput from './TagInput';
const validTagRegex = new RegExp('[^-_a-z0-9]', 'i');
function isValidTag(tagName) {
try {
return !validTagRegex.test(tagName);
} catch (e) {
return false;
}
}
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
createTagsSelector(),
(tags, tagList) => {
const sortedTags = _.sortBy(tagList, 'label');
const filteredTagList = _.filter(sortedTags, (tag) => _.indexOf(tags, tag.id) === -1);
return {
tags: tags.reduce((acc, tag) => {
const matchingTag = _.find(tagList, { id: tag });
if (matchingTag) {
acc.push({
id: tag,
name: matchingTag.label
});
}
return acc;
}, []),
tagList: filteredTagList.map(({ id, label: name }) => {
return {
id,
name
};
}),
allTags: sortedTags
};
}
);
}
const mapDispatchToProps = {
addTag
};
class TagInputConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
name,
value,
tags,
onChange
} = this.props;
if (value.length !== tags.length) {
onChange({ name, value: tags.map((tag) => tag.id) });
}
}
//
// Listeners
onTagAdd = (tag) => {
const {
name,
value,
allTags
} = this.props;
if (!tag.id) {
const existingTag =_.some(allTags, { label: tag.name });
if (isValidTag(tag.name) && !existingTag) {
this.props.addTag({
tag: { label: tag.name },
onTagCreated: this.onTagCreated
});
}
return;
}
const newValue = value.slice();
newValue.push(tag.id);
this.props.onChange({ name, value: newValue });
}
onTagDelete = ({ index }) => {
const {
name,
value
} = this.props;
const newValue = value.slice();
newValue.splice(index, 1);
this.props.onChange({
name,
value: newValue
});
}
onTagCreated = (tag) => {
const {
name,
value
} = this.props;
const newValue = value.slice();
newValue.push(tag.id);
this.props.onChange({ name, value: newValue });
}
//
// Render
render() {
return (
<TagInput
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
{...this.props}
/>
);
}
}
TagInputConnector.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.number).isRequired,
tags: PropTypes.arrayOf(PropTypes.object).isRequired,
allTags: PropTypes.arrayOf(PropTypes.object).isRequired,
onChange: PropTypes.func.isRequired,
addTag: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(TagInputConnector);

View File

@@ -0,0 +1,6 @@
.inputContainer {
display: flex;
flex-wrap: wrap;
padding: 6px 16px;
cursor: default;
}

View File

@@ -0,0 +1,76 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import { tagShape } from './TagInput';
import styles from './TagInputInput.css';
class TagInputInput extends Component {
onMouseDown = (event) => {
event.preventDefault();
const {
isFocused,
onInputContainerPress
} = this.props;
if (isFocused) {
return;
}
onInputContainerPress();
}
render() {
const {
className,
tags,
inputProps,
kind,
tagComponent: TagComponent,
onTagDelete
} = this.props;
return (
<div
className={className}
component="div"
onMouseDown={this.onMouseDown}
>
{
tags.map((tag, index) => {
return (
<TagComponent
key={tag.id}
index={index}
tag={tag}
kind={kind}
isLastTag={index === tags.length - 1}
onDelete={onTagDelete}
/>
);
})
}
<input {...inputProps} />
</div>
);
}
}
TagInputInput.propTypes = {
className: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
inputProps: PropTypes.object.isRequired,
kind: PropTypes.oneOf(kinds.all).isRequired,
isFocused: PropTypes.bool.isRequired,
tagComponent: PropTypes.func.isRequired,
onTagDelete: PropTypes.func.isRequired,
onInputContainerPress: PropTypes.func.isRequired
};
TagInputInput.defaultProps = {
className: styles.inputContainer
};
export default TagInputInput;

View File

@@ -0,0 +1,55 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import { tagShape } from './TagInput';
class TagInputTag extends Component {
//
// Listeners
onDelete = () => {
const {
index,
tag,
onDelete
} = this.props;
onDelete({
index,
id: tag.id
});
}
//
// Render
render() {
const {
tag,
kind
} = this.props;
return (
<Link
tabIndex={-1}
onPress={this.onDelete}
>
<Label kind={kind}>
{tag.name}
</Label>
</Link>
);
}
}
TagInputTag.propTypes = {
index: PropTypes.number.isRequired,
tag: PropTypes.shape(tagShape),
kind: PropTypes.oneOf(kinds.all).isRequired,
onDelete: PropTypes.func.isRequired
};
export default TagInputTag;

View File

@@ -0,0 +1,19 @@
.input {
composes: input from 'Components/Form/Input.css';
}
.readOnly {
background-color: #eee;
}
.hasError {
composes: hasError from 'Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from 'Components/Form/Input.css';
}
.hasButton {
composes: hasButton from 'Components/Form/Input.css';
}

View File

@@ -0,0 +1,188 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import styles from './TextInput.css';
class TextInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._input = null;
this._selectionStart = null;
this._selectionEnd = null;
this._selectionTimeout = null;
this._isMouseTarget = false;
}
componentDidMount() {
window.addEventListener('mouseup', this.onDocumentMouseUp);
}
componentWillUnmount() {
window.removeEventListener('mouseup', this.onDocumentMouseUp);
if (this._selectionTimeout) {
this._selectionTimeout = clearTimeout(this._selectionTimeout);
}
}
//
// Control
setInputRef = (ref) => {
this._input = ref;
}
selectionChange() {
if (this._selectionTimeout) {
this._selectionTimeout = clearTimeout(this._selectionTimeout);
}
this._selectionTimeout = setTimeout(() => {
const selectionStart = this._input.selectionStart;
const selectionEnd = this._input.selectionEnd;
const selectionChanged = (
this._selectionStart !== selectionStart ||
this._selectionEnd !== selectionEnd
);
this._selectionStart = selectionStart;
this._selectionEnd = selectionEnd;
if (this.props.onSelectionChange && selectionChanged) {
this.props.onSelectionChange(selectionStart, selectionEnd);
}
}, 10);
}
//
// Listeners
onChange = (event) => {
const {
name,
type,
onChange
} = this.props;
const payload = {
name,
value: event.target.value
};
// Also return the files for a file input type.
if (type === 'file') {
payload.files = event.target.files;
}
onChange(payload);
}
onFocus = (event) => {
if (this.props.onFocus) {
this.props.onFocus(event);
}
this.selectionChange();
}
onKeyUp = () => {
this.selectionChange();
}
onMouseDown = () => {
this._isMouseTarget = true;
}
onMouseUp = () => {
this.selectionChange();
}
onDocumentMouseUp = () => {
if (this._isMouseTarget) {
this.selectionChange();
}
this._isMouseTarget = false;
}
//
// Render
render() {
const {
className,
type,
readOnly,
autoFocus,
placeholder,
name,
value,
hasError,
hasWarning,
hasButton,
step,
onBlur
} = this.props;
return (
<input
ref={this.setInputRef}
type={type}
readOnly={readOnly}
autoFocus={autoFocus}
placeholder={placeholder}
className={classNames(
className,
readOnly && styles.readOnly,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
hasButton && styles.hasButton
)}
name={name}
value={value}
step={step}
onChange={this.onChange}
onFocus={this.onFocus}
onBlur={onBlur}
onKeyUp={this.onKeyUp}
onMouseDown={this.onMouseDown}
onMouseUp={this.onMouseUp}
/>
);
}
}
TextInput.propTypes = {
className: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
readOnly: PropTypes.bool,
autoFocus: PropTypes.bool,
placeholder: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
hasButton: PropTypes.bool,
step: PropTypes.number,
onChange: PropTypes.func.isRequired,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
onSelectionChange: PropTypes.func
};
TextInput.defaultProps = {
className: styles.input,
type: 'text',
readOnly: false,
autoFocus: false,
value: ''
};
export default TextInput;

View File

@@ -0,0 +1,95 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import split from 'Utilities/String/split';
import TagInput from './TagInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(tags) => {
const tagsArray = Array.isArray(tags) ? tags : split(tags);
return {
tags: tagsArray.reduce((result, tag) => {
if (tag) {
result.push({
id: tag,
name: tag
});
}
return result;
}, []),
valueArray: tagsArray
};
}
);
}
class TextTagInputConnector extends Component {
//
// Listeners
onTagAdd = (tag) => {
const {
name,
valueArray,
onChange
} = this.props;
// Split and trim tags before adding them to the list, this will
// cleanse tags pasted in that had commas and spaces which leads
// to oddities with restrictions (as an example).
const newValue = [...valueArray];
const newTags = split(tag.name);
newTags.forEach((newTag) => {
newValue.push(newTag.trim());
});
onChange({ name, value: newValue.join(',') });
}
onTagDelete = ({ index }) => {
const {
name,
valueArray,
onChange
} = this.props;
const newValue = [...valueArray];
newValue.splice(index, 1);
onChange({
name,
value: newValue.join(',')
});
}
//
// Render
render() {
return (
<TagInput
tagList={[]}
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
{...this.props}
/>
);
}
}
TextTagInputConnector.propTypes = {
name: PropTypes.string.isRequired,
valueArray: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, null)(TextTagInputConnector);