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 SearchableDropdownField #1625

Merged
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
4 changes: 2 additions & 2 deletions client/dist/js/bundle.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/lang/src/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
"Admin.EXPANDPANEL": "Expand panel",
"Admin.FormatExample": "Example: {format}",
"Admin.NONE": "None",
"Admin.NO_OPTIONS": "No options",
"Admin.NO_SIZE": "N/A",
"Admin.REMOVE_LINK": "Remove link",
"Admin.SELECTONEPAGE": "Please select at least one page",
"Admin.TYPE_TO_SEARCH": "Type to search",
"Admin.VALIDATIONERROR": "Validation Error",
"Admin.DELETE_CONFIRM_MESSAGE": "Deleted",
"Admin.ARCHIVE_CONFIRM_MESSAGE": "Archived",
Expand Down
2 changes: 2 additions & 0 deletions client/src/boot/registerComponents.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import NumberField from 'components/NumberField/NumberField';
import PopoverOptionSet from 'components/PopoverOptionSet/PopoverOptionSet';
import ToastsContainer from 'containers/ToastsContainer/ToastsContainer';
import ListboxField from 'components/ListboxField/ListboxField';
import SearchableDropdownField from 'components/SearchableDropdownField/SearchableDropdownField';

export default () => {
Injector.component.registerMany({
Expand Down Expand Up @@ -104,5 +105,6 @@ export default () => {
PopoverOptionSet,
ToastsContainer,
ListboxField,
SearchableDropdownField,
});
};
2 changes: 2 additions & 0 deletions client/src/bundles/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import 'expose-loader?exposes=formatWrittenNumber!lib/formatWrittenNumber';
import 'expose-loader?exposes=withDragDropContext!lib/withDragDropContext';
import 'expose-loader?exposes=withRouter!lib/withRouter';
import 'expose-loader?exposes=ssUrlLib!lib/urls';
import 'expose-loader?exposes=SearchableDropdownField!components/SearchableDropdownField/SearchableDropdownField';
GuySartorelli marked this conversation as resolved.
Show resolved Hide resolved

// Legacy CMS
import '../legacy/jquery.changetracker';
Expand Down Expand Up @@ -117,5 +118,6 @@ import '../legacy/DatetimeField';
import '../legacy/HtmlEditorField';
import '../legacy/TabSet';
import '../legacy/GridField';
import '../legacy/SearchableDropdownField/SearchableDropdownFieldEntwine';

import 'boot';
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import AsyncSelect from 'react-select/async';
import backend from 'lib/Backend';
import classNames from 'classnames';
import Config from 'lib/Config';
import debounce from 'debounce-promise';
import EmotionCssCacheProvider from 'containers/EmotionCssCacheProvider/EmotionCssCacheProvider';
import i18n from 'i18n';
import fieldHolder from 'components/FieldHolder/FieldHolder';
import PropTypes from 'prop-types';
import React, { useState, useEffect, createRef } from 'react';
import Select from 'react-select';
import url from 'url';
import FormConstants from '../Form/FormConstants';

const SearchableDropdownField = ({
clearable,
disabled,
lazyLoad,
multi,
passRef,
placeholder,
options,
optionUrl,
onChange,
searchable,
value,
SelectComponent,
AsyncSelectComponent,
...passThroughProps
}) => {
const [hasChanges, setHasChanges] = useState(false);
const [justChanged, setJustChanged] = useState(false);
const [fetchCache, setFetchCache] = useState({});
const selectComponentRef = createRef();

// This is required to get the PageForm change tracker to work by firing a bubbling change event
useEffect(() => {
if (!justChanged) {
return;
}
const element = selectComponentRef.current.inputRef;
const event = new Event('change', { bubbles: true });
element.dispatchEvent(event);
setJustChanged(false);
});

const fetchOptions = (term) => {
if (fetchCache.hasOwnProperty(term)) {
return Promise.resolve(fetchCache[term]);
}
let innerFetchOptions = () => {
const fetchUrl = url.parse(optionUrl, true);
if (fetchUrl.search) {
// Remove the search key, though keep the query key
// This is so url.format uses the query key instead of the search key below
delete fetchUrl.search;
}
fetchUrl.query.term = term;
const endpoint = url.format(fetchUrl);
const csrfHeader = FormConstants.CSRF_HEADER;
const headers = {};
headers[csrfHeader] = Config.get('SecurityID');
return backend.get(endpoint, headers)
.then(response => response.json())
.then(responseJson => {
fetchCache[term] = responseJson;
setFetchCache(fetchCache);
return responseJson;
});
};
innerFetchOptions = debounce(innerFetchOptions, 500);
return innerFetchOptions();
};

/**
* Get the options that should be shown to the user for this SearchableDropdownField,
* optionally filtering by the given string input
*/
const getOptions = (input) => {
if (!lazyLoad) {
return Promise.resolve(options);
}
if (!input) {
return Promise.resolve([]);
}
return fetchOptions(input);
};

const handleChange = (val) => {
setHasChanges(false);
if (JSON.stringify(value) !== JSON.stringify(val)) {
setHasChanges(true);
setJustChanged(true);
}
onChange(val);
};

/**
* Required to prevent SearchableDropdownField being cleared on blur
*
* @link https://github.com/JedWatson/react-select/issues/805#issuecomment-210646771
*/
const handleOnBlur = () => {};

const className = classNames({
'no-change-track': !hasChanges,
'ss-searchable-dropdown-field--lazy-load': lazyLoad
});

const optionsProps = lazyLoad ? { loadOptions: getOptions } : { options };

const noOptionsMessage = (inputValue) => {
if (inputValue) {
return i18n._t('Admin.NO_MATCHING_OPTIONS', 'No matching options');
}
return i18n._t('Admin.TYPE_TO_SEARCH', 'Type to search');
};

// Setting passRef to false is purely for jest when mocking the component with a simple function component
// It prevents - Warning: Function components cannot be given refs. Attempts to access this ref will fail.
const refProps = passRef ? { ref: selectComponentRef } : {};

let val = value;
// For non-multi only the first value is needed
if (!multi && val) {
const keys = Object.keys(val);
if (keys.length > 0) {
const key = keys[0];
const v = val[key];
if (typeof v === 'object') {
val = v;
}
}
}

const DynamicComponent = lazyLoad ? AsyncSelectComponent : SelectComponent;

return <EmotionCssCacheProvider>
<DynamicComponent
// passThroughProps needs to be first so that it can be overridden by the other props
{...passThroughProps}
classNamePrefix="ss-searchable-dropdown-field"
className={className}
isClearable={clearable}
isDisabled={disabled}
isMulti={multi}
isSearchable={searchable}
placeholder={placeholder}
onChange={handleChange}
onBlur={handleOnBlur}
{...optionsProps}
noOptionsMessage={noOptionsMessage}
{...refProps}
value={val}
/>
</EmotionCssCacheProvider>;
};

SearchableDropdownField.propTypes = {
clearable: PropTypes.bool.isRequired,
disabled: PropTypes.bool.isRequired,
lazyLoad: PropTypes.bool.isRequired,
multi: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
placeholder: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
options: PropTypes.arrayOf(PropTypes.object),
optionUrl: PropTypes.string,
passRef: PropTypes.bool.isRequired,
searchable: PropTypes.bool.isRequired,
value: PropTypes.any.isRequired,
SelectComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
AsyncSelectComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
};

SearchableDropdownField.defaultProps = {
disabled: false,
lazyLoad: false,
clearable: true,
searchable: true,
multi: false,
passRef: true,
placeholder: '',
SelectComponent: Select,
AsyncSelectComponent: AsyncSelect,
};

export { SearchableDropdownField as Component };

export default fieldHolder(SearchableDropdownField);
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
.ss-searchable-dropdown-field__control {
border-color: $gray-200;
box-shadow: none;

// this is required to override default react-select style
&:hover {
border-color: $gray-200;
}

&--is-focused {
box-shadow: none;
border-color: $input-focus-border-color;

// this is required to override default react-select style
&:hover {
border-color: $input-focus-border-color;
}
}
}

.ss-searchable-dropdown-field__menu {
margin-top: 0;
}

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

.ss-searchable-dropdown-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;

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

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

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

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

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

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

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

.ss-searchable-dropdown-field--lazy-load {
.ss-searchable-dropdown-field__dropdown-indicator,
.ss-searchable-dropdown-field__indicator-separator {
display: none;
}
}

.ss-searchable-dropdown-field__indicator {
cursor: pointer;
}

.ss-searchable-dropdown-field__clear-indicator {
&:hover,
&:focus {
color: $brand-danger;
}
}

.ss-searchable-dropdown-field__dropdown-indicator {
&:focus {
color: $body-color-dark;
}
}

.ss-searchable-dropdown-field__multi-value {
color: $body-color;
background-color: $white;
border: 1px solid $gray-200;
border-radius: $border-radius;
margin-top: 3px;
}

.ss-searchable-dropdown-field__multi-value__remove {
font-size: $font-size-lg;
padding: 3px 5px 2px 5px;
border-left: 1px solid $gray-200;
border-radius: 0;

&:hover {
background-color: #ffe0e0;
color: $brand-danger;
}
}

.ss-searchable-dropdown-field__multi-value__label {
padding: 3px 10px 3px 10px;
}
Loading