@plone/volto
Version:
Volto
431 lines (401 loc) • 12.2 kB
JSX
/**
* ArrayWidget component.
* @module components/manage/Widgets/ArrayWidget
*/
import React, { Component } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable';
import find from 'lodash/find';
import isObject from 'lodash/isObject';
import {
getVocabFromHint,
getVocabFromField,
getVocabFromItems,
} from '@plone/volto/helpers/Vocabularies/Vocabularies';
import { getVocabulary } from '@plone/volto/actions/vocabularies/vocabularies';
import {
Option,
DropdownIndicator,
ClearIndicator,
selectTheme,
customSelectStyles,
MenuList,
SortableMultiValue,
SortableMultiValueLabel,
MultiValueContainer,
} from '@plone/volto/components/manage/Widgets/SelectStyling';
import FormFieldWrapper from '@plone/volto/components/manage/Widgets/FormFieldWrapper';
const messages = defineMessages({
select: {
id: 'Select…',
defaultMessage: 'Select…',
},
no_value: {
id: 'No value',
defaultMessage: 'No value',
},
no_options: {
id: 'No options',
defaultMessage: 'No options',
},
});
function arrayMove(array, from, to) {
const slicedArray = array.slice();
slicedArray.splice(
to < 0 ? array.length + to : to,
0,
slicedArray.splice(from, 1)[0],
);
return slicedArray;
}
function normalizeArrayValue(choices, value) {
if (!value || !Array.isArray(value)) return [];
if (value.length === 0) return value;
if (typeof value[0] === 'string') {
// raw value like ['foo', 'bar']
return value.map((v) => {
return {
label: find(choices, (c) => c.value === v)?.label || v,
value: v,
};
});
}
if (
isObject(value[0]) &&
Object.keys(value[0]).includes('token') // Array of objects, w/ label+value
) {
return value
.map((v) => {
const item = find(choices, (c) => c.value === v.token);
return item
? {
label: item.label || item.title || item.token,
value: v.token,
}
: {
// avoid a crash if choices doesn't include this item
label: v.label,
value: v.token,
};
})
.filter((f) => !!f);
}
return [];
}
function normalizeChoices(choices) {
if (Array.isArray(choices) && choices.length && Array.isArray(choices[0])) {
return choices.map((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],
}));
}
return choices;
}
/**
* Compare values and return true if equal.
* Consider upper and lower case.
* @method compareOption
* @param {*} inputValue
* @param {*} option
* @param {*} accessors
* @returns {boolean}
*/
const compareOption = (inputValue = '', option, accessors) => {
const candidate = String(inputValue);
const optionValue = String(accessors.getOptionValue(option));
const optionLabel = String(accessors.getOptionLabel(option));
return optionValue === candidate || optionLabel === candidate;
};
/**
* ArrayWidget component class.
* @class ArrayWidget
* @extends Component
*
* A creatable select array widget will be rendered if the named vocabulary is
* in the widget definition (hint) like:
*
* ```
* list_field_voc_unconstrained = schema.List(
* title=u"List field with values from vocabulary but not constrained to them.",
* description=u"zope.schema.List",
* value_type=schema.TextLine(),
* required=False,
* missing_value=[],
* )
* directives.widget(
* "list_field_voc_unconstrained",
* AjaxSelectFieldWidget,
* vocabulary="plone.app.vocabularies.PortalTypes",
* )
* ```
*/
class ArrayWidget 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,
error: PropTypes.arrayOf(PropTypes.string),
getVocabulary: PropTypes.func.isRequired,
choices: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
),
vocabLoading: PropTypes.bool,
vocabLoaded: PropTypes.bool,
items: PropTypes.shape({
vocabulary: PropTypes.object,
}),
widgetOptions: PropTypes.shape({
vocabulary: PropTypes.object,
}),
value: PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
),
placeholder: PropTypes.string,
onChange: PropTypes.func.isRequired,
wrapped: PropTypes.bool,
creatable: PropTypes.bool, //if widget has no vocab and you want to be creatable
};
/**
* Default properties
* @property {Object} defaultProps Default properties.
* @static
*/
static defaultProps = {
description: null,
required: false,
items: {
vocabulary: null,
},
widgetOptions: {
vocabulary: null,
},
error: [],
choices: [],
value: null,
creatable: false,
};
/**
* Constructor
* @method constructor
* @param {Object} props Component properties
* @constructs Actions
*/
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
/**
* Component did mount
* @method componentDidMount
* @returns {undefined}
*/
componentDidMount() {
if (
!this.props.items?.choices?.length &&
!this.props.choices?.length &&
this.props.vocabBaseUrl
) {
this.props.getVocabulary({
vocabNameOrURL: this.props.vocabBaseUrl,
size: -1,
subrequest: this.props.lang,
});
}
}
componentDidUpdate() {
if (
!this.props.items?.choices?.length &&
!this.props.choices?.length &&
this.props.vocabLoading === undefined &&
!this.props.vocabLoaded &&
this.props.vocabBaseUrl
) {
this.props.getVocabulary({
vocabNameOrURL: this.props.vocabBaseUrl,
size: -1,
subrequest: this.props.lang,
});
}
}
/**
* Handle the field change, store it in the local state and back to simple
* array of tokens for correct serialization
* @method handleChange
* @param {array} selectedOption The selected options (already aggregated).
* @returns {undefined}
*/
handleChange(selectedOption) {
this.props.onChange(
this.props.id,
selectedOption ? selectedOption.map((item) => item.value) : null,
);
}
onSortEnd = (selectedOption, { oldIndex, newIndex }) => {
const newValue = arrayMove(selectedOption, oldIndex, newIndex);
this.handleChange(newValue);
};
/**
* Render method.
* @method render
* @returns {string} Markup for the component.
*/
render() {
const choices = normalizeChoices(this.props?.choices || []);
const selectedOption = normalizeArrayValue(choices, this.props.value);
const CreatableSelect = this.props.reactSelectCreateable.default;
const { SortableContainer } = this.props.reactSortableHOC;
const Select = this.props.reactSelect.default;
const SortableSelect =
// It will be only creatable if the named vocabulary is in the widget definition
// (hint) like:
// list_field_voc_unconstrained = schema.List(
// title=u"List field with values from vocabulary but not constrained to them.",
// description=u"zope.schema.List",
// value_type=schema.TextLine(),
// required=False,
// missing_value=[],
// )
// directives.widget(
// "list_field_voc_unconstrained",
// AjaxSelectFieldWidget,
// vocabulary="plone.app.vocabularies.PortalTypes",
// )
this.props?.choices &&
!getVocabFromHint(this.props) &&
!this.props.creatable
? SortableContainer(Select)
: SortableContainer(CreatableSelect);
return (
<FormFieldWrapper {...this.props}>
<SortableSelect
useDragHandle
// react-sortable-hoc props:
axis="xy"
onSortEnd={(sortProp) => {
this.onSortEnd(selectedOption, sortProp);
}}
menuShouldScrollIntoView={false}
distance={4}
// small fix for https://github.com/clauderic/react-sortable-hoc/pull/352:
getHelperDimensions={({ node }) => node.getBoundingClientRect()}
id={`field-${this.props.id}`}
aria-labelledby={`fieldset-${this.props.fieldSet}-field-label-${this.props.id}`}
key={this.props.id}
isDisabled={this.props.disabled || this.props.isDisabled}
className="react-select-container"
classNamePrefix="react-select"
/* eslint-disable jsx-a11y/no-autofocus */
autoFocus={this.props.focus}
/* eslint-enable jsx-a11y/no-autofocus */
options={
this.props.vocabBaseUrl
? choices
: this.props.choices
? [
...choices,
...(this.props.noValueOption &&
(this.props.default === undefined ||
this.props.default === null)
? [
{
label: this.props.intl.formatMessage(
messages.no_value,
),
value: 'no-value',
},
]
: []),
]
: [
{
label: this.props.intl.formatMessage(messages.no_value),
value: 'no-value',
},
]
}
styles={customSelectStyles}
theme={selectTheme}
components={{
...(this.props.choices?.length > 25 && {
MenuList,
}),
MultiValueContainer,
MultiValue: SortableMultiValue,
MultiValueLabel: SortableMultiValueLabel,
DropdownIndicator,
ClearIndicator,
Option,
}}
value={selectedOption || []}
placeholder={
this.props.placeholder ??
this.props.intl.formatMessage(messages.select)
}
onChange={this.handleChange}
isValidNewOption={(
inputValue,
selectValue,
selectOptions,
accessors,
) =>
!(
!inputValue ||
selectValue.some((option) =>
compareOption(inputValue, option, accessors),
) ||
selectOptions.some((option) =>
compareOption(inputValue, option, accessors),
)
)
}
isClearable
isMulti
/>
</FormFieldWrapper>
);
}
}
export const ArrayWidgetComponent = injectIntl(ArrayWidget);
export default compose(
injectIntl,
injectLazyLibs(['reactSelect', 'reactSelectCreateable', 'reactSortableHOC']),
connect(
(state, props) => {
const vocabBaseUrl =
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.items?.choices) {
return {
choices: props.items.choices,
lang: state.intl.locale,
};
} else if (vocabState) {
return {
choices: vocabState.items,
vocabBaseUrl,
vocabLoading: vocabState.loading,
vocabLoaded: vocabState.loaded,
lang: state.intl.locale,
};
}
return { vocabBaseUrl, lang: state.intl.locale };
},
{ getVocabulary },
),
)(ArrayWidget);