One of the most confounding things to figure out in the world of WordPress plugin development is the repeatable field. They’re just generally obnoxious to write, sadly, and there’s very little real documentation out there about them. I use them regularly in quite a few of my plugins, and even now I have to recheck how to do them each time. That said, I never considered writing a tutorial on it until a friend, and a talented developer in his own right, mentioned that he had recently been unable to make one work. So, here’s a quick run-through on how I do repeatable fields.

If you want to follow along with some working code, you can download a pre-built plugin based on this tutorial here.

Step One: The Meta Box

Writing the actual meta box HTML for a repeatable field isn’t any different than any other field, but there are a few subtle differences. First, you need to register your meta box (assuming you aren’t modifying an existing one):

/**
 * Register new meta box
 *
 * @return      void
 */
function repeatable_demo_add_meta_box() {
    add_meta_box( 'repeatable_demo', 'Repeatable Demo', 'repeatable_demo_render_meta_box', 'post', 'normal', 'default' );
}
add_action( 'add_meta_boxes', 'repeatable_demo_add_meta_box' );

Pretty straightforward, right? Moving on to rendering the meta box.

/**
 * Render the meta box
 *
 * @global      object $post The WordPress object for this post
 * @return      void
 */
function repeatable_demo_render_meta_box() {
    global $post;

    $demo_data = get_post_meta( $post->ID, '_repeatable_demo_data', true );
    ?>
    <table class="widefat repeatable-demo-repeatable-table" width="100%" cellpadding="0" cellspacing="0">
        <thead>
            <th>Field 1</th>
            <th>Field 2</th>
            <th style="width: 20px"></th>
        </thead>
        <tbody>
            <?php
            if( ! empty( $demo_data ) ) {
                foreach( $demo_data as $key => $value ) {
                    $field1 = isset( $value['field1'] ) ? $value['field1'] : '';
                    $field2 = isset( $value['field2'] ) ? $value['field2'] : '';
                    $args = compact( 'field1', 'field2' );

                    repeatable_demo_render_row( $key, $args, $post->ID );
                }
            } else {
                repeatable_demo_render_row( 1, array( 'field1' => '', 'field2' => '' ), $post->ID );
            }
            ?>
            <tr>
                <td class="submit" colspan="4" style="float: none; clear: both; background: #fff;">
                    <button class="button-secondary repeatable-demo-add-repeatable" style="margin: 6px 0;">Add New</button>
                </td>
            </tr>
        </tbody>
    </table>
    <?php
}

Ok, so this function is a bit different than usual… It’s just the same as any other field, except rather than rendering the field directly, we’re passing off the specific data for a given row to a helper function repeatable_demo_render_row() which is going to handle the rendering for us. Additionally, we’re calling that function twice; once in a foreach loop which triggers if there are existing row, and once with a few defaults for when we’re looking at a fresh post with no existing data.

Step Two: The Helper Function

Now that we’ve looked at the basics of the meta box, let’s dig into that little helper function we keep referencing:

/**
 * Render individual meta box rows
 *
 * @param       int $key The key for a given row
 * @param       array $args The values we are passing to the function
 * @param       int $post_id The ID of the post we are editing
 * @return      void
 */
function repeatable_demo_render_row( $key, $args = array(), $post_id ) {
    ?>
    <tr class="repeatable-demo-wrapper repeatable-demo-repeatable-row" data-key="<?php echo esc_attr( $key ); ?>">
        <td>
            <input type="text" name="_repeatable_demo_data[<?php echo $key; ?>][field1]" value="<?php echo $args['field1']; ?>" />
        </td>
        <td>
            <input type="text" name="_repeatable_demo_data[<?php echo $key; ?>][field2]" value="<?php echo $args['field2']; ?>" />
        </td>
        <td>
            <button href="#" class="repeatable-demo-remove-repeatable" data-type="image" style="background: url(<?php echo admin_url( '/images/xit.gif' ); ?>) no-repeat;"><span aria-hidden="true">x</span></button>
        </td>
    </tr>
    <?php
}

This should be somewhat familiar territory. It’s not too different than what we would typically render for any other meta box field. Two things to note though: first, rather than each field having a unique name, we’ve switched the name to a multi-dimensional array where the key of a given row and the field are hierarchical keys. Second, we have one odd-looking row tacked on to the end of our collection of fields. Shouldn’t take long to figure out what it’s for, but I’ll make it simple. It renders a delete button. Repeatable rows would be kind of silly if you could only add, but never delete!

Step Three: Saving the Meta Box

Saving is simple! One thing you might want to remember, though is that copying this (or any other code in this post) directly into a theme or plugin is not the best idea. Even if you rename everything. Because I’m not including any security or sanitization. Seriously. Escape everything, sanitize your data, use nonces; you know the drill.

/**
 * Save post meta when the save_post action is called
 *
 * @param       int $post_id The ID of the post we are saving
 * @global      object $post The post we are saving
 * @return      void
 */
function repeatable_demo_save_meta_box( $post_id ) {
    global $post;

    // All the fields we want to save
    $fields = array(
        '_repeatable_demo_data'
    );

    foreach( $fields as $field ) {
        if( isset( $_POST[$field] ) ) {
            if( is_string( $_POST[$field] ) ) {
                $new = esc_attr( $_POST[$field] );
            } else {
                $new = $_POST[$field];
            }

            update_post_meta( $post_id, $field, $new );
        } else {
            delete_post_meta( $post_id, $field );
        }
    }
}
add_action( 'save_post', 'repeatable_demo_save_meta_box' );

This is a stripped-down version of the standard save meta box function that I use for pretty much everything. Nothing fancy, really straightforward, and nothing ‘special’ for repeatable fields. Moving on.

Step Four: JS or Where the Magic Happens

Here’s where things get complicated. Given the length of this particular code block, and the fact that I’m going to want to explain as I go, I’m going to break this down Barney style and do it in little pieces. I’m also going to forgo stuff like loading the Javascript… hopefully, you have at least a basic understanding of how that stuff works.

var Repeatable_Demo_Configuration = {
    init: function () {
        this.add();
        this.remove();
    },

Nothing challenging so far. Basic instantiation stuff…

    clone_repeatable: function (row) {
        var key, highest, clone;

        key = highest = 1;
        row.parent().find('.repeatable-demo-repeatable-row').each(function () {
            var current = $(this).data('key');
            if (parseInt(current) > highest) {
                highest = current;
            }
        });

        highest += 1;
        key = highest;

Now we’re getting into the fun stuff! In a nutshell, all we’re doing here is finding the relevant rows (remember using the repeatable-demo-repeatable-row class earlier?) and calculating out the highest currently used key.

        clone = row.clone();
        clone.find('select').each(function () {
            $(this).val(row.find('select[name="' + $(this).attr('name') + '"]').val());
        });

        clone.attr('data-key', key);
        clone.find('td input, td select, textarea').val('');
        clone.find('input, select, textarea').each(function () {
            var name = $(this).attr('name');

            name = name.replace(/\[(\d+)\]/, '[' + parseInt(key) + ']');
            $(this).attr('name', name).attr('id', name);
        });

        clone.find('.repeatable-demo-remove-repeatable').css('display', 'inline-block');

        return clone;
    },

Here’s where the actual grunt work gets done. We’re merely cloning the last row, then running through it and clearing all data and updating the name and ID to match the new row (remember that special key we used in the name earlier?).

    add: function() {
        $('body').on('click', '.submit .repeatable-demo-add-repeatable', function (e) {
            e.preventDefault();

            var button = $(this),
                row = button.parent().parent().prev('tr'),
                clone = Repeatable_Demo_Configuration.clone_repeatable(row);

            clone.insertAfter(row);
        });
    },

This function does what it looks like: when you click the button that has the proper class, it triggers the clone function. Simple as that.

    remove: function() {
        $('body').on('click', '.repeatable-demo-remove-repeatable', function (e) {
            e.preventDefault();

            var row = $(this).parent().parent('tr'),
                count = row.parent().find('tr').length - 1;

            if (count > 1) {
                $('input, select', row).val('');
                row.fadeOut('fast').remove();
            } else {
                $('input, select', row).val('');
            }
        });
    }
};

Repeatable_Demo_Configuration.init();

Similar to the previous function, this removes a row when the button with the proper class is pressed. However, it also has one extra bit of logic: if the row we’re removing is the very last row, clear the contents rather than removing it. Empty tables don’t look good!

And that’s pretty much it! It’s not as complicated as it looks. However, if you want a more comprehensive example I’d suggest taking a look at the source code for Easy Digital Downloads. After all, that’s where I learned, and my code is just a tweaked version of what Pippin wrote!

If you have any specific questions, please feel free to ask. Writing a technical tutorial this late at night on no sleep while on a trip may well have resulted in some convoluted nonsense, but I hope that I got the point across!

Show Full Content
Previous Let’s Talk About Addons
Next Editor Wars

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Newsletter


I don't like spam either.
Your email address is secure.

Featured Posts

Close
Close