種子と植物の連携

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

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

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

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

$ git switch chapt9

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

$ git switch -c my9 chapt9

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 を実行してください。

種子をまいて植物に、植物から種子を回収する、というサイクルを実現する。

種子から植物へ

種まきをして、芽が出たら鉢に植えます。 鉢につけるラベルを作成するために、seedテーブルの情報からplantテーブルの新しいデータをつくります。 これを行うために、seedモジュールに「鉢植え」タブをつくり、plantationという機能をつくります。

ユーザーは次のことを行います。

  1. 種子の一覧で播いた種を探す(チェックをいれる)。
  2. 鉢植えのページにいって、種まきの日、鉢植えの日、育成者の情報を入力し、植物テーブルに新しいデータを追加する。
  3. 植物の一覧で追加されたもののラベルを印刷する。

2がplantationの機能で、種子のデータを植物のテーブルに追加します。 種子データ(Seedモデル)を植物データ(Plantリクエスト)に変換し、/plant/storeに送信します。

次の手順でplantation機能を実装します。

  1. resources/views/seed/module.blade.php に「鉢植え」のタブを追加する。
  2. routs/seed.php に /seed/plantation へのルートを追加する。
  3. app/Http/Controllers/SeedController.blade.php に plantation のビューを返すメソッドを追加する。
  4. resources/views/seed/plantation.blade.php をつくって、seedモジュールのitemsを/plantにPOSTするしくみをつくる。
  5. resources/views/layouts/app.blade.php を修正して、seedモジュールからplantモジュールにitemsを送信できるしくみをつくる(Javascript)。
  6. resources/views/plant/module.blade.php を修正して、送られてきたitemsを表示する(Javascript)。

resources/views/seed/module.blade.php

x-module-nav の features 属性に渡す配列に、下記を追加する。

resources/views/seed/module.blade.php の一部
'plantation' => ['label' => '鉢植え', 'permission' => 'read'],

routs/seed.php

GETの配列に plantation を追加する。

app/Http/Controllers/SeedController.blade.php

plantationビューを返すメソッドを追加する。

app/Http/Controllers/SeedController.blade.php の一部
// 種のデータを植物モジュールに送るためのフォームをつくる
public function plantation(){
  return $this->respond([
    // seed/plantation.blade.php中で、plantのテーブルの情報が必要になる。
    'plant_table' => new PlantTableDefinition(),
  ]);
}

resources/views/seed/plantation.blade.php

追加データをjsonで送って、結果を受け取るという部分は material/createと似ているので、それを元にして作成しました。 重要な部分を以下に説明します。

x-dataのtemplate(item)

引数のitemはseedテーブルのデータで、これを template で処理することで、plantの型にする。 initで、$watchを設定してあり、そこでitemsからentriesをつくるときに使われる。

x-dataのplantation()

entriesをplant.storeにpostする。 実際にこれを呼び出している部分は一か所だけなので、そちらで定義してもよいが、関数化しておくことで、動作検証がやりやすくなる。

selectModule('plant’).then()

layouts/app.blade.phpにあるselectModuleを修正して、htmxによるHTMLの差し替えが終わった後にプログラムを実行できるようにする。

新しいデータ(created)はplantモジュールのitemsに設定したいが、x-dataのスコープが異なるので直接変更することができない。 そこで、post-plantationというイベントを定義し、そのイベントに付属させる形でcreatedを渡す。

resources/views/seed/plantation.blade.php
@extends("$module_id.module")
@section('feature')
  <x-feature id='{{ $feature_id }}'>
    <div
      x-data="Material.makeStorePanel({
        required: @js($table->requiredColumns()),
 
        /* plantテーブルに合わせた初期値をつくる */
        template(item) {
          return {
            ...item,
            seed_id: item.id, // ここのitemはseedテーブルのレコード。そのidをplantテーブルのseed_idに設定する。
            created_at: @js(date('ymd')),
            owner: @js(auth()->user()->name),
          }
        },
 
        plantation() {
          /* plantモジュールに追加する。
           */
          this.store('{{ route("web.plant.store") }}')
            .then(data => {
              /* 追加に成功したものをフォームから削除する console.log('succeeded ids:', Object.keys(data.succeeded));
               */
              console.log('succeeded ids:', Object.keys(data.succeeded));
              const ids = new Set(Object.keys(data.succeeded).map(Number));
              this.entries = this.entries.filter(entry => ! ids.has(entry.id));
 
              /* 追加に成功したもののitem.checkedをはずす console.log('succeeded ids:', Object.keys(data.succeeded));
               */
              console.log('succeeded ids:', Object.keys(data.succeeded));
              checkedItems.forEach(item => {
                if(ids.has(item.id)) item.checked = false;
              });
 
              /* 鉢植えに成功したものを植物の一覧に送る
               */
              const created = data.created.map(item => ({...item, checked : true}));
              selectModule('plant').then(() => {
                /* Alpine.js 側にカスタムイベントを送信
                 */
                window.dispatchEvent(new CustomEvent('post-plantation', {
                  detail: { message: 'Load!', items: created }
                }));
              }); // selectModule().then()を閉じる
            }); // store().then()を閉じる
        }, // plantation(){} を閉じる
 
        init() {
          this.$watch('items', (items) => {
            this.entries = checkedItems.map((item) => this.template(item));
          });
        },
      })"
    >
      @if(0)  {{-- 動作確認用コード --}}
        <div class='plantation-test'
          x-init="
            query.strain.value = 'Colsssss';
            search().then(() => {
              items.slice(0, 1).map(item => item.checked = true);
            });
          "
        >plantation-test
          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>
        </div>
      @endif
 
      <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>
 
    {{-- 表型の入力フォーム 種から植物へデータコピー --}}
    <div x-show="hasChecked">
      <p class="pl-4">
        鉢植えにするサンプルにチェックをいれてください。
        「種播き・鉢植え」ボタンをクリックした後で、植物の一覧からラベルを作成します。
      </p>
      <p class="py-2 pl-4">同じ種類の種から複数の鉢植えをつくるときは、個体番号を編集してください。</p>
      <p class="py-2 pl-4">	
        <button type='submit' title="チェックの入った種を植物リストに追加します。"
          @click="plantation()"
        >
          種播き・鉢植え
        </button>
      </p>
 
      <table class='material-list'>
        <thead>
          {{-- thead 1行目 見出し --}}
          <tr class=''>
            <th></th>
            @foreach($plant_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) {
                    checkedItems.forEach(item => {if(item.id == entry.id) item.checked = false});
                    entries = entries.filter(e => e !== entry)
                  }"
                >
                <span x-text="entry.id"></span>
              </td>
 
              @foreach($plant_table->columns('create') as $key => $col)
                <td class="{{ $col['class'] }}">
                  <input x-model="entry.{{ $key }}" placeholder="{{ $col['placeholder'] ?? '' }}"
                    @focus="$el.select()"
                  >
                </td>
              @endforeach
              <td>
                seed_id: <span x-text="entry.seed_id"></span>
              </td>
            </tr>
          </template>
        </tbody>
      </table>
    </div>
 
</x-feature>
@endsection

resources/views/layouts/app.blade.php

selectModuleを修正して、Promiseを返すようにします。

すでに指定されたモジュールのHTMLが存在する場合、Promiseはresolve()をすぐに実行します。 HTMLがなかった場合、htmxが動き、HTMLの差し込みが終わったあとhtmx:afterSettleイベントを発火します。 このイベントが起きたとき、resolve()が実行されるように、document.addEventListenerで設定します。

これらの変更によって、 selectModule(module_id).then(…) というようにして、htmxがHTMLの書き換えを終えたときに次のイベント post-plantation に移ることができるようになります。

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;
 
        return new Promise((resolve) => {
          /* idで要素を取得 */
          const panel = document.getElementById(moduleId);
 
          /* 要素を取得できたときは resolve() を呼び出す -> thenに進む */
          if(panel !== null) {
            resolve();
            return;
          }
 
          /* 要素を取得できなかったときにhtmxで部分HTMLを取得する */
          
          // 奇妙に見えるがこれはhanderという関数を定義している。handlerはrevolve()をするだけ。
          const handler = () => {console.log('handler was called'); resolve()};
          
          // htmxの置き換えが終わったら、handlerが呼ばれる(=> revolve()を実行する)。
          document.body.addEventListener('htmx:afterSettle', handler, { once: true });
          
          htmx.ajax('GET', url, {
            target:'main', /* 取得したHTMLの置き換え場所 */
            swap: 'beforeend',  /* 今回はdiv.featuresの子要素として追加するのでbeforeend */
            headers: {
              'HX-Target': 'module-' + moduleId,
            }
          });
        })  // return new Promiseの終わり
      } // selectModuleの終わり
    }"
  >
    <nav class='py-2 px-2'>
      <ul id='module-tabs' class='flex'>
        @foreach([
          {{-- bg-{{ $module_id }}はtailwindcssが見逃すのでbg-oligoとする --}}
          'seed' => ['label' => '種子', 'active' => 'bg-seed', 'inactive' => 'hover:bg-seed-light'],
          'plant' => ['label' => '植物', 'active' => 'bg-plant', 'inactive' => 'hover:bg-plant-light'],
          '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

resources/views/plant/module.blade.php

seed/plantation.blade.php で発火した post-plantation イベントを受け取るように修正します。 x-on:post-plantation.window はalpineのイベント設定の方法で、トップレベルのwindowオブジェクトにpost-plantation というイベントをとらえるように設定しています。

resources/views/plant/module.blade.php
@extends('layouts.app')
 
@section('module')
  <x-module id='{{ $module_id }}'>
    <!-- モジュールのメニュー -->
    <x-module-nav
      :features="[
        'index' => ['label' => '一覧', 'permission' => 'read'],
        'label' => ['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();"
 
      {{-- seedモジュールの鉢植えによってつくられた植物のデータを表示する --}}
      x-on:post-plantation.window="
        console.log('post-plantation event in plant/module was called', $event.detail.items);
        append($event.detail.items);
      "
    >
      @yield('feature')
    </div>
  </x-module>
@endsection

複数データの追加

普通の実験では種を一つ播いて、一つの植物を育てるということはなく、たくさん播いて、その中から選んだものを鉢植えにします。

個体番号だけが異なるものを一度に登録できるように PlantService の機能を拡張します。同じものは SeedService などでも必要になるので、Traitとしてつくります。

app/Traits/ExpandItem.php
<?php
 
namespace App\Traits;
 
trait ExpandItem
{
    /**
     * $items 配列の指定した $field (例: 'no') を展開し、$items の要素を増やして返す
     */
    public function expandItems(array $items, string $field = 'no'): array
    {
        $expandedItems = [];
 
        foreach ($items as $item) {
            // 指定されたフィールドが存在しない、または空ならそのまま追加
            if (empty($item[$field])) {
                $expandedItems[] = $item;
                continue;
            }
 
            // no などを分解して複数の文字列配列(例: ['A-1', 'A-2', 'A-3'])を取得
            $expandedNos = $this->expandNo($item[$field]);
 
            // 分解された no ごとに $item を複製して追加
            foreach ($expandedNos as $no) {
                $item[$field] = $no; // 'no' フィールドを書き換え
                $expandedItems[] = $item;
            }
        }
 
        return $expandedItems;
    }
 
    /**
     * カンマ区切りや範囲指定 (A1:3 など) の文字列を分解する
     */
    public function expandNo(string $no): array
    {
        $base = '';
        $series = [];
        
        foreach (explode(',', $no) as $part) {
            $part = trim($part);
            if (preg_match("/^(.*?)(\d+)(:?)(\d+)?$/", $part, $match)) {
                $base = empty($match[1]) ? $base : $match[1];
                $start = (int)$match[2];
                $range = isset($match[4]) ? range($start, (int)$match[4]) : [$start];
 
                $series = array_merge($series, array_map(fn($i) => $base . $i, $range));
            } else {
                $series[] = $part;
            }
        }
 
        return $series;
    }
}

app/Services/PlantService.php
<?php
 
namespace App\Services;
 
use App\Models\Plant;
use App\TableDefinitions\PlantTableDefinition;
use App\TableSearch\SearchFactory;
use App\Traits\ExpandItem;  // 個体番号 #1:4,6 を #1, #2, #3, #4, #6に展開
 
class PlantService extends MaterialService
{
    public function __construct(
        protected Plant $model,
        public PlantTableDefinition $table,
        protected SearchFactory $searchFactory
    ){}
 
    use ExpandItem;
 
    public function create(array $items)
    {
        $expandedItems = $this->expandItems($items, 'no');
        return parent::create($expandedItems);
    }
}

植物から種子へ

鉢に植えた植物から種を回収します。 そのとき、世代番号を一つ増やします。 また、植物は枯れているでしょうから、同時にテーブルから削除できるようにします。

次の手順でharvest機能を実装します。

  1. resources/views/plant/module.blade.php に「収穫」のタブを追加する。
  2. routs/plant.php に /plant/harvest へのルートを追加する。
  3. app/Http/Controllers/PlantController.blade.php に harvest のビューを返すメソッドを追加する。
  4. resources/views/plant/harvest.blade.php をつくって、plantモジュールのitemsを/seedにPOSTするしくみをつくる。
  5. resources/views/seed/module.blade.php を修正して、送られてきたitemsを表示する(Javascript)。

resources/views/plant/module.blade.php

x-module-nav の features 属性に渡す配列に、下記を追加する。

resources/views/plant/module.blade.php の一部
'harvest' => ['label' => '収穫', 'permission' => 'read'],

routs/plant.php

GETの配列に harvest を追加する。

app/Http/Controllers/PlantController.blade.php

harvestビューを返すメソッドを追加する。

app/Http/Controllers/PlantController.blade.php の一部
// 植物のデータを種子モジュールに送るためのフォームをつくる
public function harvest(){
  return $this->respond([
    // plant/harvest.blade.php中で、seedのテーブルの情報が必要になる。
    'seed_table' => new SeedTableDefinition(),
  ]);
}

resources/views/plant/harvest.blade.php

seed/plantation.blade.php を元にして作成しました。 重要な部分を以下に説明します。

x-dataのtemplate(item)

引数のitemのgenerationの数字部分を1増やして、plantの初期値にしています。

selectModule('seed’).then()

収穫後にデータを削除するオプションに対する処理を実装しています。

削除は複数のidを配列で送るようにしています。 とりあえず必要なのは plant モジュールだけですが、他でも必要になるかもしれないのでMaterialControllerとMaterialServiceに実装しました。

seedモジュールのitemsにcreatedを設定したいので、plantationと同じように post-harvestイベントをつくりました。

resources/views/plant/harvest.blade.php
@extends("$module_id.module")
@section('feature')
  <x-feature id='{{ $feature_id }}'>
    <div
      x-data="Material.makeStorePanel({
        required: @js($table->requiredColumns()),
        template(item) {
          const match = (item.generation || '').match(/^(.*?)(\d+)$/);
          const g = match ? match[1] + (parseInt(match[2]) + 1) : item.generation;
 
          return {
            ...item,
            mother: item.id,
            generation: g,
            created_at: @js(date('ymd')),
            owner: @js(auth()->user()->name),
          }
        },
        discard: true,
 
        harvest() {
          this.store('{{ route("web.seed.store") }}')
            .then(data => {
              /* 1. 成功したもの(seed_id)をindex (items), plantation (rows) 表から削除する。
               * 2. 成功したものをチェックをつけて、plant に送る
               */
              // 追加に成功したものをフォームから削除する 
              const ids = new Set(Object.keys(data.succeeded).map(Number));
              this.entries = this.entries.filter(entry => ! ids.has(entry.id));
 
              // 追加に成功したもののitem.checkedをはずす console.log('checkoff ids:', ids);
              checkedItems.forEach(item => {
                if(ids.has(item.id)) item.checked = false;
              });
 
              // 追加に成功したものをデータベースから削除する
              console.log('destroy', ids);
              this.destroy('{{ route("web.$module_id.destroy")}}', Array.from(ids));
 
              /*  種採りに成功したものを種子の一覧に送る  */
              const created = data.created.map(item => ({...item, checked : true}));
              selectModule('seed').then(() => {
                // Alpine.js 側にカスタムイベントを送信
                window.dispatchEvent(new CustomEvent('post-harvest', {
                  detail: { message: 'Load!', items: created }
                }));
              }); // selectModule().then()を閉じる
            }); // store().then()を閉じる
        }, // harvest(){} を閉じる
 
        init() {
          this.$watch('items', (items) => {
            this.entries = checkedItems.map((item) => this.template(item));
          });
        },
      })"
    >
      @if(0)  {{-- デバッグ用コード --}}
        <div class='harvest-test'
          x-init="
            query.strain.value = 'Col';
            search().then(() => {
              items.map(item => item.checked = true);
            });
          "
        >harvest-test
          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>
        </div>
      @endif
 
      <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>
 
    {{-- 表型の入力フォーム 植物から種子へデータコピー --}}
    <div x-show="hasChecked">
      <p class="pl-4">
        種採りをするサンプルにチェックをいれてください。
        「種採り」ボタンをクリックした後で、種子の一覧からラベルを作成します。
      </p>
      <p class="py-2 pl-4">同じ植物の種を複数の袋に分けるときは、個体番号を編集してください。</p>
      <p class="py-2 pl-4">	
        <button type='submit' title="チェックの入った植物を種子リストに追加します。"
          @click="harvest()"
        >
          種採り
        </button>
        
        <label>
          <input type='checkbox' x-model='discard'>
          種子を追加後植物を削除する
        </label>
      </p>
 
      <table class='material-list'>
        <thead>
          {{-- thead 1行目 見出し --}}
          <tr class=''>
            <th></th>
            @foreach($seed_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) {
                    checkedItems.forEach(item => {if(item.id == entry.id) item.checked = false});
                    entries = entries.filter(e => e !== entry)
                  }"
                >
                <span x-text="entry.id"></span>
              </td>
 
              @foreach($seed_table->columns('create') as $key => $col)
                <td class="{{ $col['class'] }}">
                  <input x-model="entry.{{ $key }}" placeholder="{{ $col['placeholder'] ?? '' }}"
                    @focus="$el.select()"
                  >
                </td>
              @endforeach
              <td>
                mother: <span x-text="entry.mother"></span>
              </td>
            </tr>
          </template>
        </tbody>
      </table>
    </div>
 
</x-feature>
@endsection

resources/views/seed/module.blade.php

div.features に追加

resources/views/seed/module.blade.php の一部
{{-- plantモジュールの収穫によってつくられた種子のデータを表示する --}}
x-on:post-harvest.window="
  console.log('post-harvest event in seed/module was called', $event.detail.items);
  append($event.detail.items);
"

データ削除の実装

idの配列はCSVの出力でつくった BulkItemIdsRequest をそのまま使う。

app/Http/Controllers/MaterialController.php の一部
// 削除
public function destroy(BulkItemIdsRequest $request){
  try{
    return $this->json($this->service->destroy($request->validated('id')), 200);
  }catch(\Throwable $e){
    return $this->json(['message' => $e->getMessage()], 400);
  }
}
app/Services/MaterialService.php の一部
// テーブルからレコードを削除する
function destroy(array $ids){
  return ['destroid_items' => $this->model->destroy($ids)]; // 削除された件数が返る
}

交配して種子へ

交配に使った親株の情報をmotherとfatherにいれて、種子のデータをつくれるようにします。

機能を追加する手順は harvest とほぼ同じです。

resources/views/plant/module.blade.php

x-module-nav の features 属性に渡す配列に、下記を追加する。

resources/views/plant/module.blade.php の一部
'cross' => ['label' => '交配', 'permission' => 'read'],

routs/plant.php

GETの配列に cross を追加する。

app/Http/Controllers/PlantController.blade.php

crossビューを返すメソッドを追加する。

app/Http/Controllers/PlantController.blade.php の一部
// 植物のデータを種子モジュールに送るためのフォームをつくる
public function cross(){
  return $this->respond([
    'seed_table' => new SeedTableDefinition(),
  ]);
}

resources/views/plant/cross.blade.php

plant/cross.blade.php を元にして作成しました。 重要な部分を以下に説明します。

table – tbody – td 雄雌のselect

チェックが付いたitemから template を使って selectボックスを作ります。 x-modelでentryのmotherとfatherを指定し、optionのvalueをそれに設定します。

resources/views/plant/cross.blade.php
@extends("$module_id.module")
@section('feature')
  <x-feature id='{{ $feature_id }}'>
    <div
      x-data="Material.makeStorePanel({
        required: @js($table->requiredColumns()),
        template(item) {
          return {
            ...item,
            mother: 0,
            father: 0,
            generation: 'F1',
            create_at: @js(date('ymd')),
            owner: @js(auth()->user()->name),
          }
        },
        discard: true,
 
        cross() {
          this.store('{{ route("web.seed.store") }}')
            .then(data => {
              console.log('after cross:', data, checkedItems);
              /* 1. 成功したもの(seed_id)をindex (items), plantation (rows) 表から削除する。
                * 2. 成功したものをチェックをつけて、plant に送る
                */
              // 追加に成功したものをフォームから削除する 
              console.log('succeeded ids:', Object.keys(data.succeeded));
              const ids = new Set(Object.keys(data.succeeded).map(Number));
              this.entries = this.entries.filter(entry => ! ids.has(entry.id));
 
              // 追加に成功したもののitem.checkedをはずす
              console.log('checkoff ids:', ids);
              checkedItems.forEach(item => {
                if(ids.has(item.id)) item.checked = false;
              });
 
              // 追加に成功したものをデータベースから削除する
              console.log('destroy', ids);
              this.destroy('{{ route("web.$module_id.destroy")}}', Array.from(ids));
 
              /*  種採りに成功したものを種子の一覧に送る  */
              const created = data.created.map(item => ({...item, checked : true}));
              selectModule('seed').then(() => {
                // Alpine.js 側にカスタムイベントを送信
                window.dispatchEvent(new CustomEvent('post-cross', {
                  detail: { message: 'Load!', items: created }
                }));
              }); // selectModule().then()を閉じる
            }); // store().then()を閉じる
        }, // cross(){} を閉じる
 
        init() {
          this.$watch('items', (items) => {
            this.entries = checkedItems.map((item) => this.template(item));
          });
        },
      })"
    >
      @if(1)
        <div class='cross-test'
          x-init="
            query.strain.value = 'Col';
            search().then(() => {
              items.map(item => item.checked = true);
            });
          "
        >cross-test
          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>
        </div>
      @endif
 
      <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>
 
    {{-- 表型の入力フォーム 植物から種子へデータコピー --}}
    <div x-show="hasChecked">
      <p class="pl-4">
        種採りをするサンプルにチェックをいれてください。
        「種採り」ボタンをクリックした後で、種子の一覧からラベルを作成します。
      </p>
      <p class="py-2 pl-4">同じ植物の種を複数の袋に分けるときは、個体番号を編集してください。</p>
      <p class="py-2 pl-4">	
        <button type='submit' title="チェックの入った植物を種子リストに追加します。"
          @click="cross()"
        >
          交配した種子を登録
        </button>
        
        <label>
          <input type='checkbox' x-model='discard'>
          種子を追加後植物を削除する
        </label>
      </p>
 
      <table class='material-list'>
        <thead>
          {{-- thead 1行目 見出し --}}
          <tr class=''>
            <th></th>
            <th>雌 (♀)</th>
            <th>雄 (♂)</th>
            @foreach($seed_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) {
                    checkedItems.forEach(item => {if(item.id == entry.id) item.checked = false});
                    entries = entries.filter(e => e !== entry)
                  }"
                >
                <span x-text="entry.id"></span>
              </td>
 
              {{-- 雌しべ側選択 --}}
              <td>
                <select x-model="entry.mother"
                  @change="
                    if($event.target.value == 0) return;
                    const selected = checkedItems.find(item => item.id == $event.target.value);
                    entry.strain = selected.strain;
                  "
                >
                  <option value="0">選択してください</option>
                  <template x-for="parent in checkedItems" :key="parent.id">
                    <option :value="parent.id" x-text="[parent.strain, parent.generation, parent.no].join(' ')" />
                  </template>
                </select>
              </td>
 
              {{-- 雄しべ側選択 --}}
              <td>
                <select x-model="entry.father">
                  <option value="0">選択してください</option>
                  <template x-for="parent in checkedItems" :key="parent.id">
                    <option :value="parent.id" x-text="[parent.strain, parent.generation, parent.no].join(' ')" />
                  </template>
                </select>
              </td>
 
              @foreach($seed_table->columns('create') as $key => $col)
                <td class="{{ $col['class'] }}">
                  <input x-model="entry.{{ $key }}" placeholder="{{ $col['placeholder'] ?? '' }}"
                    @focus="$el.select()"
                  >
                </td>
              @endforeach
              <td>
                ♀: <span x-text="entry.mother"></span>
                ♂: <span x-text="entry.father"></span>
              </td>
            </tr>
          </template>
        </tbody>
      </table>
    </div>
 
</x-feature>
@endsection

resources/views/seed/module.blade.php

div.features に追加。 名前が異なるだけでやることは同じなので、resources/views/plant/cross.blade.php で発火させるイベントをpost-harvestにするほうがコードが増えなくてよいかもしれない。

resources/views/seed/module.blade.php の一部
{{-- plantモジュールの交配によってつくられた種子のデータを表示する --}}
x-on:post-cross.window="
  console.log('post-cross event in seed/module was called', $event.detail.items);
  append($event.detail.items);
"