netlify-cms-widget-async-select
Version:
An async select widget for netlify-cms which can populate entries from a valid endpoint. Allows for sending custom headers, data/value fields and transformations.
226 lines (199 loc) • 6.35 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import AsyncSelect from 'react-select/lib/Async';
import { find, isEmpty, last, debounce } from 'lodash';
import { List, Map, fromJS } from 'immutable';
import { reactSelectStyles } from 'netlify-cms-ui-default';
import fuzzy from 'fuzzy';
function optionToString(option) {
return option && option.value ? option.value : '';
}
function convertToOption(raw) {
if (typeof raw === 'string') {
return { label: raw, value: raw };
}
return Map.isMap(raw) ? raw.toJS() : raw;
}
function getSelectedValue({ value, options, isMultiple }) {
if (isMultiple) {
const selectedOptions = List.isList(value) ? value.toJS() : value;
if (!selectedOptions || !Array.isArray(selectedOptions)) {
return null;
}
return selectedOptions
.map(i => options.find(o => o.value === (i.value || i)))
.filter(Boolean)
.map(convertToOption);
} else {
return find(options, ['value', value]) || null;
}
}
export default class Control extends React.Component {
didInitialSearch = false;
static propTypes = {
onChange: PropTypes.func.isRequired,
forID: PropTypes.string.isRequired,
value: PropTypes.node,
field: ImmutablePropTypes.map,
fetchID: PropTypes.string,
query: PropTypes.func.isRequired,
queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
classNameWrapper: PropTypes.string.isRequired,
setActiveStyle: PropTypes.func.isRequired,
setInactiveStyle: PropTypes.func.isRequired,
hasActiveStyle: PropTypes.func,
};
async getOptions(term) {
const { field } = this.props;
const valueField = field.get('valueField');
const displayField = field.get('displayField') || valueField;
const searchField = field.get('searchField') || displayField;
const filterFunction = field.get('filter');
const url = field.get('url');
const method = field.get('method') || 'GET';
const dataKey = field.get('dataKey');
const headers = field.get('headers') || {};
const res = await fetch(url, {
method,
headers,
})
.then(data => data.json())
.then(json => fromJS(json));
let mappedData = res;
// Allow for drill down.
if (dataKey) {
mappedData = res.get(dataKey);
}
if (typeof filterFunction === 'function') {
mappedData = mappedData.filter(filterFunction);
}
let mappedOptions = mappedData.map(entry => ({
value: entry.getIn(valueField.split('.')),
label: entry.getIn(displayField.split('.')),
data: entry,
}));
if (term) {
mappedOptions = fuzzy
.filter(term, mappedOptions, {
extract: el => el.data.getIn(searchField.split('.')),
})
.sort((entryA, entryB) => entryB.score - entryA.score)
.map(entry => entry.original);
} else {
mappedOptions = mappedOptions.toArray();
}
return mappedOptions;
}
componentDidUpdate(prevProps) {
/**
* Load extra post data into the store after first query.
*/
if (this.didInitialSearch) return;
const { value, field, forID, queryHits, onChange } = this.props;
if (queryHits !== prevProps.queryHits && queryHits.get(forID)) {
this.didInitialSearch = true;
const valueField = field.get('valueField');
const hits = queryHits.get(forID);
if (value) {
const listValue = List.isList(value) ? value : List([value]);
listValue.forEach(val => {
const hit = hits.find(i => i.data[valueField] === val);
if (hit) {
onChange(value, {
[field.get('name')]: {
[field.get('collection')]: { [val]: hit.data },
},
});
}
});
}
}
}
handleChange = selectedOption => {
const { onChange, field } = this.props;
let value;
if (Array.isArray(selectedOption)) {
value = selectedOption.map(optionToString);
onChange(fromJS(value), {
[field.get('name')]: {
[field.get('collection')]: {
[last(value)]:
!isEmpty(selectedOption) && last(selectedOption).data,
},
},
});
} else {
value = optionToString(selectedOption);
onChange(value, {
[field.get('name')]: {
[field.get('collection')]: { [value]: selectedOption.data },
},
});
}
};
parseHitOptions = hits => {
const { field } = this.props;
const valueField = field.get('valueField');
const displayField = field.get('displayFields') || field.get('valueField');
return hits.map(hit => ({
data: hit.data,
value: hit.data[valueField],
label: List.isList(displayField)
? displayField
.toJS()
.map(key => hit.data[key])
.join(' ')
: hit.data[displayField],
}));
};
loadOptions = debounce((term, callback) => {
this.getOptions(term).then(options => {
if (!this.allOptions && !term) {
this.allOptions = options;
}
// TODO: Pagination
// For now we return entire result set, assuming this is a rest or
// graphql endpoint that can paginate for us.
callback(options);
// Refresh state to trigger a re-render.
this.setState({ ...this.state });
});
}, 500);
render() {
const {
value,
field,
forID,
classNameWrapper,
setActiveStyle,
setInactiveStyle,
queryHits,
} = this.props;
const isMultiple = field.get('multiple', false);
const isClearable = !field.get('required', true) || isMultiple;
const hits = queryHits.get(forID, []);
const options = this.allOptions || this.parseHitOptions(hits);
const selectedValue = getSelectedValue({
options,
value,
isMultiple,
});
return (
<AsyncSelect
value={selectedValue}
inputId={forID}
defaultOptions
loadOptions={this.loadOptions}
onChange={this.handleChange}
className={classNameWrapper}
onFocus={setActiveStyle}
onBlur={setInactiveStyle}
styles={reactSelectStyles}
isMulti={isMultiple}
isClearable={isClearable}
placeholder=""
/>
);
}
}