Posted at

 WordPressプラグイン開発! 管理画面のリスト表示される機能(WP_List_Table)のチュートリアル?

More than 1 year has passed since last update.


管理画面のリスト表示される機能(WP_List_Table)のチュートリアル?

ワードプレスの管理画面の投稿やコメント、ユーザーを見ると、なにやら似たような感じのリストがあります。

このリストは、WP_List_Table派生クラスが使用されていて、それぞれ投稿は「WP_Posts_List_Table」、コメントは「WP_Comments_List_Table」、ユーザーは「WP_Users_List_Table」が使用されてます。

他にもいろいろ有りますので興味がある人は調べてください。

何と言うものか知らないので、とりあえずまんまな「リストテーブル」ということにします。

そこで今回はこのリストテーブルを継承し、独自のリストテーブルを作ってみることにしました。

list-table-last-0.png

これが最終的に作ってみたオリジナルなリストテーブルです。

面倒でワードプレス更新してませんorz

「せいすい」の値段間違えましたー、20だけど直すの面倒なのでそのままにしてます。


まずはじめにリストに表示するためのクラスを用意。

適当にプラグイン作成の準備をしてください。

作り方はすでに知っているものとします。


my-list-table.php

class MyItem

{
private $id;
private $name;
private $description;
private $price;

public function getId()
{
return $this->id;
}

public function getName()
{
return $this->name;
}

public function getDescription()
{
return $this->description;
}

public function getPrice()
{
return $this->price;
}

public function __construct($id, $name, $description, $price)
{
$this->id = $id;
$this->name = $name;
$this->description = $description;
$this->price = $price;
}
}

class MyItemInfo
{
private $id;
private $items;
private $type;

public function getItems()
{
return $this->items;
}

public function getType()
{
return $this->type;
}

public function __construct($type, $items)
{
$this->type = $type;
$this->items = $items;
}

public static function createDmyData()
{
return new MyItemInfo(
'道具',
[
new MyItem(1, 'やくそう', '少し体力を回復', 8),
new MyItem(2, 'どくけしそう', '毒を治す', 10),
new MyItem(3, 'せいすい', '雑魚よけ', 10),
new MyItem(4, 'キメラのつばさ','一度行ったことのある場所へ飛ぶ', 25),
new MyItem(5, 'きえさりそう', '姿を消す(透明になる)', 300)
]
);

}
}


MyItemクラスは、単にID、名前、要項、価格を持ちます。

MyItemInfoがMyItemの配列を持っているシンプルなクラスです。

public static function createDmyData()

実験用にダミーのMyItemInfoインスタンスを生成する静的メソッドを用意。


WP_List_Tableの継承

リストテーブルをカスタムする場合は、WP_List_Tableを継承します。

class MyListTable extends WP_List_Table

{
// ここにいろいろオーバーライドする。
}

もちろんこれだけでは何も起きませんから、少しずつ順を追って実装していきます。

注:WP_List_Tableは抽象クラスになってません。


最低限の実装

list-table-single_row-1.png

まずは最低限の実装を行った場合の表示です。

class MyListTable extends WP_List_Table

{
public function __construct()
{
// 必ず親のコンストラクタを呼ぶ。
parent::__construct();
}

public function prepare_items()
{
$info = MyItemInfo::createDmyData();
$this->items = $info->getItems();
}

public function get_columns()
{
return [
'cb' => 'チェックボックス',
'id' => 'ID',
'name' => '名前',
'description' => '要項',
'price' => '価格'
];
}

// CSSなどが追加されないので特別な理由が無い限りここで構築するべきではない。
public function single_row($item)
{
list($columns, $hidden, $sortable, $primary) = $this->get_column_info();

$id = (int)$item->getId();

echo '<tr>';
printf('<td><input type="checkbox" name="checked[]" value="%d" /></td>', $id);
printf('<td>%d</td>' ,$id);
printf('<td>%s</td>', esc_html($item->getName()));
printf('<td>%s</td>', esc_html($item->getDescription()));
printf('<td>%d</td>', (int)$item->getPrice());
echo '</tr>';

}

}

コンストラクタは必ず呼び出します。

いろいろ初期化行ってるようです。

public function prepare_items()

リストテーブルに表示するリストを設定します。

$this->itemsには項目の配列をセットします。

他にもページネーション等をセットしたりしますが、とりあえずMyListインスタンスの配列をセットしました。

public function get_columns()

カラムを連想配列で設定します(カラム名 => カラムディスプレイ名)

カラム名は今後非常に重要になります。

チェックボックス列であるカラム名だけはちょっと特殊で、カラム名はcbにします。

public function single_row($item)

とりあえずpublicなのでこれを直接オーバーライドしてみました。

しかし、CSSなどが追加されないため本来の機能を発揮しません(TDタグにclassが入ってない)

このメソッドでは一列分の出力を行います。配列の個数分呼び出されます。

list($columns, $hidden, $sortable, $primary) = $this->get_column_info();

$this->get_column_info()からこのインスタンスの情報が得られます。

$columnsはカラムの情報です。

$hidden、まだ試してないです。

$sortableはソートに関するもので、あとで説明します。

$primaryは今回の設定ではidが設定されています。

デフォルトでは、「cbの次にあるもの」がプライマリとなります。

独自にプライマリを設定したければ、get_primary_column()をオーバーライドします。

list_table_primary_columnフィルタで設定することも出来ます。

さて、これだけではまだ使えません。

MyListTableクラスを作成したら、それを使うコードが必要です。

add_action('admin_menu', function(){

require_once WP_PLUGIN_DIR . '/dicon/my-list-table.php';
$table = new MyListTable();

// メニューに追加する。
add_options_page(
'マイリスト管理画面',
'マイリスト',
'manage_options',
'mylist',
function() use($table)
{
$table->prepare_items();
$table->display();
}
);

});

add_menuアクションに追加します。

MyListTableのあるPHPファイルを読み込みます。

my-list-table.phpに書きましたが、これは環境に合わせてください。

とりあえず、MyListTable()のインスタンスを生成します。

add_options_page()

これはワードプレスにメニューを追加する関数です。

以前Qiitaに投稿したことがあるので気になる人は探してみてください。

function() use($table)

クロージャを設定します。クロージャ内で$tableを使うためにuseを使います。

一応PHPに詳しくな人のための説明ですが、ワードプレスの範囲を超えるのでわからなかったら調べてください。

$table->prepare_items();

リストに表示するデータを初期化します。

先にこれを実行してないと、リストテーブルの項目を表示できません。当然ですが。

個人的にはコンストラクタですべて初期化してしまいたい人なので、自分で書くコードはコンストラクタ内で初期化してます。

$table->display();

ここでやっとリストテーブルの項目を表示します。


カラム表示その2

class MyListTable extends WP_List_Table

{
public function __construct()
{
// 必ず親のコンストラクタを呼ぶ。
parent::__construct();
}

public function prepare_items()
{
$info = MyItemInfo::createDmyData();
$this->items = $info->getItems();
}

public function get_columns()
{
return [
'cb' => 'チェックボックス',
'id' => 'ID',
'name' => '名前',
'description' => '要項',
'price' => '価格'
];
}

protected function column_cb($item)
{
$id = (int)$item->getId();
return "<input type=\"checkbox\" name=\"checked[]\" value=\"{$id}\" />";
}

protected function column_default($item, $name)
{
switch($name)
{
// ここでcbが呼び出されることはない。
// cbは特別にcolumn_cb($item)が呼び出される。
case 'id' : return (string)(int)$item->getId();
case 'name' : return esc_html($item->getName());
case 'description' : return esc_html($item->getDescription());
case 'price' : return esc_html($item->getPrice());
}
}

}

single_row()の問題はTDタグにCSSクラスなどの情報が付与されない点です。

column_default()でカラム項目を個別に出力できます。

TDタグはWP_List_Table側でやってくれるので(CSSもつけてくれる)、その中身だけ処理します。

\$itemはMyItemのインスタンス、$nameはカラム名です。

ただし、チェックボックスのカラムであるcbだけはcolumn_cb()で処理します。

ソースを見るとTDタグにクラス等が挿入されていることがわかります。


カラム表示その3

2.png

class MyListTable extends WP_List_Table

{
public function __construct()
{
// 必ず親のコンストラクタを呼ぶ。
parent::__construct();
}

public function prepare_items()
{
$info = MyItemInfo::createDmyData();
$this->items = $info->getItems();
}

public function get_columns()
{
return [
'cb' => 'チェックボックス',
'id' => 'ID',
'name' => '名前',
'description' => '要項',
'price' => '価格'
];
}

protected function column_cb($item)
{
$id = (int)$item->getId();
return "<input type=\"checkbox\" name=\"checked[]\" value=\"{$id}\" />";
}

protected function column_default($item, $name)
{
switch($name)
{
// ここでcbが呼び出されることはない。
// cbは特別にcolumn_cb($item)が呼び出される。
case 'id' : return (string)(int)$item->getId();
case 'name' : return esc_html($item->getName());
case 'description' : return esc_html($item->getDescription());
case 'price' : return esc_html($item->getPrice());
}
}

protected function column_name($item)
{
$name = esc_html($item->getName());
return "<strong>{$name}</strong>";
}
}

protected function column_name($item)

name列のカラム項目だけ強調表示されるようになりました。

「column_カラム名()」という命名規則で定義したメソッドはcolumn_default()に優先して呼び出されます。


カラム表示その4

    protected function _column_description($item, $classes, $data, $primary)

{
// TDから作成する必要あり。
// というかTDのCSSを細かく制御したい時につかうと良さそう
$desc = esc_html($item->getDescription());
return "<td class=\" {$classes}\" {$data} ><strong>{$desc}</strong></td>";
}

コードが長くなってきたので今後省略しますが、

「_column_カラム名」という命名規則を使うと、column_default()及び「column_カラム名()」に優先して呼び出されます。

今回はdescriptionカラムを変更しました。

前回と違って、TDタグのCSS処理など細かく制御出来るようになってます。

_column_カラム名() → column_カラム名() → column_default()

の順で優先されます。

もちろん、カラム名がcbの時だけは特別で「column_cb()」が最優先で呼ばれます。


ハンドルアクション

list-table-column-ha-3.png

マウスを当てると編集とかゴミ箱へ移動などのリンクが出るやつです。

本当はこの画像の「削除」のところにカーソルがあるんですが、キャプチャの関係上消えてます。


protected function handle_row_actions( $item, $column_name, $primary )
{
if( $column_name === $primary )
{
$actions = [
'edit' => '<a href="/">編集</a>',
'delete' => '<a href="/">削除</a>'
];

// div class = raw-actions がキモやね
return $this->row_actions($actions);
}
}

handle_row_actions()をオーバーライドすると、ハンドルアクション?を表示します。

プライマリ項目にマウスで触れると表示されるあれです。

$this->row_actions()には、この機能を使用するために必要なCSS(row-actions)をDIVタグに追加します。

第二引数にtrueを渡すと常に表示するCSS(visible)を追加します。

リンクは今回ダミーです。


ソート

list-table-sort-4.png

    protected function get_sortable_columns()

{
return [
'id' => 'id',
'price' => ['price' , true]
];
}

並び替えを行います。

連想配列で、ソートを行うカラム名と、その順序を指定します。

'price' => ['price' , true]

price列の設定を配列で設定してます。

falseならasc(昇順), trueならdesc(降順)のリンクになります。

'id' => 'id'

配列じゃ無い場合は、false(昇順)になります。

ちなみに\$_GET['orderby']/$_GET['order']がある場合は現在の順序と逆の順序が設定されます。

例えば、orderby=idでorder=descなら、idのカラムにはascへのリンクになります。

でも実際にはソートされていません。

http://wordpress/wp-admin/options-general.php?page=mylist&orderby=price&order=desc

「価格」をクリックしてからアドレスを見ると、orderby, orderが設定してあります。

WordPress的にはこれらのパラメータに意味がありますが、今回は自前のデータを用意したのでソート処理は自前で行う必要があります。

普通はデータベース使うと思うので、あくまで実験的なものです。

とりあえず実験的に実装します。

    public function prepare_items()

{
$info = MyItemInfo::createDmyData();
$this->items = $info->getItems();

// ソート実験用にアイテム(武器だけど)を追加
$idCnt = 5;
$this->items[] = new MyItem(++$idCnt, 'どうのつるぎ', '銅で出来た剣', 100);
$this->items[] = new MyItem(++$idCnt, 'ひのきのぼう', 'ただの棒', 5);
$this->items[] = new MyItem(++$idCnt, 'はやぶさのけん', '2回攻撃出来る', 25000);
$this->items[] = new MyItem(++$idCnt, 'まどうしのつえ', '使うとメラの効果', 1500);

// ソート(数値のみ対応)
$sort = function(int $a, int $b, int $bigA){
if($a === $b) return 0;
return $a > $b ? $bigA : -$bigA; //$bigAが1なら昇順、-1なら降順
};

$orderby = isset($_GET['orderby']) ? (string)$_GET['orderby'] : '';
$order = isset($_GET['order']) ? (string)$_GET['order'] : '';
$orderDir = $order === 'asc' ? 1 : -1;

$fnames = [
'id' => function($item){ return $item->getId(); },
'price' => function($item){ return $item->getPrice(); }
];

$getter = isset($fnames[$orderby]) ? $fnames[$orderby] : null;
if($getter)
{
usort(
$this->items,
function($a, $b) use($getter, $sort, $orderDir)
{
return $sort($getter($a), $getter($b), $orderDir);
}
);
}

}

オブジェクトのソートは流石に面倒でした。

prepare_items()で元の\$this->itemsをソートされた結果で上書きします。

ソートの実験のためアイテム数を増やしてます!

list-table-sort-ex-5.png

とりあえずソート出来てるようです。


ページネーション

list-table-pagination-6.png

    public function prepare_items()

{
$info = MyItemInfo::createDmyData();
$this->items = $info->getItems();

// ソート実験用にアイテム(武器だけど)を追加
$idCnt = 5;
$this->items[] = new MyItem(++$idCnt, 'どうのつるぎ', '銅で出来た剣', 100);
$this->items[] = new MyItem(++$idCnt, 'ひのきのぼう', 'ただの棒', 5);
$this->items[] = new MyItem(++$idCnt, 'はやぶさのけん', '2回攻撃出来る', 25000);
$this->items[] = new MyItem(++$idCnt, 'まどうしのつえ', '使うとメラの効果', 1500);

// ソート(数値のみ対応)
$sort = function(int $a, int $b, int $bigA){
if($a === $b) return 0;
return $a > $b ? $bigA : -$bigA; //$bigAが1なら昇順、-1なら降順
};

$orderby = isset($_GET['orderby']) ? (string)$_GET['orderby'] : '';
$order = isset($_GET['order']) ? (string)$_GET['order'] : '';
$orderDir = $order === 'asc' ? 1 : -1;

$fnames = [
'id' => function($item){ return $item->getId(); },
'price' => function($item){ return $item->getPrice(); }
];

$getter = isset($fnames[$orderby]) ? $fnames[$orderby] : null;
if($getter)
{
usort(
$this->items,
function($a, $b) use($getter, $sort, $orderDir)
{
return $sort($getter($a), $getter($b), $orderDir);
}
);
}

// ページネーションを使う場合は設定
$this->set_pagination_args([
'total_items' => count($this->items),
//'total_pages' => 5, //設定してないと、ceil(total_items / per_page)
'per_page' => 2
]);

// ページ数を取得
$pageLen = $this->get_pagination_arg('total_pages');

// 現在のページ($_REQUEST['paged'])を取得、範囲を外れると修正される。
$paged = $this->get_pagenum();

$per_page = $this->get_pagination_arg('per_page');

// ページネーションを独自に計算
$this->items = array_slice(
$this->items,
$per_page * ($paged - 1),
$per_page
);

}

$this->set_pagination_args

ページネーションを設定します。

total_itemsはアイテムの数($this->itemsの個数)

per_pageは1ページに表示するアイテムの数、

total_pagesはページの総数ですが、省略すると自動的に計算されます。

$pageLen = $this->get_pagination_arg('total_pages');

設定値はget_pagination_args()で取得出来ます。

$paged = $this->get_pagenum();

現在のページですね。

実はこれだけでもページネーションは表示されます。

ただ、項目はページネーションが反映されません。

前のソートのときと同じですね。

ページネーションの実装はかんたんで、

        // ページネーションを独自に計算

$this->items = array_slice(
$this->items,
$per_page * ($paged - 1),
$per_page
);

$this->items ページネーション結果に上書きします。

これだけです。

必ずソートの後に書きましょう。


アクション

list-table-action-7.png



8.png

1G値上げするを選んで適用すると、「値上げしないよ〜!」と表示される。

    protected function get_bulk_actions()

{
return [
'delete' => 'アイテムを削除する',
'priceup' => '1G値上げする',
'pricedown' => '1G値下げする'
];
}

protected function get_bulk_actions()

アクション名と表示名を連想配列で返します。

そういえば、FORMタグを書き忘れてました!

admin_menuの内容も変更します。

add_action('admin_menu', function(){

require_once WP_PLUGIN_DIR . '/dicon/my-list-table.php';
$table = new MyListTable();

// アクションを処理する
$action = $table->current_action();
$msgs = [
'delete' => '削除',
'priceup' => '値上げ',
'pricedown' => '値下げ'
];
if( isset($msgs[$action]) )
{
$msg = $msgs[$action] . 'しないよ〜!';
add_action('admin_notices', function() use($msg){
echo "<div class=\"updated\">{$msg}</div>";
});
}

// メニューに追加する。
add_options_page(
'マイリスト管理画面',
'マイリスト',
'manage_options',
'mylist',
function() use($table)
{
$table->prepare_items();

$page = esc_attr(isset($_GET['page']) ? (string)$_GET['page'] : '');

echo '<form method="get">';
printf('<input type="hidden" name="page" value="%s" />', $page);
$table->display();
echo '</form>';
}
);

});

$action = $table->current_action();

送信すると、アクション名を取得出来ます。

今回は面倒なのでアクション名による操作を実際にはせず、admin_noticesに表示しただけです。

今まではフォームタグを省略してきました。

printf('<input type="hidden" name="page" value="%s" />', $page);

前回、GETではクエリ文字は取得出来ないよーという記事を上げました。

その対策を行ってます。

https://qiita.com/diconran/items/d27bc79094b62ec81625

を読んでください。


アクションとページネーションの間で・・・

    protected function extra_tablenav($witch)

{
echo "<div class=\"alignleft actions bulkactions\">←bulk [間だよ!] ページネーション→</div>";
}

画像はなしです。

最初に上げてる画像で確認してください。

bulk_action-{スクリーンID} フィルタでも設定できる。


ビュー

    protected function get_views()

{
return [
'home' => '<a href="/">ホームへGo</a>',
'sort' => '<a href="?page=mylist&orderby=price&order=desc">高い順!</a>'
];
}


// メニューに追加する。
add_options_page(
'マイリスト管理画面',
'マイリスト',
'manage_options',
'mylist',
function() use($table)
{
$table->prepare_items();

$page = esc_attr(isset($_GET['page']) ? (string)$_GET['page'] : '');

echo $table->views();
echo '<form method="get">';
printf('<input type="hidden" name="page" value="%s" />', $page);
$table->display();
echo '</form>';
}
);

↑コードを省略しました。

これも画像なしです。

最初に上げてる画像で確認してください。

左上あたりに表示されてます。

protected function get_views()

ビュー名とそのリンクを配列で。

echo $table->views();

表示する側で呼び出します。


検索枠

list-table-search-9.png

    public function prepare_items()

{
$info = MyItemInfo::createDmyData();
$this->items = $info->getItems();

// ソート実験用にアイテム(武器だけど)を追加
$idCnt = 5;
$this->items[] = new MyItem(++$idCnt, 'どうのつるぎ', '銅で出来た剣', 100);
$this->items[] = new MyItem(++$idCnt, 'ひのきのぼう', 'ただの棒', 5);
$this->items[] = new MyItem(++$idCnt, 'はやぶさのけん', '2回攻撃出来る', 25000);
$this->items[] = new MyItem(++$idCnt, 'まどうしのつえ', '使うとメラの効果', 1500);

// 検索
$s = isset($_REQUEST['s']) ? (string)$_REQUEST['s'] : '';
if(!empty($s)){
$this->items = array_filter($this->items, function($item) use($s){
return
strpos($item->getName(), $s) ||
strpos($item->getDescription(), $s);
});
}

// ソート(数値のみ対応)
$sort = function(int $a, int $b, int $bigA){
if($a === $b) return 0;
return $a > $b ? $bigA : -$bigA; //$bigAが1なら昇順、-1なら降順
};

$orderby = isset($_GET['orderby']) ? (string)$_GET['orderby'] : '';
$order = isset($_GET['order']) ? (string)$_GET['order'] : '';
$orderDir = $order === 'asc' ? 1 : -1;

$fnames = [
'id' => function($item){ return $item->getId(); },
'price' => function($item){ return $item->getPrice(); }
];

$getter = isset($fnames[$orderby]) ? $fnames[$orderby] : null;
if($getter)
{
usort(
$this->items,
function($a, $b) use($getter, $sort, $orderDir)
{
return $sort($getter($a), $getter($b), $orderDir);
}
);
}

// ページネーションを使う場合は設定
$this->set_pagination_args([
'total_items' => count($this->items),
//'total_pages' => 5, //設定してないと、ceil(total_items / per_page)
'per_page' => 2
]);

// ページ数を取得
$pageLen = $this->get_pagination_arg('total_pages');

// 現在のページ($_REQUEST['paged'])を取得、範囲を外れると修正される。
$paged = $this->get_pagenum();

$per_page = $this->get_pagination_arg('per_page');

// ページネーションを独自に計算
$this->items = array_slice(
$this->items,
$per_page * ($paged - 1),
$per_page
);

}

    // メニューに追加する。

add_options_page(
'マイリスト管理画面',
'マイリスト',
'manage_options',
'mylist',
function() use($table)
{
$table->prepare_items();

$page = esc_attr(isset($_GET['page']) ? (string)$_GET['page'] : '');

echo $table->views();
echo '<form method="get">';
$table->search_box('検索する', 'items');
printf('<input type="hidden" name="page" value="%s" />', $page);
$table->display();
echo '</form>';
}
);

$table->search_box('検索する', 'items');

検索枠を表示します。

検索文字は$_REQUEST['s']から取得できます。

        // 検索

$s = isset($_REQUEST['s']) ? (string)$_REQUEST['s'] : '';
if(!empty($s)){
$this->items = array_filter($this->items, function($item) use($s){
return
strpos($item->getName(), $s) ||
strpos($item->getDescription(), $s);
});
}

これもソートやページネーション同様、自作してます。

名前と要項に検索枠の文字が含まれていた場合、その一覧を$this->itemsに上書きしてます。

えぇ、思いっきり手抜きしてますよ。

検索枠に空白とか入れても知らんから!

ソートの前に実行します。

検索結果 → ソート結果 → ページネーション

の順に記述します。


Ajax用のデータを追加する

list-table-ajax-10.png


public function __construct()
{
// 必ず親のコンストラクタを呼ぶ。
parent::__construct(
[
'ajax' => true
]
);
}

public function _js_vars()
{
echo '<script type="text/javascript">test = "abcdefg";</script>';
}

_js_vars()を追加、コンストラクタの引数でajax=>trueを連想配列で渡します。


その他

もう眠くて限界なので更に手抜き解説!

months_dropdown( $post_type )

投稿にあるような「すべての日付」を表示

view_switcher( $current_mode )

メディアにある「リスト」や「グリッド」など

display_tablenav( $which )

アクションやページネーション等を表示する。

項目の上下にある。

\$whichが'top'だとnonceが出力される。

display()から呼ばれる。

print_column_headers()

カラム表示部部分(IDとか価格とか)

display()からよばれる。


コンストラクタ

__construct( $args = array() )

連想配列を渡す。

$args['plural'] = 'items'

CSSクラスやnonceで使用されます。

空の時は「$this->screen->base」が設定。

<table class="wp-list-table widefat fixed striped items">

\$args['plural'] = 'items' とすると、↑のようにHTMLテーブルのCSSクラスに追加されたりします。

またWP_List_Table.get_table_classes()の戻値の一つになってます。

display_tablenav( 'top' )が実行された時(テーブルナビ上部が表示された時)、

wp_nonce_field()の引数「bulk-*」として使用され、itemsの場合は、wp_nonce_field('bulk-items')となります。

$args['singular'] = 'item'

WP_List_Table.display()を実行した際に、tbodyの要素に追加されます。

<tbody id="the-list" data-wp-lists="list:item">

コメントやユーザーなどでも確認できます。

$args['ajax'] = true

デフォルトではfalse。trueにすると\$this->_js_vars()が「admin_footer」アクションに登録されます。

\$this->_js_vars()をオーバーライドすれば「admin_footer」アクションが呼び出された時に独自のJavaScript出力させたりできます。

WP_List_Tableの_js_vars()はスクリプトにJSON形式でWP_List_Table派生クラスの名前や、スクリーンIDなどが書き出されます。

ブレークポイントを貼るとダッシュボードでヒットします。

<script type="text/javascript">

list_args = {"class":"WP_Comments_List_Table","screen":{"id":"dashboard","base":"dashboard"}};
</script>

↑ダッシュボードのHTMLに含まれる。

$args['screen']

WP_Screenのインスタンスか、スクリーンID。

スクリーン(\$this->screen)を初期化します。

この値はconvert_to_screen()に渡され、結果が$this->screenに設定されます。

WP_List_Tableはスクリーンと密接に関わっていて、スクリーンを色んな所から呼び出してます。

スクリーンIDがフィルタ名の一部として使われることもあります。

ただ、スクリーンをまだ把握していないのでこれ以上言及しません。

まだいくつか書いてないことがありますが、後はWPのソースコード追ってください。

派生クラスもだいぶ読みやすくなるはずです。


最後に、全体像!


class MyListTable extends WP_List_Table
{
public function __construct()
{
// 必ず親のコンストラクタを呼ぶ。
parent::__construct(
[
'ajax' => true
]
);
}

public function prepare_items()
{
$info = MyItemInfo::createDmyData();
$this->items = $info->getItems();

// ソート実験用にアイテム(武器だけど)を追加
$idCnt = 5;
$this->items[] = new MyItem(++$idCnt, 'どうのつるぎ', '銅で出来た剣', 100);
$this->items[] = new MyItem(++$idCnt, 'ひのきのぼう', 'ただの棒', 5);
$this->items[] = new MyItem(++$idCnt, 'はやぶさのけん', '2回攻撃出来る', 25000);
$this->items[] = new MyItem(++$idCnt, 'まどうしのつえ', '使うとメラの効果', 1500);

// 検索
$s = isset($_REQUEST['s']) ? (string)$_REQUEST['s'] : '';
if(!empty($s)){
$this->items = array_filter($this->items, function($item) use($s){
return
strpos($item->getName(), $s) ||
strpos($item->getDescription(), $s);
});
}

// ソート(数値のみ対応)
$sort = function(int $a, int $b, int $bigA){
if($a === $b) return 0;
return $a > $b ? $bigA : -$bigA; //$bigAが1なら昇順、-1なら降順
};

$orderby = isset($_GET['orderby']) ? (string)$_GET['orderby'] : '';
$order = isset($_GET['order']) ? (string)$_GET['order'] : '';
$orderDir = $order === 'asc' ? 1 : -1;

$fnames = [
'id' => function($item){ return $item->getId(); },
'price' => function($item){ return $item->getPrice(); }
];

$getter = isset($fnames[$orderby]) ? $fnames[$orderby] : null;
if($getter)
{
usort(
$this->items,
function($a, $b) use($getter, $sort, $orderDir)
{
return $sort($getter($a), $getter($b), $orderDir);
}
);
}

// ページネーションを使う場合は設定
$this->set_pagination_args([
'total_items' => count($this->items),
//'total_pages' => 5, //設定してないと、ceil(total_items / per_page)
'per_page' => 2
]);

// ページ数を取得
$pageLen = $this->get_pagination_arg('total_pages');

// 現在のページ($_REQUEST['paged'])を取得、範囲を外れると修正される。
$paged = $this->get_pagenum();

$per_page = $this->get_pagination_arg('per_page');

// ページネーションを独自に計算
$this->items = array_slice(
$this->items,
$per_page * ($paged - 1),
$per_page
);

}

public function get_columns()
{
return [
'cb' => 'チェックボックス',
'id' => 'ID',
'name' => '名前',
'description' => '要項',
'price' => '価格'
];
}

protected function column_default($item, $name)
{
switch($name)
{
case 'id' : return (string)(int)$item->getId();
case 'name' : return esc_html($item->getName());
case 'description' : return esc_html($item->getDescription());
case 'price' : return esc_html($item->getPrice());
}
}

protected function column_cb($item)
{
$id = (int)$item->getId();
return "<input type=\"checkbox\" name=\"checked[]\" value=\"{$id}\" />";
}

protected function _column_description($item, $classes, $data, $primary)
{
$desc = esc_html($item->getDescription());
return "<td class=\" {$classes}\" {$data} ><strong>{$desc}</strong></td>";
}

protected function column_name($item)
{
$name = esc_html($item->getName());
return "<strong>{$name}</strong>";
}

protected function handle_row_actions( $item, $column_name, $primary )
{
if( $column_name === $primary )
{
$actions = [
'edit' => '<a href="/">編集</a>',
'delete' => '<a href="/">削除</a>'
];

return $this->row_actions($actions);
}
}

protected function get_sortable_columns()
{
return [
'id' => 'id',
'price' => ['price' , true]
];
}

/**
* bulk_action-{ScreenID} でフィルタ登録されている。
*/

protected function get_bulk_actions()
{
return [
'delete' => 'アイテムを削除する',
'priceup' => '1G値上げする',
'pricedown' => '1G値下げする'
];
}

protected function extra_tablenav($witch)
{
echo "<div class=\"alignleft actions bulkactions\">←bulk [間だよ!] ページネーション→</div>";
}

protected function get_views()
{
return [
'home' => '<a href="/">ホームへGo</a>',
'sort' => '<a href="?page=mylist&orderby=price&order=desc">高い順!</a>'
];
}

public function _js_vars()
{
echo '<script type="text/javascript">test = "abcdefg";</script>';
}
}

add_action('admin_menu', function(){

require_once WP_PLUGIN_DIR . '/dicon/my-list-table.php';
$table = new MyListTable();

// アクションを処理する
$action = $table->current_action();
$msgs = [
'delete' => '削除',
'priceup' => '値上げ',
'pricedown' => '値下げ'
];
if( isset($msgs[$action]) )
{
$msg = $msgs[$action] . 'しないよ〜!';
add_action('admin_notices', function() use($msg){
echo "<div class=\"updated\">{$msg}</div>";
});
}

// メニューに追加する。
add_options_page(
'マイリスト管理画面',
'マイリスト',
'manage_options',
'mylist',
function() use($table)
{
$table->prepare_items();

$page = esc_attr(isset($_GET['page']) ? (string)$_GET['page'] : '');

echo $table->views();
echo '<form method="get">';
$table->search_box('検索する', 'items');
printf('<input type="hidden" name="page" value="%s" />', $page);
$table->display();
echo '</form>';
}
);

});


注意点!

add_action('admin_menu', function(){

require_once WP_PLUGIN_DIR . '/dicon/my-list-table.php';
$table = new MyListTable();

// メニューに追加する。
add_options_page(
'マイリスト管理画面',
'マイリスト',
'manage_options',
'mylist',
function() use($table)
{

$table->prepare_items();

echo '<form method="get">';
printf('<input type="hidden" name="page" value="%s" />', $page);
$table->display();
echo '</form>';
}
);

});

これが、

add_action('admin_menu', function(){

require_once WP_PLUGIN_DIR . '/dicon/my-list-table.php';

// メニューに追加する。
add_options_page(
'マイリスト管理画面',
'マイリスト',
'manage_options',
'mylist',
function() use($table)
{
$table = new MyListTable();
$table->prepare_items();

echo '<form method="get">';
printf('<input type="hidden" name="page" value="%s" />', $page);
$table->display();
echo '</form>';
}
);

});

こう変わったら!

list-table-x-11.png

何が問題かというと、

$table = new MyListTable();

を呼び出すタイミングが悪くてこういう結果になってしまいます。

WP_List_Table.get_column_info()から取得するカラムは、内部ではscreen.phpにあるget_column_headers( $screen )から取得してます。

普通に考えるとWP_List_Table.get_columns()からカラムが取得されていそうです。

でもそうではありません。

流れを追ってみると、

WP_List_Tableのコンストラクタ初期化時、

add_filter( "manage_{$this->screen->id}_columns", array( $this, 'get_columns' ), 0 );

により、get_columnsがフィルタ登録されるだけです。

get_column_headers( $screen )は内部で

$column_headers[ $screen->id ] = apply_filters( "manage_{$screen->id}_columns", array() );

フィルタから取得してます。

この$column_headersはキャッシュされていて、最初に取得されたカラムが設定されると、それ以降その値を返します。

もしこの時点でフィルタに何も登録してなかったら、空の配列で初期化されてしまいます。

それが問題を引き起こします。

何がなんでも、get_column_headers($screen)が呼び出される前にフィルタ登録する必要があります。

フィルタ登録はWP_List_Tableのコンストラクタで行われるため、コンストラクタが実行される前に呼ばれてはこまります。

ところが、add_options_page()のコールバック内ではすでに呼び出されてしまってます。

オプションメニューは、

options-general.php -> admin.php -> admin-header.php

の順でファイルが読み込まれていきます。

このadmin-header.phpのコードをたどると、

$current_screen->render_screen_meta();

if ( $this->show_screen_options() )

$columns = get_column_headers( $this );

で呼び出す記述があります。

admin-header.phpが呼ばれる前にWP_List_Tableのコンストラクタをよぶ!

ようにしましょう。

add_action('admin_menu', function(){

require_once WP_PLUGIN_DIR . '/dicon/my-list-table.php';
$table = null;

// メニューに追加する。
add_options_page(
'マイリスト管理画面',
'マイリスト',
'manage_options',
'mylist',
function() use(&$table)
{
$table->prepare_items();

echo '<form method="get">';
$table->display();
echo '</form>';
}
);

/// load-settings_page_*** 、***はメニューのスラグ。
/// admin-header.phpが呼び出される前に実行される
add_action('load-settings_page_mylist', function() use (&$table)
{
$table = new MyListTable();
});

});

こんなアクションもあります。

abstract class MyListItem extends WP_List_Table

{

public function __construct($args)
{
parent::__construct([
'plural' => 'items',
'screen' => 'myscreen'// ←ここ注目。
]);

}

}

スクリーンを変えるとキャッシュが異なる(キャッシュはスクリーンID単位なので)のでこの問題はさけられます。

ただ、スクリーンを変えてしまうと他で影響がある可能性があるので保留。


またね!

あー、つかれた!

本だったら1章くらいかけそうな量だよ、大変だったorz