WordPress / gutenberg

The Block Editor project for WordPress and beyond. Plugin is available from the official repository.
https://wordpress.org/gutenberg/
Other
10.46k stars 4.18k forks source link

How to: add "repeater" block pattern to docs #7239

Closed manake closed 6 years ago

manake commented 6 years ago

(Post removed. Mistake. Solution below.)

braco commented 6 years ago

@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.

manake commented 6 years ago

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.

braco commented 6 years ago

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

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

SL0TR commented 5 years ago

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?

Tofandel commented 5 years ago

This would still be very needed in the core

Tofandel commented 5 years ago

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

//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

michelegiorgi commented 5 years ago

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

Tofandel commented 5 years ago

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

michelegiorgi commented 5 years ago

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