0

I am trying to extend the core/table block and copy-pasted, trail and errored me into something that is close to what I am trying to do. But I it's not quite working as I like.

I like to extend the Block to have toggle elements that control the classes on the <table> the block already does this for two base classes but I want it to work with Bootstrap CSS tables.

The original Block code can be found here.

What I like to do is something Gutenberg also does great and for the table block is does so for the style. When you set it to 'Striped' it will add a is-style-striped to the block. It will live update the field and as soon as you manually remove the class it will automatically switch the style to 'Default'. I like to link a few class toggles to a text field like that. I searched a little in GB code but could not find where or how it's done. The buildTableClasses function that uses classnames/dedupe could be a way but there are a few issues here.

                        <ToggleControl
                            label={ __( 'Hover' ) }
                            checked={ !! tableHover }
                            onChange={ () => props.setAttributes( {
                                tableHover: ! tableHover,
                                tableClass: buildTableClasses( props.attributes ),
                            } ) }
                        />
  1. I do not really understand the negation and double negation used here. This code is based off some tutorial I found. Currently, is behaves weird. Like inverted, classes get added when the toggles are off, but also when toggling another toggle that last class sticks, some weird. I experimented with it but it broke completely, so I posted it this way.
  2. The background class for the table that the block already has originally does not end up in the 'table classes' text control field.
  3. When I remove a class manually from the field the changes are not bound to the toggles. So I like to find a way to make this work. I could write a function for it but I like to know if there is maybe a better way to do all this. Probably better to reuse some GB core code or model the code after it. If you can point be into the right direction that would be great.
  4. I like to know how I can extend an already existing section, like adding new controls to it. Or removing a section like the style section that makes. Because Bootstrap has so many classes that control style that can be combined the combination of styles would be huge so class toggles is probably a better way to do it.
  5. During playing around with this I noticed that it sometimes broke the block and GB seems to do some checks of the output and was actually unable to restore the block. So I wonder if I should rather fork the block but it would be annoying to maintain. But if an extended block breaks users blocks when the filters come in or get removed later breaks this solution it not great.

The complete code I have currently. Ready to try plugin on Github. The save function is copy-pasted from GB and modified for the table classes.

import classnames from 'classnames/dedupe';

const wp = window.wp;
const { __ } = wp.i18n;
const { addFilter } = wp.hooks;
const { assign } = window.lodash;
const {
    createHigherOrderComponent,
} = wp.compose;

const {
    Fragment,
} = wp.element;

const {
    RichText,
    InspectorControls,
    getColorClassName,
} = wp.editor;

const {
    PanelBody,
    TextControl,
    ToggleControl,
} = wp.components;

const filterBlocks = ( settings ) => {
    console.log( settings );

    if ( settings.name !== 'core/table' ) {
        return settings;
    }

    const newSettings = {
        ...settings,
        attributes: {
            ...settings.attributes, // spread in old attributes so we don't lose them!
            tableClass: { // here is our new attribute
                type: 'string',
                default: 'table ',
            },
            tableBordered: { // here is our new attribute
                type: 'boolean',
                default: false,
            },
            tableStriped: { // here is our new attribute
                type: 'boolean',
                default: false,
            },
            tableHover: { // here is our new attribute
                type: 'boolean',
                default: true,
            },
        },
        save( { attributes } ) {
            const {
                hasFixedLayout,
                head,
                body,
                foot,
                backgroundColor,
                caption,
                tableBordered,
                tableStriped,
                tableHover,
                tableClass,
            } = attributes;
            const isEmpty = ! head.length && ! body.length && ! foot.length;

            if ( isEmpty ) {
                return null;
            }

            const classes = buildTableClasses( attributes );

            const hasCaption = ! RichText.isEmpty( caption );

            const Section = ( { type, rows } ) => {
                if ( ! rows.length ) {
                    return null;
                }

                const Tag = `t${ type }`;

                return (
                    <Tag>
                        { rows.map( ( { cells }, rowIndex ) => (
                            <tr key={ rowIndex }>
                                { cells.map(
                                    ( { content, tag, scope, align }, cellIndex ) => {
                                        const cellClasses = classnames( {
                                            [ `has-text-align-${ align }` ]: align,
                                        } );

                                        return (
                                            <RichText.Content
                                                className={
                                                    cellClasses ?
                                                        cellClasses :
                                                        undefined
                                                }
                                                data-align={ align }
                                                tagName={ tag }
                                                value={ content }
                                                key={ cellIndex }
                                                scope={
                                                    tag === 'th' ? scope : undefined
                                                }
                                            />
                                        );
                                    }
                                ) }
                            </tr>
                        ) ) }
                    </Tag>
                );
            };

            return (
                <figure>
                    <table className={ classes === '' ? undefined : classes }>
                        <Section type="head" rows={ head } />
                        <Section type="body" rows={ body } />
                        <Section type="foot" rows={ foot } />
                    </table>
                    { hasCaption && (
                        <RichText.Content tagName="figcaption" value={ caption } />
                    ) }
                </figure>
            );
        },
    };

    return newSettings;
};

addFilter(
    'blocks.registerBlockType',
    'example/filter-blocks',
    filterBlocks
);

function buildTableClasses( attributes ) {
    const {
        hasFixedLayout,
        backgroundClass,
        tableStriped,
        tableBordered,
        tableHover,
        tableClass,
    } = attributes;

    const classes = classnames(
        tableClass.split( ' ' ),
        backgroundClass,
        {
            table: true,
            'has-fixed-layout': hasFixedLayout,
            'has-background': !! backgroundClass,
            'table-bordered': tableBordered,
            'table-striped': tableStriped,
            'table-hover': tableHover,
        }
    );

    return classes;
}

const tableClassControl = createHigherOrderComponent( ( BlockEdit ) => {
    return ( props ) => {
        if ( 'core/table' !== props.name ) {
            return (
                <BlockEdit { ...props } />
            );
        }

        const {
            tableStriped,
            tableBordered,
            tableHover,
            tableClass,
        } = props.attributes;

        return (
            <Fragment>
                <BlockEdit { ...props } />
                <InspectorControls>
                    <PanelBody
                        title={ __( 'Table classes' ) }
                        initialOpen={ true }
                    >
                        <ToggleControl
                            label={ __( 'Striped' ) }
                            checked={ !! tableStriped }
                            onChange={ () => props.setAttributes( {
                                tableStriped: ! tableStriped,
                                tableClass: buildTableClasses( props.attributes ),
                            } ) }
                        />
                        <ToggleControl
                            label={ __( 'Bordered' ) }
                            checked={ !! tableBordered }
                            onChange={ () => props.setAttributes( {
                                tableBordered: ! tableBordered,
                                tableClass: buildTableClasses( props.attributes ),
                            } ) }
                        />
                        <ToggleControl
                            label={ __( 'Hover' ) }
                            checked={ !! tableHover }
                            onChange={ () => props.setAttributes( {
                                tableHover: ! tableHover,
                                tableClass: buildTableClasses( props.attributes ),
                            } ) }
                        />
                        <TextControl
                            label={ __( '<table> classes' ) }
                            type="text"
                            value={ tableClass }
                            onChange={ ( value ) =>
                                props.setAttributes( { tableClass: value } )
                            }
                        />
                    </PanelBody>
                </InspectorControls>
            </Fragment>
        );
    };
}, 'tableClassControl' );

addFilter( 'editor.BlockEdit', 'extend-block-example/with-spacing-control', tableClassControl );
2
  • For item 1, double negation is a way of casting a value to a boolean stackoverflow.com/questions/10467475/…, otherwise your code is interesting, I haven't seen someone try too add brand new functionality for styling via filters. Normally a new block or adding block styles s recommended
    – Tom J Nowell
    Commented Jul 8, 2020 at 14:15
  • Well "recommended" really sucks for this case. I could extend the bootstrap css and work with styles that switch based on the wrapper block class. But the 3 classes I started up with would already result in 10 different style combinations. And there are actually even more classes like table-sm so it would really make things annoying. Not sure if replacing the style image based selector with a dropdown select field is possible but it would only work with custom compiled bootsrap css with a lot of scss @extend Commented Jul 12, 2020 at 2:38

1 Answer 1

5

The double NOT operator (!!)

It's simply a way to convert/type-cast a non-boolean value to a boolean value, and !! <expression> gives us the opposite of ! <expression>.

let foo = 'bar'; // non-empty string
console.log( ! foo, !! foo ); // false, true

foo = ''; // now it's an empty string
console.log( ! foo, !! foo ); // true, false

Issues in your code

  1. Despite that wp.editor works, it's been deprecated, so you should use wp.blockEditor instead:

    const {
        RichText,
        InspectorControls,
        getColorClassName
    } = wp.blockEditor; // not wp.editor
    
  2. In buildTableClasses(), backgroundClass is not an attribute in the block. So that (the first one) should be backgroundColor and define backgroundClass like so:

    const backgroundClass = getColorClassName(
        'background-color',
        backgroundColor
    );
    
  3. The buildTableClasses() should only be used with the save function, because from the edit function you'd get the "inverted" issue because the function would receive the old attributes.

  4. And I would use a dedicated function to update the "<table> classes" field when a toggle control is updated and only add/remove the associated class with that toggle, e.g. table-hover for the "Hover" toggle:

    function onChangeTableHover() {
        props.setAttributes( {
            // Toggle the state.
            tableHover: ! tableHover,
    
            // Then add/remove only the table-hover class.
            tableClass: classnames( tableClass, {
                'table-hover': ! tableHover,
            } ),
        } );
    }
    
    /* Then in the JSX:
    <ToggleControl
        label={ __( 'Hover' ) }
        checked={ !! tableHover }
        onChange={ onChangeTableHover }
    />
    */
    

    You might be thinking about the background color classes, but they should be controlled (added/removed) by the color picker toggles (in the "Color settings" panel/section).

  5. When the "<table> classes" field is updated, e.g. you typed table-hover in there or you removed it, you should change the toggle states:

    function onChangeTableClass( value ) {
        // User likely typed a whitespace.
        if ( tableClass === value.trim() ) {
            props.setAttributes( { tableClass: value } );
            return;
        }
    
        const list = value.split( / +/ );
    
        props.setAttributes( {
            // Update the value.
            tableClass: classnames( value ),
    
            // Then the toggles.
            tableHover: list.indexOf( 'table-hover' ) >= 0,
            tableBordered: list.indexOf( 'table-bordered' ) >= 0,
            tableStriped: list.indexOf( 'table-striped' ) >= 0,
        } );
    }
    
    /* Then in the JSX:
    <TextControl
        label={ __( '<table> classes' ) }
        type="text"
        value={ tableClass }
        onChange={ onChangeTableClass }
    />
    */
    
  6. The default value for the tableClass attribute should be table table-hover because the default tableHover state is true.

  7. (Just a suggestion) I think you should use attribute as the source for the tableClass attribute.

Block Validation

Yes, Gutenberg does validate the block's output:

During editor initialization, the saved markup for each block is regenerated using the attributes that were parsed from the post’s content. If the newly-generated markup does not match what was already stored in post content, the block is marked as invalid. This is because we assume that unless the user makes edits, the markup should remain identical to the saved content.

So if the saved markup is <p class="foo">bar baz</p>, but the newly-generated markup is <p class="foo new-class">bar baz</p>, then you'd get an error.

Therefore, because you're changing the output, then you'd better off copy the block, customize it and register it as a new block…

  • Creating a new block might not be as easy as simply extending the existing block, but a new block is better than having to deal with block validation errors later on, e.g. after your plugin/theme is deactivated.

If you'd rather simply extend the core table block

Then you would want to copy the original edit component, edit the code, and use it as the edit function for the block, similar to the way you did it with the save function.

Why so is because you would want to update the "<table> classes" field whenever a background color is selected/deselected, which you'd need to modify the PanelColorSettings element.

Secondly, copying also allows you to remove/reorder existing sections or add new controls to the sections.

Try my script

You can find it on GitHub (source | build), and I built it using the wp-scripts package.


Update: About the tableHover: ! tableHover or <attribute>: ! <attribute>

It's equivalent to what the fourth line below does in PHP:

<?php
$attrs = [ 'tableHover' => false ];
$tableHover = $attrs['tableHover'];            // get the current value
$attrs['tableHover'] = ! $tableHover;          // toggle/change the value
var_dump( $tableHover, $attrs['tableHover'] ); // bool(false) bool(true)

So you asked (in the comment):

I am still confused about tableHover: ! tableHover on onChange. ... why is the attribute negated when setting the attribute?

And it's because the attribute is of the boolean type, so the value should only be either true or false.

So if the current value in props.attributes is true, then the tableHover: ! tableHover toggles/changes the value to false.

// The currently saved block attributes.
const { tableHover } = props.attributes;

// When updating the tableHover, we could simply do:
props.setAttributes( { tableHover: ! tableHover } );
// .. which is equivalent to:
props.setAttributes( { tableHover: tableHover ? false : true } );

Or in an onChange callback, you can use the current state of the toggle control element that's passed as the first parameter to the callback.

<ToggleControl
    checked={ !! tableHover }
    onChange={ ( checked ) => props.setAttributes( { tableHover: checked } ) }
/>

And that's more understandable, I guess? :)

But the tableHover: ! tableHover is a simpler version without having to use the first onChange parameter.

So just use any methods you prefer, but make sure to set the correct value, e.g. if the toggle control element is checked, set the tableHover to true.

And btw, you can also use !! in PHP in place of (bool):

<?php
$foo = 'bar';
var_dump( (bool) $foo, !! $foo ); // bool(true) bool(true)
5
  • Thanks. Damn should you not have been awarded the bounty automatically? Commented Jul 12, 2020 at 2:07
  • I am still confused about tableHover: ! tableHover on onChange. I get that !! is like casting to bool in php (bool) "something" js !! "something" both true. However, why is the attribute negated when setting the attribute? Commented Jul 12, 2020 at 2:14
  • Thanks for finishing my code for me. I basically knew I had to do something like your onChangeTableClass I would not come up with list.indexOf( 'table-hover' ) >= 0, naturally because I am still learning js. I just wondered how Gutenberg itself does it for the custom classes field and block styles. Commented Jul 12, 2020 at 2:29
  • Hey there. I'm not sure how should I respond to the 3rd comment, or if I should even respond.. but for the 2nd comment, see the "Update" part in the answer. (I hope my explanation didn't make you even confused...)
    – Sally CJ
    Commented Jul 12, 2020 at 10:22
  • And about the bounty, it's alright, I just wanted to help you fix the issues in your code and for the rest in my answer, I was only hoping that you'd find them useful. :) But just so you know, bounties are awarded automatically only if the question author accepted one's answer, or that the answer received two or more vote ups.
    – Sally CJ
    Commented Jul 12, 2020 at 10:36

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.