tableの行をドラッグ&ドロップで入れ替える
はじめに
ちょっとお仕事で必要が生じて、HTMLテーブルの行をドラッグ&ドロップで入れ替える機能を実装することになったので、その検証メモ。 お仕事では、ドラッグ&ドロップによる行入れ替えのほかに、ドラッグ&ドロップでの行選択の機能も混じるので、動的にdraggable属性の値を切り替える必要があるので、簡単な行選択のロジックも組み込む。 また、入れ替えた行の順番をサーバー側で保存するためのロジックも記述していく。
今回は、Laravelを使って組んでいく。
なお、実行環境は以下だが、構築の詳細は割愛。
- VirtualBox 7.1.0
- AlmaLinux 9.4
- PHP 8.3.22
下準備
まずはLaravelのプロジェクトを作る。
cd /var/www/html composer create-project laravel/laravel:11.* sample09 chmod -R a+w sample09/storage/ chmod a+w sample09/bootstrap/cache
お仕事がLaravel 11なので、バージョンとしてLaravel 11系を指定しているが、最新のLaravel 12系でも問題なく動くと思う。
ファイルの作成・編集
sample09/resources/views/sample/index.blade.php
<!DOCTYPE html> <html lang="ja"> <head> <base href="/sample09/public/" /> <meta charset="UTF-8" /> <title>ドラッグ&ドロップによるテーブル行の順序入れ替え</title> <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> <link rel="stylesheet" type="text/css" href="sample.css" /> <script type="text/javascript" src="sample.js"></script> </head> <body> <table class="sorting-by-drag-table"> <thead> <tr> <th>メールアドレス</th> <th>姓</th> <th>名</th> </tr> </thead> <tbody> @foreach ($persons as $person) <tr data-id="{{$person->id}}"> <td>{{$person->mailAddress}}</td> <th>{{$person->lastName}}</th> <th>{{$person->firstName}}</th> </tr> @endforeach </tbody> </table> <script type="text/javascript"> initSortingByDrag('.sorting-by-drag-table'); </script> </body> </html>
sample09/app/Http/Controllers/SampleController.php
<?php namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Session; use stdClass; class SampleController extends Controller { public function index() { $persons = Session::get('persons'); if (!$persons) { $persons = []; for ($i = 1; $i <= 20; ++$i) { $person = new stdClass(); $person->id = $i + 1000; $person->mailAddress = 'メールアドレス' . mb_convert_kana($i, 'N'); $person->lastName = '姓' . mb_convert_kana($i, 'N'); $person->firstName = '名' . mb_convert_kana($i, 'N'); $person->showOrder = $i; $persons[$person->id] = $person; } Session::put('persons', $persons); } // showOrderでソート $viewPersons = []; foreach ($persons as $person) { $viewPersons[$person->showOrder] = $person; } ksort($viewPersons); return view( 'sample.index', [ 'persons' => $viewPersons, ] ); } public function insertBefore(Request $request) { $rowIds = $request->get('row_ids'); $targetId = $request->get('target_id'); $this->updateShowOrder($rowIds, $targetId, true); } public function insertAfter(Request $request) { $rowIds = $request->get('row_ids'); $targetId = $request->get('target_id'); $this->updateShowOrder($rowIds, $targetId, false); } private function updateShowOrder($rowIds, $targetId, $before) { /* * target のデータを取得する * target の showOrder を変数 targetShowOrder に格納する * selectedRows のデータをすべて取得する * selectedRows の showOrder の min, max を求めて変数に格納する * targetShowOrder と min/max の間のデータをすべて取得する * 取得したデータを selected と notSelected に分ける * after の場合は逆順に処理するので、順番を入れ替えておく * targetShowOrder を別の変数 currentShowOrder に格納する * selected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する * notSelected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する */ $persons = Session::get('persons'); // target のデータを取得する if (!isset($persons[$targetId])) { echo json_encode(['status', false, 'errorMessage' => 'target_id:指定されたIDのデータが見つかりません。']); return; } $target = $persons[$targetId]; // target の showOrder を変数 targetShowOrder に格納する $targetShowOrder = $target->showOrder; // selectedRows のデータをすべて取得する $selectedRows = []; // SQLなら、IN句で一発で取得できる foreach ($rowIds as $rowId) { if (!isset($persons[$rowId])) { echo json_encode(['status', false, 'errorMessage' => 'row_id:指定されたIDのデータが見つかりません。']); return; } $selectedRows[] = $persons[$rowId]; } // selectedRows の showOrder の min, max を求めて変数に格納する $min = 0x7fffffffffffffff; $max = -1; foreach ($selectedRows as $row) { $min = min($min, $row->showOrder); $max = max($max, $row->showOrder); } // targetShowOrder と min/max の間のデータをすべて取得する // ※サンプルではループを回して全件チェックする必要があるが、実際にはDBにrangeクエリーを出せばよい $editRows = []; foreach ($persons as $person) { if ($before) { if ($targetShowOrder <= $person->showOrder && $person->showOrder <= $max) { $editRows[] = $person; } } else { if ($min <= $person->showOrder && $person->showOrder <= $targetShowOrder) { $editRows[] = $person; } } } // 取得したデータを selected と notSelected に分ける $selected = []; $notSelected = []; foreach ($editRows as $editRow) { if (in_array($editRow->id, $rowIds)) { $selected[] = $editRow; } else { $notSelected[] = $editRow; } } // after の場合は逆順に処理するので、順番を入れ替えておく if (!$before) { $selected = array_reverse($selected); $notSelected = array_reverse($notSelected); } // targetShowOrder を別の変数 currentShowOrder に格納する $currentShowOrder = $targetShowOrder; // selected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する foreach ($selected as $row) { if ($before) { $row->showOrder = $currentShowOrder++; } else { $row->showOrder = $currentShowOrder--; } } // notSelected のデータを順に処理して showOrder を currentShowOrder++/currentShowOrder-- に更新する foreach ($notSelected as $row) { if ($before) { $row->showOrder = $currentShowOrder++; } else { $row->showOrder = $currentShowOrder--; } } // オーバーフロー/アンダーフローチェック if ($before) { if ($currentShowOrder > $max + 1) { echo json_encode(['status' => false, 'errorMessage' => 'ShowOrderがオーバーフローしました。']); return; } } else { if ($currentShowOrder < $min - 1) { echo json_encode(['status' => false, 'errorMessage' => 'ShowOrderがアンダーフローしました。']); return; } } // showOrderで並べ替えてからセッションに書き込み // ※DBを使っている場合はORDER BY句を指定して取得すればいいので不要な処理 $tmpPersons = []; foreach ($persons as $person) { $tmpPersons[$person->showOrder] = $person; } ksort($tmpPersons); $persons = []; foreach ($tmpPersons as $tmpPerson) { $persons[$tmpPerson->id] = $tmpPerson; } Session::put('persons', $persons); echo json_encode(['status' => true]); } }
sample09/public/sample.css
table { border-collapse: collapse; outline: none; } th, td { border: 1px solid #ccc; } .dragging { opacity: 0.5; /* ドラッグ中の行の透明度を調整 */ } .drop-placeholder { border-top: 2px dashed #000; /* ドロップ位置の目印 */ } .selected { background-color: #d0d8ff; /* 選択時の背景色 */ border-left: 3px solid #007bff; /* 選択時の左ボーダー */ }
sample09/public/sample.js
function initSortingByDrag(selector) { const table = document.querySelector(selector); const rows = table.querySelectorAll("tbody > tr"); let draggingRows = null; // ドラッグ中の行を保持する let min; let max; // 各行にイベントリスナーを設定 rows.forEach((row) => { row.addEventListener("dragstart", handleDragStart); row.addEventListener("dragenter", handleDragEnter); row.addEventListener("dragover", handleDragOver); row.addEventListener("dragleave", handleDragLeave); row.addEventListener("drop", handleDrop); row.addEventListener("dragend", handleDragEnd); row.addEventListener("click", handleClick); }); // ドラッグ開始時の処理 function handleDragStart(ev) { draggingRows = table.querySelectorAll(".selected"); ev.dataTransfer.effectAllowed = "move"; ev.dataTransfer.setData("text/html", this.innerHTML); // ドラッグ中の行の間にドロップ先の行が含まれないことを確認 min = 9223372036854775807; max = -1; draggingRows.forEach((draggingRow) => { if (min > draggingRow.rowIndex) { min = draggingRow.rowIndex; } if (max < draggingRow.rowIndex) { max = draggingRow.rowIndex; } }); // ドラッグ中の行にCSSクラスを追加してスタイルを適用 setTimeout(() => { draggingRows.forEach((row) => { row.classList.add("dragging"); }); }, 0); } // ドラッグ中の要素が他の要素の上に侵入した時の処理 function handleDragEnter(ev) { ev.preventDefault(); this.classList.add("drop-placeholder"); } // ドラッグ中の要素が他の要素の上にある時の処理 function handleDragOver(ev) { // ドロップ先の行を取得 let targetRow = this; if (targetRow.rowIndex < min || max < targetRow.rowIndex) { ev.preventDefault(); // デフォルトの動作をキャンセルして、ドロップを許可する ev.dataTransfer.dropEffect = "move"; } } // ドラッグ中の要素が他の要素から離れた時の処理 function handleDragLeave(ev) { this.classList.remove("drop-placeholder"); } // ドロップ時の処理 function handleDrop(ev) { ev.preventDefault(); // ドロップ先の行を取得 let targetRow = this; // ドラッグ中の行の間にドロップ先の行が含まれないことを確認 if (targetRow.rowIndex < min || max < targetRow.rowIndex) { const parent = targetRow.parentNode; const rowIds = []; // ドラッグされている行のデータID const targetId = targetRow.dataset.id; // ドロップ位置を判定し、要素を入れ替える if (targetRow.rowIndex < min) { // ドラッグしている行よりも上の行にドロップした場合 draggingRows.forEach((draggingRow) => { parent.insertBefore(draggingRow, targetRow); rowIds.push(draggingRow.dataset.id); }); console.log(rowIds + " is/are inserted before " + targetId); save("sample/insert-before", rowIds, targetId); } else { // ドラッグしている行よりも下の行にドロップした場合 draggingRows.forEach((draggingRow) => { parent.insertBefore(draggingRow, targetRow.nextSibling); // 順序を保持するために、挿入先を変える targetRow = draggingRow; rowIds.push(draggingRow.dataset.id); }); console.log(rowIds + " is/are inserted after " + targetId); save("sample/insert-after", rowIds, targetId); } } } // ドラッグ終了時の処理 function handleDragEnd(ev) { draggingRows.forEach((draggingRow) => { draggingRow.classList.remove("dragging"); }); table.querySelectorAll("tbody > tr").forEach((row) => { row.classList.remove("drop-placeholder"); }); draggingRows = null; } // 簡易的な複数行選択機能 function handleClick(ev) { const row = ev.target.closest("tr"); if (row.classList.contains("selected")) { row.classList.remove("selected"); row.draggable = false; } else { row.classList.add("selected"); row.draggable = true; } } // 順序をサーバー側に保存する function save(url, rowIds, targetId) { $.ajax({ type: "get", url: url, dataType: "json", data: { row_ids: rowIds, target_id: targetId, }, }) .done((result) => { if (!result.status) { alert(result.errorMessage); } }) .fail((jqXHR, textStatus, errorThrown) => { alert("Ajax通信に失敗しました。"); console.log("jqXHR : " + jqXHR.status); // HTTPステータスを表示 console.log("textStatus : " + textStatus); // タイムアウト、パースエラーなどのエラー情報を表示 console.log("errorThrown : " + errorThrown.message); // 例外情報を表示 }); } }
sample09/routes/web.php
<?php use Illuminate\Support\Facades\Route; Route::get('/', function () { return view('welcome'); }); Route::get('/sample', [\App\Http\Controllers\SampleController::class, 'index']); Route::get('/sample/insert-before', [\App\Http\Controllers\SampleController::class, 'insertBefore']); Route::get('/sample/insert-after', [\App\Http\Controllers\SampleController::class, 'insertAfter']);
動作確認
以下のURLにアクセスして動作確認。
http://<your-ip-address>/sample09/public/sample
- 行をクリックすると、選択状態がトグルされる
- 選択されていない行をドラッグしても何も起きない(テキストが選択されるだけ)
- 選択されている行をドラッグ&ドロップすると、ドラッグ元との位置関係によって、ドロップ先の行の上か下にドラッグした行が挿入される
- ページをリロードしても順番が保持される
辺りが確認できれば完成。
参考
Geminiと会話して得られたコードを元にカスタマイズした。