Building Admin Columns Pro filters for ACF repeater fields

Today's post is about how to filter your WordPress admin view based on ACF repater field value by building a plugin which extends Admin Columns Pro. Too much names? Let's head into the topic!

Update 8th of Nov 2017 to handle WP Core update to 4.8.3
Thanks Kokers for making me aware of changes in recent update ([Polish lang]dzięki :) [/Polish lang]).
I went for the most straightforward fix but for anyone interested in solving it in more sophisticated way here is the info on make.wordpress.org and here you can find a bit more advanced solution.
You can also use remove_placeholder_escape method but its only available from WP 4.8.3 - as this blog post is addressed to everyone (also poeple on older WP versions) I did not want to place this function here.
We should wait for this ticket to be merged into the core though. As soon as its incorporated into the core we could ditch all the hacky solutions alltogether.

All of the code can be found on github.

Big thank you to Stefan van den Dungen Gronovius, one of Admin Columns Pro developers, who performed technical review of this article.

Intro

Recently I was building a project in which I use WordPress as an CMS/API to feed small React application. The concept behind it is very simple and bases on REST API, built into the WordPress Core since few major versions.

The application data is stored in custom posts, Advanced Custom Fields Pro is used for handling all post metadata. Administration of the entries is handled through regular WordPress views.

As soon as the number of entries grew the need for better filtering and searching admin views started to arise. This problem could be easily solved with the help of Admin Columns Pro which plays very nicely with ACF. The plugin gives you a possibility to add custom columns to your views. You can pick various types of columns: starting from simple fields like taxonomy, date, author to custom fields assigned to your CPT. What is even better - you can define filtering, sorting or inline-editing of the data in the column.

Filtering repeater fields

As ACF repeater fields are complex and each repeater implementation is different there is no way to filter or sort those fields out of the box.

I was digging around ACF documentation and support forums with no success. Unfortunately Admin Columns docs were also silent about this. There was a starter template showing how to build your custom column handling but without filter examples.

I had few ideas in my mind but all seemed hacky and I was thinking if there is a way to do it proper way.

While I was wondering if filtering of such fields is doable I have contacted Stefan van den Dungen Gronovius, one of AC developers, who ensured me that this is possible and gave some very important tips.

Assumptions

Before we start coding lets assume few things:

  • We use most recent WordPress (4.8.2), Advanced Custom Fields (5.6.3) and Admin Columns Pro (4.0.10).
  • We have a Custom Post Types called order and product.
  • The order CPT contains a repeater field called kg_order_items which contains two fields: (1) relationship field (to product CPT) called kg_order_item and (2) a number field called kg_order_quantity.
  • What we are trying to achieve is to be able to filter the admin view of orders which contain particular product assigned to it.

The code

We will be basing our solution on the aforementioned starter template so let's build a plugin first by creating a directory in wp-content/plugins/custom-columns

Big thanks you for the Admin Columns development team for releasing the template!

index.php

/*
Plugin Name:    Admin Columns - Filtering repeater fields
Plugin URI:     https://kamilgrzegorczyk.com
Description:    Handling filtering for custom repeater fields
Version:        1.0
Author:         Kamil Grzegorczyk
Author URI:     https://kamilgrzegorczyk.com
*/

//Registering the column for free version of the plugin. You won't be able to use filtering though!
add_action( 'ac/column_types', function ( AC_ListScreen $list_screen ) {

    // Use the type: 'post', 'user', 'comment' or 'media'.
    if ( 'post' === $list_screen->get_meta_type() ) {
        require_once plugin_dir_path( __FILE__ ) . 'ac-column-assigned_products.php';
        $list_screen->register_column_type( new AC_Column_Assigned_Products() );
    }
});

//Registering the column for PRO version of the plugin
add_action( 'acp/column_types', function ( AC_ListScreen $list_screen ) {

    // Use the type: 'post', 'user', 'comment' or 'media'.
    if ( 'post' === $list_screen->get_meta_type() ) {
        require_once plugin_dir_path( __FILE__ ) . 'ac-column-assigned_products.php';
        require_once plugin_dir_path( __FILE__ ) . 'acp-column-assigned_products.php';
        $list_screen->register_column_type( new ACP_Column_Assigned_Products );
    }
});

As our story goal is to make the column filterable therefore we can rely only on acp/column_types action. This is due to the face that filtering is available only in PRO version.
I am adding ac/column_types action though (from the free version) just for the sake of promoting proper code structure (as each use case may be different and you may need it).

ac-column-assigned_products.php

In this file we are going to define basic information about our column. Additonally, we are going to define how the data should be fetched and displayed.

Class overview

class AC_Column_Assigned_Products extends AC_Column {

    public function __construct() {

        // Identifier, pick an unique name. Single word, no spaces. Underscores allowed.
        $this->set_type( 'column-assigned-products' );

        // Default column label.
        $this->set_label( __( 'Assigned products', 'ac-assigned-products' ) );
    }

    public function get_value( $post_id ) {}

    public function get_raw_value( $post_id ) {}
}

Data fetching
Now let's implement data fetching function which will read the products stored in our repeater field:

public function get_raw_value( $post_id ) {
    $products = get_field( 'kg_order_items', $post_id );
    $data       = [];

    if ( ! empty( $products ) ) {
        foreach ( $products as $product ) {
            $data[] = [
                'id'    => $product[ 'kg_order_item' ]->ID,
                'title' => $product[ 'kg_order_item' ]->post_title,
            ];
        }
    }

    return $data;
}

Data display
Last part would be to handle the display of the values in the admin view. As mentioned before - each use case is different - for example in my personal projects I display the title of assigned item which links to the edit page.

public function get_value( $post_id ) {
    // get raw value
    $products = $this->get_raw_value( $post_id );

    $data = [];

    if ( ! empty( $products ) ) {
        foreach ( $products as $product ) {
            $data[] = '<a href="' . esc_url( get_edit_post_link( $product[ 'id' ] ) ) . '">' . $product[ 'title' ] . '</a>';
        }
    }

    return implode( ',<br>', $data );
}

acp-column-assigned_products.php

The last file which is going to contain the "meat" of our functionality.

The file is going to contain two classes which is contrary to best practice of having one class per file but it is a conscious decission for the sake of simplicity.

Column Class
As we need only filtering therefore we implement only ACP_Column_FilteringInterface. If you need also sorting and editing you need to implement two additional interfaces (ACP_Column_EditingInterface, ACP_Column_SortingInterface). Starter template contains an example of such scenario.

class ACP_Column_Assigned_Products extends AC_Column_Assigned_Products
    implements ACP_Column_FilteringInterface {

    public function filtering() {
        return new ACP_Filtering_Model_Assigned_Products( $this );
    }
}

Model Class Overview
Model class tells the plugin how the filtering should be handled. There are many methods you may implement here but two of them are mandatory:

class ACP_Filtering_Model_Assigned_Products extends ACP_Filtering_Model {

    public function get_filtering_vars( $vars ) {}

    public function get_filtering_data() {}
}

Fetching filter options
Now we need to supply the array of options which are going to be used to populate our filter. There are many ways to do it which depend on particular scenario.

Please have in mind that in many cases populating such filter may be very expensive process which may influence performance of admin pages.

public function get_filtering_data() {
    return [
        'order'        => 'label',
        'empty_option' => false,
        'options'      => $this->get_products_for_dropdown(),
    ];
}

private function get_products_for_dropdown() {
    $args = [
        'posts_per_page' => -1,
        'post_type'      => 'product',
    ];
    $products_query = new \WP_Query( $args );
    $data             = [];

    if ( $products_query->have_posts() ) {
        while ( $products_query->have_posts() ) {
            $products_query->the_post();
            $data[ $products_query->post->ID ] = $products_query->post->post_title;
        }
        wp_reset_postdata();
    }

    return $data;
}

Handling the filter values
Let's finish our functionality and tell WordPress how it should handle filter values. For this purpose we are going to use function get_filtering_vars which exposes all query vars used to generate current admin screen.

Fetching repeater fields is a bit tricky but the whole process can be found in ACF documentation about "sub custom field values".

More information on how to modify query vars can be found in WordPress Documentation page about WP Query Class

public function get_filtering_vars( $vars ) {

    add_filter( 'posts_where', function ( $where ) {
        $where = str_replace( "meta_key = 'kg_order_items_", "meta_key LIKE 'kg_order_items_", $where );

        return $where;
    } );

    $product_id = $this->get_filter_value();

    $vars[ 'meta_query' ][] = [
        'key'     => 'kg_order_items_%_kg_order_item',
        'compare' => '=',
        'value'   => $product_id,
    ];

    return $vars;
}

Last steps and summary

After you finish your plugin you need to activate. Afterwards you need to configure columns set up at Admin Columns Pro settings page and add new column to order CPT listing view.

All of the code can be found on github
If you have any questions please do not hesitate to contact me.