Laravel + React で 共同編集ページのロック機能をつくる(引き継ぎ機能も!)

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、前回記事は Laravel Precognition でリアルタイム・バリデーションをつくる で、Laravelの新機能「Precognition」を使ってみました。

そしてリアルタイム・バリデーションはPrecognitionを使う時の分かりやすい例だったので挑戦してみたのですが、実はもう一つドキュメントにも書かれている「あること」もやってみたいと思っていました。

それは・・・・・・

(共同編集できる)ページのロック機能

です。

つまり、複数のユーザーがログインするシステムがあって、同じページを編集しようとすると後から来た人はページが編集できなくなるという機能です。

こうすることで、「あれ!?私が書いたテキストが丸々上書きされてるんだけど…😫」ということがなくなるという訳ですね。

そこで❗

今回は「Laravel + React」でこの「ロック機能」を作ってみたいと思います。

なお、ロックされた側は「引き継ぐ」機能で編集権を(言い方が悪いですが)奪うことができるようにしてみます。

ぜひ何かの参考になりましたら嬉しいです。😄✨

「納豆買いすぎて
五重塔みたいに
積み上がってます(笑)」

開発環境: Laravel 9.x、React、Inertia.js、TailwindCSS、Vite

前提として

Laravelにログイン機能がインストールされていることが前提です。
もしまだの方は以下を参考にして準備しておいてください。

📝 【Laravel】Vite + Inertia + React でログイン機能をインストールする

モデル&マイグレーション&Seeder&コントローラーをつくる

まずは中心となるファイルの準備から始めます。
以下のコマンドを実行してください。

※ ちなみに今回は「報告書」のデータを使って実装していきます。

php artisan make:model Report -msc

すると、4ファイル作成されるので、それぞれ中身を以下のように変更します。

モデル

app/Models/Report.php

<?php

namespace App\Models;

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

class Report extends Model
{
    use HasFactory;

    // Relationship
    public function edit_user()
    {
        return $this->belongsTo('App\Models\User', 'edit_user_id');
    }

    // Accessor
    public function getIsEditingByOthersAttribute()
    {
        return (
            ! is_null($this->edit_started_at) &&
            $this->edit_user_id !== auth()->id()
        );
    }
}

この中では、ユーザーテーブルへのリレーションと「他のユーザーが編集中かどうか」がわかるAccessorをつくっています。

つまり、$report->is_editing_by_othersで簡単にチェックできるようになります。

マイグレーション

database/migrations/****_**_**_******_create_reports_table.php

// 省略

public function up()
{
    Schema::create('reports', function (Blueprint $table) {
        $table->id();
        $table->string('title')->comment('タイトル');
        $table->text('body')->comment('本文');
        $table->dateTime('edit_started_at')->nullable()->comment('編集開始日時');
        $table->unsignedBigInteger('edit_user_id')->nullable()->comment('編集中のユーザーID');
        $table->timestamps();

        $table->foreign('edit_user_id')->references('id')->on('users');
    });
}

// 省略

Seeder

database/seeders/ReportSeeder.php

// 省略

public function run()
{
    for($i = 1; $i <= 25; $i++) {

        $report = new Report();
        $report->title = 'タイトル' . $i;
        $report->body = '本文' . $i;

        if($i % 5 === 0) {

            // 5の倍数のレコードは編集中にする
            $report->edit_started_at = now();
            $report->edit_user_id = 2;

        }

        $report->save();

    }
}

// 省略

ちなみに今回はテストですので、ID5の倍数の場合(5, 10, 15…)は「現在編集中」になるようにデータをつくっています。(特に意味はないですが、同じデータばかりも何なので…)

Seederは作成しただけでは実行できませんので、DatabaseSeeder.phpに登録しておきます。

database/seeders/DatabaseSeeder.php

// 省略

public function run()
{
    // 省略
    $this->call(ReportSeeder::class); // 👈 ここを追加しました
}

// 省略

ではこの状態でDBを再構築します。
以下のコマンドを実行してください。

php artisan migrate:fresh --seed

すると、実際のテーブルはこうなりました。

コントローラー

app/Http/Controllers/ReportController.php

<?php

namespace App\Http\Controllers;

use App\Models\Report;
use Illuminate\Http\Request;
use Inertia\Inertia;

class ReportController extends Controller
{
    public function index()
    {
        $reports = Report::query()
            ->select('id', 'title')
            ->paginate(5);

        return Inertia::render('Report/Index')->with([
            'reports' => $reports
        ]);
    }

    public function edit(Report $report)
    {
        return Inertia::render('Report/Edit')->with([
            'report' => $report
        ]);
    }

    public function update(Report $report, Request $request)
    {
        // 注: バリデーションは省略しています

        $report->title = $request->title;
        $report->body = $request->body;
        $report->edit_started_at = null;
        $report->edit_user_id = null;
        $report->save();

        return redirect()->route('report.index');
    }

    public function take_over(Report $report)
    {
        $report->edit_started_at = now();
        $report->edit_user_id = auth()->id();
        $report->save();

        return redirect()->route('report.edit', $report);
    }
}

この中でやっていることはIntertiaを使った基本的な表示&編集のコードとなっていますが、take_over()は「編集権を他のユーザーから奪い取る、引き継ぎ機能」として利用します。

ミドルウェアをつくる

では次に、前回利用したLaravel Precognitionを使って「コンフリクト(編集の衝突)」をチェックするミドルウェアを作っていきます。

以下のコマンドを実行してください。

php artisan make:middleware ReportConfilictMiddleware

するとファイルが作成されるので、中身を以下のようにします。

app/Http/Middleware/ReportConflictMiddleware.php

// 省略

public function handle(Request $request, Closure $next)
{
    if($request->isAttemptingPrecognition()) {

        $user_id = auth()->id();
        $report = $request->route('report');

        if(! is_null($report)) {

            $dt = now()->subMinutes(1); // 1分前

            if($report->is_editing_by_others === true && $report->edit_started_at >= $dt) { // 他の人が編集中

                return response('Locked', 423, [
                    'Edit-User-Name' => urlencode($report->edit_user->name), // ヘッダーはエンコードしておく
                ]);

            } else {

                $report->edit_started_at = now();
                $report->edit_user_id = $user_id;
                $report->save();

            }

            return response('OK', 200);

        }

    }

    return $next($request);
}

// 省略

この中でやっていることは、まずisAttemptingPrecognition()で「Precognition」モードかどうかをチェックし、もしそうならページがロックされているかどうかをチェックする流れになっています。

なお、パターンによってHTTPステータスコードは以下のように切り替わります。

  • ロックされてない: 200
  • ロックされている: 423

また、レスポンスヘッダーに「Edit-User-Name」という項目を追加していますが、この名前が「●●さんによって更新されています」という表示に使われることになります。

ビューをつくる

では、コントローラーで指定した2つのビューをつくっていきましょう。

resources/js/Pages/Report/Index.jsx

export default function Index(props) {

    const reports = props.reports.data;
    const paginationLinks = props.reports.links;

    return (
        <div className="p-5">
            <h1 className="font-medium mb-3">報告書一覧</h1>
            <table className="text-sm text-left text-gray-500 mb-7 w-80">
                <thead>
                <tr className="bg-gray-100">
                    <th className="py-3 px-6">タイトル</th>
                    <th className="py-3 px-6">操作</th>
                </tr>
                </thead>
                <tbody>
                {reports && reports.map(report => (
                    <tr key={report.id} className="bg-white border-b">
                        <td className="py-3 px-6">{report.title}</td>
                        <td className="py-3 px-6">
                            <a href={route('report.edit', report.id)} className="text-white bg-blue-500 text-xs rounded text-sm px-1.5 py-1 mr-2 mb-2">変更</a>
                        </td>
                    </tr>
                ))}
                </tbody>
            </table>
            <nav>
                <ul>
                    <li>
                    {paginationLinks && paginationLinks.map((link,index) => (
                        link.active && (
                            <a
                                key={index}
                                className="py-2 px-3 text-blue-600 bg-blue-50 border border-gray-300">{link.label}</a>
                        ) || (
                            <a
                                key={index}
                                href={link.url}
                                dangerouslySetInnerHTML={{__html: link.label}}
                                className="py-2 px-3 leading-tight text-gray-500 bg-white border border-gray-300"></a>
                        )

                    ))}
                    </li>
                </ul>
            </nav>


        </div>
    );

}

このビューは報告書の一覧を表示するためのものです。

内部的には大きく以下2つのブロックに分かれています。

  • Laravel で取得されたデータをループで1つずつ取り出し表示
  • ページリンクを表示する

resources/js/Pages/Report/Edit.jsx

import {useEffect, useState} from 'react';
import {Inertia} from '@inertiajs/inertia';
import axios from 'axios';

export default function Edit(props) {

    // フォームデータ
    const [title, setTitle] = useState(props.report.title);
    const [body, setBody] = useState(props.report.body);

    // コンフリクト(編集中かどうか)
    const [hasConflict, setHasConflict] = useState(false);
    const [editUserName, setEditUserName] = useState('');
    const checkConflict = () => {

        if(submitting === true) return; // フォームの送信中はチェックしない

        const url = route('report.update', props.report.id);
        const data = { _method: 'PUT' };
        const config = {
            headers:{
                Accept: 'application/json',
                Precognition: 'true',
            }
        };

        axios.post(url, data, config)
            .then(response => {

                const status = response.status;

                if(status === 200) {

                    setHasConflict(boolean => {

                        if(boolean === true) {

                            location.reload(); // ロック → 解除の場合、データ更新するためリロード

                        } else {

                            return false;

                        }

                    });

                }

            })
            .catch(error => {

               const status = error.response.status;
               const userName = decodeURIComponent(error.response.headers['edit-user-name']);
               setEditUserName(userName);

               if(status === 423) {

                   setHasConflict(true);

               }

            });

    };
    useEffect(() => {

        checkConflict();
        const interval = setInterval(checkConflict, 3000);

        return () => {

            clearInterval(interval); // タイマーが止まらなくなるので、ここで明示的にストップ

        };

    }, []);

    // Take over
    const handleTakeOver = () => {

        const url = route('report.take_over', props.report.id);
        const params = {
            _method: 'PATCH'
        };
        const options = {
            onSuccess() {

                location.reload();

            }
        };
        Inertia.post(url, params, options);

    };

    // Submit
    const [submitting, setSubmitting] = useState(false);
    const handleSubmit = () => {

        setSubmitting(true);

        const url = route('report.update', props.report.id);
        const data = {
            _method: 'PUT',
            title: title,
            body: body,
        }
        const options = {
            onFinish() {

                setSubmitting(false);

            }
        };
        Inertia.post(url, data, options);

    };

    return (
        <div className="p-5">
            {hasConflict && (
                <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-3">
                    <div className="block mb-2">{editUserName} さんによって更新されています</div>
                    <a
                        href="#"
                        className="text-white bg-gray-500 font-medium rounded text-xs px-3 py-2"
                        onClick={handleTakeOver}>引き継ぐ</a>
                </div>
            ) || (
                <>
                    <div className="mb-4">
                        <label>タイトル</label>
                        <br />
                        <input type="text" value={title} onChange={e => setTitle(e.target.value)} />
                    </div>
                    <div className="mb-4">
                        <label>報告内容</label>
                        <br />
                        <textarea value={body} onChange={e => setBody(e.target.value)} />
                    </div>
                    <button
                        type="button"
                        className="text-white bg-blue-700 font-medium rounded text-sm px-5 py-2.5 mr-2 mb-2"
                        onClick={handleSubmit}>
                        送信
                    </button>
                    <a href={route('report.index')} className="ml-4 text-blue-700 underline">戻る</a>
                </>
            )}
        </div>
    );

}

こちらが今回のメインになっています。
コンフリクトは以下の流れでチェックしています。

  1. checkConflict() を3秒おきに実行
  2. checkConflict() の中で Precognition モードでアクセス
  3. もしロックされていたら編集できなくする

また、もしロックされている場合でも3秒おきにチェックを続け、もし相手方の編集が終わったら(ロックが解除されたら)自動でページをリロードし、編集ができるようになります。

なお、今回のテーマとは直接関係ないのですがuseEffect()の中でsetInterval()を使う場合は注意してください。

なぜなら、繰り返し実行する部分が止まらなくなる可能性があるからです。

そのため、今回は以下のように「クリーンアップ」関数を使って明示的にタイマーを解除しています。(無い場合、コンソールに警告が出ます)

useEffect(() => {

    checkConflict();
    const interval = setInterval(checkConflict, 3000);

    return () => {

        clearInterval(interval); // 👈 これ

    };

}, []);

ルートをつくる

次に、先ほどつくったミドルウェアを使ってルートをつくっていきます。

routes/web.php

// 省略

use App\Http\Controllers\ReportController;
use App\Http\Middleware\ReportConflictMiddleware;

// 省略

Route::prefix('report')->middleware('auth')->controller(ReportController::class)->group(function(){

    Route::get('/', 'index')->name('report.index');
    Route::get('/{report}/edit', 'edit')->name('report.edit');
    Route::put('/{report}', 'update')
        ->middleware([ReportConflictMiddleware::class])
        ->name('report.update');
    Route::patch('/{report}/take_over', 'take_over')->name('report.take_over');

});

これで作業は完了です❗
お疲れ様でした😄✨

テストしてみる

では、実際にテストしてみましょう。

まずViteを起動して、どのユーザーでもいいのでログインして「http://******/report」へアクセスします。

また、ブラウザを(Google Chromeの場合は)「シークレットモード」で開き別のユーザーでログイン&同じURLへアクセスしてください。

※ これは、シークレットモードがまったく別環境として扱われる、つまり別のユーザーが同時にログインした状況を簡単につくりだすテクニックです。

では、アクセスしたときのページです。
左が太郎さん、右が次郎さんでログインしています。

では、「タイトル1」の変更ボタンを

  1. 太郎さん
  2. 次郎さん

の順でクリックしてみましょう。

すると・・・・・・

はい❗
太郎さんは編集フォームが表示されているのに対し、次郎さんの方は「太郎さんによって更新されています」と表示され編集がロックされている状態になっています。

では、次に太郎さんの「報告内容」を「本文1・変更済み by 太郎」と変更して「送信」ボタンをクリックしてみましょう。

どうなるでしょうか・・・・・・

はい❗
左側の太郎さんは一覧ページが表示され、数秒してから次郎さんのページも自動的に編集ができるようになりました。また、報告内容の中身も先ほど太郎さんが保存したものが表示されています。

では次に引き継ぎ機能をチェックするために、再度太郎さんで「タイトル1」の変更ボタンをクリックしてみます。

すると以下のように先ほどとは逆に「次郎さんによって更新されています」と表示されました。

ということで、今回は「引き継ぐ」ボタンをクリックしてみましょう。

うまくいくでしょうか・・・・・・??

はい❗
次郎さんの持っていた「編集権」が太郎さんに移りました。

すべて成功です😄✨

企業様へのご提案

今回のロック機能を使うと、特にリモートワークで作業を進める場合でも「誰かに上書きされちゃった…」ということがなくなります。

また、今回は実装していませんが「編集途中のフォーム内容を別のユーザーに引き継ぐ」こともできます。

なお、毎回の編集履歴をとり「誰が何を追加・変更・削除したか」を残すこともできるため、「責任の所在」を明確にしたいといったご希望にも対応できるかと思います。(もちろんデータを昔の状態に戻すことも可能です)

もしそういった機能をご希望でしたらぜひお問い合わせからご連絡ください。
お待ちしております。😄✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回は「Laravel + React」でページのロック機能を実装してみました。

なお、Precognitionを使わなくとも実装することはできますが、ミドルウェア内で完結できるのはとても便利でした。

ぜひ皆さんもやってみてくださいね。

ではでは〜❗

「クラフトビールの
マスターは
ナイスガイばかりです🍺」

このエントリーをはてなブックマークに追加       follow us in feedly