Gutenberg: How to: add "repeater" block pattern to docs

Created on 9 Jun 2018  路  9Comments  路  Source: WordPress/gutenberg

This is a proposed update to https://wordpress.org/gutenberg/handbook/block-api/attributes/

Description:

  • Make an example equivalent in behavior to "repeater field" in ACF plugin.
  • That is having aaa: { type: 'array', default: [{ content: 123 }, { content: 789 }] }, and generating multiple RichText for each array value:
attributes.aaa.map(function(a, index){
    return el( wp.editor.RichText, {
            tagName: 'div',
            format: 'string',
            value: a.content,
            onChange: function( content ) {
                a.content = content;
                props.setAttributes({ aaa: attributes.aaa });
            }
        },
        el('div', {
            className: 'remove-item',
            onClick: function(){
                var items = props.attributes.aaa;

                // I didn't figure out yet how to remove THIS ITEM when "this" is not set?

                props.setAttributes( { aaa: items } );
            },
        }, '+'),
    );
}),
el('div', {
    className: 'add-new-item',
    onClick: function(){
        var items = props.attributes.aaa;
        items.push({ aaa: 123 });
        props.setAttributes( { aaa: items } );
    },
}, '+'),

This is time-consuming to come up with and it's not obvious how to "remove this item". A copy-paste example for this common task would be useful.

This is an alternative to InnerBlocks that is better for many cases.

Most helpful comment

I closed this because you can code a fully functional repeater with existing functionality and I stopped seeing this as something worth for core.

It could be documented with copy/paste code though.

could you refer an example code or doc on how to create repeater blocks with existing functionality with Gutenberg blocks?

All 9 comments

@manake why'd you close this out of curiosity? This seems like a pretty huge lacking feature. Maybe a helper component would be good for Gutenberg.

I closed this because you can code a fully functional repeater with existing functionality and I stopped seeing this as something worth for core.

It could be documented with copy/paste code though.

I see, thanks. This is ugly, but it's what I'm using:

https://gist.github.com/braco/5c48ad162dcd27f072debc3967aee0b0

I closed this because you can code a fully functional repeater with existing functionality and I stopped seeing this as something worth for core.

It could be documented with copy/paste code though.

could you refer an example code or doc on how to create repeater blocks with existing functionality with Gutenberg blocks?

This would still be very needed in the core

I've made my own generic repeater control, it's sortable and has several options

With addText = '+'
Peek 2019-05-05 20-24

With empty addText and removeOnEmpty
Peek 2019-05-05 19-27

  • removeOnEmpty (bool): if the value of the row is empty the row is removed (default false)
  • addText (string): the text of the add a row button (leave empty to not have a '+' button, if the value of the last row is not empty, another one will be added, default '')
  • removeText (string): the text of the remove a row button (default '-')
  • max (number): the maximum number of rows that can be added
//npm packages (also requires babel + webpack compilation because it's es6 but no jsx compiler required)
import {SortableContainer, SortableElement} from 'react-sortable-hoc';
import cloneDeep from 'clone-deep';

let el = wp.element.createElement;
let c = wp.components;

Array.prototype.move = function (from, to) {
    this.splice(to, 0, this.splice(from, 1)[0]);
};

const countNonEmpty = function (object) {
    let c = 0;
    for (let key in object)
        if (object.hasOwnProperty(key) && ((typeof object[key] === 'string' && object[key].length) || typeof object[key] === 'number' || typeof object[key] === 'boolean'))
            c++;

    return c;
};

const repeaterData = (value, returnArray = false, removeEmpty = true) => {
    if (typeof value === 'string' && !returnArray)
        return value; //If it hasn't been rendered yet it's still a string
    else if (typeof value === 'string')
        return JSON.parse(value);

    value = cloneDeep(value);

    value = value.filterMap((v) => {
        delete v._key;
        if (!removeEmpty || countNonEmpty(v) !== 0) {
            return v;
        }
    });
    return returnArray ? value : JSON.stringify(value);
};

const SortableItem = SortableElement(({value, parentValue, index, onChangeChild, template, removeText, onRemove, addOnNonEmpty}) => {
    return el('div', {className: 'repeater-row-wrapper'}, [
        el('div', {className: 'repeater-row-inner'}, template(value, (v) => {
            onChangeChild(v, index)
        })),
        el('div', {className: 'button-wrapper'},
            addOnNonEmpty && index === parentValue.length - 1 ? null : el(c.Button, {
                    className: 'repeater-row-remove is-button is-default is-large',
                    onClick: () => {
                        onRemove(index)
                    }
                },
                removeText ? removeText : '-')
        )
    ])
});
const SortableList = SortableContainer(({items, id, template, onChangeChild, removeText, onRemove, addOnNonEmpty}) => {
    return el('div', {className: 'repeater-rows'}, items.map((value, index) => {
            return el(SortableItem, {
                key: id + '-repeater-item-' + value._key,
                index,
                value,
                parentValue: items,
                onChangeChild,
                template,
                removeText,
                onRemove,
                addOnNonEmpty
            })
        }
    ));
});
c.RepeaterControl = wp.compose.withInstanceId(function (_ref) {
    let value = [{}],
        max = _ref.max,
        addOnNonEmpty = !_ref.addText,
        removeOnEmpty = !!_ref.removeOnEmpty,
        instanceId = _ref.instanceId,
        id = "inspector-repeater-control-".concat(instanceId);
    if (typeof _ref.value === 'string') {
        try {
            const parsed = JSON.parse(_ref.value);
            value = Array.isArray(parsed) ? parsed : [];
        } catch (e) {
            value = [];
        }
    } else {
        value = cloneDeep(_ref.value); //Clone value else we would mutate the state directly
    }

    const onRemove = (i) => {
        if (value.length > 0) {
            value.splice(i, 1);
            if (value.length === 0) {
                onAdd();
            } else {
                onChangeValue(value);
            }
        }
    };
    let key = 0; //This is the key of each element, it must be unique
    value.map((v) => {
        if (typeof v._key === 'undefined')
            v._key = key++;
        else {
            key = v._key;
        }
    });

    const onAdd = () => {
        if (!max || value.length < max) {
            value.push({_key: ++key});
            onChangeValue(value);
        }
    };
    const onChangeValue = (v) => {
        return _ref.onChange(v);
    };
    const onChangeChild = (v, i) => {
        value[i] = v;
        if (i === value.length - 1) {
            if (addOnNonEmpty && countNonEmpty(v) > 1) {
                onAdd()
            } else if (removeOnEmpty && countNonEmpty(v) <= 1) {
                onRemove(i)
            } else {
                onChangeValue(value);
            }
        } else if (value.length > 1 && removeOnEmpty && countNonEmpty(v) <= 1) {
            onRemove(i)
        } else {
            onChangeValue(value);
        }
    };

    if (value.length === 0) {
        onAdd();
    } else {
        const last = value[value.length - 1];
        if (addOnNonEmpty && countNonEmpty(last) > 1) {
            onAdd()
        }
    }

    const onSortEnd = ({oldIndex, newIndex}) => {
        value.move(oldIndex, newIndex);
        onChangeValue(value);
    };

    return el(c.BaseControl, {
            label: _ref.label,
            id: id,
            help: _ref.help,
            className: _ref.className
        }, [
            el(SortableList, {
                key: id + '-sortable-list',
                id: id,
                items: value,
                lockAxis: 'y',
                helperContainer: function () {
                    //This is an awaiting PR in react-sortable-hoc, until implemented, jQuery has to do the job :(
                    return typeof this.container !== "undefined" ? this.container : jQuery(".edit-post-sidebar").get(0)
                },
                template: _ref.children,
                removeText: _ref.removeText,
                addOnNonEmpty,
                onRemove,
                onChangeChild,
                onSortEnd
            }),
            !addOnNonEmpty && (!max || value.length < max) ? el(c.Button, {
                    className: 'repeater-row-add is-button is-default is-large',
                    onClick: onAdd
                },
                _ref.addText ? _ref.addText : '+') : null
        ]
    );
});

It needs a bit of styling

.edit-post-settings-sidebar__panel-block .components-panel__body .components-base-control {
    width: 100%;
}
.edit-post-settings-sidebar__panel-block .repeater-row-wrapper {
    display: flex;
    flex-direction: row;
    align-items: stretch;
    border-bottom: 1px solid #e2e4e7;
    margin-bottom: 10px;
    padding-bottom: 5px;
    background: inherit;
}
.edit-post-settings-sidebar__panel-block .repeater-row-wrapper:last-child {
    border: none;
}
.edit-post-settings-sidebar__panel-block .components-panel__body .repeater-row-wrapper .components-base-control {
    margin: 0 0 .5em;
}
.edit-post-settings-sidebar__panel-block .repeater-row-inner {
    flex: 1;
}
.edit-post-settings-sidebar__panel-block .repeater-row-wrapper .button-wrapper {
    margin-top: 23px;
    margin-bottom: 7px;
}
.edit-post-settings-sidebar__panel-block .repeater-row-wrapper .button-wrapper button {
    margin-left: 10px;
    display: block;
}

.edit-post-settings-sidebar__panel-block .repeater-row-wrapper .button-wrapper button.is-large {
    height: 100%;
}
.edit-post-settings-sidebar__panel-block .repeater-row-add.is-large {
    width: 100%;
    display: inline-block;
}

Basic Usage

const attributes = {
    my_repeater: {
        type: 'string|array', // It's a string when persisted but when working on gutenberg it's an array
        source: 'attribute',
        selector: 'select',
        attribute: 'my_repeater',
        default: []
    }
};
const edit = (props) => {
    return <RepeaterControl max={5} value={props.attributes.my_repeater} onChange={(val) => {
        props.setAttributes({my_repeater: val});
    }}>
        {
            //Since this is a template, the content of the repeater MUST be a function, the first parameter is the value of the Repeater row and the second is a callback to call when the value of the row is changed
            (value, onChange) => {
                return [
                   // Don't worry about directly modifying the value, it's sent cloned to avoid mutating the state
                    <TextControl label="Key" value={value.my_key} onChange={(v) => {
                        value.my_key = v;
                        onChange(value)
                    }}/>,
                    <TextControl label="Value" value={value.my_val} onChange={(v) => {
                        value.my_val = v;
                        onChange(value)
                    }}/>
                ]
            }
        }
    </RepeaterControl>
};
const save = (props) => {
    const arData = repeaterData(props.attributes.my_repeater, true);
    return <select my_repeater={JSON.stringify(arData)}>
        {
            arData.map((v) => {
                return <option value={v.my_key}>{v.my_val}</option>
            })
        }
    </select>
};

It should in theorie work with nested RepeaterControl as well, but I haven't tested this use case yet

I'll do an npm or composer package for easier use soon

It looks great, but I can't get it to work. Can you help me with a full working example?
w variable and countNonEmpty function are not defined

I indeed forgot to include the function, I fixed the provided code

Thanks Adrien! It works like a charm now.
It's exactly the component I needed

Was this page helpful?
0 / 5 - 0 ratings

Related issues

cr101 picture cr101  路  3Comments

pfefferle picture pfefferle  路  3Comments

mhenrylucero picture mhenrylucero  路  3Comments

spocke picture spocke  路  3Comments

davidsword picture davidsword  路  3Comments