OctoberCMS 管理画面実装テク：Soft Deleteしたデータの完全削除・復元するページを実装する

まず、Soft Deleteとは削除済みとマークして実際にはDBのデータは残しておく削除方法を言います。つまり、削除済みマークを消すことで簡単に復元できる、ゴミ箱機能のようなものです。October CMSはLaravelベースなので、このSoft Deleteも利用できます。

しかし、残念なことにこのSoft Deleteを活用する管理画面UIをOctoberは今のところ提供していません。削除済みデータの一覧表示や、元に戻す機能、永久に削除する機能などを操作するUIが標準では無いのです。なので、今回はその管理画面を実装します。

Octoberの管理画面のフレームワークを活用して実装することになるので、Octoberでの管理画面の作成の勉強にもなると思います。

## Soft Deleteの基礎おさらい
まずは、Soft Deleteの超基本的な使い方のおさらい。詳しくは[公式ドキュメント](https://octobercms.com/docs/database/traits#soft-deleting)を見てください。

マイグレーションで`softDeletes()`を使用するDBテーブルがSoft Deleteに対応します。具体的には `deleted_at` という削除した時のタイムスタンプを入れるカラムが追加されるだけです。

```php
Schema::table('posts', function ($table) {
    $table->softDeletes();
});
```

そして、モデルにTraitを追加します。

```php
class Item extends Model
{
    use \October\Rain\Database\Traits\SoftDelete; // <- これ

    protected $dates = [
        'created_at',
        'updated_at',
        'deleted_at', // <- これも
    ];
}
```

これで、通常と同じくモデルを削除すると、`deleted_at` にタイムスタンプが入って削除済みと認識されるようになります。

```php
$item->delete(); // テーブルからは削除されない
```

復活させたい場合は

```php
$item->restore();
```

実際にテーブルからも削除したい場合は

```php
$item->forceDelete();
```

といった具合です。


## 作りたい管理画面
まず、完成図

これは通常の一覧ページ。上記のSoft Deleteの対応をするだけで、削除したらこの一覧に表示されなくなるのは変わらず、実データはDBに残っています。通常の一覧ページに「ゴミ箱の中を見る」リンクを追加します。
![Screen_Shot_2021-04-08_at_10_33_29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138322/a5baba24-b16e-2f4f-af65-ed7ed73d0392.png)

そしてこちらが、ゴミ箱の中のリスト。
![Screen_Shot_2021-04-08_at_10_33_46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138322/d86d8071-29ca-bcf9-6524-86780bedac02.png)
通常のページで削除したアイテムが表示されます。
また、「元に戻す」と「完全に削除する」ボタンを設置しています。

## 実装ステップ

手順まとめ
* ゴミ箱ページを表示するリンクを追加
* ゴミ箱ページを表示するコントローラアクションを追加
* ゴミ箱ページのテンプレートを追加
* ゴミ箱ページ用の一覧設定ファイルを追加
* ゴミ箱ページ用のクエリを削除済みアイテムにする
* ゴミ箱ページのツールバーに「元に戻す」と「完全に削除する」ボタンを設置する
* コントローラに「元に戻す」と「完全に削除する」ボタンから呼び出されるAjaxハンドラを実装する

### ゴミ箱ページを表示するリンクを追加
![Screen_Shot_2021-04-07_at_15_12_41-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/138322/573db775-cd4f-a7f5-b5e9-9f74171a485a.png)
[コントローラのスキャフォールディング](https://octobercms.com/docs/console/scaffolding#scaffold-create-controller)でコントローラクラスと関連ファイルを作成すると作成されたコントローラ用のディレクトリの中に config_list.yaml という一覧の設定ファイルがあります。このファイルの下記の部分が上記ツールバーのテンプレートを指定しています。

```yaml:controllers/items/config_list.yaml
# Toolbar widget configuration
toolbar:
    # Partial for toolbar buttons
    buttons: list_toolbar
```

ここで指定されている `list_toolbar` は同じディレクトリにある _list_toolbar.htm を意味しています。

_list_toolbar.htmを開くと下記のようなコードが入っていますが、一番下の `<a>` タグを追加します。URLはパスのアクション部分を `trashed` にしていますが、コントローラのアクションメソッドと合わせれば何でも構いません。

```html:controllers/items/_list_toolbar.htm
<div data-control="toolbar">
	<a
		href="<?= Backend::url('myauthor/myplugin/items/create') ?>"
		class="btn btn-primary oc-icon-plus">
		追加
	</a>

	<button
		class="btn btn-danger oc-icon-trash-o"
		disabled="disabled"
		onclick="$(this).data('request-data', { checked: $('.control-list').listWidget('getChecked') })"
		data-request="onDelete"
		data-request-confirm="Are you sure you want to delete the selected Items?"
		data-trigger-action="enable"
		data-trigger=".control-list input[type=checkbox]"
		data-trigger-condition="checked"
		data-request-success="$(this).prop('disabled', 'disabled')"
		data-stripe-load-indicator>
		選択した項目を削除
	</button>

	<a
		href="<?= Backend::url('myauthor/myplugin/items/trashed') ?>"
		class="btn-text oc-icon-trash-o m-l">
		ゴミ箱の中を見る
	</a>
</div>
```

### ゴミ箱ページを表示するコントローラアクションを追加
次に、上で作ったリンクのURL `/myauthor/myplugin/items/trashed` を開くと呼び出されるコントローラのアクションメソッドを追加します。
URLのアクション部分と同じ名前にする必要があります。この場合 `trashed` です。
実は処理内容は通常の一覧と同じになるので、通常の一覧用のアクションメソッド `index()` を呼び出すだけになります。

```php:controllers/Items.php
public function trashed()
{
   $this->index();
}
```

### ゴミ箱ページのテンプレートを追加
アクションと同じ名前のテンプレートが読み込まれるので、trashed.htmというファイルを作成します。まずは、index.htmをコピーして作成します。実際の中身はこれだけ。

```html:controllers/items/trashed.htm
<?= $this->listRender() ?>
```

これで、「ゴミ箱の中を見る」リンクを開くとページが表示されます。ただ、今の段階では通常の一覧ページと同じものが表示されます。

### ゴミ箱ページ用の一覧設定ファイルを追加
さて、Octoberの管理画面実装では `ListController` ビヘイビアが一覧表示を担っているわけですが、[このビヘイビアは複数の一覧設定をサポートしており](https://octobercms.com/docs/backend/lists#multiple-list-definitions)、今回はそれを利用します。

まず、上の方で出てきた config_list.yaml という一覧設定ファイルを複製して、config_list_trashed.yaml を作成します。
そのファイルの中で`recordUrl`コメントアウトします。これは、リストのアイテムの一つをクリックしたときにそのアイテムの詳細画面にジャンプするためのURLテンプレートですが、削除されたアイテムであるため表示できません。なので、コメントアウトしてジャンプしないようにします。

```yaml:controllers/items/config_list_trashed.yaml
# Link URL for each record
#recordUrl: myauthor/myplugin/items/update/:id
```

また、変更するかどうかは必要に応じてですが、`list`でindexページとは異なるリストの定義（どんな列を表示するかなど）ファイルを指定することも可能です。

```yaml:controllers/items/config_list_trashed.yaml
# Model List Column configuration
list: $/myauthor/myplugin/models/item/columns_trashed.yaml
```


そして、このyamlファイルをコントローラの `$listConfig` で指定します。配列にして、キーを任意の設定名とします。

```php:controllers/Items.php
class Items extends Controller
{
    // 他でも使用する名前なので定数として定義します。
    const LIST_CONFIG_INDEX = 'index';
    const LIST_CONFIG_TRASHED = 'trashed';
    ...

    public $listConfig = [
        self::LIST_CONFIG_INDEX => 'config_list.yaml',
        self::LIST_CONFIG_TRASHED => 'config_list_trashed.yaml',
    ];
    ...
}
```

そして、この設定を使うように index.htm と trashed.htm テンプレートをそれぞれ変更します。

```html:controllers/items/index.htm
<?php
use MyAuthor\MyPlugin\Controllers\Items;
?>

<?= $this->listRender(Items::LIST_CONFIG_INDEX) ?>
```

```html:controllers/items/trashed.htm
<?php
use MyAuthor\MyPlugin\Controllers\Items;
?>

<?= $this->listRender(Items::LIST_CONFIG_INDEX) ?>
```

要するに、`listRender()`の呼び出しで`$listConfig`のキーを渡すように変更しただけです。

### ゴミ箱ページ用のクエリを削除済みアイテムにする
ここまでだと、設定内容も変えていないので何も変わりませんが、設定名を`listRender()`に渡すように変更したことで、通常の一覧かゴミ箱のどちらを表示しようとしているかわかるようになりました。

リストのクエリは `ListController::listExtendQueryBefore` をオーバーライドすることで可能ですが、ここにこの設定名が渡されるので、ゴミ箱用の設定が読み込まれている場合にクエリを変更するようにします。

```php:controllers/Items.php
class Items extends Controller
{
    ...

    public function listExtendQueryBefore($query, $definition = null)
    {
        // $definition に設定名が渡されてくるので、これがゴミ箱用の設定名のときにクエリを変更します。
        if ($definition == self::LIST_CONFIG_TRASHED) {
            // Soft Deleteに使用しているカラム名を取得。ここでは`deleted_at`だとわかっているが、
            // 一応、動的に取得することで変更された場合に対応しています。
            $config = $this->listGetConfig($definition);
            $class = $config->modelClass;
            $column = (new $class)->getQualifiedDeletedAtColumn();

　　　　　　　// 元々のクエリがSoft Deleteされたものを除外しているので、そのスコーピングを外しています。
            $query->withoutGlobalScope(new SoftDeletingScope);

            // Soft Deleteされたものに絞り込みます。
            $query->whereNotNull($column);
        }
    }

    ...
}
```

これで、ゴミ箱ページに削除されたアイテムのみが表示されるようになりました。

### ゴミ箱ページのツールバーに「元に戻す」と「完全に削除する」ボタンを設置する
さて、ゴミ箱の中が見れたら、それらを操作する機能を追加します。
まずは、ツールバーにボタンを実装します。

コントローラのディレクトリに _list_toolbar_trashed.htm というファイルを作成します。中身は後ほど。

```html:controllers/items/_list_toolbar_trashed.htm
<div data-control="toolbar">
</div>
```

そして、一覧の設定ファイル config_list_trashed.yaml でこのテンプレートを使用するように変更します。テンプレートファイル名から先頭の`_`と拡張子の`.htm`を取った`list_toolbar_trashed`になります。

```yaml:controllers/items/config_list_trashed.yaml
# Toolbar widget configuration
toolbar:
    # Partial for toolbar buttons
    buttons: list_toolbar_trashed
```

これで、テンプレート _list_toolbar_trashed.htm がゴミ箱ページの上部に表示されるようになりました。下記のように `<button>` タグを追加します。

```html
<div data-control="toolbar">
	<button
		class="btn btn-primary oc-icon-reply"
		disabled="disabled"
		onclick="$(this).data('request-data', { checked: $('.control-list').listWidget('getChecked'), definition: 'trashed' })"
		data-request="onRestore"
		data-trigger-action="enable"
		data-trigger=".control-list input[type=checkbox]"
		data-trigger-condition="checked"
		data-request-success="$(this).prop('disabled', 'disabled')"
		data-stripe-load-indicator>
		元に戻す
	</button>

	<button
		class="btn btn-danger oc-icon-trash-o"
		disabled="disabled"
		onclick="$(this).data('request-data', { checked: $('.control-list').listWidget('getChecked'), definition: 'trashed' })"
		data-request="onForceDelete"
		data-request-confirm="データベースから完全に削除しますか？"
		data-trigger-action="enable"
		data-trigger=".control-list input[type=checkbox]"
		data-trigger-condition="checked"
		data-request-success="$(this).prop('disabled', 'disabled')"
		data-stripe-load-indicator>
		完全に削除する
	</button>
</div>
```

_list_toolbar.htm に記述してある削除ボタンの `<button>` タグをベースに作成しています。これは、「削除」ボタンの挙動が「元に戻す」と「完全に削除する」ボタンと下記に挙げるような基本部分は同じだからです。

* アイテムをチェックしないとボタンはdisable状態で、チェックすると有効になる
* クリックするとAjaxでコントローラのメソッドを呼び出す
* そのAjaxコールでチェックされたアイテムのIDをコントローラに渡している

など。

「削除」ボタンからの変更は、まず、`data-request`の値を`onRestore`と`onForceDelete`に変更します。このボタンをクリックすることでAjaxでコントローラのメソッドが呼び出されるのですが、そのメソッド名を指定しています（[このOctoberの仕組みの詳細はこちら](https://octobercms.com/docs/backend/controllers-ajax#ajax-handlers)）。後ほど、コントローラに実装します。

そして、`onclick` の最後の部分に `definition: 'trashed'` を追加しています。これは、`onRestore`と`onForceDelete`メソッドやそこから呼ばれる先述の`listExtendQueryBefore`メソッドなどに渡される一覧設定の名前を指定しています。

### コントローラに「元に戻す」と「完全に削除する」ボタンから呼び出されるAjaxハンドラを実装する
そして最後に、実際に「元に戻す」と「完全に削除する」処理を実施するAjaxハンドラをコントローラに実装します。上で実装したボタンタグの`data-request`属性で指定したメソッド名で作成します。

```php:controllers/Items.php
class Items extends Controller
{
    ...
    public function onForceDelete(): array
    {
       ...
    }

    public function onRestore(): array
    {
       ...
    }
    ...
}
```

これらのメソッドは、元に戻すか削除するか以外の処理、つまり下記の処理は同じになるので別途共通メソッドを作成します。

* チェックが付けられたアイテムのIDを受け取る
* モデルリストを取得する
* 成功か失敗かに応じてメッセージを表示する
* 結果に応じて一覧を更新する

そして、この共通処理は通常の一覧ページの「削除」ボタンが呼び出すAjaxハンドラ `Backend\Behaviors\ListController::index_onDelete` とも同じなので、このメソッドをコピーしてきて動くように修正します。実用的なコードにするため、補助的な行も省かずに記載しています。

```php:controllers/Items.php
class Items extends Controller
{
    ...
    protected function performOnCheckedItems(callable $callback, array $messages): array
    {
        // ListControllerビヘイビアを実装していないと動かないので、実装されていることを確認します
        if (!(in_array('Backend.Behaviors.ListController', $this->implement) ||
            in_array(ListController::class, $this->implement))) {
            $msg = __METHOD__ . ' is called on a controller that does not implement ListController';
            throw new BadImplementationException($msg);
        }

        // パラメータチェック
        if (!isset($messages['success']) || !isset($messages['empty'])) {
            $msg = 'Necessary messages are missing in $messages passed to ' . __METHOD__;
            throw new BadImplementationException($msg);
        }

        // 一覧設定名のデフォルトを取得。\Backend\Behaviors\ListController::__construct を真似て実装。
        // post('definition')ではボタンタグの`onclick`で`definition`として渡している文字列が取得できますが、
        // 正しく実装されていれば本来不要ですが、セーフティーネットです。
        $listDefinitions = is_array($this->listConfig) ? $this->listConfig: ['list' => $this->listConfig];
        $primaryDefinition = key($listDefinitions);
        $definition = post('definition', $primaryDefinition);

        // 一覧設定を取得
        if (!isset($listDefinitions[$definition])) {
            throw new ApplicationException(
                Lang::get('backend::lang.list.missing_parent_definition', compact('definition'))
            );
        }
        $listConfig = $this->listGetConfig($definition);

        // POSTされた一覧でチェックされていたIDの妥当性をチェック
        $checkedIds = post('checked');
        if (!$checkedIds || !is_array($checkedIds) || !count($checkedIds)) {
            Flash::error(Lang::get($messages['empty']));
            return $this->listRefresh();
        }

        // 取得した一覧設定からモデルのインスタンスを作成し拡張用のlistExtendModelメソッドを呼んでおきます
        // 今回はlistExtendModelを使用していないので無くてもも問題ないですが、汎用性を高めるために。
        $class = $listConfig->modelClass;
        $model = new $class;
        $model = $this->listExtendModel($model, $definition);

        // クエリ(Builder)インスタンスを作成し、拡張用のlistExtendQueryBeforeメソッドを呼ぶ
        // listExtendQueryBeforeは上で実装しているのが呼ばれることになります。
        $query = $model->newQuery();
        $this->listExtendQueryBefore($query, $definition);

        // クエリをPOSTされたIDのアイテムに絞り込みます
        // listExtendQueryは拡張性のために一応呼んでいます
        $query->whereIn($model->getKeyName(), $checkedIds);
        $this->listExtendQuery($query, $definition);

        // DBにクエリを投げてデータを取得します
        $records = $query->get();

        if ($records->count()) {
            // 取得したデータに対して、引数で渡された関数を実行します
            $callback($records);

            // フラッシュメッセージを画面に表示します
            Flash::success(Lang::get($messages['success']));
        }
        else {
            Flash::error(Lang::get($messages['empty']));
        }

        // 表示されているリストのHTMLを置き換える新しいHTMLを作成して返します
        return $this->listRefresh($definition);
    }
}
```

元に戻すか削除するかの実処理の部分が共通ではないので引数`callable $callback`として渡せるようにしています。
`$messages`は成功時と失敗時にフラッシュメッセージで表示するための文言を渡すためのものです。

そして、この汎用メソッドを利用して、先の`onForceDelete`と`onRestore`メソッドの中身を実装します。

```php:controllers/Items.php
class Items extends Controller
{
    ...
    public function onForceDelete(): array
    {
        return $this->performOnCheckedItems(
            function ($records) {
                foreach ($records as $record) {
                    $record->forceDelete();
                }
            },
            [
                'success' => '正常に削除しました',
                'empty' => '削除できるアイテムがありませんでした',
            ]
        );
    }

    public function onRestore(): array
    {
        return $this->performOnCheckedItems(
            function ($records) {
                foreach ($records as $record) {
                    $record->restore();
                }
            },
            [
                'success' => '正常に元に戻しました',
                'empty' => '元に戻せるアイテムがありませんでした',
            ]
        );
    }
    ...
}
```

コールバックには取得したアイテムのモデルインスタンスのリストが渡されるので、イテレートしてそれぞれに元に戻すや削除する処理を実施します。

そして、Ajaxコールの結果として、表示しているリストを更新するために`performOnCheckedItems`で返している値をそのまま返します。


以上で、Soft Deleteしたデータの完全削除・復元するページの実装は終わりです。
コントローラに実装した`performOnCheckedItems`メソッドは汎用的な実装にしたのでコントローラのベースクラスとして切り出せば、このメソッドを使いまわして同じ要領で他のSoft Deleteのモデルにも同様の機能が簡単に追加できます。