feat(netlify-cms-widget-list): allow 'summary' field (#3616)
This commit is contained in:
parent
c9a2fec2da
commit
7cc4c89539
@ -31,6 +31,7 @@
|
|||||||
"lodash": "^4.17.11",
|
"lodash": "^4.17.11",
|
||||||
"netlify-cms-ui-default": "^2.6.0",
|
"netlify-cms-ui-default": "^2.6.0",
|
||||||
"netlify-cms-widget-object": "^2.2.0",
|
"netlify-cms-widget-object": "^2.2.0",
|
||||||
|
"netlify-cms-lib-widgets": "^1.0.0",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react": "^16.8.4",
|
"react": "^16.8.4",
|
||||||
"react-immutable-proptypes": "^2.1.0"
|
"react-immutable-proptypes": "^2.1.0"
|
||||||
|
@ -15,6 +15,7 @@ import {
|
|||||||
getErrorMessageForTypedFieldAndValue,
|
getErrorMessageForTypedFieldAndValue,
|
||||||
} from './typedListHelpers';
|
} from './typedListHelpers';
|
||||||
import { ListItemTopBar, ObjectWidgetTopBar, colors, lengths } from 'netlify-cms-ui-default';
|
import { ListItemTopBar, ObjectWidgetTopBar, colors, lengths } from 'netlify-cms-ui-default';
|
||||||
|
import { stringTemplate } from 'netlify-cms-lib-widgets';
|
||||||
|
|
||||||
function valueToString(value) {
|
function valueToString(value) {
|
||||||
return value ? value.join(',').replace(/,([^\s]|$)/g, ', $1') : '';
|
return value ? value.join(',').replace(/,([^\s]|$)/g, ', $1') : '';
|
||||||
@ -71,6 +72,14 @@ const valueTypes = {
|
|||||||
MIXED: 'MIXED',
|
MIXED: 'MIXED',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSummary = (summary, entry, label, item) => {
|
||||||
|
const data = stringTemplate.addFileTemplateFields(
|
||||||
|
entry.get('path'),
|
||||||
|
item.set('fields.label', label),
|
||||||
|
);
|
||||||
|
return stringTemplate.compileStringTemplate(summary, null, '', data);
|
||||||
|
};
|
||||||
|
|
||||||
export default class ListControl extends React.Component {
|
export default class ListControl extends React.Component {
|
||||||
validations = [];
|
validations = [];
|
||||||
|
|
||||||
@ -96,6 +105,7 @@ export default class ListControl extends React.Component {
|
|||||||
resolveWidget: PropTypes.func.isRequired,
|
resolveWidget: PropTypes.func.isRequired,
|
||||||
clearFieldErrors: PropTypes.func.isRequired,
|
clearFieldErrors: PropTypes.func.isRequired,
|
||||||
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
fieldsErrors: ImmutablePropTypes.map.isRequired,
|
||||||
|
entry: ImmutablePropTypes.map.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
@ -317,17 +327,35 @@ export default class ListControl extends React.Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
objectLabel(item) {
|
objectLabel(item) {
|
||||||
const { field } = this.props;
|
const { field, entry } = this.props;
|
||||||
if (this.getValueType() === valueTypes.MIXED) {
|
const valueType = this.getValueType();
|
||||||
return getTypedFieldForValue(field, item).get('label', field.get('name'));
|
switch (valueType) {
|
||||||
|
case valueTypes.MIXED: {
|
||||||
|
const itemType = getTypedFieldForValue(field, item);
|
||||||
|
const label = itemType.get('label', itemType.get('name'));
|
||||||
|
// each type can have its own summary, but default to the list summary if exists
|
||||||
|
const summary = itemType.get('summary', field.get('summary'));
|
||||||
|
const labelReturn = summary ? handleSummary(summary, entry, label, item) : label;
|
||||||
|
return labelReturn;
|
||||||
|
}
|
||||||
|
case valueTypes.SINGLE: {
|
||||||
|
const singleField = field.get('field');
|
||||||
|
const label = singleField.get('label', singleField.get('name'));
|
||||||
|
const summary = field.get('summary');
|
||||||
|
const data = fromJS({ [singleField.get('name')]: item });
|
||||||
|
const labelReturn = summary ? handleSummary(summary, entry, label, data) : label;
|
||||||
|
return labelReturn;
|
||||||
|
}
|
||||||
|
case valueTypes.MULTIPLE: {
|
||||||
|
const multiFields = field.get('fields');
|
||||||
|
const labelField = multiFields && multiFields.first();
|
||||||
|
const value = item.get(labelField.get('name'));
|
||||||
|
const summary = field.get('summary');
|
||||||
|
const labelReturn = summary ? handleSummary(summary, entry, value, item) : value;
|
||||||
|
return (labelReturn || `No ${labelField.get('name')}`).toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const multiFields = field.get('fields');
|
return '';
|
||||||
const singleField = field.get('field');
|
|
||||||
const labelField = (multiFields && multiFields.first()) || singleField;
|
|
||||||
const value = multiFields
|
|
||||||
? item.get(multiFields.first().get('name'))
|
|
||||||
: singleField.get('label');
|
|
||||||
return (value || `No ${labelField.get('name')}`).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSortEnd = ({ oldIndex, newIndex }) => {
|
onSortEnd = ({ oldIndex, newIndex }) => {
|
||||||
|
@ -48,6 +48,9 @@ describe('ListControl', () => {
|
|||||||
resolveWidget: jest.fn(),
|
resolveWidget: jest.fn(),
|
||||||
clearFieldErrors: jest.fn(),
|
clearFieldErrors: jest.fn(),
|
||||||
fieldsErrors: fromJS({}),
|
fieldsErrors: fromJS({}),
|
||||||
|
entry: fromJS({
|
||||||
|
path: 'posts/index.md',
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -270,4 +273,185 @@ describe('ListControl', () => {
|
|||||||
expect(getByTestId('object-control-0')).toHaveAttribute('collapsed', 'false');
|
expect(getByTestId('object-control-0')).toHaveAttribute('collapsed', 'false');
|
||||||
expect(getByTestId('object-control-1')).toHaveAttribute('collapsed', 'true');
|
expect(getByTestId('object-control-1')).toHaveAttribute('collapsed', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use widget name when no summary or label are configured for mixed types', () => {
|
||||||
|
const field = fromJS({
|
||||||
|
name: 'list',
|
||||||
|
label: 'List',
|
||||||
|
collapsed: true,
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
name: 'type_1_object',
|
||||||
|
widget: 'object',
|
||||||
|
fields: [
|
||||||
|
{ label: 'First Name', name: 'first_name', widget: 'string' },
|
||||||
|
{ label: 'Last Name', name: 'last_name', widget: 'string' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<ListControl
|
||||||
|
{...props}
|
||||||
|
field={field}
|
||||||
|
value={fromJS([{ first_name: 'hello', last_name: 'world', type: 'type_1_object' }])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(getByText('type_1_object')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use label when no summary is configured for mixed types', () => {
|
||||||
|
const field = fromJS({
|
||||||
|
name: 'list',
|
||||||
|
label: 'List',
|
||||||
|
collapsed: true,
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
label: 'Type 1 Object',
|
||||||
|
name: 'type_1_object',
|
||||||
|
widget: 'object',
|
||||||
|
fields: [
|
||||||
|
{ label: 'First Name', name: 'first_name', widget: 'string' },
|
||||||
|
{ label: 'Last Name', name: 'last_name', widget: 'string' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<ListControl
|
||||||
|
{...props}
|
||||||
|
field={field}
|
||||||
|
value={fromJS([{ first_name: 'hello', last_name: 'world', type: 'type_1_object' }])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(getByText('Type 1 Object')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use summary when configured for mixed types', () => {
|
||||||
|
const field = fromJS({
|
||||||
|
name: 'list',
|
||||||
|
label: 'List',
|
||||||
|
collapsed: true,
|
||||||
|
types: [
|
||||||
|
{
|
||||||
|
label: 'Type 1 Object',
|
||||||
|
name: 'type_1_object',
|
||||||
|
summary: '{{first_name}} - {{last_name}} - {{filename}}.{{extension}}',
|
||||||
|
widget: 'object',
|
||||||
|
fields: [
|
||||||
|
{ label: 'First Name', name: 'first_name', widget: 'string' },
|
||||||
|
{ label: 'Last Name', name: 'last_name', widget: 'string' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<ListControl
|
||||||
|
{...props}
|
||||||
|
field={field}
|
||||||
|
value={fromJS([{ first_name: 'hello', last_name: 'world', type: 'type_1_object' }])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(getByText('hello - world - index.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use widget name when no summary or label are configured for a single field', () => {
|
||||||
|
const field = fromJS({
|
||||||
|
name: 'list',
|
||||||
|
label: 'List',
|
||||||
|
collapsed: true,
|
||||||
|
field: { name: 'name', widget: 'string' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(<ListControl {...props} field={field} value={fromJS(['Name'])} />);
|
||||||
|
expect(getByText('name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use label when no summary is configured for a single field', () => {
|
||||||
|
const field = fromJS({
|
||||||
|
name: 'list',
|
||||||
|
label: 'List',
|
||||||
|
collapsed: true,
|
||||||
|
field: { name: 'name', widget: 'string', label: 'Name' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(<ListControl {...props} field={field} value={fromJS(['Name'])} />);
|
||||||
|
expect(getByText('Name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use summary when configured for a single field', () => {
|
||||||
|
const field = fromJS({
|
||||||
|
name: 'list',
|
||||||
|
label: 'List',
|
||||||
|
collapsed: true,
|
||||||
|
summary: 'Name - {{fields.name}}',
|
||||||
|
field: { name: 'name', widget: 'string', label: 'Name' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(<ListControl {...props} field={field} value={fromJS(['Name'])} />);
|
||||||
|
expect(getByText('Name - Name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use first field value when no summary or label are configured for multiple fields', () => {
|
||||||
|
const field = fromJS({
|
||||||
|
name: 'list',
|
||||||
|
label: 'List',
|
||||||
|
collapsed: true,
|
||||||
|
fields: [
|
||||||
|
{ name: 'first_name', widget: 'string', label: 'First Name' },
|
||||||
|
{ name: 'last_name', widget: 'string', label: 'Last Name' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<ListControl
|
||||||
|
{...props}
|
||||||
|
field={field}
|
||||||
|
value={fromJS([{ first_name: 'hello', last_name: 'world' }])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(getByText('hello')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show `No <field>` when value is missing from first field for multiple fields', () => {
|
||||||
|
const field = fromJS({
|
||||||
|
name: 'list',
|
||||||
|
label: 'List',
|
||||||
|
collapsed: true,
|
||||||
|
fields: [
|
||||||
|
{ name: 'first_name', widget: 'string', label: 'First Name' },
|
||||||
|
{ name: 'last_name', widget: 'string', label: 'Last Name' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<ListControl {...props} field={field} value={fromJS([{ last_name: 'world' }])} />,
|
||||||
|
);
|
||||||
|
expect(getByText('No first_name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use summary when configured for multiple fields', () => {
|
||||||
|
const field = fromJS({
|
||||||
|
name: 'list',
|
||||||
|
label: 'List',
|
||||||
|
collapsed: true,
|
||||||
|
summary: '{{first_name}} - {{last_name}} - {{filename}}.{{extension}}',
|
||||||
|
fields: [
|
||||||
|
{ name: 'first_name', widget: 'string', label: 'First Name' },
|
||||||
|
{ name: 'last_name', widget: 'string', label: 'Last Name' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<ListControl
|
||||||
|
{...props}
|
||||||
|
field={field}
|
||||||
|
value={fromJS([{ first_name: 'hello', last_name: 'world' }])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(getByText('hello - world - index.md')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -163,6 +163,7 @@ To use variable types in the list widget, update your field configuration as fol
|
|||||||
|
|
||||||
- `types`: a nested list of object widgets. All widgets must be of type `object`. Every object widget may define different set of fields.
|
- `types`: a nested list of object widgets. All widgets must be of type `object`. Every object widget may define different set of fields.
|
||||||
- `typeKey`: the name of the field that will be added to every item in list representing the name of the object widget that item belongs to. Ignored if `types` is not defined. Default is `type`.
|
- `typeKey`: the name of the field that will be added to every item in list representing the name of the object widget that item belongs to. Ignored if `types` is not defined. Default is `type`.
|
||||||
|
- `summary`: allows customization of a collapsed list item object in a similar way to a [collection summary](/docs/configuration-options/?#summary)
|
||||||
|
|
||||||
### Example Configuration
|
### Example Configuration
|
||||||
|
|
||||||
@ -177,6 +178,7 @@ either a "carousel" or a "spotlight". Each type has a unique name and set of fie
|
|||||||
- label: 'Carousel'
|
- label: 'Carousel'
|
||||||
name: 'carousel'
|
name: 'carousel'
|
||||||
widget: object
|
widget: object
|
||||||
|
summary: '{{fields.header}}'
|
||||||
fields:
|
fields:
|
||||||
- { label: Header, name: header, widget: string, default: 'Image Gallery' }
|
- { label: Header, name: header, widget: string, default: 'Image Gallery' }
|
||||||
- { label: Template, name: template, widget: string, default: 'carousel.html' }
|
- { label: Template, name: template, widget: string, default: 'carousel.html' }
|
||||||
|
@ -12,6 +12,7 @@ The list widget allows you to create a repeatable item in the UI which saves as
|
|||||||
- `default`: if `fields` is specified, declare defaults on the child widgets; if not, you may specify a list of strings to populate the text field
|
- `default`: if `fields` is specified, declare defaults on the child widgets; if not, you may specify a list of strings to populate the text field
|
||||||
- `allow_add`: if added and labeled `false`, button to add additional widgets disappears
|
- `allow_add`: if added and labeled `false`, button to add additional widgets disappears
|
||||||
- `collapsed`: if added and labeled `false`, the list widget's content does not collapse by default
|
- `collapsed`: if added and labeled `false`, the list widget's content does not collapse by default
|
||||||
|
- `summary`: allows customization of a collapsed list item object in a similar way to a [collection summary](/docs/configuration-options/?#summary)
|
||||||
- `field`: a single widget field to be repeated
|
- `field`: a single widget field to be repeated
|
||||||
- `fields`: a nested list of multiple widget fields to be included in each repeatable iteration
|
- `fields`: a nested list of multiple widget fields to be included in each repeatable iteration
|
||||||
- **Example** (`field`/`fields` not specified):
|
- **Example** (`field`/`fields` not specified):
|
||||||
@ -34,6 +35,7 @@ The list widget allows you to create a repeatable item in the UI which saves as
|
|||||||
- label: "Gallery"
|
- label: "Gallery"
|
||||||
name: "galleryImages"
|
name: "galleryImages"
|
||||||
widget: "list"
|
widget: "list"
|
||||||
|
summary: '{{fields.image}}'
|
||||||
field: {label: Image, name: image, widget: image}
|
field: {label: Image, name: image, widget: image}
|
||||||
```
|
```
|
||||||
- **Example** (with `fields`):
|
- **Example** (with `fields`):
|
||||||
@ -41,6 +43,7 @@ The list widget allows you to create a repeatable item in the UI which saves as
|
|||||||
- label: "Testimonials"
|
- label: "Testimonials"
|
||||||
name: "testimonials"
|
name: "testimonials"
|
||||||
widget: "list"
|
widget: "list"
|
||||||
|
summary: '{{fields.quote}} - {{fields.author.name}}'
|
||||||
fields:
|
fields:
|
||||||
- {label: Quote, name: quote, widget: string, default: "Everything is awesome!"}
|
- {label: Quote, name: quote, widget: string, default: "Everything is awesome!"}
|
||||||
- label: Author
|
- label: Author
|
||||||
|
Loading…
x
Reference in New Issue
Block a user