Skip to content

Commit

Permalink
NEW SearchableDropdownField
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Dec 13, 2023
1 parent ecd166c commit d077dfd
Show file tree
Hide file tree
Showing 14 changed files with 499 additions and 3 deletions.
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';

// 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 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 fetchCache = {};

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;
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

0 comments on commit d077dfd

Please sign in to comment.