WP Page Builder From Scratch #3: Data Structure and Saving Page Builder Data

This is Part #3 of Custom Page Builder Tutorials Series. Read the intro here.

In earlier tutorial, I cover all about the page builder design, and how to make all the control works (create, delete, and reorder rows).

In this post I want to explain how I structure the data and save it as custom fields. Specially in how to update the row order number so we can properly save each rows data.

page-builder-ss-order

Download example plugin to follow tutorial easier:

Data Structure

Let’s make this very simple. We are going to save all this data as single post meta (custom field) with meta key “fxpb“. And we are going to save page builder data as multidimensional array.

Here’s the example of the data structure:

$page_builder_data = array(
    '1' => array(
        'type' => 'col-1',
        'content' => 'Lorem Ipsum...',
    ),
    '2' => array(
        'type' => 'col-2',
        'content-1' => '1st Col Content...',
        'content-2' => '2nd Col Content, Lorem Ipsum...',
    ),
    '3' => array(
        'type' => 'col-1',
        'content' => 'Another Lorem Ipsum...',
    ),
);

And here’s the screenshot of the page builder with the above data:

page-builder-data-ss

So, the simple explanation:

  • We stored the data in post meta with meta key “fxpb“.
  • We save each row data as array with the keys (“1″,”2″,”3”) as the order of the row/row position. (1st level of the array)
  • each or rows are array containing each rows data such as rows type and content. (2nd level of the array)

Side Note:
1) It’s probably best to save the data using post meta key starting with underscore. e.g “_fxpb”, so user will not edit it.
2) You can store the data in various data structure, such as saving each rows in different custom fields for extend-ability, etc.

To save this page builder data, each field need to have a correct “name” attributes.

What we have now is each field have no input “name”. And we need to add proper input name in each fields.

To make it easier to understand, here’s the same screenshot with all input needed:

page-builder-data-with-input-overlay

So, each input have the same pattern:

fxpb[num][field]
  • fxpb as (base) field name.
  • [num] as the order of the row (1st level of the array)
  • [field] is the data identifier for the input. in each field we have “data-field” attribute with the field id. (2nd level of the array)

The problem is how to dynamically change these input name attributes when we:

  • add new row,
  • reorder the rows, and
  • delete a row.

Add Order Number to each Row

To make this easier to debug, let’s add a number in each row title:

<div class="fxpb-row-title">
    <span class="fxpb-handle dashicons dashicons-sort"></span>
    <span class="fxpb-order">0</span>
    <span class="fxpb-row-title-text">2 Columns</span>
    <span class="fxpb-remove dashicons dashicons-trash"></span>
</div><!-- .fxpb-row-title -->

So, in each template I added this .fxpb-order. And with a little CSS, here’s the screenshot:

page-builder-order-zero

Now back to JavaScript
Let’s create a function to change the value of the .fxpb-order:

/* Function: Update Order */
function fxPB_UpdateOrder(){

    /* In each of rows */
    $('.fxpb-rows > .fxpb-row').each( function(i){

        /* Increase index by 1 to avoid "0" as first number. */
        var num = i + 1;

        /* Update order number in row title */
        $( this ).find( '.fxpb-order' ).text( num );
    });
}

So, it’s pretty straight forward. jQuery .each() function has index variable we can use. this index (i) is started from 0 (zero), that’s why I add +1 to it to avoid zero index.

And with text() function, we change the .fxpb-order element content with the order/index number.

Note:
The order number in the title is not really needed. But it’s easier to understand when we have some visual to see.

The next step is to use this function when ever we do some action to the page builder (please check the JavaScript from earlier tutorial).

Add new row:

/* Add Row */
$( 'body' ).on( 'click', '.fxpb-add-row', function(e){
    e.preventDefault();
    var template = '.fxpb-templates > .fxpb-' + $( this ).attr( 'data-template' );
    $( template ).clone().appendTo( '.fxpb-rows' );
    $( '.fxpb-rows-message' ).hide();

    /* Update Order */
    fxPB_UpdateOrder();
});

Delete a row:

/* Delete Row */
$( 'body' ).on( 'click', '.fxpb-remove', function(e){
    e.preventDefault();
    $( this ).parents( '.fxpb-row' ).remove();
    if( ! $( '.fxpb-rows > .fxpb-row' ).length ){
        $( '.fxpb-rows-message' ).show();
    }

    /* Update Order */
    fxPB_UpdateOrder();
});

When we re-order the rows:

/* Make Row Sortable */
$( '.fxpb-rows' ).sortable({
    handle: '.fxpb-handle',
    cursor: 'grabbing',
    stop: function( e, ui ) {
        fxPB_UpdateOrder();
    },
});

In the code above, I use stop() event from jQuery Sortable. jQuery Sortable have several event we can use such as stop() and update().

Now we have this:

Now we have the number in each title updated with the correct order of the row.

Update “name” Attributes Of Each Fields

The next step is to use the fxPB_UpdateOrder() function in our JavaScript above to update each field name in each rows using the pattern we discuss in previous section.

fxpb[num][field]

In Part #2 tutorial, we add the [field] part of the pattern in the input/fields as “data-field” attribute, so this is what we want to do:

data-field-to-name

The updated JavaScript function:

/* Function: Update Order */
function fxPB_UpdateOrder(){

    /* In each of rows */
    $('.fxpb-rows > .fxpb-row').each( function(i){

        /* Increase num by 1 to avoid "0" as first index. */
        var num = i + 1;

        /* Update order number in row title */
        $( this ).find( '.fxpb-order' ).text( num );

        /* In each input in the row */
        $( this ).find( '.fxpb-row-input' ).each( function(i) {

            /* Get field id for this input */
            var field = $( this ).attr( 'data-field' );

            /* Update name attribute with order and field name.  */
            $( this ).attr( 'name', 'fxpb[' + num + '][' + field + ']');
        });
    });
}

Very easy right ! Now, if you check the fields in using Firebug/inspect element, you will see all the input name updated when we add new row, edit, or reorder.

Awesome! all good.

The next step is to save the data 🙂

Save Page Builder Data

If you familiar with Meta Box API, you’re gonna feel like home 🙂

Adding the Nonce to Page Builder

Just add it anywhere in fx_pbbase_form_callback() function. Example:

<?php wp_nonce_field( "fxpb_nonce_action", "fxpb_nonce" ) ?>

Now we are ready to save the data.

Save the data

Because we have page builder ready with all the field using correct attribute, the saving of the data is simple:

/* Save post meta on the 'save_post' hook. */
add_action( 'save_post', 'fx_pbbase_save_post', 10, 2 );

/**
 * Save Page Builder Data When Saving Page
 * @since 1.0.0
 */
function fx_pbbase_save_post( $post_id, $post ){

    /* Stripslashes Submitted Data */
    $request = stripslashes_deep( $_POST );

    /* Verify/validate */
    if ( ! isset( $request['fxpb_nonce'] ) || ! wp_verify_nonce( $request['fxpb_nonce'], 'fxpb_nonce_action' ) ){
        return $post_id;
    }
    /* Do not save on autosave */
    if ( defined('DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return $post_id;
    }
    /* Check post type and user caps. */
    $post_type = get_post_type_object( $post->post_type );
    if ( 'page' != $post->post_type || !current_user_can( $post_type->cap->edit_post, $post_id ) ){
        return $post_id;
    }

    /* == Save, Delete, or Update Page Builder Data == */

    /* Get (old) saved page builder data */
    $saved_data = get_post_meta( $post_id, 'fxpb', true );

    /* Get new submitted data and sanitize it. */
    $submitted_data = isset( $request['fxpb'] ) ? fxpb_sanitize( $request['fxpb'] ) : null;

    /* New data submitted, No previous data, create it  */
    if ( $submitted_data && '' == $saved_data ){
        add_post_meta( $post_id, 'fxpb', $submitted_data, true );
    }
    /* New data submitted, but it's different data than previously stored data, update it */
    elseif( $submitted_data && ( $submitted_data != $saved_data ) ){
        update_post_meta( $post_id, 'fxpb', $submitted_data );
    }
    /* New data submitted is empty, but there's old data available, delete it. */
    elseif ( empty( $submitted_data ) && $saved_data ){
        delete_post_meta( $post_id, 'fxpb' );
    }
}

In the function above we have fxpb_sanitize() function.

/* Get new submitted data and sanitize it. */
$submitted_data = isset( $request['fxpb'] ) ? fxpb_sanitize( $request['fxpb'] ) : null;

and this will be use to sanitize submitted data before saving it as post meta/database:

/**
 * Sanitize Page Builder Data
 * @since 1.0.0
 */
function fxpb_sanitize( $input ){

    /* If data is not array, return. */
    if( !is_array( $input ) ){
        return null;
    }

    /* Output var */
    $output = array();

    /* Loop the data submitted */
    foreach( $input as $row_order => $row_data ){

        /* Only if row type is set */
        if( isset( $row_data['type'] ) && $row_data['type'] ){

            /* Get type of row ("col-1" or "col-2") */
            $row_type = esc_attr( $row_data['type'] );

            /* Row with 1 Column */
            if( 'col-1' == $row_type ){

                /* Sanitize value for "content" field. */
                $output[$row_order]['content'] = wp_kses_post( $row_data['content'] );
                $output[$row_order]['type'] = $row_type;
            }

            /* Row with 2 Columns */
            elseif( 'col-2' == $row_type ){

                /* Sanitize value for "content-1" and "content-2" field */
                $output[$row_order]['content-1'] = wp_kses_post( $row_data['content-1'] );
                $output[$row_order]['content-2'] = wp_kses_post( $row_data['content-1'] );
                $output[$row_order]['type'] = $row_type;
            }
        }
    }

    return $output;
}

The code above basically looping all submitted data and re-check and sanitize each field/input individually.

After you save the page, page builder area will be empty, not because it’s not saved properly, but because we didn’t output the default/stored page builder data as rows/fields yet.

So, next step is back to fx_pbbase_form_callback() function and to show saved rows in the manage rows element:

<div class="fxpb-rows">
    <p class="fxpb-rows-message">This is where we manage rows.</p>
    <?php /* We will display saved page builder data here. */ ?>
</div><!-- .fxpb-rows -->

To make it cleaner, I create a new function to render the saved page builder data so we can display the rows, and we will also move the default message in that function.

<div class="fxpb-rows">
    <?php fxpb_render_rows( $post ); // display saved rows ?>
</div><!-- .fxpb-rows -->

And here’s the function:

/**
 * Render Saved Rows
 * @since 1.0.0
 */
function fxpb_render_rows( $post ){

    /* Get saved rows data and sanitize it */
    $row_datas = fxpb_sanitize( get_post_meta( $post->ID, 'fxpb', true ) );

    /* Default Message */
    $default_message = 'Please add row to start!';

    /* return if no rows data */
    if( !$row_datas ){
        echo '<p class="fxpb-rows-message">' . $default_message . '</p>';
        return;
    }
    /* Data available, hide default notice */
    else{
        echo '<p class="fxpb-rows-message" style="display:none;">' . $default_message . '</p>';
    }

    /* Loop for each rows */
    foreach( $row_datas as $order => $row_data ){
        $order = intval( $order );

        /* === Row with 1 column === */
        if( 'col-1' == $row_data['type'] ){
            ?>
            <div class="fxpb-row fxpb-col-1">

                <div class="fxpb-row-title">
                    <span class="fxpb-handle dashicons dashicons-sort"></span>
                    <span class="fxpb-order"><?php echo $order; ?></span>
                    <span class="fxpb-row-title-text">1 Column</span>
                    <span class="fxpb-remove dashicons dashicons-trash"></span>
                </div><!-- .fxpb-row-title -->

                <div class="fxpb-row-fields">
                    <textarea class="fxpb-row-input" name="fxpb[<?php echo $order; ?>][content]" data-field="content" placeholder="Add HTML here..."><?php echo esc_textarea( $row_data['content'] ); ?></textarea>
                    <input class="fxpb-row-input" type="hidden" name="fxpb[<?php echo $order; ?>][type]" data-field="type" value="col-1">
                </div><!-- .fxpb-row-fields -->

            </div><!-- .fxpb-row.fxpb-col-1 -->
            <?php
        }
        /* === Row with 2 columns === */
        elseif( 'col-2' == $row_data['type'] ){
            ?>
            <div class="fxpb-row fxpb-col-2">

                <div class="fxpb-row-title">
                    <span class="fxpb-handle dashicons dashicons-sort"></span>
                    <span class="fxpb-order"><?php echo $order; ?></span>
                    <span class="fxpb-row-title-text">2 Columns</span>
                    <span class="fxpb-remove dashicons dashicons-trash"></span>
                </div><!-- .fxpb-row-title -->

                <div class="fxpb-row-fields">
                    <div class="fxpb-col-2-left">
                        <textarea class="fxpb-row-input" name="fxpb[<?php echo $order; ?>][content-1]" data-field="content-1" placeholder="1st column content here..."><?php echo esc_textarea( $row_data['content-1'] ); ?></textarea>
                    </div><!-- .fxpb-col-2-left -->
                    <div class="fxpb-col-2-right">
                        <textarea class="fxpb-row-input" name="fxpb[<?php echo $order; ?>][content-2]" data-field="content-2" placeholder="2nd column content here..."><?php echo esc_textarea( $row_data['content-2'] ); ?></textarea>
                    </div><!-- .fxpb-col-2-right -->
                    <input class="fxpb-row-input" type="hidden" name="fxpb[<?php echo $order; ?>][type]" data-field="type" value="col-2">
                </div><!-- .fxpb-row-fields -->

            </div><!-- .fxpb-row.fxpb-col-2 -->
            <?php
        }
    }
}

So, basically we create each rows using the exact template we use to create it, but with proper value in each row. And that’s it.

All done. Now we have a fully working page builder. We can create rows, delete rows, re-order, and save the data properly.

Download source for this tutorial:

In the next tutorial I will explain how to output the data in the page/front-end, with several strategy to do this.

Read Part #4 (last) Tutorial

To get updates, follow my twitter @turtlepod.

2 Comments

  1. Rodrigo D'Agostino

    First of all, thank you so much for such a great tutorial 🙂 And here’s my question: would it be possible to store the page builder data inside the content window instead?

Comments are closed.