laravel-adminでlaravel-nestedsetのツリーを扱う

laravel-adminにはデフォルトでツリー構造のモデルを扱うための機能がついていますが、これはナイーブツリー向けのものであるためlaravel-nestedsetにそのまま使用することができません。

本記事はそれを解決するための手順メモです。

# バージョン
使用したライブラリのバージョンは以下の通り。

* laravel: v6.2.0
* laravel-admin: v1.7.7
* laravel-nestedset: v5.0.0

# 方針
laravel-adminにはナイーブツリー向けの`Encore\Admin\Traits\ModelTree`traitがあるため、これを参考にlaravel-nestedset向けのtraitを作る。

ツリーの取り扱いには以下が参考になる。

* [laravel-nestedsetのREADME](https://github.com/lazychaser/laravel-nestedset/blob/v5/README.markdown)
* [laravel-adminのリファレンス](https://laravel-admin.org/docs/#/en/model-tree)
* `Encore\Admin\Controllers\MenuController`まわりのソース

※ ただし、2020/01/08現在、laravel-adminのリファレンスのmodel-treeの項は非推奨な機能を用いているため注意

# laravel-nestedset向けのtraitを追加する
以下のtraitを追加する。

```php:ModelNestedSetTree.php
<?php

namespace App\Admin\Traits;

use Encore\Admin\Tree;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Request;

trait ModelNestedSetTree
{
    /**
     * @var string
     */
    protected $titleColumn = 'title';

    /**
     * @var \Closure
     */
    protected $queryCallback;

    /**
     * @return string
     */
    public function getParentColumn()
    {
        return $this->getParentIdName();
    }

    /**
     * Get title column.
     *
     * @return string
     */
    public function getTitleColumn()
    {
        return $this->titleColumn;
    }

    /**
     * Set title column.
     *
     * @param string $column
     */
    public function setTitleColumn($column)
    {
        $this->titleColumn = $column;
    }

    /**
     * Get order column name.
     *
     * @return string
     */
    public function getOrderColumn()
    {
        return $this->getLftName();
    }

    /**
     * Set query callback to model.
     *
     * @param \Closure|null $query
     *
     * @return $this
     */
    public function withQuery(\Closure $query = null)
    {
        $this->queryCallback = $query;

        return $this;
    }

    /**
     * Format data to tree like array.
     *
     * @return array
     */
    public function toTree()
    {
        $self = new static();

        if ($this->queryCallback instanceof \Closure) {
            $self = call_user_func($this->queryCallback, $self);
        }

        return $self->get()->toTree()->toArray();
    }

    /**
     * Get all elements.
     *
     * @return array
     */
    public function allNodes()
    {
        return static::defaultOrder()->get()->toArray();
    }

    /**
     * Save tree order from a tree like array.
     *
     * @param array $tree
     */
    public static function saveOrder($tree = [])
    {
        static::rebuildTree($tree);
    }

    /**
     * Get options for Select field in form.
     *
     * @param \Closure|null $closure
     * @param string        $rootText
     *
     * @return array
     */
    public static function selectOptions(\Closure $closure = null, $rootText = 'ROOT')
    {
        $options = (new static())->withQuery($closure)->buildSelectOptions();

        return collect($options)->prepend($rootText, 0)->all();
    }

    /**
     * Build options of select field in form.
     *
     * @param array  $nodes
     * @param int    $parentId
     * @param string $prefix
     * @param string $space
     *
     * @return array
     */
    protected function buildSelectOptions(array $nodes = [], $parentId = 0, $prefix = '', $space = '&nbsp;')
    {
        $prefix = $prefix ?: '┝'.$space;

        $options = [];

        if (empty($nodes)) {
            $nodes = $this->allNodes();
        }

        foreach ($nodes as $index => $node) {
            if ($node[$this->getParentColumn()] == $parentId) {
                $node[$this->titleColumn] = $prefix.$space.$node[$this->titleColumn];

                $childrenPrefix = str_replace('┝', str_repeat($space, 6), $prefix).'┝'.str_replace(['┝', $space], '', $prefix);

                $children = $this->buildSelectOptions($nodes, $node[$this->getKeyName()], $childrenPrefix);

                $options[$node[$this->getKeyName()]] = $node[$this->titleColumn];

                if ($children) {
                    $options += $children;
                }
            }
        }

        return $options;
    }

    /**
     * {@inheritdoc}
     */
    protected static function boot()
    {
        parent::boot();

        static::saving(function (Model $branch) {
            $parentColumn = $branch->getParentColumn();

            if (Request::has($parentColumn) && Request::input($parentColumn) == $branch->getKey()) {
                throw new \Exception(trans('admin.parent_select_error'));
            }

            if (Request::has('_order')) {
                $order = Request::input('_order');

                Request::offsetUnset('_order');

                $tree = new Tree(new static());
                $tree->saveOrder($order);

                return false;
            }

            return $branch;
        });
    }
}
```

`ModelTree`traitから主に変更したのは`toTree`と`allNodes`、`saveOrder`あたり。

`ModelTree`では`boot`の中で`static::tree()`を用いているが、これを定義している`AdminBuilder`traitは非推奨であるため、`new Tree(new static())`に変更した。

# `ModelNestedSetTree`の使い方
`ModelNestedSetTree`は基本的に`ModelTree`と同様に使える。

1. ツリーのモデルに`ModelNestedSetTree`traitをつける（`AdminBuilder`は不要）
1. コンストラクタ内で`$this->setTitleColumn()`を用いてタイトルとなるカラム名を指定する
1. コントローラ内で`new Tree(new ModelClass())`して使う

※ laravel-adminのリファレンスで使っている`ModelForm`traitは非推奨であるため注意

以下例

```php:Category.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Kalnoy\Nestedset\NodeTrait;
use App\Admin\Traits\ModelNestedSetTree;

class Category extends Model
{
    use NodeTrait;
    use ModelNestedSetTree;

    protected $fillable = [
        'parent_id',
        'name',
    ];

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);

        $this->setTitleColumn('name');
    }
}
```

```php:CategoryController.php
<?php

namespace App\Admin\Controllers;

use Encore\Admin\Controllers\AdminController;
use Encore\Admin\Tree;
use Encore\Admin\Form;
use Encore\Admin\Show;
use App\Models\Category;

class CategoryController extends AdminController
{
    /**
     * Title for current resource.
     *
     * @var string
     */
    protected $title = 'カテゴリ';

    /**
     * Make a grid builder.
     *
     * @return Tree
     */
    protected function grid()
    {
        $tree = new Tree(new Category());

        $tree->branch(function ($branch) {
            return $branch['name'];
        });

        return $tree;
    }

    ...

    /**
     * Make a form builder.
     *
     * @return Form
     */
    protected function form()
    {
        $form = new Form(new Category());

        $form->select('parent_id')->options(Category::selectOptions());
        $form->text('name');

        return $form;
    }
}
```