Skip to content

Commit

Permalink
feat(widget-list): add hiding list content with minimize_collapsed op…
Browse files Browse the repository at this point in the history
…tion (#3607)
  • Loading branch information
hanneskuettner committed May 17, 2020
1 parent 088b1a8 commit 4dd58c5
Show file tree
Hide file tree
Showing 4 changed files with 1,930 additions and 38 deletions.
93 changes: 64 additions & 29 deletions packages/netlify-cms-widget-list/src/ListControl.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,15 @@ export default class ListControl extends React.Component {
constructor(props) {
super(props);
const { field, value } = props;
const allItemsCollapsed = field.get('collapsed', true);
const itemsCollapsed = value && Array(value.size).fill(allItemsCollapsed);
const keys = value && Array.from({ length: value.size }, () => uuid());
const listCollapsed = field.get('collapsed', true);
const itemsCollapsed = (value && Array(value.size).fill(listCollapsed)) || [];
const keys = (value && Array.from({ length: value.size }, () => uuid())) || [];

this.state = {
itemsCollapsed: List(itemsCollapsed),
listCollapsed,
itemsCollapsed,
value: valueToString(value),
keys: List(keys),
keys,
};
}

Expand Down Expand Up @@ -184,8 +185,8 @@ export default class ListControl extends React.Component {
? this.singleDefault()
: fromJS(this.multipleDefault(field.get('fields')));
this.setState({
itemsCollapsed: this.state.itemsCollapsed.push(false),
keys: this.state.keys.push(uuid()),
itemsCollapsed: [...this.state.itemsCollapsed, false],
keys: [...this.state.keys, uuid()],
});
onChange((value || List()).push(parsedValue));
};
Expand All @@ -202,8 +203,8 @@ export default class ListControl extends React.Component {
const { value, onChange } = this.props;
const parsedValue = fromJS(this.mixedDefault(typeKey, type));
this.setState({
itemsCollapsed: this.state.itemsCollapsed.push(false),
keys: this.state.keys.push(uuid()),
itemsCollapsed: [...this.state.itemsCollapsed, false],
keys: [...this.state.keys, uuid()],
});
onChange((value || List()).push(parsedValue));
};
Expand Down Expand Up @@ -300,7 +301,10 @@ export default class ListControl extends React.Component {
? { [collectionName]: metadata.removeIn(metadataRemovePath) }
: 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);
clearFieldErrors();
Expand All @@ -314,16 +318,35 @@ export default class ListControl extends React.Component {
handleItemCollapseToggle = (index, event) => {
event.preventDefault();
const { itemsCollapsed } = this.state;
const collapsed = itemsCollapsed.get(index);
this.setState({ itemsCollapsed: itemsCollapsed.set(index, !collapsed) });
const newItemsCollapsed = itemsCollapsed.map((collapsed, itemIndex) => {
if (index === itemIndex) {
return !collapsed;
}
return collapsed;
});
this.setState({
itemsCollapsed: newItemsCollapsed,
});
};

handleCollapseAllToggle = e => {
e.preventDefault();
const { value } = this.props;
const { itemsCollapsed } = this.state;
const { value, field } = this.props;
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);
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) {
Expand Down Expand Up @@ -368,11 +391,18 @@ export default class ListControl extends React.Component {
this.props.onChange(newValue);

// Update collapsing
const collapsed = itemsCollapsed.get(oldIndex);
const updatedItemsCollapsed = itemsCollapsed.delete(oldIndex).insert(newIndex, collapsed);
const collapsed = itemsCollapsed[oldIndex];
itemsCollapsed.splice(oldIndex, 1);
const updatedItemsCollapsed = [...itemsCollapsed];
updatedItemsCollapsed.splice(newIndex, 0, collapsed);

// 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 });

//clear error fields and remove old validations
Expand All @@ -394,8 +424,8 @@ export default class ListControl extends React.Component {
} = this.props;

const { itemsCollapsed, keys } = this.state;
const collapsed = itemsCollapsed.get(index);
const key = keys.get(index);
const collapsed = itemsCollapsed[index];
const key = keys[index];
let field = this.props.field;

if (this.getValueType() === valueTypes.MIXED) {
Expand Down Expand Up @@ -473,11 +503,14 @@ export default class ListControl extends React.Component {

renderListControl() {
const { value, forID, field, classNameWrapper } = this.props;
const { itemsCollapsed } = this.state;
const { itemsCollapsed, listCollapsed } = this.state;
const items = value || List();
const label = 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 minimizeCollapsedItems = field.get('minimize_collapsed', false);
const allItemsCollapsed = itemsCollapsed.every(val => val === true);
const selfCollapsed = allItemsCollapsed && (listCollapsed || !minimizeCollapsedItems);

return (
<ClassNames>
Expand All @@ -499,15 +532,17 @@ export default class ListControl extends React.Component {
heading={`${items.size} ${listLabel}`}
label={labelSingular.toLowerCase()}
onCollapseToggle={this.handleCollapseAllToggle}
collapsed={itemsCollapsed.every(val => val === true)}
/>
<SortableList
items={items}
renderItem={this.renderItem}
onSortEnd={this.onSortEnd}
useDragHandle
lockAxis="y"
collapsed={selfCollapsed}
/>
{(!selfCollapsed || !minimizeCollapsedItems) && (
<SortableList
items={items}
renderItem={this.renderItem}
onSortEnd={this.onSortEnd}
useDragHandle
lockAxis="y"
/>
)}
</div>
)}
</ClassNames>
Expand Down
174 changes: 174 additions & 0 deletions packages/netlify-cms-widget-list/src/__tests__/ListControl.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jest.mock('netlify-cms-ui-default', () => {
const actual = jest.requireActual('netlify-cms-ui-default');
const ListItemTopBar = props => (
<mock-list-item-top-bar {...props} onClick={props.onCollapseToggle}>
<button onClick={props.onRemove}>Remove</button>
{props.children}
</mock-list-item-top-bar>
);
Expand Down Expand Up @@ -454,4 +455,177 @@ describe('ListControl', () => {
);
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();
}
});
});

0 comments on commit 4dd58c5

Please sign in to comment.