LaravelでUndo/Redo機能を実装

# 経緯

先日実装したUndo/Redo機能（元に戻す/やり直す）がそれなりの工数がかかったので、
今後のためにざっくり残します。

スタッフのシフトを管理するWebアプリケーションに実装しました。
本記事ではバックエンド側の実装のみ残します。

※以前の関連記事
[Vue.js3で「ctrl+z」「ctrl+shift+z」の処理を実装する](https://qiita.com/shin-gee/items/4b7818105c5b20d8ed28)

# 用語定義

- 履歴
    - 画面操作した時のテーブルのレコードを保存したもの
- 元に戻す(Undo)
    - 操作を過去に戻す↩️ 一つ過去の履歴を適用する
- やり直す(Redo)
    - 操作を先に進める↪️ 一つ先の履歴を適用する

# 動作のイメージ

まず初めにブラウザ更新をして履歴1が作成され、何かしらの操作を3回行ったとする。
そうすると履歴2〜4が作成され、履歴4が適用されている状況になる。

![first.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3836454/dd64a449-684c-9505-356e-1482d0e0d890.png)
<br>

仮に「元に戻す」を3回実行すると、履歴が過去へ戻り、履歴1が適用されている状況になる。

![undo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3836454/3b117864-d1e9-28c8-7efc-7fe9658028ed.png)
<br>

更にここで「やり直す」を1回実行すると、履歴が先に進み、履歴2が適用されている状況になる。

![redo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3836454/0acf9091-9b61-65f5-68e9-6bec47519757.png)
<br>

この状況で新たに操作を実行すると、履歴3以降が削除されて、新たな履歴（履歴3'）が適用された状態になる。

![do_again.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3836454/da7f2ae3-b972-ae12-1375-73bd587d42b8.png)
<br>

※ブラウザが更新されたタイミングで、履歴が全て削除されて、履歴1から始まる。

# Undo/Redo機能概要

### 履歴をリセットする

- 保存してある履歴を全て削除、テーブルからdeleteする
- ブラウザ更新された際に実行する

### 履歴を保存する

- その時の状態を履歴として保存、テーブルにinsertする
- 画面上で操作が発生した際に実行する

### 操作を元に戻す・やり直す

- 保存された履歴、テーブルからselectして、過去へ戻す or 先へ進める
- Undo/Redoボタン押下時に実行する

### やり直す対象の履歴をリセットする

- **やり直す対象の履歴**を全て削除、テーブルからdeleteする
    - 過去の履歴は残す
- 画面上で操作が発生した際に実行する

# テーブル構成

### 既存テーブル

- users

| カラム | 型 |   |
|:-:|:-:|:-:|
|id   |int   |PK   |
|name   |varchar   |   |

- staffs

| カラム | 型 |   |
|:-:|:-:|:-:|
|id   |int   |PK   |
|name   |varchar   |   |

- shifts

| カラム | 型 |   |
|:-:|:-:|:-:|
|id   |int   |PK   |
|date   |datetime   |   |

- shift_staffs

| カラム | 型 |   |
|:-:|:-:|:-:|
|id   |int   |PK   |
|shift_id   |int   |FK   |
|staff_id   |int   |FK   |
|start_time   |int   |   |
|end_time   |int   |   |

### 履歴テーブル ※今回実装

- histories

| カラム | 型 |   |
|:-:|:-:|:-:|
|id   |int   |PK   |
|user_id   |int   |FK   |
|status   |int   |   |

- history_shift_staffs

| カラム | 型 |   |
|:-:|:-:|:-:|
|id   |int   |PK   |
|history_id   |int   |FK   |
|shift_id   |int   |FK   |
|staff_id   |int   |FK   |
|start_time   |int   |   |
|end_time   |int   |   |

対象のテーブルが増える場合は、`history_*`テーブルを作成して、`history_id`をFKにする
※今回実際は`history_shift_staffs`の子テーブルが存在していたので、その場合は`history_id`ではなく`history_shift_staff_id`をFKにしました

# 実装

具体的なコードは、いったんHistoryモデルとHistoryServiceクラスのみgithubに公開します。
※いずれ時間を取れたら整理したい…
https://github.com/shinji-yoshino/undo_redo_laravel

## Historyモデル

 ```
 <?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class History extends Model
{
    use HasFactory;

    const CURRENT = 1;
    const UNDO_TARGET = 2;
    const REDO_TARGET = 3;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'user_id',
        'status',
    ];

    public function history_shift_staffs(): HasMany
    {
        return $this->hasMany(HistoryShiftStaff::class);
    }

}
 ```

## HistoryServiceクラス

コード量が比較的多いので、ここではpublicメソッドの説明だけ書きます。

- `create_history()`
    - 履歴を作成するメソッドで、操作が発生するたびに呼び出される
- `copy_history_to_target()`
    - 履歴からシフト・画面に反映させる処理で、いわゆるUndo/Redo処理を行う
- `exist_undo_target()`
    - Undo対象の履歴が存在するか確認、Undoボタンの制御に使用
- `exist_redo_target()`
    - Redo対象の履歴が存在するか確認、Redoボタンの制御に使用

# 参考記事

- [Undoの実装の考え方](https://qiita.com/Azleep/items/861ee64867d7b016a966)