Skip to content

Instantly share code, notes, and snippets.

@pozymasika
Created August 28, 2020 17:40
Show Gist options
  • Save pozymasika/b2f51298363420d940afdaad15d834d2 to your computer and use it in GitHub Desktop.
Save pozymasika/b2f51298363420d940afdaad15d834d2 to your computer and use it in GitHub Desktop.
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