Created
August 28, 2020 17:40
-
-
Save pozymasika/b2f51298363420d940afdaad15d834d2 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { Fragment } from 'react'; | |
import PropTypes from 'prop-types'; | |
import classNames from 'classnames'; | |
import Select, { AsyncCreatable, Async } from 'react-select'; | |
import { withStyles } from '@material-ui/core/styles'; | |
import Typography from '@material-ui/core/Typography'; | |
import TextField from '@material-ui/core/TextField'; | |
import Paper from '@material-ui/core/Paper'; | |
import Chip from '@material-ui/core/Chip'; | |
import MenuItem from '@material-ui/core/MenuItem'; | |
import CancelIcon from '@material-ui/icons/Cancel'; | |
import { emphasize } from '@material-ui/core/styles/colorManipulator'; | |
import { compose } from 'recompose'; | |
import { connect } from 'react-redux'; | |
import { | |
GET_LIST, | |
CREATE, | |
crudGetMany, | |
showNotification, | |
withDataProvider | |
} from 'react-admin'; | |
import { Field } from 'redux-form'; | |
import { FormHelperText } from '@material-ui/core'; | |
import { populateRequiredFields } from '../../searchFiltersMap'; | |
import { Link } from 'ra-ui-materialui'; | |
export const styles = theme => ({ | |
root: { | |
width: 256, | |
marginTop: theme.spacing.unit, | |
}, | |
helper: { | |
marginTop: 0 | |
}, | |
input: { | |
display: 'flex', | |
padding: 0, | |
}, | |
valueContainer: { | |
display: 'flex', | |
flexWrap: 'wrap', | |
flex: 1, | |
alignItems: 'center', | |
overflow: 'hidden', | |
}, | |
chip: { | |
margin: `${theme.spacing.unit / 2}px ${theme.spacing.unit / 4}px`, | |
height: 'auto', | |
minHeight: 32 | |
}, | |
chipFocused: { | |
backgroundColor: emphasize( | |
theme.palette.type === 'light' ? theme.palette.grey[300] : theme.palette.grey[700], | |
0.08, | |
), | |
}, | |
noOptionsMessage: { | |
padding: `${theme.spacing.unit}px ${theme.spacing.unit * 2}px`, | |
}, | |
singleValue: { | |
fontSize: 16, | |
whiteSpace: 'normal', | |
wordBreak: 'break-word', | |
}, | |
placeholder: { | |
position: 'absolute', | |
left: 2, | |
fontSize: 16, | |
}, | |
paper: { | |
position: 'absolute', | |
zIndex: 1, | |
marginTop: theme.spacing.unit, | |
left: 0, | |
right: 0, | |
}, | |
divider: { | |
height: theme.spacing.unit * 2, | |
}, | |
formError: { | |
color: theme.palette.error.main | |
}, | |
chipLabel: { | |
maxWidth: 150, | |
whiteSpace: 'normal', | |
wordBreak: 'break-word', | |
}, | |
menuItem: { | |
width: '100%', | |
whiteSpace: 'normal', | |
}, | |
disabled: { | |
color: theme.palette.common.black | |
} | |
}); | |
function NoOptionsMessage(props) { | |
return ( | |
<Typography | |
color="textSecondary" | |
className={props.selectProps.classes.noOptionsMessage} | |
{...props.innerProps} | |
> | |
{props.children} | |
</Typography> | |
); | |
} | |
function inputComponent({ inputRef, ...props }) { | |
return <div ref={inputRef} {...props} />; | |
} | |
function Option(props) { | |
return ( | |
<MenuItem | |
className={props.selectProps.classes.menuItem} | |
buttonRef={props.innerRef} | |
selected={props.isFocused} | |
component="div" | |
style={{ | |
fontWeight: props.isSelected ? 500 : 400, | |
}} | |
{...props.innerProps} | |
> | |
{props.children} | |
</MenuItem> | |
); | |
} | |
function Placeholder(props) { | |
return ( | |
<Typography | |
color="textSecondary" | |
className={props.selectProps.classes.placeholder} | |
{...props.innerProps} | |
> | |
{props.children} | |
</Typography> | |
); | |
} | |
function SingleValue(props) { | |
let asyncProps = { | |
component: Link, | |
to: `/${props.selectProps.reference}/${props.data.value}`, | |
}; | |
const { isDisabled, classes, async } = props.selectProps; | |
return ( | |
<Typography | |
{...(async && asyncProps)} | |
className={ | |
`${classes.singleValue} ${isDisabled ? classes.disabled : ''}` | |
} | |
{...props.innerProps}> | |
{props.children} | |
</Typography> | |
); | |
} | |
function ValueContainer(props) { | |
return <div className={props.selectProps.classes.valueContainer}>{props.children}</div>; | |
} | |
function MultiValue(props) { | |
return ( | |
<Chip | |
classes={{ | |
label: props.selectProps.classes.chipLabel | |
}} | |
tabIndex={-1} | |
label={props.selectProps.async ? ( | |
<Link | |
to={`/${props.selectProps.reference}/${props.data.value}`} | |
className={props.selectProps.isDisabled ? props.selectProps.classes.disabled : ''} | |
> | |
{props.children} | |
</Link> | |
) : | |
props.children | |
} | |
className={classNames(props.selectProps.classes.chip, { | |
[props.selectProps.classes.chipFocused]: props.isFocused, | |
})} | |
onDelete={props.removeProps.onClick} | |
deleteIcon={<CancelIcon {...props.removeProps} />} | |
/> | |
); | |
} | |
function Menu(props) { | |
return ( | |
<Paper square className={props.selectProps.classes.paper} {...props.innerProps}> | |
{props.children} | |
</Paper> | |
); | |
} | |
function Control(props) { | |
return ( | |
<TextField | |
fullWidth | |
InputProps={{ | |
inputComponent, | |
inputProps: { | |
className: props.selectProps.classes.input, | |
inputRef: props.innerRef, | |
children: props.children, | |
...props.innerProps, | |
}, | |
}} | |
{...props.selectProps.textFieldProps} | |
/> | |
); | |
} | |
function getOptionText (record, optionText) { | |
return typeof optionText === 'function' ? | |
optionText(record) : | |
record[optionText]; | |
} | |
export const components = { | |
Control, | |
Menu, | |
MultiValue, | |
NoOptionsMessage, | |
Option, | |
Placeholder, | |
SingleValue, | |
ValueContainer, | |
IndicatorSeparator: null, | |
}; | |
const defaultParams = { | |
filter: { | |
q: '' | |
}, | |
pagination: { | |
page: 1, | |
perPage: 25 | |
}, | |
sort: { | |
field: 'id', | |
order: 'DESC' | |
} | |
} | |
class MultiSelectInput extends React.Component { | |
state = { | |
multi: undefined, | |
searchText: '', | |
defaultOptions: null | |
}; | |
componentDidMount() { | |
if (this.props.async) { | |
const { | |
record, | |
reference, | |
source, | |
crudGetMany, | |
parseRecordValue, | |
recordData, | |
data, | |
} = this.props; | |
let value = record[source]; | |
if (value && parseRecordValue) { | |
value = parseRecordValue(value); | |
} | |
if (!recordData && value) { | |
crudGetMany(reference, Array.isArray(value) ? value : [value]); | |
} | |
if (data.length > 0) { | |
// get other items not already in state. | |
const filter = data.map(item => { | |
return { | |
id: { | |
neq: item.id | |
} | |
} | |
}); | |
this.fetchData('', reference, filter); | |
} | |
} | |
} | |
componentDidUpdate(prevProps) { | |
if (this.props.async) { | |
const { reference, filter = [] } = prevProps; | |
const currentFilter = this.props.filter || []; | |
if ( | |
this.props.reference !== reference || | |
currentFilter.length !== filter.length | |
) { | |
// clear selected first because data fetching may take longer | |
this.setState({ multi: undefined }); | |
// don't fetch data if it exists in state | |
if (!this.props.data) { | |
this.fetchData('', this.props.reference, currentFilter); | |
} | |
} | |
} | |
} | |
fetchData = (query, reference, filter) => { | |
let fullFilter = { | |
...(query && { q: query }), | |
...(filter && { and: filter }) | |
}; | |
return this.props.dataProvider(GET_LIST, reference, { | |
...defaultParams, | |
filter: fullFilter | |
}); | |
}; | |
createItem = itemValue => { | |
const { optionValue, optionText, reference, dataProvider } = this.props; | |
if (typeof optionText === 'function' && !optionValue) { | |
throw Error('Please supply `optionValue` prop to the component for auto-creating to work correctly.'); | |
} | |
// some fields in a model may be required so to avoid running into | |
// errors when creating a new item just populate the fields with placeholders | |
const data = { | |
...populateRequiredFields(reference), | |
[optionValue]: itemValue, | |
} | |
return dataProvider(CREATE, reference, { data }); | |
} | |
handleChange = name => value => { | |
const { isMulti, optionValue, useOptionTextAsValue } = this.props; | |
let withNewIds = value; | |
if (isMulti) { | |
// check if a new item was added and create it | |
const newItemPromises = value.filter( | |
item => item.__isNew__ | |
).map( | |
item => this.createItem(item.value) | |
); | |
return Promise.all(newItemPromises) | |
.then(items => { | |
items.forEach(({ data: newItem }) => { | |
// inject ids into the original value | |
withNewIds = withNewIds.map(oldItem => { | |
if (oldItem.value === newItem[optionValue]) { | |
return { | |
value: useOptionTextAsValue ? newItem[optionValue] : newItem.id, | |
label: oldItem.label, | |
} | |
} | |
return oldItem; | |
}) | |
}); | |
return this.onChangeValues(name, withNewIds); | |
}) | |
} else if (value && value.__isNew__) { | |
return this.createItem(value.value) | |
.then(({ data: newItem }) => { | |
return this.onChangeValues(name, { | |
value: useOptionTextAsValue ? newItem[optionValue] : newItem.id, | |
label: value.value, | |
}); | |
}); | |
} | |
return this.onChangeValues(name, value); | |
}; | |
onChange = values => { | |
if (this.props.onSelectChange) { | |
this.props.onSelectChange(values); | |
} | |
if (!this.props.useAsControl) { | |
this.props.input.onChange(values); | |
} | |
} | |
onChangeValues = (name, values) => { | |
const { isMulti } = this.props; | |
this.setState({ | |
[name]: values, | |
}, () => { | |
if (isMulti) { | |
const ids = values.map(val => val.value); | |
return this.onChange(ids); | |
} | |
this.onChange(values && values.value); | |
}); | |
} | |
loadOptions = inputValue => { | |
const { | |
optionText, | |
reference, | |
useOptionTextAsValue, | |
optionValue, | |
filter, | |
} = this.props; | |
return this.fetchData(inputValue || '', reference, filter) | |
.then(({ data }) => { | |
return data.map( | |
(item) => ({ | |
value: useOptionTextAsValue ? item[optionValue] : item.id, | |
label: getOptionText(item, optionText), | |
}) | |
); | |
}); | |
} | |
// Avoid react-input crashing if using AsyncCreatable/Creatable | |
// https://github.com/JedWatson/react-select/issues/2944 | |
isValidNewOption = (inputValue, selectValue, selectOptions) => { | |
const isNotDuplicated = !selectOptions | |
.map(option => option.label) | |
.includes(inputValue); | |
const isNotEmpty = inputValue !== ''; | |
return isNotEmpty && isNotDuplicated; | |
} | |
render() { | |
const { | |
classes, | |
theme, | |
label, | |
isMulti, | |
recordData, | |
meta, | |
disableCreate, | |
isDisabled, | |
reference, | |
async, | |
data, | |
options: selectOptions | |
} = this.props; | |
const { multi } = this.state; | |
const value = typeof multi !== 'undefined' ? multi : recordData; | |
const selectStyles = { | |
input: base => ({ | |
...base, | |
color: theme.palette.text.primary, | |
'& input': { | |
font: 'inherit', | |
}, | |
}), | |
}; | |
const selectParams = { | |
isMulti, | |
components, | |
classes, | |
value, | |
isDisabled, | |
async, | |
reference, | |
onChange: this.handleChange('multi'), | |
options: selectOptions, | |
styles: selectStyles, | |
placeholder: label, | |
isClearable: true, | |
isSearchable: true, | |
} | |
const asyncParams = { | |
cacheOptions: true, | |
defaultOptions: data || true, | |
loadOptions: this.loadOptions, | |
isValidNewOption: this.isValidNewOption, | |
createOptionPosition:'last', | |
allowCreateWhileLoading: true, | |
...selectParams, | |
} | |
return ( | |
<div className={classes.root}> | |
<FormHelperText className={classes.helper}> {label} </FormHelperText> | |
{async ? ( | |
<Fragment> | |
{disableCreate ? | |
<Async {...asyncParams} /> : | |
<AsyncCreatable {...asyncParams}/> | |
} | |
</Fragment> | |
) | |
: | |
<Select {...selectParams} /> | |
} | |
<FormHelperText className={classes.formError}> | |
{meta && meta.error} | |
</FormHelperText> | |
</div> | |
); | |
} | |
} | |
MultiSelectInput.propTypes = { | |
classes: PropTypes.object.isRequired, | |
theme: PropTypes.object.isRequired, | |
label: PropTypes.string.isRequired, | |
source: PropTypes.string.isRequired, | |
reference: PropTypes.string, | |
optionText: PropTypes.oneOfType([ | |
PropTypes.string, | |
PropTypes.func | |
]), | |
recordData: PropTypes.oneOfType([ | |
PropTypes.array, | |
PropTypes.object | |
]), | |
optionValue: PropTypes.string, | |
useOptionTextAsValue: PropTypes.bool, | |
isMulti: PropTypes.bool, | |
parseRecordValue: PropTypes.func, | |
useAsControl: PropTypes.bool, | |
onSelectChange: PropTypes.func, | |
disableCreate: PropTypes.bool, | |
isDisabled: PropTypes.bool, | |
async: PropTypes.bool, | |
options: PropTypes.any | |
}; | |
const mapStateToProps = (state, ownProps) => { | |
let record = ownProps.record[ownProps.source]; | |
const defaultProps = { | |
optionValue: ownProps.optionValue || (typeof ownProps.optionText === 'string' ? ownProps.optionText : null), | |
label: ownProps.label || ownProps.source.split('_').join(' '), | |
} | |
if (!ownProps.async) { | |
return { | |
...defaultProps, | |
recordData: ownProps.isMulti ? | |
record.map((val) => ({ label: val, values: val })) : | |
{ label: record, value: record } | |
} | |
} | |
const { admin: { resources }, router: {location: {pathname}} } = state; | |
const data = resources[ownProps.reference].data; | |
let recordData = null; | |
if (record && ownProps.parseRecordValue) { | |
record = ownProps.parseRecordValue(record); | |
} | |
// support => a = [1,2,3] and a = 6; | |
if (Array.isArray(record)) { | |
for (let i = 0; i < record.length; i++) { | |
let item = data[record[i]]; | |
if (item) { | |
recordData = (recordData || []).concat({ | |
value: record[i], | |
label: getOptionText(item, ownProps.optionText) | |
}); | |
} | |
} | |
} else if (record && data[record]) { | |
const item = data[record]; | |
recordData = { | |
value: record, | |
label: getOptionText(item, ownProps.optionText), | |
} | |
} | |
// remove record from data shown in dropdown | |
const withoutRecord = Object.keys(data).filter( | |
(id) => { | |
const intId = parseInt(id, 10); | |
return (Array.isArray(record) ? !record.includes(intId) : intId !== record) && | |
// if a record is fetching other records of the same reference, remove this record | |
// from the list | |
(pathname.indexOf(ownProps.reference) !== -1 ? ownProps.record.id !== intId : true); | |
} | |
).map( | |
(id) => ({ | |
value: ownProps.useOptionTextAsValue ? data[id][ownProps.optionValue] : parseInt(id, 10), | |
label: getOptionText(data[id], ownProps.optionText), | |
id: parseInt(id, 10), | |
}) | |
); | |
return { | |
data: withoutRecord.length > 0 && withoutRecord, | |
recordData, | |
...defaultProps | |
} | |
} | |
const mapDispatchToProps = { | |
crudGetMany, | |
showNotification | |
} | |
const enhance = compose( | |
withStyles(styles, { withTheme: true }), | |
connect(mapStateToProps, mapDispatchToProps), | |
) | |
const enhanced = enhance(withDataProvider(MultiSelectInput)); | |
export default ({ | |
useAsControl, | |
source, | |
async = true, | |
record = {}, | |
...rest | |
}) => { | |
if (useAsControl) { | |
return React.createElement(enhanced, { | |
useAsControl, | |
source, | |
record, | |
async, | |
...rest | |
}); | |
} | |
return ( | |
<Field | |
name={source} | |
source={source} | |
component={enhanced} | |
record={record} | |
async={async} | |
{...rest} | |
/> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment