Building a WordPress “product box” plugin, part 2 – shortcodes and frontend

Part 1 – Initial idea, design, and first-pass implementation
Part 2 – Building the product box that gets displayed in posts
Part 3 – Refactoring the codebase into classes, views, and separate files
Part 4 – Adding image uploading, shared plugin options, and uninstallation

Now it’s time to render the Product Box in a post and give it some attractive styling.

Day 6: Embedding the Product Box in a post using a Shortcode

With the management page built (or at least built enough), the next thing I wanted to do was see a product box in a post. I knew I wanted to do this with a WordPress shortcode, so I started by reading the Shortcode API and a Shortcode tutorial and used it to whip up the most basic Shortcode-driven bit of code in the history of WP plugins:

function amazin_product_box_shortcode( $atts ) {
    $a = shortcode_atts( array(
        'id' => 'id'
        ), $atts );
    return "Hello from Amazin Product Box for post ID " . $a['id'] . '!';
}
add_shortcode( 'amazin-product-box', 'amazin_product_box_shortcode' );

I have the new Gutenberg (blocks) version of WP so I used the “Shortcode” block to put my plugin’s Shortcode into the post:

And here it is in the published post! (It’s the last line, the one that begins with “Hello”.)

Sweet – it’s in the post!

The next step was to make it HTML, not just a string. I encountered a few problems along the way, so I documented them and their fixes here to help explain the code a bit better.

I now have a product box in a post displaying real data from the database.

The code for this step can be found here.

Tying up a few loose ends: classes on the HTML tags, Shortcodes in the management table

In preparation for styling the box, I added classes to all the HTML elements. The plugin will have some default styles, but will also expose the CSS for the user to edit to their liking (and save it somewhere).

Here are the classes in their own commit (yeah, it’s a tiny commit :) )

I also went back to the admin page and made it so Shortcodes display here without rendering as product boxes inside the table. That commit is here.

Day 7: Styling the Product Box

The next thing I did was add a stylesheet to the project, enqueue it, and add a bunch of styles to make the default Product Box look better. Here it is in my post, now styled:

Here is the commit that adds the box’s styling.

I also added the “We Recommend” text at the top, which is hard-coded for now but will ultimately be something the user can edit in the plugin’s settings (so that the user only has to change it in one place if they want it to say something different, like “Our Pick”, or hide it entirely) and fixed a problem with loading the posts for editing (it was the same “NULL fix” I used for displaying them in the post).

I considered letting the user modify the Amazin’ Product Box’s CSS via the plugin, but realized that 1. this would be a ton of work to develop, debug, and support, likely involving sanitizing the user’s CSS input and saving it to a file in the plugin directory, retrieving it, using it (or parts of it) based on the user’s settings and 2. this functionality is basically already present in WordPress’s own Appearance > Customize tab.

As a test, I restyled the button using WP’s custom CSS area. Anyone who wants to change the look of an Amazin’ Product Box should be able to safely do so via their theme or child theme or WP’s Customize using this same technique.

The photo area is just a placeholder for now, I’ll come back to that in a bit. For now, move on to Part 3 where I refactor everything I’ve built so far to be more “standardized”.

Building a WordPress “product box” plugin, part 1 – making my own plugin management page

Part 1 – Initial idea, design, and first-pass implementation
Part 2 – Building the product box that gets displayed in posts
Part 3 – Refactoring the codebase into classes, views, and separate files
Part 4 – Adding image uploading, shared plugin options, and uninstallation

In this series: watch me code a WordPress plugin from scratch. I’ve never made a WordPress plugin before but I did a bootcamp half a decade ago and I’m good at Googling and trying things until it works, so I’m sure I’ll figure it out.

This began innocently enough. I wanted a thing, so I did what any self-respecting developer would do and spent 5x as long making it myself as it would’ve cost me to simply buy someone else’s solution. (On the bright side, I got exactly what I wanted.)

Here’s my finished product. It’s a “product box” that the user creates, manages, edits from a backend and displays in a post using a shortcode. The idea is to call attention to recommended products and capture affiliate revenue from the button (link) click.

The next 4 posts (and 6000+ words) are my “dev journal” that I added to every time I worked on it. You can follow along and watch me go from plugin newbie to finishing my first plugin and deploying it on a live site.

PS: This first post is by far the longest of the series.

Day 1: Making a plan, setting up a GitHub repo, and getting started

First, I captured the requirements for this plugin. It seemed like a small project but I still like to bang out a page of requirements first so I have something to refer back to and can (hopefully) catch things I didn’t think about when I was all excited about the fun parts of the new idea.

Behold, my simple mockup:

I made this in wireframe.cc. Paper or a drawing in some sand would’ve also been fine.

This simple mockup revealed a lot of requirements:

  • a way for the user to input a title
  • a tagline
  • a short blurb
  • an affiliate URL for the button to use
  • an image
  • maybe I want to let them customize the colors of the button and the text, too
  • a way to insert it into the post with a shortcode (might look something like [amazin-wp-box id=1])
  • a way to manage and edit existing Amazin’ product boxes

I collected all of this in a Google Doc that will serve as this project’s documentation and idea repository. No matter how small the project seems at first, it always seems bigger once I start writing down every little thing it needs.

Make a github repo for this project

I usually start projects off with a new GitHub repo. It’s just a good practice and it takes like 2 seconds to make a repo and I sleep better at night knowing my work is backed up offsite.

New github repo. I made it public so everyone can see my process (for better or worse).

And then I cloned it locally and cd’d into it.

Boilerplate plugin coding: getting started by adding a button to the admin menu

I’ve never made a WP plugin before but I’m know a ton of other people have and, if I’m lucky, a few of them have documented the process.

This tutorial from wpbeaverbuilder was the first thing I followed. It got me as far as making a .php file like so:

amazin-product-box.php

<?php
/**
 * Plugin Name: Amazin' Product Box
 * Plugin URI: http://majoh.dev
 * Description: Customizable product box for Amazon products with an affiliate link
 * Version: 1.0
 * Author: Mandi Grant
 * Author URI: http://majoh.dev
 */

..and uploading it to my site’s plugins directory like so:

I took a domain I wasn’t using for anything, hooked it up to my shared hosting account, and spun up a new WordPress installation on it.

Hey, look, here it is in my site’s plugins! I AM NOW A PLUGIN DEVELOPER

(I’m kidding about the being a plugin developer thing, this is literally all it does so far.)

The next thing I wanted to do was make the plugin have a “page” of its own where the user can add product boxes, delete them, edit them, etc.

That meant:

  • Adding a “button” (or maybe “link” is a better term for it) to the plugin’s page on the admin_menu (that’s the toolbar down the left side of the WordPress interface)
  • Make the plugin admin page load and display something so I know it loaded

Mostly this was a lot of trial and error. I didn’t know what WordPress calls that menu down the left, I didn’t know what hook to use, and the Internet is bursting with outdated WordPress tutorials.

It took some Googling to figure out what this was called and how to “hook” into it. Ultimately, I found my answers in the WP Codex and ended up with this:

Amazin’ Product Box now has a button in the admin menu and some placeholder text I wrote in the page itself.

Here’s a link to the git commit that contains the code for this work.

Designing the management page

The next thing I wanted was a management page for my plugin. I went back to my Google Doc and mocked up a quick layout for the management page. Now I have a spec to code to. ;)

Quick text-based mockup of the management page. I’m sure it’ll change as I develop it, but this helps me envision it as I’m coding it.

Building the management page wireframe (just the HTML)

I knew I’d need a form and a table, so I built the HTML portion first. This part was easy, just an HTML form with some inputs.

Simple management page wireframe (just the HTML).

Here’s a link to the git commit that adds the HTML you see in the screenshot above.

Easy part is over. Next step is going to be making this form take the user’s input and stick it in the blog’s database.

Day 2: Saving the user’s data to the database as a post

This part felt like the first challenging step of this project, but like most things WordPress, I found it well-documented.

Writing the user’s product box data to the user’s WordPress db

The first thing I had to figure out was where to put my product box data in the WP database.

From the Codex:

Use the existing database tables instead of creating new custom tables if possible. Most use-cases can be accomplished with custom post types and metadata, custom taxonomy and/or one of the other standard tables and using the standard tables provides a lot of UI and other functionality “for free.” Think very carefully before adding a table because it adds complexity to your plugin that many users and site builders prefer to avoid.”

Okay – sounds like I shouldn’t make a new table just for these things. I looked at how TablePress saves its tables, since that plugin works (in some ways) like how I want mine to work.

As it turns out, TablePress saves its tables as posts. Neat.

I can see how I might do the same: product title could be saved as post title, but what about tagline and the affiliate link? I then did some reading on custom post types and considered a custom post type that had all the fields I needed, but I also realized I probably didn’t need a custom post type and I could potentially just serialize the product box data into an array and pull it out for display, thus making the normal post type fine.

Or, I could use custom fields on a normal post. For now, I decided to go with the normal post type and see if I can get a bare-minimum implementation going with just the normal post type. So that’s what I decided on: I’ll ahve it save the form data to the db as a normal post.

That was easy enough: I made the form call a function like so:

<form action="<?php echo esc_url( post_new_product_box() ); ?>" method="post">
   ...
</form>

And stuck a bunch of placeholder values in the function itself:

function post_new_product_box() {
    $my_post = array(
        'post_title'    => 'Test product box',
        'post_content'  => 'Test description',
        'post_status'   => 'publish',
        'post_author'   => 1,
        'post_category' => array( 8,39 )
    );

    // Insert the post into the database.
    wp_insert_post( $my_post );
}

This works – sort of. The new post is in the db, but it also shows up with my regular posts in the Posts section of my blog’s dashboard. (It also shows up twice. Maybe I double clicked the submit button.)

Here’s the commit – I wouldn’t get too excited about it, though. It’s buggy and doesn’t use the form data yet.

Getting the form data from the HTML form, into the php method, and into WordPress’s MySQL database

The next thing I did was start reading on how to get data from the form. This was also about the time I realized that there was a non-zero chance I was doing this all wrong and probably introducing a huge security hole.

But for now, I just want to see form data make it to the database (I can secure it later).

(I’m going to spare you my dozen or so trial and error steps here, such as the “step” where I realized I had a typo in “name” on all of the form inputs and the “steps” where I experimented with different ways to serialize the data.)

I ultimately settled on this way of capturing the form data and encoding it for the database.

function post_new_product_box() {
    if ( isset( $_POST['submit'] ) ) {
        // retrieve the form data by using the element's name attributes
        // value as key $firstname = $_GET['firstname']; $lastname = $_GET['lastname'];
        // display the results echo '<h3>Form GET Method</h3>'; echo 'Your name is ' . $lastname . ' ' . $firstname; exit;
        $content = array(
            "amazin-product-name" => $_POST['amazin-product-name'],
            "amazin-product-tagline" => $_POST['amazin-product-tagline'],
            "amazin-product-description" => $_POST['amazin-product-description'],
            "amazin-product-url" => $_POST['amazin-product-url'],
            "amazin-product-button-text" => $_POST['amazin-product-button-text']
        );

        $product_box = array(
            'post_title'    => $_REQUEST['amazin-product-box-name'],
            'post_content'  => wp_json_encode($content), //broke when switched this from 'none' to the content array
            'post_status'   => 'publish',
            'post_author'   => 1,
            'post_category' => array( 8,39 )
        );

        // Insert the post into the database.
        wp_insert_post( $product_box );
    }
}

(Aside: I swear, I use JSON in everything I build. IT’S JUST SO USEFUL. Turns out WordPress even has a method for encoding stuff into json, so at least this feels legit.)

Anyway, this posts to the db. Hooray! Here is my form data, turned into a post and inserted into the db.

I included my earlier failures so that 1. I don’t look like one of those people who magically gets it right on the first try and 2. so you can see my earlier attempts at formatting the data correctly (the weird a:5 {… formatting is an artifact of using serialize() instead of wp_json_encode()).

Now I have this for my “post content”.

{"amazin-product-name":"TILCODE\'s New Book","amazin-product-tagline":"Tagline goes here","amazin-product-description":"Description goes here","amazin-product-url":"http://buy-my-book.com","amazin-product-button-text":"Buy my book!"}

(I’d have called this an “object”, since it’s wrapped in curlies and uses keys, but php likes to call this an array. I am new in php land so I will abide by its terminology and also call this an array.)

And here it is as a commit, if you’re into that sort of thing.

Day 3: Adding nonce to the form

I suspected my form was insecure based on the fact that I had done absolutely nothing so far to secure it or sanitize the data sent through it.

So, before I went any further, I read a bit on WordPress plugin and form security. A lot of guides say that using something called a “nonce” (a number used once) is a good, easy way to secure WordPress forms. This page from the Codex explained it nicely and gave an example I could adapt to my own plugin’s code. Getting nonce working was easy though I’m not yet sure how to test that it’s actually doing what it’s supposed to. I’ll come back to this.

This commit adds nonce to the plugin code.

Day 4: Hiding the Product Box “posts” from the blog’s main Posts page and displaying them on the plugin page using Custom Post Types

I had a suspicion that my Product Box posts were showing up as normal Posts in WordPress, and sure enough, here they are:

These are product boxes, not posts, and I don’t want to see them when I view my blog’s Posts.

I did some reading and decided that this might be a job for custom post types. This tutorial even says, “One advantage of using custom post types is that it keeps your custom content types away from your regular posts.”

Good enough for me, let’s try it.

The only thing I wasn’t sure about was the part about “create post type” needing to be called “on init”. I wasn’t sure where that was in my plugin, but I guessed it belonged in the same block of code (at the top) that adds the plugin menu to the admin menu.

if ( is_admin() ){ // admin actions
    add_action( 'admin_menu', 'amazin_plugin_menu' );
    add_action( 'init', 'create_post_type' );
} else {
  // non-admin enqueues, actions, and filters
}

The code for actually creating the custom post type looks like this. I found this Codex page on registering post types helpful for identifying what parameters to include. (I can always come back and add more later, too, if I need to.)

function create_post_type() {
    register_post_type('amazin_product_box',
        //custom post type options
        array(
            'labels' => array(
                'name' => __( 'Amazin Product Boxes' ),
                'singular_name' => __( ' Amazin Product Box ')
            ),
            'public'            => false,
            'show_ui'           => false,
            'query_var'         => false,
            'rewrite'           => false,
            'capability_type'   => 'amazin_product_box',
            'has_archive'       => true,
            'can_export'        => true,
        )
    );
}

To bring it all together, the last step was to modify the array of parameters that are passed when creating a product box post. There’s a param for “post_type”, so I added my “amazin_product_box” as the post type here.

$product_box = array(
                'post_title'    => $_REQUEST['amazin-product-box-name'],
                'post_type'     => 'amazin_product_box',
                'post_content'  => wp_json_encode($content)
                'post_status'   => 'publish',
                'post_author'   => 1,
                'post_category' => array( 8,39 )
            );

Make a new product box, go back to php myadmin and reload the posts in the db and.. success! My newest product box is now of post_type “amazin_product_box”, and it is nowhere to be seen in the WP dashboard page for Posts.

I deleted all the “post” type product boxes to clean up the Posts screen. We’re gonna be amazin_product_boxes from here on out!

Here’s the commit that changes the posts to a new custom post type.

Displaying the product boxes on the Amazin Product Box admin page

So, now that they’re not in the Posts page anymore, maybe now’s a good time to get them to show up on the admin page as their creator intended:

Since they are now their own post type I thought it might be easy to select them by post type. The tricky part will be stuffing their data into my table (I mean, I don’t want the full posts displayed like they’re blog posts…).

I started by Googling “WordPress select posts by post type” and read this page on get_posts() and this page on the args available for use with get_posts(). I also found this (5 year old) tutorial helpful when it came to picking out parts of the post data.

The general idea was:

  1. Get each post of type ‘amazin_product_box’
  2. Pick out its product name field, author name, last modified
  3. Display each one as a table row using a loop

It wasn’t super difficult but it took some trial and error to get the date right and figure out how, exactly, to pick out things like author meta from a post ID.

Here’s what I ended up with:

If you’re thinking, “Wow, that’s kinda ugly!” you’re not alone! But I want to get all the hookups working before I venture off into styling land, so it’s going to stay ugly for a little while longer.

Here’s the commit that grabs all the Amazin’ Product Box post data and renders it into a table.

(The code’s a bit ugly with the php and HTML mixed together, but I’m not sure yet what the proper conventions are and there’ll be time to tidy it up later.)

Day 5: Hooking up the Delete buttons

Each existing product box should be able to be edited or deleted independently of the others. The Edit and Delete buttons are client-side (HTML) and the php methods they need to call are server-side. AJAX and JQuery to the rescue.

First, I read this guide on using JavaScript in a WordPress plugin. Then, I got to work on hooking up a console log to each button that logs the post ID of the product box associated with the table row.

When faced with a novel problem (or just one that I know is going to take some trial and error), I like to do a bare minimum “wiring” as the first pass. For this particular bit of work, that meant 1. associating the post ID with each edit/delete button, 2. passing that ID into the JS methods, 3. logging that ID from the JS method to verify the hookup was successful.

Clicking Edit and Delete logs the post ID. From here, I’ll build up an actual edit and delete functionality.

Looks good – now I know the post IDs can make it over to the JS file’s methods.

Note: I had to include a version number in my call to wp_enqueue_script and increment it every change to the .js file. This is called “cache busting” and without it, the plugin used a stale version of the .js code even though the latest was uploaded to the server.

Every time I change the JS code I increment the last parameter. Below, it’s at 1.06. Next time I update the JS code I’ll change it to be 1.07, and so on.

wp_enqueue_script('scripts', $jsurl, array('jquery'), 1.06); 

Here’s the commit that adds console logs and IDs to the Edit and Delete buttons.

Hooking up Delete functionality

I thought Delete might be easier to start with since it doesn’t involve putting data back into the form nor anything with canceling the edit state.

Unsurprisingly, WordPress has a method appropriately named wp_delete_post(). But I couldn’t just call it from the “on click” method in scripts.js – WordPress doesn’t know what it is unless it’s in the php (server-side) code.

Using wp_delete_post() from the JS file was a no-go.

Turns out I needed to use Ajax, but WordPress is already set up for this sort of thing. I found this tutorial very helpful for this step. I skipped anything relating to permissions, confirmations, or security on this first iteration, but if you check out this commit, the delete button now deletes the appropriate row (it fades out!) in the table and in the wp database.

Securing the Delete functionality with a nonce

After getting a bare-bones delete functionality working, I went back to the same tutorial that got me started and followed its examples for adding a nonce. My code was a bit different than theirs, but a few educated (lucky?) guesses later and I had it working.

I put the nonce directly on the Delete button input itself like so:

<input type="button" id="<?php echo $id; ?>" class="delete-button" nonce="<?php echo wp_create_nonce('amazin_delete_post_nonce') ?>" value="Delete"/></td>

Notice how the input tag accepts nonce=”…”. Surprisingly (to me, anyway) that’s valid syntax. Over in scripts.js I’m then able to pluck it out of the event (“e”) like so:

var nonce = e.target.nonce;

Here’s the commit that adds nonce to delete. I tested it by changing the nonce value on one of the delete buttons (to “12345”) and then clicking delete – and nothing happened. So the nonces do actually seem to do something. Hooray!

Building the “Edit” functionality

First, a few small UX changes: I removed the product name box field (I think I’ll just refer to boxes by their IDs, not by names) and exposed the ID in the table. This commit contains that work.

Populating the existing form

The first bit of work here was to make the Edit button populate the form with the existing post’s contents.

In scripts.js, an ajax call that calls an action (‘amazin_get_existing_post’) defined in the php part of the project and stuffs the data into the appropriate form fields upon success:

$ ( '#admin-table').on( 'click', '.edit-button', function(e) {
        console.log("Gonna edit a product box with ID:", e.target.id);
        var id = e.target.id;

        $.ajax({
            type: 'get',
            url: MyAjax.ajaxurl,
            data: {
                action: 'amazin_get_existing_post',
                id: id
            },
            success: function ( response ) {
                console.log( response );
                var data = JSON.parse(response.productBoxData);
                $ ("#product-name").val(data.productName);
                $ ("#product-tagline").val(data.productTagline);
                $ ("#product-description").val(data.productDescription);
                $ ("#product-url").val(data.productUrl);
                $ ("#product-button-text").val(data.productButtonText);
            }
        });
        return false;
    } );

In amazin-product-box.php, the new method:

function amazin_get_existing_post( ) {
    $post = get_post($_REQUEST['id']);
    $postDataToBePassed = array(
        'productBoxProductName' => $post->post_title,
        'productBoxData' => $post->post_content
    );

    wp_send_json($postDataToBePassed);
    die();
}

The tricky part here, for me, was figuring out how to “send” the post data from the php method back to the js. It took a bit of Googling and being stumped for a while, but it turns out WordPress has something made specifically for the job of sending a JSON response back to an AJAX request: wp_send_json(). The response could then be captured and picked apart back in the .js file.

(Hopefully that makes sense – the overall “journey” goes something like this: the clicking of the Edit button is handled in JS -> that JS method calls an action defined in the PHP file -> that PHP file action gets the post from the db and uses wp_send_json() to kick the data back to the JS method -> the JS method captures that data as the “response” parameter.)

Here’s the commit that populates the form fields – hitting “Submit” just creates a new one, though – it doesn’t yet update the existing one.

Saving the form contents as an “update” to an existing post

The last bit of work to do on “Edit” was to make the form recognize two states: making a new one and editing an existing one. I chose to do this with a hidden ID field in the form. The hidden field holds either “undefined” or a post ID (placed there by getting a post to edit). When the user submits the form, it uses that ID (or lack of ID) to figure out what to do – either submit a new post or update an existing.

Here’s the commit for the invisible ID field and the logic that either creates a new post or updates an existing one.

I tested creating, editing, creating after editing, going into edit and then canceling, and clearing the form.

Some test posts to verify the various user flows for the form are working.

Yay, the bare-bones backend is working! Continue on with Part 2 where I add the ability to display a Product Box in an actual post.