4

I am trying to make a custom WP_List_Table extension to display a custom post type.

Here is what the table and the DOM look like. You can see that it is rendering the table rows empty, and no header. There should be two posts so that much is working.

enter image description here

Here is the code for the page:

<?php
function customer_list_page() {
  if (!class_exists('WP_List_Table')) {
    require_once(ABSPATH . 'wp-admin/includes/class-wp-list-table.php');
  }

  class CustomerList extends WP_List_Table {
    public function __construct() {
      parent::__construct([
        'singular' => __('Customer', 'text-domain'),
        'plural' => __('Customers', 'text-domain'),
        'ajax' => false
      ]);
    }

    public static function get_customers() {
      $query = new WP_Query(array(
        'post_type' => 'customer'
      ));

      $customer_posts = $query->posts;
      $customers = array();

      foreach($customer_posts as $customer_post) {
        $new_customer = array();
        $new_customer['postId'] = $customer_post->ID;
        $new_customer['firstName'] = get_post_meta($customer_post->ID, 'lws-first-name', true);
        $new_customer['lastName'] = get_post_meta($customer_post->ID, 'lws-last-name', true);
        $new_customer['notes'] = get_post_meta($customer_post->ID, 'lws-notes', true);
        $new_customer['customerId'] = get_post_meta($customer_post->ID, 'lws-customer-id', true);
        $new_customer['email'] = get_post_meta($customer_post->ID, 'lws-email', true);
        $new_customer['phone'] = get_post_meta($customer_post->ID, 'lws-phone', true);
        $new_customer['mobile'] = get_post_meta($customer_post->ID, 'lws-mobile', true);
        $new_customer['emailNotifications'] = get_post_meta($customer_post->ID, 'lws-email-notifications', true);
        $new_customer['acceptChecks'] = get_post_meta($customer_post->ID, 'lws-accept-checks', true);
        $new_customer['altContact'] = get_post_meta($customer_post->ID, 'lws-alt-contact', true);
        $new_customer['altPhone'] = get_post_meta($customer_post->ID, 'lws-alt-phone', true);
        $new_customer['street'] = get_post_meta($customer_post->ID, 'lws-street', true);
        $new_customer['city'] = get_post_meta($customer_post->ID, 'lws-city', true);
        $new_customer['state'] = get_post_meta($customer_post->ID, 'lws-state', true);
        $new_customer['zip'] = get_post_meta($customer_post->ID, 'lws-zip', true);
        $new_customer['pets'] = json_decode(get_post_meta($customer_post->ID, 'lws-pets', true));
        array_push($customers, $new_customer);
      }

      return $customers;
    }

    public static function delete_customer($id) {
      wp_delete_post($id);
    }

    public static function record_count() {
      return wp_count_posts('customer')->draft;
    }

    public function no_items() {
      _e('No customers to display', 'text-domain');
    }

    function column_name($customer) {
      $delete_nonce = wp_create_nonce('lws-delete-customer');
      $title = '<strong>' . $customer['firstName'] . ' ' . $customer['lastName'] . '</strong>';
      $actions = [
        'delete' => sprintf('<a href="?page=%s&action=%s&customer=%s&_wpnonce=%s">Delete</a>', esc_attr($_REQUEST['page']), 'delete', absint($customer['postId']), $delete_nonce)
      ];
      return $title . $this->row_actions($actions);
    }

    function column_default($customer, $column_name) {
      return $customer[$column_name];
    }

    function column_cb($customer) {
      return sprintf(
        '<input type="checkbox" name="bulk-delete[]" value="%s"/>', $customer['postId']
      );
    }

    function get_columns() {
      return [
        'cb' => '<input type="checkbox" />',
        'customerId' => __('Customer ID', 'text-domain'),
        'name' => __('Name', 'text-domain'),
        'email' => __('E-Mail', 'text-doamin'),
        'phone' => __('Phone #', 'text-domain')
      ];
    }

    public function get_sortable_columns() {
      return [
        'customerId' => ['customerId', true],
        'name' => ['name', false],
        'email' => ['emial', false],
        'phone' => ['phone', false]
      ];
    }

    public function get_bulk_actions() {
      return [
        'bulk-delete' => 'Delete'
      ];
    }

    public function prepare_items() {
      $this->_column_headers = $this->get_column_info();

      $this->process_bulk_actions();

      $per_page = $this->get_items_per_page('customers_per_page', 20);
      $current_page = $this->get_pagenum();
      $total_items = self::record_count();

      $this->set_pagination_args([
        'total_items' => $total_items,
        'per_page' => $per_page
      ]);

      $this->items = self::get_customers($per_page, $current_page);
    }

    public function process_bulk_action() {
      if($this->current_action() === 'delete') {
        $nonce = esc_attr($_REQUEST['_wpnonce']);
        if(!wp_verify_nonce($nonce, 'lws-delete-customer')) {
          die('What the heck');
        }
        else {
          self::delete_customer(absint($_GET['customer']));
          wp_redirect(esc_url(add_query_arg()));
          exit;
        }
      }
      if(
        (isset($_POST['action']) && $_POST['action'] == 'bulk-delete') ||
        (isset($_POST['action2']) && $_POST['action2'] == 'bulk-delete')
      ) {
        $delete_ids = esc_sql($_POST['bulk-delete']);
        foreach($delete_ids as $id) {
          self::delete_customer($id);
        }
        wp_redirect(esc_url(add_query_arg()));
        exit;
      }
    }
  }

  $customers_table = new CustomerList();
  ?>
    <div class="wrap">
      <h2>Customers</h2>
      <form method="post">
        <?php
          $customers_table->prepare_items();
          $customers_table->display();
        ?>
      </form>
    </div>
  <?php
}
?>

I can't figure it out from my debugging. As far as I can tell everything is nearly identical to what I followed here.

2
  • There are other issues in your code, but the answer focuses on showing the column headers (and eventually the whole table rows). However, I should still comment on 2 things: 1) There's a typo here: 'email' => ['emial', false], - that emial should be email.. 2) In record_count(), why are you counting draft posts? You could've used $query->found_posts in get_customers().
    – Sally CJ
    Mar 14, 2020 at 15:24
  • 1
    @SallyCJ Thanks for pointing out the typo. As for counting on draft posts, that was just a temporary thing I was looking at and forgot to remove. Mar 14, 2020 at 15:37

1 Answer 1

7

You're getting the blank rows because your column headers are registered late. And you should register the headers (i.e. initialize the list table class instance) before admin notices are rendered on the page, i.e. before WordPress fires hooks like admin_notices.

But your customer_list_page() function, which I believe, is a callback for either add_menu_page() or add_submenu_page(), is only being called after the admin notices (and other stuff) are displayed, hence get_column_headers() (that's used by WP_List_Table::get_column_info()) didn't recognize the column headers for your list table. And that is because get_column_headers() stores the column headers in a static variable/array which once set, will not be modified anymore, so the next time the function is called for your screen/page, the function returns the "old" column headers.

More specifically, your class instance ($customers_table = new CustomerList()) should be initialized before WP_Screen::render_screen_meta() is called in wp-admin/admin-header.php. And if you look at WP_List_Table::single_row_columns() which is used (although not directly) by WP_List_Table::display(), the body/content rows will only be displayed if there are valid column headers registered for the list table. Hence that explains why the column headers are mandatory for the content rows to be displayed. Well, just like a human can't live without a head... ( :D )

If you can't initialize the class instance before admin notices are rendered... you can manually set the $_column_headers value.

So in the prepare_items() method, just replace this:

$this->_column_headers = $this->get_column_info();

with this:

$this->_column_headers = [
    $this->get_columns(),
    [], // hidden columns
    $this->get_sortable_columns(),
    $this->get_primary_column_name(),
];

But try to always initialize it "on-time". :)

And here's an example using the load-<page hook> hook:

class My_Plugin {
    private $list_table;

    public function __construct() {
        add_action( 'admin_menu', [ $this, 'add_admin_menus' ] );
    }

    public function add_admin_menus() {
        $hook_name = add_menu_page( 'Customers', 'Customers',
            'edit_posts', 'my-page', [ $this, 'render_admin_page' ] );

        // Initialize the list table instance when the page is loaded.
        add_action( "load-$hook_name", [ $this, 'init_list_table' ] );
    }

    public function init_list_table() {
        $this->list_table = new CustomerList;
    }

    public function render_admin_page() {
        ?>
            <div class="wrap my-plugin">
                <h1>Customers</h1>
                <?php $this->list_table->prepare_items(); ?>
                <?php $this->list_table->display(); ?>
            </div>
        <?php
    }
}

if ( is_admin() ) {
    new My_Plugin;
}

And just a gentle reminder to double-check for typos like the emial in your get_sortable_columns()... =) Happy coding!

1
  • Thanks for sharing, may I know how did you find out that sequence order? What technique or method to trace back to understand get_column_headers() need to load before Admin Notice hook? Jun 1, 2020 at 13:36

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.