Skip to content

Commit

Permalink
NEW SearchableDropdownField
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Nov 29, 2023
1 parent e494e57 commit b58b63c
Show file tree
Hide file tree
Showing 14 changed files with 473 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,175 @@
import AsyncSelect from 'react-select/async';
import backend from 'lib/Backend';
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);
});

let fetchOptions = (input) => {
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 = input;
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 => responseJson);
};
fetchOptions = debounce(fetchOptions, 500);

/**
* 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 = hasChanges ? '' : 'no-change-track';

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,110 @@
.ss-searchable-dropdown-field__control {
border-color: $gray-200;
box-shadow: none;
}

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

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

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

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

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

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

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

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

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

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

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

.ss-searchable-dropdown-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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react';
import SearchableDropdownField from '../SearchableDropdownField';

const options = [
{ label: 'One', value: 1 },
{ label: 'Two', value: 2 },
{ label: 'Three', value: 3 },
];

export default {
title: 'Admin/SearchableDropdownField',
component: SearchableDropdownField,
decorators: [],
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'The SearchableDropdownField component.'
},
canvas: {
sourceState: 'shown',
},
}
},
argTypes: {
name: {
control: 'text',
table: {
type: { summary: 'string' },
defaultValue: { summary: null },
},
},
options: {
description: 'List of tags options',
control: 'select',
table: {
type: { summary: 'string' },
},
},
},
args: {
clearable: true,
disabled: false,
multi: false,
searchable: true,
placeholder: '',
options,
name: 'Test',
}
};

export const _SearchableDropdownField = (props) => {
let value = props.value;
const onChange = (val) => {
value = val;
};
return <SearchableDropdownField
{...props}
onChange={onChange}
value={value}
/>;
};
Loading

0 comments on commit b58b63c

Please sign in to comment.