Skip to content

Commit

Permalink
NEW add ListboxField react component
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewandante committed Jun 28, 2023
1 parent 971046c commit e03bc32
Show file tree
Hide file tree
Showing 9 changed files with 400 additions and 24 deletions.
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

0 comments on commit e03bc32

Please sign in to comment.