@plone/volto
Version:
Volto
355 lines (335 loc) • 9.39 kB
JSX
/**
* SelectWidget component.
* @module components/manage/Widgets/SelectWidget
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose } from 'redux';
import filter from 'lodash/filter';
import map from 'lodash/map';
import sortBy from 'lodash/sortBy';
import { defineMessages, injectIntl } from 'react-intl';
import {
getVocabFromHint,
getVocabFromField,
getVocabFromItems,
} from '@plone/volto/helpers/Vocabularies/Vocabularies';
import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper';
import {
getVocabulary,
getVocabularyTokenTitle,
} from '@plone/volto/actions/vocabularies/vocabularies';
import { normalizeValue } from '@plone/volto/components/manage/Widgets/SelectUtils';
import {
customSelectStyles,
DropdownIndicator,
ClearIndicator,
Option,
selectTheme,
MenuList,
MultiValueContainer,
} from '@plone/volto/components/manage/Widgets/SelectStyling';
import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable';
const messages = defineMessages({
default: {
id: 'Default',
defaultMessage: 'Default',
},
idTitle: {
id: 'Short Name',
defaultMessage: 'Short Name',
},
idDescription: {
id: 'Used for programmatic access to the fieldset.',
defaultMessage: 'Used for programmatic access to the fieldset.',
},
title: {
id: 'Title',
defaultMessage: 'Title',
},
description: {
id: 'Description',
defaultMessage: 'Description',
},
close: {
id: 'Close',
defaultMessage: 'Close',
},
choices: {
id: 'Choices',
defaultMessage: 'Choices',
},
required: {
id: 'Required',
defaultMessage: 'Required',
},
select: {
id: 'Select…',
defaultMessage: 'Select…',
},
no_value: {
id: 'No value',
defaultMessage: 'No value',
},
no_options: {
id: 'No options',
defaultMessage: 'No options',
},
});
/**
* SelectWidget component class.
* @function SelectWidget
* @returns {string} Markup of the component.
*/
class SelectWidget extends Component {
/**
* Property types.
* @property {Object} propTypes Property types.
* @static
*/
static propTypes = {
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
description: PropTypes.string,
required: PropTypes.bool,
filterChoices: PropTypes.arrayOf(PropTypes.string),
error: PropTypes.arrayOf(PropTypes.string),
getVocabulary: PropTypes.func.isRequired,
getVocabularyTokenTitle: PropTypes.func.isRequired,
choices: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
),
items: PropTypes.shape({
vocabulary: PropTypes.object,
}),
widgetOptions: PropTypes.shape({
vocabulary: PropTypes.object,
}),
value: PropTypes.oneOfType([
PropTypes.object,
PropTypes.string,
PropTypes.bool,
PropTypes.func,
PropTypes.array,
]),
onChange: PropTypes.func.isRequired,
onBlur: PropTypes.func,
onClick: PropTypes.func,
onEdit: PropTypes.func,
onDelete: PropTypes.func,
wrapped: PropTypes.bool,
noValueOption: PropTypes.bool,
customOptionStyling: PropTypes.any,
isMulti: PropTypes.bool,
placeholder: PropTypes.string,
sort: PropTypes.bool,
isClearable: PropTypes.bool,
};
/**
* Default properties
* @property {Object} defaultProps Default properties.
* @static
*/
static defaultProps = {
description: null,
required: false,
filterChoices: null,
items: {
vocabulary: null,
},
widgetOptions: {
vocabulary: null,
},
error: [],
choices: [],
value: null,
onChange: () => {},
onBlur: () => {},
onClick: () => {},
onEdit: null,
onDelete: null,
noValueOption: true,
customOptionStyling: null,
sort: false,
isClearable: true,
};
/**
* Component did mount
* @method componentDidMount
* @returns {undefined}
*/
componentDidMount() {
if (
(!this.props.choices || this.props.choices?.length === 0) &&
this.props.vocabBaseUrl
) {
this.props.getVocabulary({
vocabNameOrURL: this.props.vocabBaseUrl,
size: -1,
subrequest: this.props.lang,
});
}
}
componentDidUpdate(prevProps) {
if (
this.props.vocabBaseUrl !== prevProps.vocabBaseUrl &&
(!this.props.choices || this.props.choices?.length === 0)
) {
this.props.getVocabulary({
vocabNameOrURL: this.props.vocabBaseUrl,
size: -1,
subrequest: this.props.lang,
});
}
}
/**
* Render method.
* @method render
* @returns {string} Markup for the component.
*/
render() {
const {
id,
choices,
value,
intl,
onChange,
filterChoices,
additionalChoices,
} = this.props;
// Make sure that both disabled and isDisabled (from the DX layout feat work)
const disabled = this.props.disabled || this.props.isDisabled;
const Select = this.props.reactSelect.default;
let options = this.props.vocabBaseUrl
? this.props.choices
: [
...map(choices, (option) => ({
value: option[0],
label:
// Fix "None" on the serializer, to remove when fixed in p.restapi
option[1] !== 'None' && option[1] ? option[1] : option[0],
})),
// Only set "no-value" option if there's no default in the field
// TODO: also if this.props.defaultValue?
...(this.props.noValueOption &&
(this.props.default === undefined || this.props.default === null)
? [
{
label: this.props.intl.formatMessage(messages.no_value),
value: 'no-value',
},
]
: []),
];
if (additionalChoices) {
options = [
...(options || []),
...map(additionalChoices, (choice) => ({
value: choice.value,
label: intl.formatMessage({
id: choice.value,
defaultMessage: choice.label,
}),
})),
];
}
if (filterChoices) {
options = filter(options, (item) => filterChoices.includes(item.value));
}
if (this.props.sort) {
options = sortBy(options, ['label']);
}
const normalizedValue = normalizeValue(options, value, intl);
const isMulti = this.props.isMulti
? this.props.isMulti
: id === 'roles' || id === 'groups' || this.props.type === 'array';
return (
<FormFieldWrapper {...this.props}>
<Select
id={`field-${id}`}
key={choices}
name={id}
aria-labelledby={`fieldset-${this.props.fieldSet}-field-label-${id}`}
menuShouldScrollIntoView={false}
isDisabled={disabled}
isSearchable={true}
className="react-select-container"
classNamePrefix="react-select"
isMulti={isMulti}
options={options}
styles={customSelectStyles}
theme={selectTheme}
components={{
...(options?.length > 25 && {
MenuList,
}),
MultiValueContainer,
DropdownIndicator,
ClearIndicator,
Option: this.props.customOptionStyling || Option,
}}
value={normalizedValue}
placeholder={
this.props.placeholder ??
this.props.intl.formatMessage(messages.select)
}
onBlur={() => this.props.onBlur(id, value)}
onChange={(selectedOption) => {
if (isMulti) {
return onChange(
id,
selectedOption.map((el) => el.value),
);
}
return onChange(
id,
selectedOption && selectedOption.value !== 'no-value'
? selectedOption.value
: undefined,
);
}}
isClearable={this.props.isClearable}
/>
</FormFieldWrapper>
);
}
}
export const SelectWidgetComponent = injectIntl(SelectWidget);
export default compose(
injectLazyLibs(['reactSelect']),
connect(
(state, props) => {
const vocabBaseUrl = !props.choices
? getVocabFromHint(props) ||
getVocabFromField(props) ||
getVocabFromItems(props)
: '';
const vocabState =
state.vocabularies?.[vocabBaseUrl]?.subrequests?.[state.intl.locale];
// If the schema already has the choices in it, then do not try to get the vocab,
// even if there is one
if (props.choices) {
return {
choices: props.choices,
lang: state.intl.locale,
};
} else if (vocabState) {
return {
vocabBaseUrl,
choices: vocabState?.items ?? [],
lang: state.intl.locale,
};
// There is a moment that vocabState is not there yet, so we need to pass the
// vocabBaseUrl to the component.
} else if (vocabBaseUrl) {
return {
vocabBaseUrl,
lang: state.intl.locale,
};
}
return { lang: state.intl.locale };
},
{ getVocabulary, getVocabularyTokenTitle },
),
)(SelectWidgetComponent);