種子と植物のデータベース
前回終了時のソースコード
Laravelを使ったアプリケーション開発のソースコードは以下のようにしてダウンロードすることができます。
$ git clone https://kiku3.tsbio.info/git/study-laravel.git study_laravel
この章開始時点のソースコードは chapt8 ブランチにあります。
$ git switch chapt8
ソースコードを自分で書いていく場合は、自分用のブランチをつくるとよいです。
$ git switch -c my8 chapt8
Laravelのコードはsrcディレクトリからの相対パスになっています。
実験材料の種と植物を管理する機能の拡張
モジュールの共通機能をくくりだす。
ルート
routes/oligo.php を移植しやすいように修正する。
oligoという文字を$moduleという変数に置き換える。
routes/oligo.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\OligoController as Controller; // 別名をつけて他のモジュールに流用しやすくする
// オリゴDNAのデータベース
$module = 'oligo';
Route::prefix("/$module")
->middleware(['auth', 'verified', "can:$module.read"])
->as("web.$module.")
->group(function (){
Route::get('/', [Controller::class, 'index'])->name('index');
Route::get('/search', [Controller::class, 'search'])->name('search');
Route::get('/create', [Controller::class, 'create'])->name('create');
Route::post('/', [Controller::class, 'store'])->name('store');
// 編集フォームとデータ更新
Route::get('/edit', [Controller::class, 'edit'])->name('edit');
Route::put('/{item}', [Controller::class, 'update'])->name('update');
});
コントローラー
OligoControllerを汎用化してMaterialControllerとする。 OligoControllerはMaterialControllerを継承(extends)して、Models/Oligoと関連するクラスの依存関係だけを設定する。
app/Http/Controllers/MaterialController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class MaterialController
{
/* リクエストクラスは、コントローラーのメソッドの引数として型指定し、Laravelが自動的に渡すようにしていた。
* その場合、基底クラスでメソッドを共通化すると型を指定できなくなり、派生クラスでメソッド引数を指定すると
* 基底クラスのメソッドを__parent::method()として呼ばないといけなくなる。
* コントローラーの子クラスでメソッドを変更することなく、使用するリクエストクラスを変更するために、
* ここでリクエストクラスの名前をコントローラーのプロパティとして設定する。
* リクエストクラスを使用するメソッドでは、クラス名からapp(class_name)でインスタンスを作成する。
* リクエストクラスはnewでインスタンス化するとややこしいので、Laravelのしくみを使う。
*/
protected string $singleRequest = Request::class;
protected string $multiRequest = Request::class;
protected string $queryRequest = Request::class;
// モデル($model)とサービス($service)は子クラスのコンストラクタで型指定する
/*
* リクエストに応じてビューを返す部分を共通化する
*/
public function respond($data = [], $view = null){
// アプリケーション固有のルートにだけ対応させればよいので、
// app/Providers/AppServiceProvider.phpでやっていた処理をここにもってきた
$route_name = request()->route()->getName();
list($module, $feature) = array_slice(explode('.', $route_name), 1, 2);
view()->share([
'module_id' => $module,
'feature_id' => "$module-$feature",
// テーブル(Model)のカラムの扱いを一元化したtableDefinitionをつくり、それをビューに渡す
'tableDefinition' => $this->service->tableDefinition,
]);
// htmxからの部分HTMLのリクエストに対応する
if($fragment = request()->hasHeader('HX-Request')){
$fragment = request()->header('HX-Target');
}
return view("$module.$feature", $data)
->fragmentIf($fragment, $fragment)
;
}
/*
* POST、PUTなどJSONでやり取りするときのレスポンス
*/
public function json($data = [], $code = 200)
{
if($code == 200){
$status = 'success';
}else {
$status = 'error';
}
return response()->json(array_merge(['status' => $status], $data), $code);
}
// GETリクエスト(ビューを返す)
public function index(){
return $this->respond(['items' => $this->model->limit(10)->get()]);
}
public function create(){
return $this->respond();
}
public function edit(){
return $this->respond();
}
// 検索
public function search()
{
try{
// app(クラス名)とすることで、Laravelがオブジェクトをつくる。
// 以後は request() でqueryRequestクラスのオブジェクトが返ってくる
$query = app($this->queryRequest)->validated();
return $this->json($this->service->search($query), 200);
}catch(\Throwable $e){
return $this->json(['message' => $e->getMessage()], 400);
}
}
// 追加
public function store()
{
try{
$items = app($this->multiRequest)->valids;
return $this->json($this->service->create($items), 200);
}catch(\Throwable $e){
return $this->json(['message' => $e->getMessage()], 400);
}
}
// 更新
public function update(int $id){
try{
$data = app($this->singleRequest)->validated();
$item = $this->model->findOrFail($id)->update($data);
return $this->json(['item' => $item], 200);
}catch(\Throwable $e){
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
}
}
}
app/Http/Controllers/OligoController.php
<?php
namespace App\Http\Controllers;
use App\Models\Oligo;
use App\Services\OligoService;
use App\Http\Requests\OligoRequest;
use App\Http\Requests\OligoSearchRequest;
use App\Http\Requests\OligosRequest;
class OligoController extends MaterialController
{
protected string $singleRequest = OligoRequest::class;
protected string $multiRequest = OligosRequest::class;
protected string $queryRequest = OligoSearchRequest::class;
public function __construct(
protected OligoService $service,
protected Oligo $model
) {}
}
リクエスト
リクエストの検証ルールはTableDefinitionに移動した。 ブラウザーからJSONで送られてくるデータの型と検証ルールの型を同じファイルにおいて、情報を一元化する。
app/Http/Requests/OligoSearchRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use App\TableDefinitions\OligoTableDefinition;
class OligoSearchRequest extends FormRequest
{
public function rules(OligoTableDefinition $table): array
{
return $table->searchRules();
}
}
app/Http/Requests/OligoRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use App\TableDefinitions\OligoTableDefinition;
class OligoRequest extends FormRequest
{
public function rules(OligoTableDefinition $table): array
{
return $table->insertRules();
}
}
複数のデータ用のOligosRequestがもつ配列検証の方法を MultiItemsRequest に移動する。
OligosRequestはMultiItemsRequestを継承して、単体のリクエストのクラス名だけをもつようにする。
app/Http/Requests/MultiItemsRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
class MultiItemsRequest extends FormRequest
{
protected string $singleRequest = FormRequest::class;
public array $valids = [];
public array $invalids = [];
public function rules(): array
{
return [
'items' => ['required', 'array'],
'items.*' => ['array'],
];
}
public function messages(): array
{
return [
'items.required' => '入力値がありません。',
'items.array' => '送信されたデータは配列でありません。',
];
}
protected function passedValidation()
{
$single = new $this->singleRequest();
$rules = app()->call([$single, 'rules']);
$messages = app()->call([$single, 'messages']);//$single->messages();
$items = $this->input('items', []);
foreach ($items as $index => $item) {
$validator = \Validator::make($item, $rules, $messages);
if ($validator->fails()) {
$this->invalids[$index] = [
'data' => $item,
'errors' => $validator->errors()->toArray(),
];
} else {
$this->valids[] = $validator->validated();
}
}
}
}
app/Http/Requests/OligosRequest.php
<?php
namespace App\Http\Requests;
class OligosRequest extends MultiItemsRequest
{
protected string $singleRequest = OligoRequest::class;
}
サービス
OligoServiceを汎用化してMaterialServiceとする。 そのためにはテーブルのどの列をどういう方法で検索するのかという情報を、TableDefinitionクラスに持っていって、そこから検索方法で取得するようにする。
OligoServiceはMaterialserviceを継承して、依存関係だけを定義する。
app/Services/MaterialService.php
<?php
namespace App\Services;
class MaterialService
{
/* 以下のものは派生クラスのコンストラクタで型指定し、注入(DI)する
* $model: データベースのテーブル
* $tableDefinition: テーブルのカラムの扱いを一元化した定義
* $searchFactory: 検索の種類を切り替えるためのファクトリー
*/
/* 検索
* $queryはリクエストクラスの検証済みデータ。列名 => 検索値の連想配列
*/
public function search(array $query)
{
$builder = $this->model->newQuery();
foreach ($query as $column => $value) {
if (is_string($value) && trim($value) === ''){
continue;
}
// テーブルの定義から、列の情報を取得する
$definition = $this->tableDefinition->column($column);
if (!$definition) {
continue;
}
// 検索の種類を取得する
$type = $definition['search']['type'] ?? null;
if (!$type) {
continue;
}
// 検索の種類に応じて、検索を適用する
$this->searchFactory->make($type)->apply($builder, $column, $value);
}
return [
'items' => $builder->get(),
'sql' => $builder->toSql(),
'query' => $query
];
}
public function create(array $items)
{
$results = [
'created' => [],
'succeeded' => [],
'failed' => [],
];
foreach($items as $index => $item){
try{
$created = $this->model->create($item);
$results['created'][] = $created;
$results['succeeded'][$index] = $created;
}catch(\Throwable $e){
$results['failed'][$index] = array_merge($item, ['error' => $e->getMessage()]);
}
}
return $results;
}
}
app/Services/OligoService.php
<?php
namespace App\Services;
use App\Models\Oligo;
use App\TableDefinitions\OligoTableDefinition;
use App\TableSearch\SearchFactory;
class OligoService extends MaterialService
{
public function __construct(
protected Oligo $model,
public OligoTableDefinition $tableDefinition,
protected SearchFactory $searchFactory
){}
}
検索
テーブルの検索の実装。 Eloquentのクエリービルダーを使って、条件(where)を追加していく。
SearchFactoryでSearchTypeを継承したクラスのインスタンスをつくり、そこでSQLを組み立てる。 とりあえず、like、正規表現、likeまたは正規表現、6桁日付の範囲検索、を可能にする。
app/TableSearch/SearchFactory.php
<?php
namespace App\TableSearch;
use Illuminate\Database\Eloquent\Builder;
use InvalidArgumentException;
class SearchFactory
{
protected array $searchTypes = [
'like' => LikeSearch::class,
'equal' => EqualSearch::class,
'regex' => RegexSearch::class,
'likeOrRegex' => LikeOrRegexSearch::class,
'date6' => Date6Search::class,
];
public function make(string $type)
{
$class = $this->searchTypes[$type]
?? throw new InvalidArgumentException(
"Unknown search type: {$type}"
);
return app($class);
}
}
app/TableSearch/SearchType.php
<?php
namespace App\TableSearch;
use Illuminate\Database\Eloquent\Builder;
interface SearchType
{
public function apply(Builder $builder, string $column, mixed $value): Builder;
}
app/TableSearch/LikeSearch.php
<?php
namespace App\TableSearch;
use Illuminate\Database\Eloquent\Builder;
class LikeSearch implements SearchType
{
public function apply(Builder $builder, string $column, mixed $value): Builder
{
if (!is_string($value) || trim($value) === '') {
return $builder;
}
$pattern = '/"([^"]*)"|\'([^\']*)\'|([^\s]+)/u';
if (preg_match_all($pattern, $value, $matches, PREG_SET_ORDER)) {
foreach($matches as $m){
$token = current(array_filter(
array_slice($m, 1),
fn($v) => $v !== ''
));
if($token !== false){
$builder->where($column, 'like', "%{$token}%");
}
}
}
return $builder;
}
}
app/TableSearch/RegexSearch.php
<?php
namespace App\TableSearch;
use Illuminate\Database\Eloquent\Builder;
class RegexSearch implements SearchType
{
public function apply(Builder $builder, string $column, mixed $value): Builder
{
if (!is_string($value) || trim($value) === '') {
return $builder;
}
return $builder->where($column, '~', $value);
}
}
app/TableSearch/LikeOrRegexSearch.php
<?php
namespace App\TableSearch;
use Illuminate\Database\Eloquent\Builder;
class LikeOrRegexSearch implements SearchType
{
public function __construct(protected SearchFactory $searchFactory)
{}
public function apply(Builder $builder, string $column, mixed $value): Builder
{
if (!is_array($value) || !isset($value['value'])) {
return $builder;
}
if(isset($value['mode']) && $value['mode'] == 'regex'){
$search = $this->searchFactory->make('regex');
}else{
$search = $this->searchFactory->make('like');
}
return $search->apply($builder, $column, $value['value']);
}
}
app/TableSearch/Date6Search.php
<?php
namespace App\TableSearch;
use Illuminate\Database\Eloquent\Builder;
use App\Traits\ConvertsYmdDate;
class Date6Search implements SearchType
{
use ConvertsYmdDate;
public function apply(Builder $builder, string $column, mixed $value): Builder
{
if (!is_string($value) || trim($value) === '') {
return $builder;
}
// 例: "210101-221231", "210101-", "-221231", "210101"
$parts = explode('-', $value);
// 210304
if (count($parts) === 1) {
return $builder->where($column, '=', $this->ymdToDate($parts[0]));
}
[$start, $end] = $parts;
// 210304-
if ($start !== '') {
$builder->where($column, '>=', $this->ymdToDate($start));
}
// -250607
if ($end !== '') {
$builder->where($column, '<=', $this->ymdToDate($end));
}
return $builder;
}
}
テーブル定義
テーブルの列の取り扱いかた(検索方法、HTMLでの表示方法)をもつクラスを定義する。
app/TableDefinitions/AbstractTableDefinition.php
<?php
namespace App\TableDefinitions;
use Illuminate\Validation\Rule;
abstract class AbstractTableDefinition
{
protected array $columns = [];
// indexとcreateとeditで表示する列を変える
public function columns(?string $feature = null): array
{
if ($feature === null) {
return $this->columns;
}
return array_filter(
$this->columns,
fn (array $column) =>
in_array($feature, $column['features'] ?? [], true)
);
}
// 列の情報を取り出す。
public function column(string $name): ?array
{
return $this->columns[$name] ?? null;
}
// 一段階階層を減らして、コーディングを楽にする
public function mergeSection(string $section): array
{
$results = [];
foreach($this->columns as $column_name => $col){
if(! isset($col[$section])) continue;
$results[$column_name] = array_merge($col, $col[$section]);
}
return $results;
}
public function searchColumns(): array
{
return $this->mergeSection('search');
}
public function formColumns(): array
{
return $this->mergeSection('form');
}
/*
* 検索方法と検証とJSONの形式を一致させるための処理
* LikeOrRegexに $column, $value = ['value' => 'query_string', 'mode' => 'like|regex'] を渡すようにする
* SearchRequest->rule()には column.value => 'rules', column.mode => 'like|regex' が必要。
* x-data = {query: {name: {value:'', mode:'regex'}, ...}}
* HTMLで <input type='text' x-model = 'column.value'>と <input type='checkbox' x-model = 'column.mode' value='regex'>
*/
// OligoSearchRequestなどで呼ぶ。
public function searchRules(): array
{
$rules = [];
foreach ($this->searchColumns() as $name => $column) {
switch($column['type']) {
case 'likeOrRegex':
$rules["{$name}.value"] = $column['rule'] ?? ['nullable', 'string', 'max:100'];
if (isset($column['modes'])) {
$rules["{$name}.mode"] = [
'nullable',
Rule::in($column['modes']),
];
}
break;
case 'date6':
$rules[$name] = $column['rule'] ?? ['nullable', 'regex:/^[0-9]*-?[0-9]*$/'];
break;
case 'like':
case 'equal':
case 'regex':
case 'date':
case 'date_range':
$rules[$name] = $column['rule'] ?? ['nullable', 'string', 'max:100'];
break;
default:
throw new \InvalidArgumentException("Unknown search type: {$column['type']}");
}
}
return $rules;
}
// SingleRequestで呼ぶ。
public function insertRules(): array
{
$rules = [];
foreach ($this->columns() as $name => $column) {
$rules[$name] = $column['insert']['rule'] ?? ['nullable'];
}
return $rules;
}
public function buildQueryDefaults(): array
{
$keys = [];
foreach ($this->searchColumns() as $name => $column) {
switch($column['type']) {
case 'likeOrRegex':
$keys[$name] = ["value" => '', "mode" => ''];
break;
default:
$keys[$name] = '';
break;
}
}
return $keys;
}
// HTMLフォームで空欄チェックに使う配列
public function requiredColumns(): array
{
$requiredColumns = [];
foreach($this->insertRules() as $name => $rules){
if(is_string($rules)) $rules = explode("|", $rules);
if(! is_array($rules)) continue;
if(in_array('required', $rules)) $requiredColumns[] = $name;
}
return $requiredColumns;
}
}
Model/Oligo のカラムに関する情報を集約する。
いざやってみるとあまり見やすくないので、今後配列の構造については要検討。
app/TableDefinitions/OligoTableDefinition.php
<?php
namespace App\TableDefinitions;
class OligoTableDefinition extends AbstractTableDefinition
{
protected array $columns = [
'name' => [
// HTMLのtableにおいてこの列を表示するフィーチャー
'features' => ['index', 'create', 'edit', 'delete'],
// 列名 th
'label' => '名前',
// クラス thまたはtdで使用
'class' => 'name',
// inputのplacehoder属性
'form' => [
'placeholder' => 'DROL1',
],
// 検索方法
'search' => [
'type' => 'likeOrRegex',
'modes' => ['like', 'regex'],
'rule' => 'nullable|string|max:255', // OligoSearchReqestで使用
],
'insert' => [
// OligoReqestで使用。HTMLのtableで空行判定にも使用
'rule' => ['required', 'string', 'max:100'],
]
],
'sequence' => [
'features' => ['index', 'create', 'edit', 'delete'],
'label' => '配列',
'class' => 'sequence',
'form' => [
'placeholder' => "ATCG...",
],
'search' => [
'type' => 'like',
'rule' => 'nullable|string|max:255',
],
'insert' => [
'rule' => ['required', 'string', 'regex:/^[A-Z]{6,255}$/'],
]
],
'created_at' => [
'features' => ['index', 'create', 'edit', 'delete'],
'label' => '作成日',
'class' => 'date6',
'form' => [
'placeholder' => "260101",
'title'=>"6桁の数字で入力してください。-で範囲を指定できます。",
],
'search' => [
'type' => 'date6',
],
'insert' => [
'rule' => ['nullable', 'string', 'regex:/^[0-9]{6}$/'],
],
],
'updated_at' => [
'features' => ['edit'],
'label' => '更新日',
'class' => 'date6',
'form' => [
'placeholder' => "260101",
'title'=>"6桁の数字で入力してください。-で範囲を指定できます。",
],
'search' => [
'type' => 'date6',
],
],
'owner' => [
'features' => ['index', 'create', 'edit', 'delete'],
'label' => '作成者',
'class' => 'owner',
'form' => [
'placeholder' => "作成者",
],
'search' => [
'type' => 'like',
'rule' => 'nullable|string|max:255',
],
'insert' => [
'rule' => ['nullable', 'string', 'max:100'],
]
],
/* データベースのテーブルではつくってある。
'box' => 'nullable|string|max:255',
'note' => 'nullable|string|max:255',
*/
];
}
ビューの修正
oligo以下のビューをmaterial以下にコピーして、オリゴに関する情報を削除する。
resources/views/components/material/index.blade.php
@props(['id', 'items' => [],])
<x-feature :id='$id'>
<table
class='material-list border-t-2 border-b-2 border-x' {{-- 色はモジュールごとに指定する --}}
x-init="
{{-- index経由のときに初期値として渡される$itemsをitemsに設定する --}}
if(items.length === 0) {
items = @js($items);
}
"
>
<thead>
{{-- thead 1行目 見出し --}}
<tr class='query'>
<th></th>
{{ $header_prepend ?? '' }} {{-- モジュールごとに異なる列数への対応 --}}
{{-- 列数をモジュールやフィーチャーから独立させる --}}
@foreach($tableDefinition->columns('index') as $col)
<x-material.table-th :column=$col />
@endforeach
{{ $header_append ?? '' }}
</tr>
{{-- thead 2行目 フォーム --}}
<tr @input.debounce.500="search()" class="query border-b-2 align-text-top">
<th>検索</th>
{{-- 表のセル(td)もコンポーネントにしてもよいかもしれないが、共通性が高くないので今後の課題とする --}}
@foreach($tableDefinition->columns('index') as $key => $col)
<td class="{{ $col['class'] }}">
@if($col['search']['type'] == 'likeOrRegex')
<input x-model="query.{{ $key }}.value"
placeholder="{{ $col['form']['placeholder'] ?? '' }}"
>
<label>
<input type="checkbox" x-model="query.{{ $key }}.mode" value=1 title="正規表現">
正規表現
</label>
@endif
@if($col['search']['type'] != 'likeOrRegex')
<input x-model="query.{{ $key }}"
placeholder="{{ $col['form']['placeholder'] ?? '' }}"
>
@endif
</td>
@endforeach
<td></td>
</tr>
{{-- thead 3行目 ボタン --}}
<tr>
<td colspan="9" class='px-2 py-2'>
<button @click="search()"> {{-- @inputで自動実行されるが、ボタンも加えておく --}}
検索
<x-ajax-state var='query' />
</button>
{{-- チェックボタンで選択・非選択をする --}}
<button @click.prevent="
items.forEach(item => item.checked = !item.checked);
console.log('check reverse', items.length, getCheckedItems().length, hasChecked());
">
チェックを反転
</button>
<button @click="resetQuery();">条件クリア</button>
</td>
</tr>
{{-- thead 4行目 検索結果 --}}
<tr class='border-b-2'>
<td x-show="items.length == 0" colspan="9">
条件に合うものは見つかりませんでした。
</td>
<td x-show="items.length > 0" colspan="9">
検索結果: <span x-text="items.length"></span>件
(☑: <span x-text="getCheckedItems().length"></span>件)
query:
<template x-for="key in Object.keys(query)" :key="key">
<template x-if="query[key] !== undefined && query[key] !== ''">
<span x-text="key"></span>=<span x-text="query[key]"></span>
</template>
</template>
</td>
</tr>
</thead>
<tbody>
{{-- 検索結果 --}}
{{-- Alpine.js (x-for)によって、items配列のそれぞれの要素をitemに取り出し、template要素を増やす --}}
<template x-for="item in items" :key="item.id">
<tr>
<td><input x-model="item.checked" type='checkbox'></td>
{{ $body_prepend ?? '' }}
@foreach($tableDefinition->columns('index') as $key => $col)
<td x-text="item.{{ $key }}" class="{{ $col['class'] }}"></td>
@endforeach
{{ $body_append ?? '' }}
</tr>
</template>
</tbody>
</table>
</x-feature>
resources/views/components/material/create.blade.php
@props(['id', 'modes' => [],])
<x-feature :id='$id'>
<x-slot:x_attributes> // x-featureに渡す属性にbladeの式を入れられない。そういうデータを渡すためのスロット
x-data="{
...Material.makeCreatePanel(), // モジュール間で共通する処理をまとめる。material.js
row_template: @js([ // 初期値
'created_at' => date('ymd'),
'owner' => auth()->user()->name
]),
// 表型のフォームで示されている列名
formColumns: @js(array_keys($tableDefinition->columns('create'))),
// 空行の判定をする列
requiredColumns: @js($tableDefinition->requiredColumns()),
// 入力フォームの表示モード
mode: '{{ count($modes) > 0 ? $modes[0] : 'form' }}',
}"
x-init="
ensureTrailingEmptyRow();
// rowsの中身を監視する
$watch('rows', () => {
ensureTrailingEmptyRow();
}, { deep: true }); /* オブジェクトの内部の変更を検知するために deep: true が必須 */
"
</x-slot:x_attributes>
{{-- テキストエリアからの一括挿入を必要としない場合に備える --}}
@if(count($modes) > 0) {{ $mode_selector }} @endif
{{-- 表型の入力フォーム --}}
<div
x-show="mode === 'form'"
>
<table class='material-list border-t-2 border-b-2 border-x my-2'>
<thead>
{{-- thead 1行目 見出し --}}
<tr class=''>
@foreach($tableDefinition->columns('create') as $col)
<x-material.table-th :column=$col />
@endforeach
</tr>
</thead>
<tbody>
<!-- 対象サンプル -->
<template x-for="row in rows" :key="row.id">
<tr>
@foreach($tableDefinition->columns('create') as $key => $col)
<td class="{{ $col['class'] }}">
<input x-model="row.{{ $key }}" placeholder="{{ $col['placeholder'] ?? '' }}">
</td>
@endforeach
</tr>
</template>
</tbody>
</table>
<button :disabled="rows.length <= 1"
@click.prevent="
fetch(url.store, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
items: rows.filter(row => ! isRowEmpty(row))
})
})
.then(response => response.json())
.then(data => {
console.log('data.created:', data.created);
rows = [];
return append(data.created.map(item => {
item.checked = true;
return item;
}));
});
"
>
追加
</button>
</div>
{{ $slot }}
</x-feature>
resources/views/components/material/edit.blade.php
@props(['id',])
<x-feature :id='$id'>
<p x-show="!hasChecked()" class="pb-10 pl-4">
<a
href="{{ route("web.$module_id.index") }}"
@click.prevent="selectFeature('{{ "${module_id}-index" }}', event)"
>
先に編集対象のサンプルにチェックをいれてください。
</a>
</p>
<table class='material-list border-t-2 border-b-2 my-2'
x-show="hasChecked()"
>
<thead>
<tr class=''>
<th></th>
@foreach($tableDefinition->columns('edit') as $col)
<x-material.table-th :column=$col />
@endforeach
</tr>
</thead>
<tbody>
<!-- 検索結果 -->
<template x-for="item in getCheckedItems()" :key="item.id">
<tr
@input.debounce.500="
item.status = 'loading';
try{
fetch(
url = `${url.edit}/${item.id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify(item)
}
)
.then(response => response.json())
.then(data => {
console.log('data:', data);
item.status = data.status;
});
}catch(e){
item.status = 'error';
}
setTimeout(() => item.status = null, 2000);
"
>
<td class="status-cell min-w-10">
<x-ajax-state var='item' />
</td>
@foreach($tableDefinition->columns('edit') as $key => $col)
<td class="{{ $col['class'] }}">
<input x-model="item.{{ $key }}" placeholder="{{ $col['placeholder'] ?? '' }}">
</td>
@endforeach
</tr>
</template>
</tbody>
</table>
</x-feature>
resources/views/components/material/table-th.blade.php
@props([
'column' => ['label' => '', 'class' => ''],
])
<th {{ $attributes->merge(['class' => $column['class']]) }}
>
{{ $column['label'] }}
</th>
material以下をx-material.indexのように呼び出すように、モジュール固有のビューを修正。
resources/views/oligo/index.blade.php
@extends("$module_id.module")
@section('feature')
<x-material.index id='{{ $feature_id }}' :items="$items"/>
@endsection
resources/views/oligo/create.blade.php
@extends("$module_id.module")
@section('feature')
<x-material.create id='{{ $feature_id }}'
:modes="['paste', 'form']"
>
{{-- 表型の入力フォームは material/create.blade.phpに持っていったので、
テキスト貼り付けの方法だけをここに書く。
--}}
<x-slot:mode_selector>
<div class="flex gap-2 my-2">
入力方法 (現在値: <span x-text="mode"></span>)
<input type='radio' name='mode' id='mode-paste' value='paste' checked x-model='mode'>
<label for='mode-paste'>貼り付け</label>
<input type='radio' name='mode' id='mode-form' value='form' x-model='mode'>
<label for='mode-form'>フォーム</label>
</div>
</x-slot:mode_selector>
{{-- タブまたはカンマ区切りのテキストを貼り付ける場所 --}}
<div x-show="mode == 'paste'"
x-data="{
raw: '',
parse() {
if (!this.raw.trim()) return;
this.raw.trim().split('\n').map(l => addRow(l.split(/[\t,]/)));
ensureTrailingEmptyRow();
if(rows.length > 0){
mode = 'form'; // フォーム表示に切り替える
}
return;
}
}"
x-init="
if(0){ // デバッグ用にデータを入力した状態にする
raw = 'Oligo1\tACTGG\nOligo2\tAGGCCCT';
parse();
}
"
>
<p>名前と配列を、タブまたはカンマで区切って、一行に一つずつ入力してください。</p>
<textarea class="border w-full min-w-0 my-2 p-1 h-[10rem] font-mono text-sm"
placeholder="{{ "name\tsequence" }}"
x-model="raw"
@change="parse()"
></textarea>
<button :disabled="!raw.trim()" @click.prevent="parse()">
貼り付けたデータをフォームに反映
</button>
</div>
</x-material.create>
@endsection
resources/views/oligo/edit.blade.php
@extends("$module_id.module")
@section('feature')
<x-material.edit id='{{ $feature_id }}'/>
@endsection
複数モジュールの切り替えをできるように selectModule を作成。
resources/views/layouts/app.blade.php
@extends('layouts.html')
@section('body')
<header class='bg-blue-500 text-white py-2 px-2 flex justify-between items-start'>
<h1 class='text-xl leading-none'>{{ config('app.name') }}</h1>
<div class="flex items-baseline">
@auth
<span class='px-2 text-sm'>{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button type="submit" class="text-sm text-black bg-gray-200 border-gray-400 px-2 hover:bg-gray-400 hover:text-white">
{{ __('ログアウト') }}
</button>
</form>
@endauth
@guest
<span class='px-2 text-sm'>ゲスト</span>
<form method="GET" action="{{ route('login') }}" class="inline">
<button type="submit" class="text-sm text-black bg-gray-200 border-gray-400 px-2 hover:bg-gray-400 hover:text-white">
{{ __('ログイン') }}
</button>
</form>
@endguest
</div>
</header>
<main id='modules' class='bg-yellow-100 px-2 min-h-screen'
x-data="{
activeModule: '{{ $module_id }}',
/* activeModuleの値を変更し、そのHTML要素がないときに取得する */
selectModule(moudleId, event){
/* aタグのhref属性からmoduleのidを取得する */
const a = event.currentTarget;
const url = a.href;
/* activeModuleにidを設定 */
this.activeModule = moudleId;
/* URLを変更する。これによってブラウザーのリロードをしても、以前のページを取得できるようになる */
history.pushState({}, '', url);
/* idで要素を取得 */
const panel = document.getElementById(moudleId);
/* 要素を取得できなかったときにhtmxで部分HTMLを取得する */
if(null == panel) {
htmx.ajax('GET', url, {
target:'main', /* 取得したHTMLの置き換え場所 */
swap: 'beforeend', /* 今回はdiv.featuresの子要素として追加するのでbeforeend */
headers: {
'HX-Target': 'module-' + moudleId,
}
});
}
}
}"
>
<nav class='bg-yellow-100 py-2 px-2'>
<ul id='module-tabs' class='flex'>
@foreach([
{{-- bg-{{ $module_id }}はtailwindcssが見逃すのでbg-oligoとする --}}
'oligo' => ['label' => 'オリゴDNA', 'active' => 'bg-oligo', 'inactive' => 'hover:bg-oligo-light'],
'user' => ['label' => 'ユーザー', 'active' => 'bg-user', 'inactive' => 'hover:bg-user-light'],
] as $module => $info)
<li class='module-tab'
:class="{
'{{ $info['active'] }} text-white': activeModule === '{{ $module }}',
'{{ $info['inactive'] }} bg-white text-gray-600' : activeModule !== '{{ $module }}'
}"
>
<a id='{{ $module }}-tab' role='tab'
@click.prevent="selectModule('{{ $module }}', event)"
>
{{ $info['label'] }}
</a>
</li>
@endforeach
<li x-text="activeModule" class='module-tab bg-gray-400'></li>
</ul>
</nav>
@yield('module')
</main>
@endsection
モジュールにfragmentを設定。 selectFeatureの引数をfeature_idに変更して、クリック元のaタグとは関係なくタブを変更できるようにした。 htmx.ajaxのリクエストヘッダーにHX-Targetを設定し、取得するビューの範囲を指定する。
resources/views/components/module.blade.php
@props(['id'])
@fragment("module-{$id}") {{-- moduleの部分HTMLの範囲を示す --}}
<div id="{{ $id }}" role="tabpanel"
{{ $attributes->merge([
'class' => 'module-panel',
'x-show' => "activeModule === \$el.id"
]) }}
x-data="{
activeFeature: '{{ $feature_id }}',
/* activeFeatureの値を変更し、そのHTML要素がないときに取得する */
selectFeature(featureId, event){
/* aタグのhref属性からfeatureのidを取得する */
const a = event.currentTarget;
const url = a.href;
/* activeFeatureにidを設定 */
this.activeFeature = featureId;
/* URLを変更する。これによってブラウザーのリロードをしても、以前のページを取得できるようになる */
history.pushState({}, '', url);
/* idで要素を取得 */
const panel = document.getElementById(featureId);
/* 要素を取得できなかったときにhtmxで部分HTMLを取得する */
if(null == panel) {
htmx.ajax('GET', url, {
target: '#{{ $id }} div.features', /* 取得したHTMLの置き換え場所 */
swap: 'beforeend', /* 今回はdiv.featuresの子要素として追加するのでbeforeend */
headers: {
'HX-Target': 'feature-' + featureId,
}
});
}
}
}"
>
{{ $slot }}
</div>
@endfragment
create.blade.phpからbladeを埋め込んだx-dataをスロット経由で受け取るための修正。
resources/views/components/feature.blade.php
@props(['id'])
@fragment("feature-{$id}") {{-- featureの部分HTMLの範囲を示す --}}
<div id="{{ $id }}" role="tabpanel"
{{ $attributes->merge([
'class' => 'feature-panel',
'x-show' => "activeFeature === \$el.id"
]) }}
@if(isset($x_attributes)) {{ $x_attributes }} @endif
>
{{ $slot }}
</div>
@endfragment
CSSの調整
モジュールごとに色を設定するため、app.cssに色の定義を加える。
resources/css/app.css
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
/* モジュールの色の設定 */
--color-oligo: #0891B2;
--color-oligo-light: #22D3EE;
}
@import 'lab.css';
@import 'oligo.css';
@import 'user.css';
material.jsの作成
長くなったAlpine.jsのオブジェクト生成をmaterial.jsに書き出す。
resources/js/material.js
/* create.blade.phpで使用するx-data */
function makeCreatePanel()
{
return {
id: 1, // このidはrowsの中でのみ有効。template x-forでキーがあったほうが便利なので、重複しない値をつくる。
rows:[],
isRowEmpty(row) {
return this.requiredColumns.every(col => !row[col] || row[col] === '');
},
// データを追加
addRow(values) {
if (this.rows.length > 50) return; // バグによって行数が増えすぎないための安全装置
let row;
const lastRow = this.rows[this.rows.length - 1];
if(lastRow && this.isRowEmpty(lastRow)){
Object.assign(lastRow, this.row_template);
row = lastRow;
}else{
row = { ...this.row_template, id: this.id ++};
this.rows.push(row);
}
values.forEach((v, i) => {
if(this.formColumns[i]) {
row[this.formColumns[i]] = v;
}
});
},
// 最終行に常に空行をいれる
ensureTrailingEmptyRow() {
const lastRow = this.rows[this.rows.length - 1];
if (!lastRow || !this.isRowEmpty(lastRow)) {
this.addRow([]);
}
},
};
}
/* module.blade.phpで使用するx-data */
function makeModulePanel()
{
return {
items: [],
query: {},
// 検索 fetchは非同期(async)に実行されるので、それにそろえる
async search(){
const parameter = new URLSearchParams(); // 同じx-data内なので this.queryとなる。これをGETパラメーター(?key1=value1&key2=value2)に変換する
Object.entries(this.query).forEach(([key, value]) => {
if (value !== null && typeof value === 'object') {
// ネストされたオブジェクト(value と mode など)をブラケット記法 name[value], name[mode] に展開
Object.entries(value).forEach(([subKey, subValue]) => {
if (subValue !== '' && subValue !== null) {
parameter.append(`${key}[${subKey}]`, subValue);
}
});
} else if (value !== '' && value !== null) {
// 通常の文字列・数値
parameter.append(key, value);
}
});
console.log('search parameter was constructed:', parameter.toString()); // 動作確認
const options = {
method: 'GET',
headers: {
'Accept': 'application/json', // 返り値の設定。Laravelが判断するのに使う。
'Content-Type': 'application/json',
},
};
return fetch(this.url.search + '?' + parameter.toString(), options)
.then(response => response.json())
.then(response => {
console.log('search done', response); // 動作確認
return this.append(response.items); // itemsを置き換えるappendを呼ぶ
})
.catch(error => {console.log(error)})
;
},
// 検索結果をitemsに反映させる。今後、新しい検索結果で置き換えるのではなく、追加するように修正していくので、appendとしてある。
append(appendedItems){
const checkedIds = new Set(this.getCheckedItems().map(item => item.id));
const additional = appendedItems.filter(item => ! checkedIds.has(item.id));
console.log('current items:', this.items.length, 'checked:', checkedIds.length, 'added:', appendedItems.length);
this.items = [...this.getCheckedItems(), ...additional];
return additional;
},
// チェックがいれられたものだけを取得する。
getCheckedItems(){
return this.items.filter(item => item.checked);
},
hasChecked(){
return this.getCheckedItems().length > 0;
},
resetQuery(){
this.query = JSON.parse(JSON.stringify(this.query_template));
}
}
}
// window オブジェクトに Material を定義する
window.Material = {
makeModulePanel,
makeCreatePanel,
}
これをapp.jsで読み込む。 @import で ./material とする。
resources/js/app.js
import './material'; // htmx の読み込みとグローバル登録 import htmx from 'htmx.org'; window.htmx = htmx; // Alpine.js の読み込みと初期化 import Alpine from 'alpinejs'; window.Alpine = Alpine; Alpine.start();
種子データベースの作成
権限の追加
permissionsにseed.readとseed.writeを追加し、適当なroleに追加する。
ルートの作成
routes/oligo.phpをコピーしてroutes/seed.phpを作成する。 これをweb.phpで読み込むようにする。
routes/seed.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SeedController as Controller;
// 種子のデータベース
$module = 'seed';
Route::prefix("/$module")
->middleware(['auth', 'verified', "can:$module.read"])
->as("web.$module.")
->group(function (){
Route::get('/', [Controller::class, 'index'])->name('index');
Route::get('/search', [Controller::class, 'search'])->name('search');
Route::get('/create', [Controller::class, 'create'])->name('create');
Route::get('/edit', [Controller::class, 'edit'])->name('edit');
// 編集フォームとデータ更新
Route::post('/', [Controller::class, 'store'])->name('store');
Route::put('/{item}', [Controller::class, 'update'])->name('update');
});
コントローラー、リクエスト、サービス、
こんな感じでコピーしていく
cd app/Http/Requests
for F in Oligo*.php ; do
sed -e "s/Oligo/Seed/g" -e "s/oligo/seed/g" $F >${F/Oligo/Seed}
done
テーブル定義
マイグレーションを作成。
database/migrations/2026_09_01_000001_create_seeds_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const SEQUENCE = 'lab_id';
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('seeds', function (Blueprint $table) {
$table->integer('id')
->primary()
->default(DB::raw("nextval('" . self::SEQUENCE . "')"));
$table->integer('mother')->nullable(); // plant id 外部キーの設定をするといろいろとややこしくなるので運用でカバーする
$table->integer('father')->nullable(); // plant id
$table->string('labo_id')->nullable(); // 研究室独自のIDを使っている場合に対応
$table->string('strain');
$table->string('generation')->nullable();
$table->string('no')->nullable();
$table->string('box')->nullable(); // 育成場所
$table->string('owner')->nullable(); // 所有者
$table->string('note')->nullable();
$table->timestamps(); // created_at, updated_at
$table->softDeletes(); // deleted_at
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('seeds');
}
};
実行
# ./artisan migrate
モデルを作成
app/Models/Seed.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use App\Traits\ConvertsYmdDate;
#[Fillable(['strain', 'generation', 'no', 'box', 'owner', 'note', 'mother', 'father', 'created_at', 'labo_id'])]
class Seed extends Model
{
use SoftDeletes;
use ConvertsYmdDate;
// 表示しない(toArray, jsonなどで出力されない)
protected $hidden = ['updated_at', 'deleted_at'];
protected function createdAt(): Attribute
{
return Attribute::make(
get: function ($value, $attributes) {
return isset($attributes['created_at'])
? $this->dateToYmd($attributes['created_at'])
: null;
},
set: function ($value) {
return ['created_at' => $value ? $this->ymdToDate($value) : null];
},
);
}
}
テーブルの情報を集約。
app/TableDefinitions/SeedTableDefinition.php
<?php
namespace App\TableDefinitions;
class SeedTableDefinition extends AbstractTableDefinition
{
protected array $columns = [
'strain' => [
// HTMLのtableにおいてこの列を表示するフィーチャー
'features' => ['index', 'create', 'edit', 'delete'],
// 列名 th
'label' => 'サンプル名',
// クラス thまたはtdで使用
'class' => 'strain',
// inputのplacehoder属性
'form' => [
'placeholder' => 'Col',
],
// 検索方法
'search' => [
'type' => 'likeOrRegex',
'mode' => ['like', 'regex'],
'rule' => 'nullable|string|max:255', // OligoSearchReqestで使用
],
'insert' => [
// OligoReqestで使用。HTMLのtableで空行判定にも使用
'rule' => ['required', 'string', 'max:255'],
]
],
'generation' => [
'features' => ['index', 'create', 'edit', 'delete'],
'label' => '世代',
'class' => 'generation',
'form' => [
'placeholder' => 'F1',
],
'search' => [
'type' => 'like',
'rule' => ['nullable', 'string', 'max:255'],
],
'insert' => [
'rule' => ['nullable', 'string', 'max:255'],
]
],
'no' => [
'features' => ['index', 'create', 'edit', 'delete'],
'label' => '個体番号',
'class' => 'no',
'form' => [
'placeholder' => '1:10',
],
'search' => [
'type' => 'like',
'rule' => ['nullable', 'string', 'max:255'],
],
'insert' => [
'rule' => ['nullable', 'string', 'max:255'],
]
],
'created_at' => [
'features' => ['index', 'create', 'edit', 'delete'],
'label' => '収穫日',
'class' => 'date6',
'form' => [
'placeholder' => "260101",
'title'=>"6桁の数字で入力してください。-で範囲を指定できます。",
],
'search' => [
'type' => 'date6',
],
'insert' => [
'rule' => ['nullable', 'string', 'regex:/^[0-9]{6}$/'],
],
],
'owner' => [
'features' => ['index', 'create', 'edit', 'delete'],
'label' => '収穫者',
'class' => 'owner',
'form' => [
'placeholder' => "鈴木",
],
'search' => [
'type' => 'like',
'rule' => 'nullable|string|max:255',
],
'insert' => [
'rule' => ['nullable', 'string', 'max:100'],
]
],
'note' => [
'features' => ['index', 'create', 'edit', 'delete'],
'label' => '備考',
'class' => 'note',
'form' => [
'placeholder' => "",
],
'search' => [
'type' => 'like',
'rule' => 'nullable|string|max:255',
],
'insert' => [
'rule' => ['nullable', 'string', 'max:100'],
]
],
'mother' => [
'features' => [],
'label' => 'mother_id',
'class' => 'parent',
'form' => [
'placeholder' => "鈴木",
],
'search' => [
'type' => 'none',
'rule' => 'nullable|integer',
],
'insert' => [
'rule' => ['nullable', 'integer'],
]
],
'father' => [
'features' => [],
'label' => 'father_id',
'class' => 'parent',
'form' => [
'placeholder' => "鈴木",
],
'search' => [
'type' => 'none',
'rule' => 'nullable|integer',
],
'insert' => [
'rule' => ['nullable', 'integer'],
]
],
/* データベースのテーブルではつくってある。
'labo_id' => 'nullable|string|max:255',
'box' => 'nullable|string|max:255',
*/
];
}
ビュー
$ cd resources/views $ cp -r oligo seed
layouts/app.blade.phpを修正して、種子の項目を追加。
CSS
app.cssにモジュールのテーマカラーをつくる。
resources/css/app.css
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
/* モジュールの色の設定 */
--color-oligo: #0891B2;
--color-oligo-light: #22D3EE;
--color-seed: #ee852a;
--color-seed-light: #ffb370;
}
@import 'lab.css';
@import 'oligo.css';
@import 'seed.css';
@import 'user.css';
それを使う。
resources/css/seed.css
#seed {
table {
@apply border-seed;
thead tr:first-child {
@apply bg-seed-light;
}
thead th,thead td {
@apply border-x border-seed;
}
tr {
@apply border-seed;
}
td {
@apply border-x border-seed p-1;
}
td input {
&[type=text], &:not([type]) {
@apply border border-seed w-full min-w-0 px-1 h-[24px];
}
}
.strain {
width: 10em;
}
.generation, .no {
width: 4em;
}
.owner {
width: 5em;
}
.date6, .created_at, .updated_at {
width: 5em;
}
.note {
width: 15em;
}
}
button {
@apply border-seed bg-seed-light;
&:hover {
@apply bg-seed text-white;
}
}
}
植物データベースの作成
育成中の植物のデータベース。次の種の元になる。
ルート、コントローラー、リクエスト、サービス、ビュー(CSSを含む)は種子モジュールを増やしたときと同じ。
migrationでテーブルの定義をつくり、Model/Plantをつくって、PlantTableDefinitionでビューやリクエストとの関係をつくる。
ラベル印刷用CSVの出力
P-touchというラベルプリンタがあります。 これはCSVのデータをテンプレートにあてはめて印刷することができ、種子袋や植物の鉢につけるラベルをつくることができます。
これのために、選択したデータのCSVを出力する機能を実装します。
ルート、コントローラー
Seedのほうを書きますが、Plantにも同じものをいれるとよいです。
routes/seed.phpとapp/Http/Controllers/MaterialController.phpにlabel機能へのルートを設定します。
実際の処理はサービスに委ねます。
CSVをダウンロードするにはPOSTへの応答が楽です。
routes/seed.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SeedController as Controller;
// 種子のデータベース
$module = 'seed';
Route::prefix("/$module")
->middleware(['auth', 'verified', "can:$module.read"])
->as("web.$module.")
->group(function (){
Route::get('/', [Controller::class, 'index'])->name('index');
Route::get('/search', [Controller::class, 'search'])->name('search');
Route::get('/create', [Controller::class, 'create'])->name('create');
Route::get('/edit', [Controller::class, 'edit'])->name('edit');
Route::post('/', [Controller::class, 'store'])->name('store');
Route::put('/{item}', [Controller::class, 'update'])->name('update');
});
app/Http/Controllers/MaterialController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class MaterialController
{
/* リクエストクラスは、コントローラーのメソッドの引数として型指定し、Laravelが自動的に渡すようにしていた。
* その場合、基底クラスでメソッドを共通化すると型を指定できなくなり、派生クラスでメソッド引数を指定すると
* 基底クラスのメソッドを__parent::method()として呼ばないといけなくなる。
* コントローラーの子クラスでメソッドを変更することなく、使用するリクエストクラスを変更するために、
* ここでリクエストクラスの名前をコントローラーのプロパティとして設定する。
* リクエストクラスを使用するメソッドでは、クラス名からapp(class_name)でインスタンスを作成する。
* リクエストクラスはnewでインスタンス化するとややこしいので、Laravelのしくみを使う。
*/
protected string $singleRequest = Request::class;
protected string $multiRequest = Request::class;
protected string $queryRequest = Request::class;
// モデル($model)とサービス($service)は子クラスのコンストラクタで型指定する
/*
* リクエストに応じてビューを返す部分を共通化する
*/
public function respond($data = [], $view = null){
// アプリケーション固有のルートにだけ対応させればよいので、
// app/Providers/AppServiceProvider.phpでやっていた処理をここにもってきた
$route_name = request()->route()->getName();
list($module, $feature) = array_slice(explode('.', $route_name), 1, 2);
view()->share([
'module_id' => $module,
'feature_id' => "$module-$feature",
// テーブル(Model)のカラムの扱いを一元化したtableDefinitionをつくり、それをビューに渡す
'tableDefinition' => $this->service->tableDefinition,
]);
// htmxからの部分HTMLのリクエストに対応する
if($fragment = request()->hasHeader('HX-Request')){
$fragment = request()->header('HX-Target');
}
return view("$module.$feature", $data)
->fragmentIf($fragment, $fragment)
;
}
/*
* POST、PUTなどJSONでやり取りするときのレスポンス
*/
public function json($data = [], $code = 200)
{
if($code == 200){
$status = 'success';
}else {
$status = 'error';
}
return response()->json(array_merge(['status' => $status], $data), $code);
}
// GETリクエスト(ビューを返す)
public function index(){
return $this->respond(['items' => $this->model->limit(10)->get()]);
}
public function create(){
return $this->respond();
}
public function edit(){
return $this->respond();
}
// 検索
public function search()
{
try{
// app(クラス名)とすることで、Laravelがオブジェクトをつくる。
// 以後は request() でqueryRequestクラスのオブジェクトが返ってくる
$query = app($this->queryRequest)->validated();
return $this->json($this->service->search($query), 200);
}catch(\Throwable $e){
return $this->json(['message' => $e->getMessage()], 400);
}
}
// 追加
public function store()
{
try{
$items = app($this->multiRequest)->valids;
return $this->json($this->service->create($items), 200);
}catch(\Throwable $e){
return $this->json(['message' => $e->getMessage()], 400);
}
}
// 更新
public function update(int $id){
try{
$data = app($this->singleRequest)->validated();
$item = $this->model->findOrFail($id)->update($data);
return $this->json(['item' => $item], 200);
}catch(\Throwable $e){
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
}
}
}