Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW add ListboxField react component #1533

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 22 additions & 22 deletions client/dist/js/bundle.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client/dist/js/vendor.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client/dist/styles/bundle.css

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions client/src/boot/registerComponents.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import HtmlEditorField from 'components/HtmlEditorField/HtmlEditorField';
import NumberField from 'components/NumberField/NumberField';
import PopoverOptionSet from 'components/PopoverOptionSet/PopoverOptionSet';
import ToastsContainer from 'containers/ToastsContainer/ToastsContainer';
import ListboxField from 'components/ListboxField/ListboxField';

export default () => {
Injector.component.registerMany({
Expand Down Expand Up @@ -102,5 +103,6 @@ export default () => {
NumberField,
PopoverOptionSet,
ToastsContainer,
ListboxField,
});
};
258 changes: 258 additions & 0 deletions client/src/components/ListboxField/ListboxField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import React, { Component } from 'react';
import Select from 'react-select';
import AsyncSelect from 'react-select/async';
import AsyncCreatableSelect from 'react-select/async-creatable';
import CreatableSelect from 'react-select/creatable';
import EmotionCssCacheProvider from 'containers/EmotionCssCacheProvider/EmotionCssCacheProvider';
import i18n from 'i18n';
import fetch from 'isomorphic-fetch';
import fieldHolder from 'components/FieldHolder/FieldHolder';
import url from 'url';
import debounce from 'debounce-promise';
import PropTypes from 'prop-types';

class ListboxField extends Component {
constructor(props) {
super(props);

if (!this.isControlled()) {
this.state = {
value: props.value,
};
}

this.handleChange = this.handleChange.bind(this);
this.handleOnBlur = this.handleOnBlur.bind(this);
this.isValidNewOption = this.isValidNewOption.bind(this);
this.getOptions = this.getOptions.bind(this);
this.fetchOptions = debounce(this.fetchOptions, 500);
}

/**
* Get the options that should be shown to the user for this ListboxField, optionally filtering by the
* given string input
*
* @param {string} input
* @return {Promise<Array<Object>>|Promise<{options: Array<Object>}>}
*/
getOptions(input) {
const { lazyLoad, options } = this.props;

if (!lazyLoad) {
return Promise.resolve(options);
}

if (!input) {
return Promise.resolve([]);
}
return this.fetchOptions(input);
}

/**
* Handle a change, either calling the change handler provided (if controlled) or updating
* internal state of this component
*
* @param {string} value
*/
handleChange(value) {
if (this.isControlled()) {
this.props.onChange(value);
return;
}

this.setState({
value,
});
}

/**
* Determine if this input should be "controlled" or not. Controlled inputs should rely on their
* value coming from props and a change handler provided to update the state stored elsewhere.
* This is specifically the case for use with `redux-form`.
*
* @return {boolean}
*/
isControlled() {
return typeof this.props.onChange === 'function';
}

/**
* Required to prevent ListboxField being cleared on blur
*
* @link https://github.com/JedWatson/react-select/issues/805
*/
handleOnBlur() { }

/**
* Initiate a request to fetch options, optionally using the given string as a filter.
*
* @param {string} input
* @return {Promise<{options: Array<Object>}>}
*/
fetchOptions(input) {
const { optionUrl, labelKey, valueKey } = this.props;
const fetchURL = url.parse(optionUrl, true);
fetchURL.query.term = input;

return fetch(url.format(fetchURL), { credentials: 'same-origin' })
.then((response) => response.json())
.then((json) => json.items.map(
(item) => ({
[labelKey]: item.Title,
[valueKey]: item.Value,
Selected: item.Selected,
})
));
}

/**
* Check if a new option can be created based on a given input
* @param {string} inputValue
* @param {array|object} value
* @param {array} currentOptions
* @returns {boolean}
*/
isValidNewOption(inputValue, value, currentOptions) {
const { valueKey } = this.props;

// Don't allow empty options
if (!inputValue) {
return false;
}

// Don't repeat the currently selected option
if (Array.isArray(value)) {
if (this.valueInOptions(inputValue, value, valueKey)) {
return false;
}
} else if (inputValue === value[valueKey]) {
return false;
}

// Don't repeat any existing option
return !this.valueInOptions(inputValue, currentOptions, valueKey);
}

/**
* Check if a value is in an array of options already
* @param {string} value
* @param {array} options
* @param {string} valueKey
* @returns {boolean}
*/
valueInOptions(value, options, valueKey) {
// eslint-disable-next-line no-restricted-syntax
for (const item of options) {
if (value === item[valueKey]) {
return true;
}
}
return false;
}

render() {
const {
lazyLoad,
options,
creatable,
multi,
disabled,
labelKey,
valueKey,
SelectComponent,
AsyncCreatableSelectComponent,
AsyncSelectComponent,
CreatableSelectComponent,
...passThroughAttributes
} = this.props;

const optionAttributes = lazyLoad
? { loadOptions: this.getOptions }
: { options };

let DynamicSelect = SelectComponent;
if (lazyLoad && creatable) {
DynamicSelect = AsyncCreatableSelectComponent;
} else if (lazyLoad) {
DynamicSelect = AsyncSelectComponent;
} else if (creatable) {
DynamicSelect = CreatableSelectComponent;
}

// Update the value to passthrough with the kept state provided this component is not
// "controlled"
if (!this.isControlled()) {
passThroughAttributes.value = this.state.value;
}

// if this is a single select then we just need the first value
if (!multi && passThroughAttributes.value) {
if (Object.keys(passThroughAttributes.value).length > 0) {
const value =
passThroughAttributes.value[
Object.keys(passThroughAttributes.value)[0]
];

if (typeof value === 'object') {
passThroughAttributes.value = value;
}
}
}

return (
<EmotionCssCacheProvider>
<DynamicSelect
{...passThroughAttributes}
isMulti={multi}
isDisabled={disabled}
cacheOptions
onChange={this.handleChange}
onBlur={this.handleOnBlur}
{...optionAttributes}
getOptionLabel={(option) => option[labelKey]}
getOptionValue={(option) => option[valueKey]}
noOptionsMessage={({ inputValue }) => (inputValue ? i18n._t('ListboxField.NO_OPTIONS', 'No options') : i18n._t('ListboxField.TYPE_TO_SEARCH', 'Type to search'))}
isValidNewOption={this.isValidNewOption}
getNewOptionData={(inputValue, label) => ({ [labelKey]: label, [valueKey]: inputValue })}
classNamePrefix="ss-listbox-field"
/>
</EmotionCssCacheProvider>
);
}
}

ListboxField.propTypes = {
name: PropTypes.string.isRequired,
labelKey: PropTypes.string.isRequired,
valueKey: PropTypes.string.isRequired,
lazyLoad: PropTypes.bool,
creatable: PropTypes.bool,
multi: PropTypes.bool,
disabled: PropTypes.bool,
options: PropTypes.arrayOf(PropTypes.object),
optionUrl: PropTypes.string,
value: PropTypes.any,
onChange: PropTypes.func,
onBlur: PropTypes.func,
SelectComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),
AsyncCreatableSelectComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),
AsyncSelectComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),
CreatableSelectComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),
};

ListboxField.defaultProps = {
labelKey: 'Title',
valueKey: 'Value',
disabled: false,
lazyLoad: false,
creatable: false,
multi: false,
SelectComponent: Select,
AsyncCreatableSelectComponent: AsyncCreatableSelect,
AsyncSelectComponent: AsyncSelect,
CreatableSelectComponent: CreatableSelect,
};

export { ListboxField as Component };

export default fieldHolder(ListboxField);
109 changes: 109 additions & 0 deletions client/src/components/ListboxField/ListboxField.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Styles below here are duplicates of the treedropdownfield styles, but with the appropriate classnames for listboxfield
.ss-listbox-field__control {
border-color: $gray-200;
box-shadow: none;
}

.ss-listbox-field__control--is-focused {
border-color: $brand-primary;
box-shadow: none;
}

.ss-listbox-field__option+.ss-listbox-field__option {
border-top: 1px solid $border-color-light;
}

.ss-listbox-field__option-button {
border: 1px solid $border-color-light;
border-radius: $border-radius;
background: $white;
// needed to override the width rule in .fill-width
width: auto !important; // sass-lint:disable-line no-important
max-width: 25%;
margin: -4px -5px -4px 5px;
padding: 4px 5px 4px 4px;
cursor: pointer;

&:hover {
background: $gray-200;
}

.font-icon-right-open-big {
margin: 2px 0 0 -1px;
width: 24px;
}
}

.ss-listbox-field__option-count-icon {
padding: 0 calc($spacer / 2);
line-height: 0.8;
}

.ss-listbox-field__option-context {
color: $gray-600;
font-size: $font-size-sm;
}

.ss-listbox-field__option--is-focused {
background-color: $list-group-hover-bg;
}

.ss-listbox-field__option--is-selected {
background: $link-color;
color: $white;

.ss-listbox-field__option-button {
border-color: $brand-primary;
background: none;
color: $white;

&:hover {
background: rgba(0, 0, 0, 0.2);
}
}
}

.ss-listbox-field__option-title--highlighted {
font-weight: bold;
}

.ss-listbox-field__indicator {
cursor: pointer;
}

.ss-listbox-field__clear-indicator {

&:hover,
&:focus {
color: $brand-danger;
}
}

.ss-listbox-field__dropdown-indicator {

&:hover,
&:focus {
color: $body-color-dark;
}
}

.ss-listbox-field__multi-value {
margin-top: 3px;
color: $body-color;
background-color: $white;
border: 1px solid $input-focus-border-color;
border-radius: $border-radius;
}

.ss-listbox-field__multi-value__remove {
font-size: $font-size-lg;
padding: 0 5px 2px;
border-left: 1px solid $input-focus-border-color;
border-radius: 0;

&:focus,
&:hover {
background-color: rgba(0, 113, 230, .08);
color: #0071e6;
}
}
1 change: 1 addition & 0 deletions client/src/styles/bundle.scss
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
@import "../components/HiddenField/HiddenField";
@import "../components/IframeDialog/IframeDialog";
@import "../components/Label/Label";
@import "../components/ListboxField/ListboxField";
@import "../components/ListGroup/ListGroup";
@import "../components/Loading/CircularLoading";
@import "../components/Menu/Menu";
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"chosen-js": "^1.8.7",
"classnames": "^2.3.2",
"core-js": "^3.26.0",
"debounce-promise": "^3.1.2",
"deep-equal": "^2.0.5",
"deep-freeze-strict": "^1.1.1",
"detect-browser": "^5.3.0",
Expand Down
Loading