Tutorials‎ > ‎

Add ACF Fields to Quick Edit

This tutorial can help you add your custom fields created with the Advanced Custom Fields (ACF) plugin to be editable in the quick edit menu in the admin panel of your WordPress website. This tutorial is a little bit hacky because I've yet to find a good solution (and I'm not particularly well versed in WordPress/PHP yet), but as of writing this in October 2017, it works.

Create Custom Field in the ACF Plugin

ACF provides pretty comprehensive documentation on how to use their plugin. If you're not familiar with how to create custom fields, I suggest reading it before starting. After creating your custom fields, note their Field Names as we'll be needing to reference them later. For the sake of this tutorial, I'm pretending that I created a field with the field name topaz (that's the name of my cat). I'll be changing up the field type throughout the tutorial for various examples.

Add Custom Column to the Admin Page

In your functions.php file, you'll first need to list out all of the columns to be displayed using the "manage_edit-{$post_type}_columns" hook, replacing the {$post_type} with a built in post type or a custom one you've made. Here's the function I created:

functions.php
//Add the columns for the "page" post type
add_filter('manage_edit-page_columns', 'tessa_add_acf_columns');
function tessa_add_acf_columns($columns) {
$columns = array(
        'cb' => '<input type="checkbox" />',
        'title' => 'Title',
'topaz' => 'Topaz',
        'date' => 'Date',
    );
return $columns;
}

In my code, I've got four columns. The "cb", "title", and "date" are all default columns that WordPress knows what to do with. The only new one is the one I've defined as "topaz" with the display name "Topaz".

The second part is rendering out the post's content that is saved for that column. For this, we'll be using the "manage_{$post_type}_posts_custom_column" hook, again replacing the {$post_type} with a built in post type or a custom one you've made.

functions.php
// Render the custom columns for the "page" post type
add_action('manage_page_posts_custom_column', 'tessa_render_acf_columns', 10, 2);
function tessa_render_acf_columns($column_name) {
    global $post;
    switch ($column_name) {
case 'topaz':
$topaz = get_field($column_name, $post->ID);
if(!empty($topaz)) {
echo(sprintf( '<span class="acf-field %s">%s</span>', $column_name, $topaz ) );
}
break;
}
}

You can echo the content of the field however you want depending on the field type you made it. In my example code above, I'm pretending that topaz is a simple textstring and I'm just placing it into a span element with the class "acf-field" and "topaz".

One of my more complicated projects uses a Post Object field type that allows multiple selections. If topaz was a Post Object field type that allowed multiple selections, I'd have to loop through the array that my ACF field returns (if it has options selected and isn't false of course).

functions.php - Example
// Render the custom columns for the "page" post type
add_action('manage_page_posts_custom_column', 'tessa_render_acf_columns', 10, 2);
function tessa_render_acf_columns($column_name) {
    global $post;
    switch ($column_name) {
case 'topaz':
$topaz = get_field($column_name, $post->ID);
if($topaz) {
                $out = array();
                foreach($topaz as $foo) {
                    $out[] = sprintf( '<span class="acf-field %s" data-id="%s">%s</span>', $column_name, $foo->ID, $foo->post_title ) );
                }
                echo( join(', ', $out) );
}
break;
}
}

So instead of one span element, I'll have a comma separated list of the post object's names, and each span element will have a data-id attribute with the value set to that post object's ID.

Your custom column and the post type's content for that custom field should be happily appearing on the admin page now. On to the next step!

Add Form Input to the Quick Edit Menu

Your form element(s) will be different based on the field type. A textstring is simply a text input box. A select field or radio buttons can be a select field for your form to optimize space, but maybe you allowed it to have multiple values, so perhaps check boxes would be better used. Either way, we're going to use the "quick_edit_custom_box" hook and it's not specific to any post type because your form field will only show up when the column exists, and that is specific to a post type.

functions.php
// Render the custom form fields for the ACF fields to the "Quick Edit" menu
add_action('quick_edit_custom_box', 'tessa_add_quick_edit', 10, 2);
function tessa_add_quick_edit($column_name, $post_type) {
switch($column_name) {
case 'topaz': ?>
<fieldset class="inline-edit-col-left">
             <div class="inline-edit-col">
<label>
<span class="title">Topaz</span>
<span class="input-text-wrap"><input type="text" id="<?php echo($column_name); ?>" name="<?php echo($column_name); ?>" /></span>
</label>
</div>
</fieldset>
<?php break;
}
}

In this piece, topaz  is once again pretending just to be a textstring, so I set its input field to a text type. I gave it an id and name attributes both set to the $column_name variable, but I could've set it to anything at this point since those values don't matter yet. The downside to this hook is that there is no information on the current post, so we have no way of getting the current value. We'll have to do that in the next step. If I were creating radio buttons, I would've created a unique id to match the field and then the value like "topaz_1", "topaz_2", and "topaz_3".

If topaz was a Post Object field type that allowed multiple selections, I'd query all the post objects I could assign it to, and perform a loop to dynamically generate the form fields as check boxes to match like this:

functions.php - Example
// Render the custom form fields for the ACF fields to the "Quick Edit" menu
add_action('quick_edit_custom_box', 'tessa_add_quick_edit', 10, 2);
function tessa_add_quick_edit($column_name, $post_type) {
switch($column_name) {
case 'topaz': ?>
<fieldset class="inline-edit-col-center">
             <div class="inline-edit-col">
                    <span class="title">Topaz</span>
                    <ul class="cat-checklist <?php echo($column_name); ?>-checklist">
                        <?php
                            $query = new WP_Query(array(
                                'post_type' => 'page',
                                'posts_per_page' => -1,
                                'orderby' => 'title',
                                'order' => 'ASC'
                            ));
                            while ($query->have_posts()) : $query->the_post(); $post_id = get_the_ID(); ?>
                                <li><label><input type="checkbox" name="<?php echo($column_name); ?>_<?php echo($post_id); ?>" value="<?php echo($post_id); ?>" /></label></li>
                        <?php endwhile; wp_reset_postdata(); ?>
                    </ul>
</div>
</fieldset>
<?php break;
}
}

The result would be a list of checkboxes with the names set to something like "topaz_1234" and "topaz_1235". Just make sure that your query matches the options you set in ACF for the field, like if you specified a custom post type or if you only want to display published posts as options. Had my field not have multiple selections turned on, I would've used this same method to generate a select field with the query results as the options.

Populate with the Current Value

As I mentioned in the previous step, the "quick_edit_custom_box" hook does not have any post data available to pre-populate the form fields with the current value. My solution to that is to use JavaScript/jQuery to get that information from the columns HTML and populate it. First, we'll need to add our custom JS file to the admin panel for use using the "admin_enqueue_scripts" hook.

functions.php
//Add the JS file for "edit.php" admin pages
add_action('admin_enqueue_scripts', 'tessa_add_admin_js');
function tessa_add_admin_js($hook) {
if ( $hook !== 'edit.php') { return; }//Only add these scripts to the admin panel
wp_enqueue_script('tessa-admin-js', get_template_directory_uri() . '/source/js/tessa-admin.js', array('jquery'));
wp_localize_script('tessa-admin-js', 'my_ajax_object', array( 'ajax_url' => admin_url( 'admin-ajax.php' ) ) );
}

First, I made it so that these scripts are only added if we are viewing an "edit.php" page in the admin panel. I add my JavaScript file that's located in my theme folder at "/source/js/tessa-admin.js"; the file uses jQuery, so I added the parameter to include it. The next line where it says "wp_localize_script(..." is where I'm defining some built-in stuff so I can use the "my_ajax_object.ajax_url" variable in my JS file in a later step.

As a personal preference, I tend to store my JS functions in a single object variable to avoid namespace conflicts. So here's what my JS file looks like to start:

/source/js/tessa-admin.js
var $ = jQuery.noConflict();
var TessaAdmin = {
variables: {
post_id: 0,
checker: '',
saving: false
},
Init: function() {
//Add an event listener for the "Quick Edit" button in the admin panel
$('.editinline').on('click', TessaAdmin.ClickEdit);
},
ClickEdit: function(e) {
console.log('Clicked Edit');
}
};
$(document).ready(function() { TessaAdmin.Init(); });

Nothing too complicated yet. I have my namespace variable TessaAdmin which is just an object and inside it has a variables object, an Init function, and a ClickEdit function. At the bottom of the file, I use $(document).ready() to initalize my admin script, and all it does is add a click listener to the elements on the page with a class of editinline. This targets the "Quick Edit" button in the admin panel.

The first thing we need to do is get the post id of post we're editing. I've done so by grabbing the number from the row's id attribute.

/source/js/tessa-admin.js: ClickEdit function
ClickEdit: function(e) {
    console.log('Clicked Edit');
    var $post = $(this).closest('tr');
    TessaAdmin.variables.post_id = $post.attr('id').replace('post-', '');//Set the post_id variable to the row's post ID number
}

Next, we need to perform a check if whether or not we even need to worry about this custom functionality. Maybe you're editing a post that doesn't have any custom ACF fields in the quick edit menu, or it does it but none of them have any values set, so we don't want to waste resources. Remember that I added the class acf-field to my span element when rendering the column? We're going to check to see if any of those exist, because really the only reason for this script is to carry that data over to this form.

/source/js/tessa-admin.js: ClickEdit function
ClickEdit: function(e) {
    console.log('Clicked Edit');
    var $post = $(this).closest('tr');
    TessaAdmin.variables.post_id = $post.attr('id').replace('post-', '');//Set the post_id variable to the row's post ID number
    if($('#post-' + TessaAdmin.variables.post_id + ' .acf-field').length) {
        //Do something
    }
}

With the post ID (let's pretend I'm editing a post with the ID of 4789), I would be targeting an element on the page using the selector "#post-4789 .acf-field". If any fields exist that is specific to the post we're editing, it'll execute the next part inside our if-statement.

Here's where it starts to get a little hacky. I'm totally open to suggestions on optimizing this.

The quick edit form does not appear right away after clicking on the button. It takes a little bit of time before it loads because it too is loaded via AJAX. So we run a little interval timer, checking to see if the quick edit form has appeared on the DOM yet by looking for an element with an id of "edit-<post id>".

/source/js/tessa-admin.js: ClickEdit function
ClickEdit: function(e) {
    console.log('Clicked Edit');
    var $post = $(this).closest('tr');
    TessaAdmin.variables.post_id = $post.attr('id').replace('post-', '');//Set the post_id variable to the row's post ID number
    if($('#post-' + TessaAdmin.variables.post_id + ' .acf-field').length) {
        TessaAdmin.variables.checker = setInterval(function(){
            if($('#edit-' + TessaAdmin.variables.post_id).length) {
                clearInterval(TessaAdmin.variables.checker);
                TessaAdmin.SetDefaults();
            }
        }, 50);
    }
}

Once that if statement inside the interval is true, it stops the interval by clearing it out, and then we're ready for the next step: getting the values.

We've conveniently added our content in the columns. Those aren't gone from the page, they're just hidden, so we can easily access them with our JS. For organizational purposes, I'm going to put this part in its own function inside my object and call it SetDefaults.

/source/js/tessa-admin.js: ClickEdit function - inside the Interval
SetDefaults: function() {
    //Add the listener to the save button
    $('#edit-' + TessaAdmin.variables.post_id + ' button.save').on('click', TessaAdmin.ClickSave);

    //Update the "topaz" text field
    $('#edit-' + TessaAdmin.variables.post_id + ' input[name=topaz]').val($('#post-' + TessaAdmin.variables.post_id + ' .acf-field.topaz').text());
}

As I wrote in the comments, the first thing I do is add a click listener for the save button to call a function I haven't written yet called ClickSave. That'll come in a later step.

The second part is where I grab the text from (using my example post ID number from before) "#post-4789 .acf-field.topaz" to target the span with my "topaz" class.

With my other example of topaz being a Post Object field type, I'd write it like this:

/source/js/tessa-admin.js: ClickEdit function - inside the Interval
SetDefaults: function() {
    //Add the listener to the save button
    $('#edit-' + TessaAdmin.variables.post_id + ' button.save').on('click', TessaAdmin.ClickSave);

    //Update the "topaz" text field
    $('#post-' + TessaAdmin.variables.post_id + ' .acf-field.topaz').each(function(){
        $('#edit-' + TessaAdmin.variables.post_id + ' input[name=topaz_' + $(this).data('id') + ']').attr('checked', 'checked');
    });
}

Because the checkbox input fields in the Quick Edit form have a name attribute like "topaz_123", that's how I'm targeting them to check their boxes, and I've got the ID number because I I included that as a data attribute when rendering them out in the column HTML.

So now you have it! You should be able to click on the Quick Edit button, see your custom ACF field show up in the form and see its default value populate! Let's move onto saving that data when a user changes it.

Save Quick Edit Form

This tutorial is about to get hackier, so if you find a more optimized way of doing this, please share!

In my research, I tried using the "save_post" hook, but for whatever reason, the "update_field()" function provided by ACF didn't work. Plus, it was messing with my ACF fields by preventing them from being saved when editing the actual page, not just in quick edit. So I couldn't get it to work. I imagine if there is an optimal way, using this hook would be it.

I also desperately wanted to use the "acf/save_post" hook, but it just doesn't trigger when saving the quick edit form either. So no go there.

So I got creative, and wrote my own JS function to execute an AJAX call to update the values. In order to use the "update_field()" function, we need to pass it three parameters:
  1. The field name (which is topaz)
  2. The new value (which is whatever is in the form and depends on field type)
  3. The post id that is being edited (which we already have in the variable called TessaAdmin.variables.post_id

Since we know that the field name is topaz, we really only need to pass the last two parameters in our AJAX call. So this is what that ClickSave function started to look like.

/source/js/tessa-admin.js: ClickSave function
ClickSave: function(e) {
    TessaAdmin.variables.saving = true;
    var postData = {
        'action': 'tessa_acf_update_topaz_js', //name of function in functions.php,
        'post_id': parseInt(FC.variables.post_id),//Because it was a string
        'topaz': $('#edit-' + FC.variables.post_id + ' input[name=topaz]').val()
    };
    $.ajax({
        type: 'post',
        url: my_ajax_object.ajax_url,
        data: postData,
        dataType: 'json',
        cache: false,
        error: function(xhr) {
             console.log('Error');
            console.log(xhr);
            debugger;
        },
        success: function(response) {
            //Do something
        }
    });
}

After I set my TessaAdmin.variables.saving variable to true, I created a new object variable named postData which is what I'm sending as the data in my AJAX call and it has our two important parameters, plus another one called action. This key/value pair is super important for the AJAX call to work and must be included. You can set the value to whatever you want, but it must be the same as the name of a PHP function we're going to write later on.

Pretending that topaz is a textstring again, we easily grabbed the value from the text field using the "val()" function. If it were anything else like radio buttons or checkboxes, you should loop through the fields and adding their values to an array (which should be stringified before sending). Something like this:

/source/js/tessa-admin.js: ClickSave function - Example
ClickSave: function(e) {
    TessaAdmin.variables.saving = true;
    var postData = {
        'action': 'tessa_acf_update_topaz_js', //name of function in functions.php,
        'post_id': parseInt(FC.variables.post_id),//Because it was a string
        'topaz': []
    };
    if($('#edit-' + FC.variables.post_id + ' .topaz-checklist input:checked').length) {
        $('#edit-' + FC.variables.post_id + ' .topaz-checklist input:checked').each(function(){
            postData['topaz'].push(parseInt($(this).val()));
        });
        postData['topaz'] = JSON.stringify(postData['topaz']);
    } else {
        postData['topaz'] = false;
    }
    $.ajax({
        type: 'post',
        url: my_ajax_object.ajax_url,
        data: postData,
        dataType: 'json',
        cache: false,
        error: function(xhr) {
             console.log('Error');
            console.log(xhr);
            debugger;
        },
        success: function(response) {
            //Do something
        }
    });
}

So now that we've written our AJAX call, we need to write the PHP function to actually perform the task and update the field in WordPress. We already know a few things:
  1. The function we need to write in functions.php that retrieves this value will be called tessa_acf_update_topaz_js  (if you haven't figured out by now, I prefix the names of my custom PHP functions with "tessa" to differentiate them from other developers' functions, and since its a function called via AJAX from a JS file, I suffixed it with "js"; you can use whatever naming conventions you like)
  2. That function requires a parameter called post_id
  3. That function requires a parameter called topaz that holds the new value
  4. The function should return a JSON response since the "dataType" was specified as "json".
This function will be using the "wp_ajax_{$action}" hook, replacing {$action} with whatever you want to name your action. So this is what it looks like in your functions.php file now:

functions.php
//AJAX call from /source/js/tessa-admin.js to update the value of ACF field "topaz"
add_action('wp_ajax_tessa_acf_update_topaz_js', 'tessa_acf_update_topaz_js');
function tessa_acf_update_topaz_js() {
    //Get the posted fields
    $post_id = $_POST['post_id'];   
$topaz = $_POST['topaz'];

    //Prepare array to return with old and new values
    $return = array(
        'post_id' => $post_id,
        'topaz' => array(
            'old' => get_field('topaz', $post_id),
            'new' => $topaz,
            'html' => sprintf( '<span class="acf-field topaz">%s</span>', $topaz ) )
    ));

    //Update value in ACF
    update_field('topaz', $topaz, $post_id);

    //Return array
echo(json_encode($return));
die();
}

There are essentially four steps in this function:
  1. Get the posted fields
  2. Get the old value and set up the return array
  3. Update field to the new value
  4. Return the array
This looks pretty easy since I'm pretending that the field type of topaz is a textstring. If topaz was a Post Object field type that allowed multiple selections, I'd need an array of ID's because that's what the ACF documentation for that post type requires when using the "update_field()" function. So it would look something like this:

functions.php
//AJAX call from /source/js/tessa-admin.js to update the value of ACF field "topaz"
add_action('wp_ajax_tessa_acf_update_topaz_js', 'tessa_acf_update_topaz_js');
function tessa_acf_update_topaz_js() {
    //Get the posted fields
    $post_id = $_POST['post_id'];   
$topaz = $_POST['topaz'];

    //Prepare array to return with old and new values
    $return = array(
        'post_id' => $post_id,
        'topaz' => array(
            'old' => get_field('topaz', $post_id),
            'new' => false
    ));

    //Update the return array's new value to hold the array
    if($topaz) {
        $topaz = json_decode($topaz);//Decode the stringified array
        //Checking again because sometimes the boolean false wasn't recognized until after decoding
        if($topaz) {
            $return['topaz']['new'] = array();//Change the variable type to an empty array
            foreach($topaz as $foo) {
                $foo = get_post($foo);//get the post object via by id
                $return['topaz']['new'][] = array(
                    'id' => $foo->ID,
                    'html' => sprintf( '<span class="acf-field topaz" data-id="%s">%s</span>', $foo->ID, $foo->post_title ) )//Same as column HTML
                );
            }
        }
        
    }
    

    //Update value in ACF
    update_field('topaz', $topaz, $post_id);


    //Return array
echo(json_encode($return));
die();
}

And there you have it! Your fields should now be happily updating. There are just a few more last things to do since the hackiness of it all allowed for some bugs.

Final Tweaks / Bug Fixing

Can't Quick Edit Post a Second Time After Saving Quick Edit

The first bug you may notice is that once you use the quick edit to update your ACF fields and save it, if you click on that button again, the default fields are not populated. This is because, for whatever reason, the click listener for that post only gets removed. So it needs to be re-added when the quick edit form has disappeared.

To fix, we're going to update the ClickSave function to utilize our checker interval again to keep checking on if WordPress is done saving. When it is, we can easily re-add the click listener. This is done before the AJAX call occurs because the WordPress saving completion is done independent of that of our AJAX call.

/source/js/tessa-admin.js: ClickSave function
ClickSave: function(e) {
    TessaAdmin.variables.saving = true;
    var postData = {
        'action': 'tessa_acf_update_topaz_js', //name of function in functions.php,
        'post_id': parseInt(TessaAdmin.variables.post_id),//Because it was a string
        'topaz': $('#edit-' + TessaAdmin.variables.post_id + ' input[name=topaz]').val()
    };
    clearInterval(TessaAdmin.variables.checker);
    TessaAdmin.variables.checker = setInterval(function(){
        if($('#edit-' + TessaAdmin.variables.post_id).length == 0) {
            clearInterval(TessaAdmin.variables.checker);
            TessaAdmin.variables.saving = false;
            $('#post-' + TessaAdmin.variables.post_id + ' .editinline').on('click', TessaAdmin.ClickEdit);
        }
    }, 50);
    $.ajax({
        type: 'post',
        url: my_ajax_object.ajax_url,
        data: postData,
        dataType: 'json',
        cache: false,
        error: function(xhr) {
             console.log('Error');
            console.log(xhr);
            debugger;
        },
        success: function(response) {
            //Do something
        }
    });
}

Columns Don't Always Update After Saving

The second bug you may notice is that sometimes your updated values don't show in the columns after saving. This is because the AJAX call we made from the button click listener is asynchronous and runs simultaneously as the one WordPress is executing from the same button click. In some cases, your AJAX call completes first and when WordPress refreshes that row's content, you'll see your updates. In other cases when your AJAX call completes second, WordPress will have already refreshed that row's content but wouldn't have your new values yet. So we need to make a check to see if the HTML of that column needs updating when your AJAX call is completed in case it came in second.

To fix, we're going to check to see if we came in second place when the AJAX function has been successful by looking at the TessaAdmin.variables.saving boolean that we've been setting to true/false to track the WordPress saving function in the previous fix, and if we did come in second place, to overwite the HTML in the column with what we received in the AJAX response. So the final ClickSave success function looks like this (replacing the "//Do Something" comment in the last code snippet):

/source/js/tessa-admin.js: ClickSave function
success: function(response) {
    //Update the string only if its value changed
    if(!TessaAdmin.variables.saving && response.topaz.new !== response.topaz.old) {
        $('#post-' + TessaAdmin.variables.post_id + ' td.topaz').html(response.topaz.html);
    }
}


Remember in the tessa_acf_update_topaz_js function we added an "html" key/value pair? We get to use it here just in case it comes in second place. And if it was the infamous Post Object field type, I'd have made the success function look like this:

/source/js/tessa-admin.js: ClickSave function - Success
success: function(response) {
    if(!TessaAdmin.variables.saving) {
        var html = '';
        if(typeof($response.topaz.new) == 'object') {
            html = [];
            $.each(response.topaz.new, function() {
                html.push(this.html);
            });
            html = html.join(', ');
        }
        $('#post-' + TessaAdmin.variables.post_id + ' td.topaz').html(html);
    }
}


So now your Quick Edit menu should be smooth using in the admin panel! Happy developing!
Comments