リソースと機能の拡張

前回終了時のソースコード

Laravelを使ったアプリケーション開発のソースコードは以下のようにしてダウンロードすることができます。

$ git clone https://kiku3.tsbio.info/git/study-laravel.git study_laravel

この章開始時点のソースコードは chapt8 ブランチにあります。

$ git switch chapt8

ソースコードを自分で書いていく場合は、自分用のブランチをつくるとよいです。

$ git switch -c my8 chapt8

Laravelのコードはsrcディレクトリからの相対パスになっています。

エラーへの対処

  • Laravelのパッケージがない

    composer install を実行してください。開発段階に応じて不足するパッケージがあるときは、再度 composer install が必要な場合があります。

  • ディレクトリのパーミッションが正しくない

    tempnam()が実行できないなどのエラーが出るときは、storageとbootstrap/cacheディレクトリの所有者とパーミッションを確認してください(第1章を参照)

  • Vite manifest not found

    開発中なら npm run dev、本番環境なら npm run buildを実行してください。

  • npm run devが動かない

    npm install を実行してください。

種子や植物といったリソースの管理もできるように、システムの構成を整理します。 最後に、CSVのファイルをダウンロードできるようにします。

共通する機能と固有の設定を分離して、機能拡張に備える

これまでオリゴDNA(プライマー)の情報を検索したり、追加・編集できるようにしてきました。 これをもとに、種子と鉢植えにした植物といった研究に必要な他のリソースも管理できるようにします。

リソースの種類が増えても、検索や編集といった機能は必要なので、共通する機能をくくりだしていきます。 リソースに固有の設定をプログラムに埋め込まないことで、システムが拡張しやすなります。

リソースを固有のURL(ルート)に結びつけます。 それに対応するコントローラーを用意し、データベースのテーブル(モデル)とそれを取り扱うサービスへとつなげます。 取り出された情報をビューにまとめて、画面に表示します。

以降ではこの流れにそって、プログラムを書き換えていきます。

ルートの設定

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(["can:$module.read"])
    ->as("$module.")
    ->group(function (){
        Route::get('/', [Controller::class, 'index'])->name('index');
 
        // GETは全て同じパターンなのでまとめる
        foreach(['search', 'create', 'edit'] as $name){
            Route::get($name, [Controller::class, $name])->name($name);
        }
 
        // 編集フォームとデータ更新
        Route::post('/', [Controller::class, 'store'])->name('store');
        Route::put('/{item}', [Controller::class, 'update'])->name('update');
    });

認証などの共通化できるところを material.php に移します。 web.phpはmaterial.phpをrequireするようにします。

routes/material.php
<?php
 
use Illuminate\Support\Facades\Route;
 
// リソース共通のルート
Route::middleware(['auth', 'verified', 'auto.login:test user'])
    ->as('web.')  // この名前もresourceとかに変更してもよいかも。その場合、ビューのroute()を全て変更する必要がある。
    ->group(function (){
        require __DIR__.'/oligo.php';
    });

コントローラーの作成

OligoControllerを汎用化してMaterialControllerにします。

汎用化したコントローラーは、ユーザーの要求と、テーブル(モデル)のメソッドを結びつけます。 コントローラーのindexメソッドが呼ばれた場合、モデルのallメソッドを実行し、結果を返します。 storeメソッドが呼ばれた場合、サービスを介してモデルのcreateメソッドを実行します。

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クラスをつくり、それをビューに渡す
            'table' => $this->service->table,
        ]);
 
        if(is_null($view)) $view = "$module.$feature";
 
        // htmxからの部分HTMLのリクエストに対応する
        if($fragment = request()->hasHeader('HX-Request')){
            $fragment = request()->header('HX-Target');
        }
        return view($view, $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(){
        $items = request()->hasHeader('HX-Request') ? []
            : $this->model->orderBy('created_at', 'desc')->limit(100)->get()
        ;
        return $this->respond(['items' => $items]);
    }
 
    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);
        }
    }
}

リソース固有のコントローラーは、要求(リクエスト)がリソースごとの要件に合致しているかどうかを検証し、リソースのモデルを操作します。 そのために、クラス変数や、コンストラクタ(__constructメソッド)にリソース固有のオブジェクトを指定します。

コンストラクタに public|protected|private のアクセス修飾子と、クラスの型、引数名を指定すると、Laravelがそれを自動的にクラス変数として設定してくれます。 つまり public function __construct(protected Oligo $model) と宣言することで、そのクラス内で $this->model として使うことができます。

Requestクラスはオブジェクトにするときに、自動的に検証が行われて、それが失敗すると例外が投げられます。 複数のリクエストクラスの検証ルールが同時に成立することはまずないので、コンストラクタ渡しにすると常に失敗することになります。 そのため、ここではリソース固有のリクエストクラスは、そのクラス名をクラス変数に設定し、必要とするメソッド内でインスタンス化するようにしています。

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
    ) {}
}

リクエストの作成

リクエストとして送信するデータはビューのほうで用意することになります。 つまりどのプロパティを用意するのかという情報が、リクエストとビューの2か所で必要になります。 それらを一元化するためにTableDefinitionクラスを新たに作成し、ルールの情報はそこに移動しました。

リクエストにnameプロパティが必要 = ビューでnameプロパティに値を設定できるようにする

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()
    {
        // Requestクラスはapp()でオブジェクトにすると、request()で呼ばれる
        // 元々のリクエストオブジェクトを更新してしまう。
        // ここではrulesがほしいだけなので、newを使う。
        $single = new $this->singleRequest();
 
        // rulesメソッドはTableDefinitionクラスのオブジェクトを必要とする。
        // そのためにapp()->call()として、LaravelにDIさせる。
        $rules = app()->call([$single, 'rules']);
        if(! isset($rules['id'])) $rules['id'] = 'nullable'; // 送信元のentry.idを取得する
        $messages = app()->call([$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クラスにあるので、それをコンストラクタで受け取り、検索のSQLを組み立てるのに使用するようにしました。

app/Services/MaterialService.php
<?php
 
namespace App\Services;
 
class MaterialService
{
    /* 以下のものは派生クラスのコンストラクタで型指定し、注入(DI)する
     * $model: データベースのテーブル
     * $table: テーブルのカラムの扱いを一元化した定義
     * $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->table->column($column);
            if (!$definition) {
                continue;
            }
 
            // 検索の種類を取得する
            $type = $definition['search']['type'] ?? null;
            if (!$type) {
                continue;
            }
 
            // 検索の種類に応じて、検索を適用する
            try{
                $this->searchFactory->make($type)->apply($builder, $column, $value);
            }catch(\Throwable $e){
                continue;
            }
        }
 
        return [
            'items' => $builder->orderBy('created_at', 'desc')->get(),
            'sql' => $builder->toSql(),
            'query' => $query
        ];
    }
 
    public function create(array $items)
    {
        $results = [
            'created' => [], 
            'succeeded' => [],
            'failed' => [], 
        ];
 
        foreach($items as $item){
            try{
                $created = $this->model->create($item);
                $results['created'][] = $created;
                if(isset($item['id'])){
                    $results['succeeded'][$item['id']] = $created;
                }
            }catch(\Throwable $e){
                $results['failed'][] = array_merge($item, ['error' => $e->getMessage()]);
            }
        }
        return $results;
    }
}

OligoServiceはMaterialserviceを継承して、依存関係(操作対象のモデル)だけを定義します。

TableDefinitionクラスは、コントローラーからビューに渡す必要があるので、publicとしてあります。

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 $table,
        protected SearchFactory $searchFactory
    ){}
}

検索

Eloquentのクエリービルダーを使って、カラムの条件(where)を追加していきます。

SearchFactoryでSearchTypeを継承したクラスのインスタンスをつくり、そこでSQLを組み立てる。 とりあえず、like、一致、正規表現、likeまたは正規表現、6桁日付の範囲検索、を実装しました。

SearchFactoryでクエリービルダーを操作するクラス(SearchType)をつくり、そのクラスで実際にカラムに条件を設定していきます。

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',
        */
    ];
}

ビューの共通部分をくくりだす

一覧・検索、追加、編集のビューはリソースが変わっても構成は同じなので、共通部分をくくりだして、コンポートmaterial以下のにつくりました。

resources/views/components/material/index.blade.php
@props(['items' => []])
<table
  class='material-list'
  x-init="
    {{-- index経由のときに初期値として渡される$itemsをitemsに設定する --}}
    if(items.length === 0) {
      items = @js($items);
    }
  "
>
  <thead>
    {{-- thead 1行目 見出し --}}
    <tr class='query'>
      <th></th>
      {{ $header_prepend ?? '' }}  {{-- モジュールごとに異なる列数への対応 --}}
      {{-- 列数をモジュールやフィーチャーから独立させる --}}
      @foreach($table->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>
 
      {{-- TableDefinitionクラスからindexのビューで使用するカラムを取得する --}}
      @foreach($table->columns('index') as $key => $col)
        <td class="{{ $col['class'] }}">
          @if($col['search']['type'] == 'likeOrRegex')
            {{-- 表のセル(td)もコンポーネントにしてもよいかもしれないが、共通性が高くないので今後の課題とする --}}
            <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, checkedItems.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="checkedItems.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 @click="item.checked = !item.checked">
        <td><input x-model="item.checked" @click.stop type='checkbox'></td>
        {{ $body_prepend ?? '' }}
        @foreach($table->columns('index') as $key => $col)
          <td x-text="item.{{ $key }}" class="{{ $col['class'] }}"></td>
        @endforeach
        {{ $body_append ?? '' }}
      </tr>
    </template>
  </tbody>
</table>

resources/views/components/material/edit.blade.php
<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'
  x-show="hasChecked"
>
  <thead>
    <tr>
      <th></th>
      @foreach($table->columns('edit') as $col)
        <x-material.table-th :column=$col />
      @endforeach
    </tr>
  </thead>
 
  <tbody>
    <!-- 検索結果 -->
    <template x-for="item in checkedItems" :key="item.id">
      <tr
        @input.debounce.500="
          item.status = 'loading';
          try{
            /* CSRFとかContent-Typeの設定をまとめたapi.put (app.jsに定義) を呼ぶ
             */
            api.put(`${url.update}/${item.id}`, item)
              .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($table->columns('edit') as $key => $col)
          <td class="{{ $col['class'] }}">
            <input x-model="item.{{ $key }}" placeholder="{{ $col['placeholder'] ?? '' }}"
              @focus="$el.select()"
            >
          </td>
        @endforeach
      </tr>
    </template>
  </tbody>
</table>

resources/views/components/material/create.blade.php
@props([
  'entry_default' => [],  // 入力値のデフォルトを設定する。リソースのcreateから送られてくる。
  'rawtext_default' => '' // テキストエリアのデフォルト値を設定する。デバッグ用。
])
<div
  {{-- Material.makeStorePanel は material.js に定義されている。
    -- 引数のオブジェクトと結合して、複数の追加データ(entries)などのAlpineのオブジェクトを返す。
    --}}
  x-data="Material.makeStorePanel({
    required: @js($table->requiredColumns()),
    formColumns: @js(array_keys($table->columns('create'))),
    id: 1,
    /*
     * 追加データ(配列)をテンプレートにはめてオブジェクトをつくる
     */
    template(values = []) {
      const entry = {
        id: this.id ++, checked: true,
        ...@js($entry_default),
      }
 
      // テーブルの列順に値を設定する
      values.forEach((v, i) => {
        if(this.formColumns[i]) {
          entry[this.formColumns[i]] = v;
        }
      });
 
      return entry;
    },
    mode: 'paste',
 
    storeEntries() {
      api.post(url.store, {items: this.entries})
        .then(data => {
          // 追加に成功したものをフォームから削除する console.log('succeeded ids:', ids);
          const ids = new Set(Object.keys(data.succeeded).map(Number));
          this.entries = this.entries.filter(entry => ! ids.has(entry.id));
 
          // 追加に成功したものを一覧に表示する
          selectFeature('{{ $module_id }}-index');
          return append(data.created.map(item => {
            item.checked = true;
            return item;
          }));
        });
    },
 
    init() {
      // entriesの中身を監視する
      this.$watch('entries', (newEntries) => {
        if(!Array.isArray(newEntries)){
          this.entries = [this.template()];
          return;
        }
 
        if(newEntries.length > 0){
          const entry = this.entries[this.entries.length - 1];
          if (this.isEntryEmpty(entry)) {
            return;
          }
        }
 
        this.addEntry(this.template());
      }, { deep: true }); /* オブジェクトの内部の変更を検知するために deep: true が必須 */
    }
  })"
>
  <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>
 
  {{-- 表型の入力フォーム --}}
  <div
    x-show="mode === 'form'"
  >
    <table class='material-list'>
      <thead>
        {{-- thead 1行目 見出し --}}
        <tr class=''>
          <th></th>
          @foreach($table->columns('create') as $col)
            <x-material.table-th :column=$col />
          @endforeach
        </tr>
      </thead>
 
      <tbody>
        <!-- 対象サンプル -->
        <template x-for="entry in entries" :key="entry.id">
          <tr>
            <td>
              <input type='checkbox' checked='checked'
                x-model="entry.checked"
                @change="if (!entry.checked) {
                  entries = entries.filter(e => e !== entry)
                }"
              >
            </td>
            @foreach($table->columns('create') as $key => $col)
              <td class="{{ $col['class'] }}">
                <input x-model="entry.{{ $key }}" placeholder="{{ $col['placeholder'] ?? '' }}"
                  @focus="$el.select()"
                >
              </td>
            @endforeach
            <td x-text="entry.id"></td>
          </tr>
        </template>
      </tbody>
    </table>
 
    <button :disabled="entries.length <= 1"
      @click="storeEntries()"
    >
      追加
    </button>
  </div>
 
  {{-- タブまたはカンマ区切りのテキストを貼り付ける場所 --}}
  <div x-show="mode == 'paste'"
    x-data="{
      rawtext: '',
      parse() {
        if (!this.rawtext.trim()) return;
        this.rawtext.trim().split('\n')
          .map(l => template(l.split(/[\t,]/)))
          .map(entry => addEntry(entry));
        mode = 'form';
      }
    }"
    @if($rawtext_default)
      x-init="
        if(1){ // デバッグ用にデータを入力した状態にする
          rawtext = '{{ $rawtext_default }}';
          parse();
        }
      "
    @endif
  >
    <p>名前と配列を、タブまたはカンマで区切って、一行に一つずつ入力してください。</p>
    <textarea class="border w-full min-w-0 my-2 p-1 h-[10rem] font-mono text-sm"
      placeholder="{{ join("\t", array_keys($table->columns('create'))) }}"
      x-model="rawtext"
      @change="parse()"
    ></textarea>
 
    <button :disabled="!rawtext.trim()" @click.prevent="parse()">
      貼り付けたデータをフォームに反映
    </button>
  </div>
</div>

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のように呼び出すように、モジュール固有のビューを修正。 その結果、index(一覧)とedit(編集)はほぼ空っぽになった。

resources/views/oligo/index.blade.php
@extends("$module_id.module")
@section('feature')
  <x-feature id='{{ $feature_id }}' class='feature-index'>
    <x-material.index :items="$items" />
  </x-feature>
@endsection

resources/views/oligo/edit.blade.php
@extends("$module_id.module")
@section('feature')
  <x-feature id='{{ $feature_id }}' class='feature-edit'>
    <x-material.edit />
  </x-feature>
@endsection

resources/views/oligo/create.blade.php
@extends("$module_id.module")
@section('feature')
  <x-feature id='{{ $feature_id }}' class='feature-create'>
    <x-material.create
      :entry_default="['created_at' => date('ymd'), 'owner' => auth()->user()->name]"
      {{-- rawtext_default="Oligo1\tACTGGATGCTA\nOligo2\tAGGCCCT" デバッグのときは有効にするとよい --}}
    />
  </x-feature>
@endsection

oligモジュールの構成をつくっていたoligo/module.blade.phpからx-dataの定義をmaterial.jsに移動した。

resources/views/oligo/module.blade.php
@extends('layouts.app')
 
@section('module')
  <x-module id='{{ $module_id }}'>
    <!-- モジュールのメニュー -->
    <x-module-nav
      :features="[
        'index' => ['label' => '一覧', 'permission' => 'read'],
        'create' => ['label' => '追加', 'permission' => 'read'],
        'edit' => ['label' => '編集', 'permission' => 'read'],
      ]"
    />
    <div class='features'
      x-data="Material.makeModulePanel({
        query_template: @js($table->buildQueryDefaults()),
        url: {
          search: '{{ route("web.{$module_id}.search") }}',
          store: '{{ route("web.$module_id.store") }}',
          update: '{{ route("web.{$module_id}.index") }}',
        },
      })"
      x-init="resetQuery();"
    >
      @yield('feature')
    </div>
  </x-module>
@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(moduleId){
        /* タブのhref属性からurlを取得する */
        const tab = document.getElementById(`${moduleId}-tab`);
        if(!tab) return;
        const url = tab.href;
 
        /* URLを変更する。これによってブラウザーのリロードをしても、以前のページを取得できるようになる */
        history.pushState({}, '', url);
 
        /* activeModuleにidを設定 */
        this.activeModule = moduleId;
 
        /* idで要素を取得 */
        const panel = document.getElementById(moduleId);
 
        /* 要素を取得できなかったときにhtmxで部分HTMLを取得する */
        if(null == panel) {
          htmx.ajax('GET', url, {
            target:'main', /* 取得したHTMLの置き換え場所 */
            swap: 'beforeend',  /* 今回はdiv.featuresの子要素として追加するのでbeforeend */
            headers: {
              'HX-Target': 'module-' + moduleId,
            }
          });
        }
      }
    }"
  >
    <nav class='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'],
        ] 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'
              href='{{ route("web.{$module}.index") }}'
              @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){
        /* タブのhref属性からURLを取得する */
        const tab = document.getElementById(`${featureId}-tab`);
        if(!tab) return;
        const url = tab.href;
 
        /* URLを変更する。これによってブラウザーのリロードをしても、以前のページを取得できるようになる */
        history.pushState({}, '', url);
 
        /* activeFeatureにidを設定 */
        this.activeFeature = featureId;
 
        /* 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

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';

material.jsの作成

長くなったAlpine.jsのオブジェクト生成をmaterial.jsに書き出す。

resources/js/material.js
/* module.blade.phpで使用するx-data */
function makeModulePanel(initial = {}) 
{
    return {
        items: [],
        query: {},
        ...initial,
 
        // 検索 fetchは非同期(async)に実行されるので、それにそろえる
        async search(){
            const parameter = new URLSearchParams();  
 
            // queryをGETパラメーターに変換する
            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(founds){
            // チェックがついているID
            const checkedIds = new Set(this.checkedItems.map(item => item.id));
            // 追加されるものから既存のものを除く
            const adds = founds.filter(item => ! checkedIds.has(item.id));
            console.log('items:', this.items.length, 'checked:', checkedIds.length, 'adds:', adds.length);
            this.items = [...this.checkedItems, ...adds];
            return founds;
        },
 
        // チェックがいれられたものだけを取得する。
        get checkedItems(){
            return this.items.filter(item => item.checked);
        },
 
        get hasChecked(){
            return this.checkedItems.length > 0;
        },
 
        resetQuery(){
            this.query = JSON.parse(JSON.stringify(this.query_template));            
        }
    }
}
 
/* create.blade.phpで使用するx-data */
function makeStorePanel(initial = {})
{
    return {
        entries:[],
        template: {}, // 初期値
        required: [], // 必須プロパティ
 
        init(){
            if(typeof initial.init === 'function') {
                initial.init.call(this);
            }
        },
 
        ...initial,
 
        isEntryEmpty(entry) {
            if(! entry) return true;
            return this.required.every(col => !entry[col] || entry[col] === '');
        },
 
        // データを追加
        addEntry(entry = {}) {
            console.log('addEntry:', entry);
            if (this.entries.length > 50) return; // バグによって行数が増えすぎないための安全装置
 
            const lastIndex = this.entries.length - 1;
            const lastEntry = this.entries[lastIndex];
 
            // 最終行が空ならそれを使う
            console.log('check empty index', lastIndex, lastEntry, this.isEntryEmpty(lastEntry));
            if(lastIndex >= 0 && this.isEntryEmpty(lastEntry)){
            console.log('use lastIndex:', lastIndex, entry);
                this.entries.splice(lastIndex, 1, entry);
 
            // 最終行に入力値がないときは新しくつくる
            }else{
            console.log('push:', entry);
                this.entries.push(entry);
            }
            console.log('addEntry finished', this.entries.length);
        },
 
        store(url){
            console.log('store was called:', JSON.stringify(this.entries.filter(entry => ! this.isEntryEmpty(entry))));
            return api.post(url,
                {
                    items: this.entries.filter(entry => ! this.isEntryEmpty(entry)),
                }
            )
        },
 
        destroy(url, ids){
            return api.post(url,
                {
                    id: ids
                }
            )
        }
    }
}
 
// window オブジェクトに Material を定義する
window.Material = {
    makeModulePanel,
    makeStorePanel,
}

これを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();
 
window.api = {
    async request(url, options = null){
        const defaults = {
            headers: {
                'Content-Type': 'application/json',
                'Accept': 'application/json',
                'X-CSRF-TOKEN': document
                    .querySelector('meta[name="csrf-token"]')?.content,
            },
        };
 
        const response = await fetch(url, {...defaults, ...options});
        if(! response.ok){
            throw response;
        }
        return response.json();
    },
 
    post(url, data) {
        return this.request(url, { method: 'POST', body: JSON.stringify(data)});
    },
 
    put(url, data = {}) {
        return this.request(url, { method: 'PUT', body: JSON.stringify(data)});
    },
 
    /* とりあえず使っていない */
    get(url, data = {}) {
        const parameter = new URLSearchParams(data).toString();
        return this.request(`${url}?${parameter}`);
    },
}

CSRFをmetaタグから読み込むので、html.blade.phpを編集しておく。

resources/views/layouts/html.blade.php
<!DOCTYPE html>
<html lang="{{ config('app.locale') }}">
  <head>
    <meta charset="utf-8">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ config('app.name') }}</title>
    {{-- viteによって、コンパイルしたファイルを示すlinkとかscriptタグに置き換える --}}
    @vite(['resources/css/app.css', 'resources/js/app.js'])  
  </head>
  <body>
    @yield('body')
  </body>
</html>

いきなり全部書き換えて動かそうとしても、おそらくたくさんのバグがからんで、手の付けようがなくなります。 少しずつコードを変更しながら、動作を確認していくとよいです。

種子データベースの作成

リソースとして種子を追加します。 まず、どのような情報を入れるのかを考えて、テーブルの定義をします。 あとから変更できますが、テーブルの変更は波及する範囲が広いので、よく考えて行うのがよいです。

テーブル定義

artisan make:migration create_seeds_table でマイグレーションを作成。

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 でテーブルを作成。

# ./artisan migrate

権限の追加

permissionsにseed.readとseed.writeを追加し、適当なroleに追加する。

ルートの設定

routes/oligo.phpをコピーしてroutes/seed.phpを作成する。 これをmaterial.phpで読み込むようにする。

routes/seed.php
<?php
 
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SeedController as Controller;
 
// 種子のデータベース
$module = 'seed';
Route::prefix($module)
    ->middleware(["can:$module.read"])
    ->as("$module.")
    ->group(function (){
        Route::get('/', [Controller::class, 'index'])->name('index');
 
        foreach(['search', 'create', 'edit'] as $name){
            Route::get($name, [Controller::class, $name])->name($name);
        }
 
        // 編集フォームとデータ更新
        Route::post('/', [Controller::class, 'store'])->name('store');
        Route::put('/{item}', [Controller::class, 'update'])->name('update');
    });

モデル、コントローラー、リクエスト、サービスの作成

こんな感じでコピーする。

find app -name "Oligo*" | while read F ;
  do sed -e "s/Oligo/Seed/g" -e "s/oligo/seed/g" $F >${F/Oligo/Seed}
done

Models/Seed.phpとSeedTableDefinition.phpはテーブルに合わせて修正する。

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',
                'modes' => ['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' => 'equal',
                'rule' => 'nullable|integer',
            ],
            'insert' => [
                'rule' => ['nullable', 'integer'],
            ]
        ],
        'father' => [
            'features' => [],
            'label' => 'father_id',
            'class' => 'parent',
            'form' => [
                'placeholder' => "鈴木",
            ],
            'search' => [
                'type' => 'equal',
                'rule' => 'nullable|integer',
            ],
            'insert' => [
                'rule' => ['nullable', 'integer'],
            ]
        ],
        /* データベースのテーブルではつくってある。
            'labo_id' => 'nullable|string|max:255',
            'box' => 'nullable|string|max:255',
        */
    ];
}

ビューの作成

これもviews/oligo を元に作成する。 実際には無修正でいけると思う。

$ cd resources/views
$ cp -r oligo seed

layouts/app.blade.phpを修正して、種子の項目を追加。

CSS

app.cssにモジュールのテーマカラーをつくり、seed.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';

resources/css/seed.css
#seed {
    table, tbody, thead, tr, th, td {
        @apply border-seed;
    }
 
    .feature-index table tbody tr:hover {
        @apply bg-seed-light;
    }
 
    table {
        thead {
            tr:first-child {
                @apply bg-seed-light;
            }
        }
 
        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-light text-white;
        }
    }
}

植物データベースの作成

seed リソースの作成がうまくできれば、同じ要領で plant リソースも作れる。

ルート、コントローラー、リクエスト、サービス、ビュー(CSSを含む)は種子モジュールを増やしたときと同じ。

migrationでテーブルの定義をつくり、Model/Plantをつくって、PlantTableDefinitionでビューやリクエストとの関係をつくる。

ラベル印刷用CSVの出力

P-touchというラベルプリンタがあります。 これはCSVのデータをテンプレートにあてはめて印刷することができ、種子袋や植物の鉢につけるラベルをつくることができます。

これのために、選択したデータのCSVを出力する機能を実装します。

モデル

app/Models/Seed.phpにCSVファイル名をいれておく。

# コントローラーで 260901種子ラベル.csvに変換する
public string $csvFilePrefix = '種子ラベル';

ルート、コントローラーの作成

Oligoのみ書いていますが、CSV出力がほしいものに同じものをいれます。

routes/oligo.phpapp/Http/Controllers/MaterialController.phpにlabel機能へのルートを設定します。 実際の処理はサービスに委ねます。

ブラウザーでファイルを保存できるようにするには、POSTリクエストがよいです。

routes/oligo.php
<?php
 
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\OligoController as Controller; // 別名をつけて他のモジュールに流用しやすくする
 
// オリゴDNAのデータベース
$module = 'oligo';
Route::prefix($module)
    ->middleware(["can:$module.read"])
    ->as("$module.")
    ->group(function (){
        Route::get('/', [Controller::class, 'index'])->name('index');
 
        // ラベル用CSVをダウンロードするためのフォームへのルートを追加
        foreach(['search', 'create', 'edit', 'label'] as $name){
            Route::get($name, [Controller::class, $name])->name($name);
        }
 
        // 編集フォームとデータ更新
        Route::post('/', [Controller::class, 'store'])->name('store');
        Route::put('/{item}', [Controller::class, 'update'])->name('update');
 
        // ラベル用CSV出力
        Route::post('label', [Controller::class, 'exportLabelCsv'])->name('label.export');
    });
app/Http/Controllers/MaterialController.php
<?php
 
namespace App\Http\Controllers;
 
use Illuminate\Http\Request;
use App\Http\Requests\BulkItemIdsRequest;
 
class MaterialController
{
    protected string $singleRequest = Request::class;
    protected string $multiRequest = Request::class;
    protected string $queryRequest = Request::class;
 
    /*
     * リクエストに応じてビューを返す部分を共通化する
     */
    public function respond($data = [], $view = null){
        $route_name = request()->route()->getName();
        list($module, $feature) = array_slice(explode('.', $route_name), 1, 2);
        view()->share([
            'module_id' => $module,
            'feature_id' => "$module-$feature",
            'table' => $this->service->table,
        ]);
 
        // 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(){
        $items = request()->hasHeader('HX-Request') ? []
            : $this->model->orderBy('created_at', 'desc')->limit(100)->get()
        ;
        return $this->respond(['items' => $items]);
    }
 
    public function create(){
        return $this->respond();
    }
 
    public function edit(){
        return $this->respond();
    }
 
    // 検索
    public function search()
    {
        try{
            $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);
        }
    }
 
    // ラベル出力のためのビューを返す
    public function label(){
        return $this->respond();
    }
    // CSVを返す
    public function exportLabelCsv(BulkItemIdsRequest $request){
        $ids = $request->validated('id');
        $file = sprintf('%s%s.csv', date('Ymd'), $this->model->csvFilePrefix);
        
        return response()->streamDownload(function() use ($ids) {
            $handle = fopen('php://output', 'w');
            $this->service->writeCsvToStream($handle, $ids, 'SJIS-win');
            fclose($handle);
        }, $file,
            ['Content-Type' => 'text/csv']
        );
    }
}

リクエストの作成

ブラウザーから送られてくるIDの配列を受け取ります。

app/Http/Requests/BulkItemIdsRequest.php
<?php
 
namespace App\Http\Requests;
 
use Illuminate\Foundation\Http\FormRequest;
 
/*
 *	IDを使ってデータを取得するリクエスト
 */
class BulkItemIdsRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'id' => ['required', 'array', 'min:1'],
            'id.*' => ['required', 'integer'],
        ];
    }
 }

サービスの作成

配列をCSV形式にして出力するクラス CsvWriterを作成する。

app/Services/CsvWriter.php
<?php
namespace App\Services;
 
use Illuminate\Database\Eloquent\Collection;
 
class CsvWriter
{
    protected bool $isHeaderWritten = false;
    protected array $orderedColums = [];
    protected array $headerLabels = [];
 
    /**
     * @param resource $output fopen('php://output', 'w') などのストリーム
     * @param string $encoding 'UTF-8' または 'SJIS-win'
     * @param array $labels ['column1' => '名前', 'column2' => '値']
     */
    public function __construct(
        protected $output,
        protected string $encoding = 'UTF-8',
        protected array $labels = []
    ) {
        // UTF-8 の場合は先頭に BOM を書き込む
        if (strtoupper($this->encoding) === 'UTF-8') {
            fwrite($this->output, "\xEF\xBB\xBF");
        }
    }
 
    /**
     * テーブルのレコードのコレクションを書き出す
     */
    public function write(Collection $items): void
    {
        foreach ($items as $item) {
            $array = $item->toArray();
 
            // 最初の1回目のデータ処理時に順序とヘッダーを確定・出力する
            if (!$this->isHeaderWritten) {
                $this->prepareHeadersAndOrder(array_keys($array));
                $this->writeRow($this->headerLabels);
                $this->isHeaderWritten = true;
            }
 
            // 指定された並び順に従ってデータを整列
            $row = [];
            foreach ($this->orderedColums as $col) {
                $row[] = $array[$col] ?? null;
            }
 
            $this->writeRow($row);
        }
    }
 
    /**
     * labels の順序を優先し、未定義カラムを後ろに追加する
     */
    protected function prepareHeadersAndOrder(array $allColumns): void
    {
        $ordered = [];
        $labels = [];
 
        // ['col1' => 'label1', 'col2', 'col3' => 'label3']という配列から, 
        // ['col1' => 'label1', 'col2' => 'col2', 'col3' => 'label3']をつくる
        foreach($this->labels as $col => $label){
            $columns[] = is_int($col) ? $label : $col;
            $labels[] = $label;
        }
        
        // labelsから漏れている列名を抽出
        $omitted = array_diff($allColumns, $columns);
 
        // 最終的なカラム順と対応するラベル
        $this->orderedColums = array_merge($columns, $omitted);
        $this->headerLabels = array_merge($labels, $omitted);
    }
 
    /**
     * 文字コード変換を行いながら 1 行を書き出す
     */
    protected function writeRow(array $row): void
    {
        // SJIS-win の場合は文字コードを一括変換
        if (strtoupper($this->encoding) === 'SJIS-WIN') {
            mb_convert_variables($this->encoding, 'UTF-8', $row);
        }
 
        fputcsv($this->output, $row, ',', "\"", "\\", "\n");
    }
}

MaterialServiceのwriteCsvToStreamはコントローラーから出力先、IDの配列を受け取る。

モデルからクエリービルダーをつくって、IDを検索する。chunkで指定された行数ずつ取り出し、CsvWriterのwriteメソッドに送り込む。

app/Services/MaterialService.php
<?php
 
namespace App\Services;
 
use App\Services\CsvWriter;
 
class MaterialService
{
    /* 以下のものは派生クラスのコンストラクタで型指定し、注入(DI)する
     * $model: データベースのテーブル
     * $table: テーブルのカラムの扱いを一元化した定義
     * $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->table->column($column);
            if (!$definition) {
                continue;
            }
 
            // 検索の種類を取得する
            $type = $definition['search']['type'] ?? null;
            if (!$type) {
                continue;
            }
 
            // 検索の種類に応じて、検索を適用する
            try{
                $this->searchFactory->make($type)->apply($builder, $column, $value);
            }catch(\Throwable $e){
                continue;
            }
        }
 
        return [
            'items' => $builder->orderBy('created_at', 'desc')->get(),
            'sql' => $builder->toSql(),
            'query' => $query
        ];
    }
 
    public function create(array $items)
    {
        $results = [
            'created' => [], 
            'succeeded' => [],
            'failed' => [], 
        ];
 
        foreach($items as $item){
            try{
                $created = $this->model->create($item);
                $results['created'][] = $created;
                if(isset($item['id'])){
                    $results['succeeded'][$item['id']] = $created;
                }
            }catch(\Throwable $e){
                $results['failed'][] = array_merge($item, ['error' => $e->getMessage()]);
            }
        }
        return $results;
    }
 
    /**
     * CSV ストリーム出力
     */
    public function writeCsvToStream($output, ?array $ids = null, string $encoding = 'UTF-8')
    {
        $query = $this->model->query();
        if (!empty($ids)) {
            $query->whereIn('id', $ids);
        }
 
        // CSVファイルの見出しをつくる
        $labels = [];
        foreach($this->table->columns('index') as $name => $column){
            $labels[$name] = $column['label'] ?? $name;
        }
 
        // CsvWriter インスタンスを作成
        $csvWriter =  new CsvWriter(
            output: $output,
            encoding: $encoding,
            labels: $labels
        );
 
        // chunkは自動的に500レコードごとに [$csvWriter, 'write'] を実行する
        $query->chunk(500, [$csvWriter, 'write']);
    }
}

ビューの作成

material/label.blade.phpをつくって、必要なモジュールでこれを読み込むようにする。

IDの送信はフォームで行う。サーバーから送られてくるファイルを保存できるように制御することがJavascriptではできない(?)ため。

resources/views/components/material/label.blade.php
<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>
 
{{-- ダウンロードさせるにはformが楽 --}}
<form method='POST'
  x-show="hasChecked"
  action='{{ route("web.$module_id.label.export") }}'
>
  @csrf
  <template x-for="item in checkedItems" :key="item.id">
    <input type='hidden' name='id[]' :value="item.id">
  </template>
 
  <p class="py-2 pl-4">
    <button type="submit" title='チェックの入ったサンプルのラベルを作ります'
    >ラベル印刷用データをダウンロード</button>
  </p>
 
  <table class='material-list'>
    <thead>
      <tr>
        <th></th>
        @foreach($table->columns('index') as $col)
          <x-material.table-th :column=$col />
        @endforeach
      </tr>
    </thead>
 
    <tbody>
      <!-- 対象サンプル -->
      <template x-for="item in checkedItems" :key="item.id">
        <tr>
          {{ $body_prepend ?? '' }}
          <td><input x-model="item.checked" type='checkbox'></td>
          @foreach($table->columns('index') as $key => $col)
            <td x-text="item.{{ $key }}" class="{{ $col['class'] }}"></td>
          @endforeach
          {{ $body_append ?? '' }}
      </tr>
      </template>
    </tbody>
  </table>
</form>

CSVに出力するデータの検索・選択はindexビューで行うようにしている。 そのため、/seed/label を開いた直後は選択データがなく、テストがしにくい。 x-initを設定して、画面を再読み込みしたところで選択されたアイテムがあるようにしてある。

resources/views/oligo/label.blade.php
@extends("$module_id.module")
@section('feature')
  <x-feature id='{{ $feature_id }}'>
    @if(0)  {{-- プログラム開発中はここを1にして、自動的にいくつか選択した状態にすると、動作検証がしやすい。 --}}
      <div class='label-test'
        x-init="
          query.name.value = 'Oligo1';
          search().then(() => {
            items.map(item => item.checked = true);
          });
        "
      >label-test </div>
      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>
    @endif
 
    <x-material.label />
  </x-feature>
@endsection

oligo/module.blade.phpを編集して、ラベル印刷のタブを加えておく。