Skip to content

Commit

Permalink
Fix Select edge cases, make labels work. Better overall, backport PR …
Browse files Browse the repository at this point in the history
…#2563
  • Loading branch information
sneridagh committed Jul 5, 2021
1 parent 640f844 commit 9e7364a
Show file tree
Hide file tree
Showing 6 changed files with 183 additions and 77 deletions.
65 changes: 58 additions & 7 deletions src/components/Select/Select.stories.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ Errored.args = {
title: 'Errored field title',
description: 'Optional help text',
placeholder: 'Type something…',
// Simplest example in Plone - a "hardcoded, hand made" vocab using SimpleVocabulary/SimpleTerm
// allow_discussion = schema.Choice(
// title=_(u'Allow discussion'),
// description=_(u'Allow discussion for this content object.'),
// vocabulary=SimpleVocabulary([
// SimpleTerm(value=True, title=_(u'Yes')),
// SimpleTerm(value=False, title=_(u'No')),
// ]),
// required=False,
// default=None,
// )
choices: [
['Foo', 'Foo'],
['Bar', 'Bar'],
Expand All @@ -69,12 +80,25 @@ Errored.args = {
required: true,
};

export const WithoutNoValue = Select.bind({});
WithoutNoValue.args = {
id: 'field-empty',
title: 'field 1 title',
description: 'Optional help text',
placeholder: 'Type something…',
export const NoPlaceholder = Select.bind({});
NoPlaceholder.args = {
id: 'field-without-novalue',
title: 'Field title',
description: 'This field has no value option',
choices: [
['Foo', 'Foo'],
['Bar', 'Bar'],
['FooBar', 'FooBar'],
],
required: true,
};

export const WithoutNoValueOption = Select.bind({});
WithoutNoValueOption.args = {
id: 'field-without-novalue',
title: 'Field title',
description: 'This field has no value option',
placeholder: 'Select something…',
choices: [
['Foo', 'Foo'],
['Bar', 'Bar'],
Expand All @@ -84,12 +108,39 @@ WithoutNoValue.args = {
noValueOption: false,
};

export const VocabularyBased = Select.bind({});
VocabularyBased.args = {
id: 'field-vocab-based',
title: 'field title',
description: 'This is a vocab-based field (AsyncSelect based)',
placeholder: 'Select something…',
// choices in Vocabulary based selects that has choices and spects a string in return
// Use case: Language select - A Choice schema that spects a string as value
// language = schema.Choice(
// title=_(u'label_language', default=u'Language'),
// vocabulary='plone.app.vocabularies.SupportedContentLanguages',
// required=False,
// missing_value='',
// defaultFactory=default_language,
// )
// p.restapi vocab endpoint outputs
// "items": [{title: "English", token: "en"}, ...]
// The widget sends a string as value in the PATCH/POST:
// value: "en"
choices: [
{ label: 'English', value: 'en' },
{ label: 'Catala', value: 'ca' },
],
required: true,
vocabBaseUrl: 'https://anapivocabularyURL',
};

export const Disabled = Select.bind({});
Disabled.args = {
id: 'field-disabled',
title: 'Disabled field title',
description: 'Optional help text',
placeholder: 'Type something…',
placeholder: 'Select something…',
disabled: true,
};

Expand Down
27 changes: 27 additions & 0 deletions src/components/Select/SelectStyling.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@ import checkSVG from '@plone/volto/icons/check.svg';

const ReactSelect = loadable.lib(() => import('react-select'));

export const SelectContainer = ({ children, ...props }) => {
return (
<ReactSelect>
{({ components }) => (
<>
{console.log(props)}
<components.SelectContainer
{...props}
className={props.cx(
{
'--is-disabled': props.isDisabled,
'--is-rtl': props.isRtl,
'--is-focused': props.isFocused,
'--has-value': props.hasValue,
'--has-placeholder': props.selectProps.placeholder,
},
props.className,
)}
>
{children}
</components.SelectContainer>
</>
)}
</ReactSelect>
);
};

export const Option = (props) => {
return (
<ReactSelect>
Expand Down
65 changes: 65 additions & 0 deletions src/components/Select/SelectUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { find, isBoolean, isObject, isArray } from 'lodash';
import { getBoolean } from '@plone/volto/helpers';
import { defineMessages } from 'react-intl';

const messages = defineMessages({
no_value: {
id: 'No value',
defaultMessage: 'No value',
},
});

/**
* Given the value from the API, it normalizes to a value valid to use in react-select.
* This is necessary because of the inconsistencies in p.restapi vocabularies implementations as
* they need to adapt to react-select public interface.
* @function normalizeValue
* @param {array} choices The choices
* @param {string|object|boolean|array} value The value
* @returns {Object} An object of shape {label: "", value: ""}.
*/
export function normalizeValue(choices, value) {
if (!isObject(value) && isBoolean(value)) {
// We have a boolean value, which means we need to provide a "No value"
// option
const label = find(choices, (o) => getBoolean(o[0]) === value);
return label
? {
label: label[1],
value,
}
: {};
}
if (value === undefined) return null;
if (!value || value.length === 0) return null;
if (value === 'no-value') {
return {
label: this.props.intl.formatMessage(messages.no_value),
value: 'no-value',
};
}

if (isArray(value) && choices.length > 0) {
return value.map((v) => ({
label: find(choices, (o) => o[0] === v)?.[1] || v,
value: v,
}));
} else if (isObject(value)) {
return {
label: value.title !== 'None' && value.title ? value.title : value.token,
value: value.token,
};
} else if (value && choices && choices.length > 0 && isArray(choices[0])) {
return { label: find(choices, (o) => o[0] === value)?.[1] || value, value };
} else if (
value &&
choices &&
choices.length > 0 &&
Object.keys(choices[0]).includes('value') &&
Object.keys(choices[0]).includes('label')
) {
return find(choices, (o) => o.value === value) || null;
} else {
return null;
}
}
96 changes: 28 additions & 68 deletions src/components/Select/SelectWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { map, find, isBoolean, isObject, intersection, isArray } from 'lodash';
import { map, intersection } from 'lodash';
import { defineMessages, injectIntl } from 'react-intl';
// import loadable from '@loadable/component';

import {
getBoolean,
getVocabFromHint,
Expand All @@ -19,9 +17,10 @@ import {
} from '@plone/volto/helpers';
import FormFieldWrapper from '../FormFieldWrapper/FormFieldWrapper';
import { getVocabulary, getVocabularyTokenTitle } from '@plone/volto/actions';
import { normalizeValue } from './SelectUtils';

import {
Control,
SelectContainer,
Option,
DropdownIndicator,
selectTheme,
Expand Down Expand Up @@ -80,42 +79,6 @@ const messages = defineMessages({
},
});

function getDefaultValues(choices, value) {
if (!isObject(value) && isBoolean(value)) {
// We have a boolean value, which means we need to provide a "No value"
// option
const label = find(choices, (o) => getBoolean(o[0]) === value);
return label
? {
label: label[1],
value,
}
: {};
}
if (value === 'no-value') {
return {
label: this.props.intl.formatMessage(messages.no_value),
value: 'no-value',
};
}

if (isArray(value) && choices.length > 0) {
return value.map((v) => ({
label: find(choices, (o) => o[0] === v)?.[1] || v,
value: v,
}));
} else if (isObject(value)) {
return {
label: value.title !== 'None' && value.title ? value.title : value.token,
value: value.token,
};
} else if (value && choices.length > 0) {
return { label: find(choices, (o) => o[0] === value)?.[1] || value, value };
} else {
return {};
}
}

/**
* SelectWidget component class.
* @function SelectWidget
Expand Down Expand Up @@ -187,9 +150,9 @@ class SelectWidget extends Component {
};

state = {
selectedOption: this.props.value
? { label: this.props.value.title, value: this.props.value.value }
: {},
// TODO: also take into account this.props.defaultValue?
selectedOption: normalizeValue(this.props.choices, this.props.value),
search: '',
};

/**
Expand All @@ -213,10 +176,11 @@ class SelectWidget extends Component {
*/
loadOptions = (search, previousOptions, additional) => {
let hasMore = this.props.itemsTotal > previousOptions.length;
if (hasMore) {
const offset = this.state.search !== search ? 0 : additional.offset;
const offset = this.state.search !== search ? 0 : additional.offset;
this.setState({ search });

if (hasMore || this.state.search !== search) {
this.props.getVocabulary(this.props.vocabBaseUrl, search, offset);
this.setState({ search });

return {
options:
Expand All @@ -230,7 +194,8 @@ class SelectWidget extends Component {
},
};
}
return null;
// We should return always an object like this, if not it complains:
return { options: [] };
};

/**
Expand All @@ -251,27 +216,27 @@ class SelectWidget extends Component {
* @returns {string} Markup for the component.
*/
render() {
const { id, choices, value, onChange, required } = this.props;
const { id, choices, onChange, required } = this.props;

return (
<FormFieldWrapper {...this.props}>
{this.props.vocabBaseUrl ? (
<>
<AsyncPaginate
isDisabled={this.props.isDisabled}
className="react-select-container"
className="q react-select-container"
classNamePrefix="react-select"
options={this.props.choices || []}
styles={customSelectStyles}
theme={selectTheme}
components={{ DropdownIndicator, Option }}
components={{ DropdownIndicator, Option, SelectContainer }}
value={this.state.selectedOption}
loadOptions={this.loadOptions}
onChange={this.handleChange}
additional={{
offset: 25,
}}
placeholder={this.props.intl.formatMessage(messages.select)}
placeholder={this.props.placeholder || null}
noOptionsMessage={() =>
this.props.intl.formatMessage(messages.no_options)
}
Expand All @@ -284,7 +249,7 @@ class SelectWidget extends Component {
id={`field-${id}`}
key={this.props.choices}
name={id}
placeholder={this.props.placeholder | ' '}
placeholder={this.props.placeholder || null}
isDisabled={this.props.isDisabled}
className="q react-select-container"
classNamePrefix="react-select"
Expand All @@ -296,7 +261,9 @@ class SelectWidget extends Component {
// Fix "None" on the serializer, to remove when fixed in p.restapi
option[1] !== 'None' && option[1] ? option[1] : option[0],
})),
...(this.props.noValueOption
// 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
? [
{
label: this.props.intl.formatMessage(messages.no_value),
Expand All @@ -307,22 +274,15 @@ class SelectWidget extends Component {
]}
styles={customSelectStyles}
theme={selectTheme}
components={{ DropdownIndicator, Option }}
defaultValue={getDefaultValues(
choices,
value || this.props.defaultValue,
)}
onChange={(data) => {
let dataValue = [];
if (Array.isArray(data)) {
for (let obj of data) {
dataValue.push(obj.value);
}
return onChange(id, dataValue);
}
components={{ DropdownIndicator, Option, SelectContainer }}
value={this.state.selectedOption}
onChange={(selectedOption) => {
this.setState({ selectedOption });
return onChange(
id,
data.value === 'no-value' ? undefined : data.value,
selectedOption && selectedOption.value !== 'no-value'
? selectedOption.value
: undefined,
);
}}
/>
Expand All @@ -334,8 +294,8 @@ class SelectWidget extends Component {
tabIndex={-1}
hidden
autoComplete="off"
value={value}
required={required}
placeholder="Dummy"
/>
</FormFieldWrapper>
);
Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default (config) => {
export function overrideDefaultControls(config) {
config.widgets.default = Input;
config.widgets.widget.textarea = TextArea;
config.widgets.choices = SelectWidget;
// config.widgets.choices = SelectWidget;

return config;
}
Loading

0 comments on commit 9e7364a

Please sign in to comment.