種子と植物のデータベース

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

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.phpapp/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);
        }
    }
}