feat(widget-list): add hiding list content with minimize_collapsed option (#3607)

This commit is contained in:
Hannes Küttner 2020-05-17 15:47:07 +02:00 committed by GitHub
parent 088b1a8ab6
commit 4dd58c5dcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 1925 additions and 33 deletions

View File

@ -115,14 +115,15 @@ export default class ListControl extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const { field, value } = props; const { field, value } = props;
const allItemsCollapsed = field.get('collapsed', true); const listCollapsed = field.get('collapsed', true);
const itemsCollapsed = value && Array(value.size).fill(allItemsCollapsed); const itemsCollapsed = (value && Array(value.size).fill(listCollapsed)) || [];
const keys = value && Array.from({ length: value.size }, () => uuid()); const keys = (value && Array.from({ length: value.size }, () => uuid())) || [];
this.state = { this.state = {
itemsCollapsed: List(itemsCollapsed), listCollapsed,
itemsCollapsed,
value: valueToString(value), value: valueToString(value),
keys: List(keys), keys,
}; };
} }
@ -184,8 +185,8 @@ export default class ListControl extends React.Component {
? this.singleDefault() ? this.singleDefault()
: fromJS(this.multipleDefault(field.get('fields'))); : fromJS(this.multipleDefault(field.get('fields')));
this.setState({ this.setState({
itemsCollapsed: this.state.itemsCollapsed.push(false), itemsCollapsed: [...this.state.itemsCollapsed, false],
keys: this.state.keys.push(uuid()), keys: [...this.state.keys, uuid()],
}); });
onChange((value || List()).push(parsedValue)); onChange((value || List()).push(parsedValue));
}; };
@ -202,8 +203,8 @@ export default class ListControl extends React.Component {
const { value, onChange } = this.props; const { value, onChange } = this.props;
const parsedValue = fromJS(this.mixedDefault(typeKey, type)); const parsedValue = fromJS(this.mixedDefault(typeKey, type));
this.setState({ this.setState({
itemsCollapsed: this.state.itemsCollapsed.push(false), itemsCollapsed: [...this.state.itemsCollapsed, false],
keys: this.state.keys.push(uuid()), keys: [...this.state.keys, uuid()],
}); });
onChange((value || List()).push(parsedValue)); onChange((value || List()).push(parsedValue));
}; };
@ -300,7 +301,10 @@ export default class ListControl extends React.Component {
? { [collectionName]: metadata.removeIn(metadataRemovePath) } ? { [collectionName]: metadata.removeIn(metadataRemovePath) }
: metadata; : metadata;
this.setState({ itemsCollapsed: itemsCollapsed.delete(index), keys: keys.delete(index) }); itemsCollapsed.splice(index, 1);
keys.splice(index, 1);
this.setState({ itemsCollapsed: [...itemsCollapsed], keys: [...keys] });
onChange(value.remove(index), parsedMetadata); onChange(value.remove(index), parsedMetadata);
clearFieldErrors(); clearFieldErrors();
@ -314,16 +318,35 @@ export default class ListControl extends React.Component {
handleItemCollapseToggle = (index, event) => { handleItemCollapseToggle = (index, event) => {
event.preventDefault(); event.preventDefault();
const { itemsCollapsed } = this.state; const { itemsCollapsed } = this.state;
const collapsed = itemsCollapsed.get(index); const newItemsCollapsed = itemsCollapsed.map((collapsed, itemIndex) => {
this.setState({ itemsCollapsed: itemsCollapsed.set(index, !collapsed) }); if (index === itemIndex) {
return !collapsed;
}
return collapsed;
});
this.setState({
itemsCollapsed: newItemsCollapsed,
});
}; };
handleCollapseAllToggle = e => { handleCollapseAllToggle = e => {
e.preventDefault(); e.preventDefault();
const { value } = this.props; const { value, field } = this.props;
const { itemsCollapsed } = this.state; const { itemsCollapsed, listCollapsed } = this.state;
const minimizeCollapsedItems = field.get('minimize_collapsed', false);
const listCollapsedByDefault = field.get('collapsed', true);
const allItemsCollapsed = itemsCollapsed.every(val => val === true); const allItemsCollapsed = itemsCollapsed.every(val => val === true);
this.setState({ itemsCollapsed: List(Array(value.size).fill(!allItemsCollapsed)) });
if (minimizeCollapsedItems) {
let updatedItemsCollapsed = itemsCollapsed;
// Only allow collapsing all items in this mode but not opening all at once
if (!listCollapsed || !listCollapsedByDefault) {
updatedItemsCollapsed = Array(value.size).fill(!listCollapsed);
}
this.setState({ listCollapsed: !listCollapsed, itemsCollapsed: updatedItemsCollapsed });
} else {
this.setState({ itemsCollapsed: Array(value.size).fill(!allItemsCollapsed) });
}
}; };
objectLabel(item) { objectLabel(item) {
@ -368,11 +391,18 @@ export default class ListControl extends React.Component {
this.props.onChange(newValue); this.props.onChange(newValue);
// Update collapsing // Update collapsing
const collapsed = itemsCollapsed.get(oldIndex); const collapsed = itemsCollapsed[oldIndex];
const updatedItemsCollapsed = itemsCollapsed.delete(oldIndex).insert(newIndex, collapsed); itemsCollapsed.splice(oldIndex, 1);
const updatedItemsCollapsed = [...itemsCollapsed];
updatedItemsCollapsed.splice(newIndex, 0, collapsed);
// Reset item to ensure updated state // Reset item to ensure updated state
const updatedKeys = keys.set(oldIndex, uuid()).set(newIndex, uuid()); const updatedKeys = keys.map((key, keyIndex) => {
if (keyIndex === oldIndex || keyIndex === newIndex) {
return uuid();
}
return key;
});
this.setState({ itemsCollapsed: updatedItemsCollapsed, keys: updatedKeys }); this.setState({ itemsCollapsed: updatedItemsCollapsed, keys: updatedKeys });
//clear error fields and remove old validations //clear error fields and remove old validations
@ -394,8 +424,8 @@ export default class ListControl extends React.Component {
} = this.props; } = this.props;
const { itemsCollapsed, keys } = this.state; const { itemsCollapsed, keys } = this.state;
const collapsed = itemsCollapsed.get(index); const collapsed = itemsCollapsed[index];
const key = keys.get(index); const key = keys[index];
let field = this.props.field; let field = this.props.field;
if (this.getValueType() === valueTypes.MIXED) { if (this.getValueType() === valueTypes.MIXED) {
@ -473,11 +503,14 @@ export default class ListControl extends React.Component {
renderListControl() { renderListControl() {
const { value, forID, field, classNameWrapper } = this.props; const { value, forID, field, classNameWrapper } = this.props;
const { itemsCollapsed } = this.state; const { itemsCollapsed, listCollapsed } = this.state;
const items = value || List(); const items = value || List();
const label = field.get('label', field.get('name')); const label = field.get('label', field.get('name'));
const labelSingular = field.get('label_singular') || field.get('label', field.get('name')); const labelSingular = field.get('label_singular') || field.get('label', field.get('name'));
const listLabel = items.size === 1 ? labelSingular.toLowerCase() : label.toLowerCase(); const listLabel = items.size === 1 ? labelSingular.toLowerCase() : label.toLowerCase();
const minimizeCollapsedItems = field.get('minimize_collapsed', false);
const allItemsCollapsed = itemsCollapsed.every(val => val === true);
const selfCollapsed = allItemsCollapsed && (listCollapsed || !minimizeCollapsedItems);
return ( return (
<ClassNames> <ClassNames>
@ -499,15 +532,17 @@ export default class ListControl extends React.Component {
heading={`${items.size} ${listLabel}`} heading={`${items.size} ${listLabel}`}
label={labelSingular.toLowerCase()} label={labelSingular.toLowerCase()}
onCollapseToggle={this.handleCollapseAllToggle} onCollapseToggle={this.handleCollapseAllToggle}
collapsed={itemsCollapsed.every(val => val === true)} collapsed={selfCollapsed}
/>
<SortableList
items={items}
renderItem={this.renderItem}
onSortEnd={this.onSortEnd}
useDragHandle
lockAxis="y"
/> />
{(!selfCollapsed || !minimizeCollapsedItems) && (
<SortableList
items={items}
renderItem={this.renderItem}
onSortEnd={this.onSortEnd}
useDragHandle
lockAxis="y"
/>
)}
</div> </div>
)} )}
</ClassNames> </ClassNames>

View File

@ -20,6 +20,7 @@ jest.mock('netlify-cms-ui-default', () => {
const actual = jest.requireActual('netlify-cms-ui-default'); const actual = jest.requireActual('netlify-cms-ui-default');
const ListItemTopBar = props => ( const ListItemTopBar = props => (
<mock-list-item-top-bar {...props} onClick={props.onCollapseToggle}> <mock-list-item-top-bar {...props} onClick={props.onCollapseToggle}>
<button onClick={props.onRemove}>Remove</button>
{props.children} {props.children}
</mock-list-item-top-bar> </mock-list-item-top-bar>
); );
@ -454,4 +455,177 @@ describe('ListControl', () => {
); );
expect(getByText('hello - world - index.md')).toBeInTheDocument(); expect(getByText('hello - world - index.md')).toBeInTheDocument();
}); });
it('should render list with fields with default collapse ("true") and minimize_collapsed ("false")', () => {
const field = fromJS({
name: 'list',
label: 'List',
fields: [{ label: 'String', name: 'string', widget: 'string' }],
});
const { asFragment, getByTestId } = render(
<ListControl
{...props}
field={field}
value={fromJS([{ string: 'item 1' }, { string: 'item 2' }])}
/>,
);
expect(getByTestId('styled-list-item-top-bar-0')).toHaveAttribute('collapsed', 'true');
expect(getByTestId('styled-list-item-top-bar-1')).toHaveAttribute('collapsed', 'true');
expect(getByTestId('object-control-0')).toHaveAttribute('collapsed', 'true');
expect(getByTestId('object-control-1')).toHaveAttribute('collapsed', 'true');
expect(asFragment()).toMatchSnapshot();
});
it('should render list with fields with collapse = "false" and default minimize_collapsed ("false")', () => {
const field = fromJS({
name: 'list',
label: 'List',
collapsed: false,
fields: [{ label: 'String', name: 'string', widget: 'string' }],
});
const { asFragment, getByTestId } = render(
<ListControl
{...props}
field={field}
value={fromJS([{ string: 'item 1' }, { string: 'item 2' }])}
/>,
);
expect(getByTestId('styled-list-item-top-bar-0')).toHaveAttribute('collapsed', 'false');
expect(getByTestId('styled-list-item-top-bar-1')).toHaveAttribute('collapsed', 'false');
expect(getByTestId('object-control-0')).toHaveAttribute('collapsed', 'false');
expect(getByTestId('object-control-1')).toHaveAttribute('collapsed', 'false');
expect(asFragment()).toMatchSnapshot();
});
it('should render list with fields with default collapse ("true") and minimize_collapsed = "true"', () => {
const field = fromJS({
name: 'list',
label: 'List',
minimize_collapsed: true,
fields: [{ label: 'String', name: 'string', widget: 'string' }],
});
const { asFragment, getByTestId, queryByTestId } = render(
<ListControl
{...props}
field={field}
value={fromJS([{ string: 'item 1' }, { string: 'item 2' }])}
/>,
);
expect(queryByTestId('styled-list-item-top-bar-0')).toBeNull();
expect(queryByTestId('styled-list-item-top-bar-1')).toBeNull();
expect(queryByTestId('object-control-0')).toBeNull();
expect(queryByTestId('object-control-1')).toBeNull();
expect(asFragment()).toMatchSnapshot();
fireEvent.click(getByTestId('expand-button'));
expect(getByTestId('styled-list-item-top-bar-0')).toHaveAttribute('collapsed', 'true');
expect(getByTestId('styled-list-item-top-bar-1')).toHaveAttribute('collapsed', 'true');
expect(getByTestId('object-control-0')).toHaveAttribute('collapsed', 'true');
expect(getByTestId('object-control-1')).toHaveAttribute('collapsed', 'true');
});
it('should render list with fields with collapse = "false" and default minimize_collapsed = "true"', () => {
const field = fromJS({
name: 'list',
label: 'List',
collapsed: false,
minimize_collapsed: true,
fields: [{ label: 'String', name: 'string', widget: 'string' }],
});
const { asFragment, getByTestId, queryByTestId } = render(
<ListControl
{...props}
field={field}
value={fromJS([{ string: 'item 1' }, { string: 'item 2' }])}
/>,
);
expect(getByTestId('styled-list-item-top-bar-0')).toHaveAttribute('collapsed', 'false');
expect(getByTestId('styled-list-item-top-bar-1')).toHaveAttribute('collapsed', 'false');
expect(getByTestId('object-control-0')).toHaveAttribute('collapsed', 'false');
expect(getByTestId('object-control-1')).toHaveAttribute('collapsed', 'false');
expect(asFragment()).toMatchSnapshot();
fireEvent.click(getByTestId('expand-button'));
expect(queryByTestId('styled-list-item-top-bar-0')).toBeNull();
expect(queryByTestId('styled-list-item-top-bar-1')).toBeNull();
expect(queryByTestId('object-control-0')).toBeNull();
expect(queryByTestId('object-control-1')).toBeNull();
});
it('should add to list when add button is clicked', () => {
const field = fromJS({
name: 'list',
label: 'List',
fields: [{ label: 'String', name: 'string', widget: 'string' }],
});
const { asFragment, getByText, queryByTestId, rerender, getByTestId } = render(
<ListControl {...props} field={field} value={fromJS([])} />,
);
expect(queryByTestId('object-control-0')).toBeNull();
fireEvent.click(getByText('Add list'));
expect(props.onChange).toHaveBeenCalledTimes(1);
expect(props.onChange).toHaveBeenCalledWith(fromJS([{}]));
rerender(<ListControl {...props} field={field} value={fromJS([{}])} />);
expect(getByTestId('styled-list-item-top-bar-0')).toHaveAttribute('collapsed', 'false');
expect(getByTestId('object-control-0')).toHaveAttribute('collapsed', 'false');
expect(asFragment()).toMatchSnapshot();
});
it('should remove from list when remove button is clicked', () => {
const field = fromJS({
name: 'list',
label: 'List',
collapsed: false,
minimize_collapsed: true,
fields: [{ label: 'String', name: 'string', widget: 'string' }],
});
const { asFragment, getAllByText, rerender } = render(
<ListControl
{...props}
field={field}
value={fromJS([{ string: 'item 1' }, { string: 'item 2' }])}
/>,
);
expect(asFragment()).toMatchSnapshot();
let mock;
try {
mock = jest.spyOn(console, 'error').mockImplementation(() => undefined);
const items = getAllByText('Remove');
fireEvent.click(items[0]);
expect(props.onChange).toHaveBeenCalledTimes(1);
expect(props.onChange).toHaveBeenCalledWith(fromJS([{ string: 'item 2' }]), undefined);
rerender(<ListControl {...props} field={field} value={fromJS([{ string: 'item 2' }])} />);
expect(asFragment()).toMatchSnapshot();
} finally {
mock.mockRestore();
}
});
}); });

View File

@ -13,6 +13,7 @@ The list widget allows you to create a repeatable item in the UI which saves as
- `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) - `summary`: allows customization of a collapsed list item object in a similar way to a [collection summary](/docs/configuration-options/?#summary)
- `minimize_collapsed`: if added and labeled `true`, the list widget's content will be completely hidden instead of only collapsed if the list widget itself is collapsed
- `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):
@ -63,3 +64,13 @@ The list widget allows you to create a repeatable item in the UI which saves as
- {label: Quote, name: quote, widget: string, default: "Everything is awesome!"} - {label: Quote, name: quote, widget: string, default: "Everything is awesome!"}
- {label: Author, name: author, widget: string } - {label: Author, name: author, widget: string }
``` ```
- **Example** (`minimize_collapsed` marked `true`):
```yaml
- label: "Testimonials"
name: "testimonials"
minimize_collapsed: true
widget: "list"
fields:
- {label: Quote, name: quote, widget: string, default: "Everything is awesome!"}
- {label: Author, name: author, widget: string }
```