BEAR.Sunday+HTMXでSPAを作る

# はじめに
こんにちは、@pazworld です。

今年、BEAR.Sundayに出会いました。すべてがリソースでできていてURIでアクセスできることや、すっきりとしたディレクトリ構成、コンポーネントをDIで柔軟に入れ替えられることなど、設計の素晴らしさに感銘を受け、PHP入門をしながら勉強しました。

今年勉強したことのまとめとして、BEAR.SundayとHTMXを使った1行メモアプリをSPAで作成しました。これからBEAR.Sundayを勉強しようとしている方への参考になれば幸いです。

(SPA = Single Page Application: 最初に読み込んだWebページからページ移動せず動作するWebアプリケーション)

## BEAR.Sunday

[BEAR.Sunday](https://bearsunday.github.io/)はPHPで書かれたWebフレームワークです。MVCを採用する多くのWebフレームワークと異なり、BEAR.Sundayはリソースで構成されていて、各リソースはURIを持ち、HTTPの動詞(GET/POST/PUT/DELETE)でアクセスします。

DIとAOPによって様々な機能を組み合わせて使うことができます。昔のバージョンから互換性を維持していることも特徴です。

## HTMX

[HTMX](https://htmx.org/)は簡単にいうとWebページの一部をサーバから送られてきたHTMLで置き換えるJavaScriptライブラリです。似ている技術にはRails 7から導入された[Hotwire](https://hotwired.dev/)のTurbo Frameがあります。昔のWebでiframeを使っていた方には「ページの好きな部分をiframe化するもの」というとわかりやすいかもしれません。

ひとたびHTMXライブラリを読み込むと、その後はJavaScriptを使わずにHTMLタグだけで動的なアプリケーションを作ることができます。

## 今回作るもの

今回作るのは1行メモアプリで、次のような構成になっています。

![advent-memo-chart.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25938/2f4ed5f0-47d3-379f-7593-93a5e264ca3b.png)

ブラウザでアクセスするとメモの一覧を表示します。一覧には次のような行が含まれます。

* 通常行: メモが表示される行です
* 編集行: メモを編集できる行です
* 追加入力行: メモを追加できる行です

## ファイル構成

作成・修正するファイルの配置は次の通りです。

```
MyVendor.HtmxMemo
├ bin
│ └ setup.php
├ public
│ └ index.php
├ src
│ ├ Module
│ │ ├ AppModule.php
│ │ └ HtmlModule.php
│ └ Resource
│ 　 ├ App
│ 　 │ ├ Memo.php
│ 　 │ └ Memos.php
│ 　 └ Page
│ 　 　 ├ AddMemo.php
│ 　 　 ├ EditMemo.php
│ 　 　 ├ Index.php
│ 　 　 └ Memo.php
└ var
　 ├ db
　 │ └ database.sqlite3
　 └ qiq
　 　 └ template
　 　 　 ├ layout
　 　 　 │ └ base.php
　 　 　 └ Page
　 　 　 　 ├ AddMemo.php
　 　 　 　 ├ EditMemo.php
　 　 　 　 ├ Index.php
　 　 　 　 └ Memo.php
```

# プロジェクトの作成

PHP 8以上と[Composer](https://getcomposer.org/) 2が必要です。GitHub上に今回作成する[ソース一式](https://github.com/pazworld/MyVendor.HtmxMemo)がありますのでご参照ください。

composerでプロジェクトを作成します。途中で作成者とプロジェクト名を聞かれますので入力します。

```
composer create-project bear/skeleton MyVendor.HtmxMemo
...
What is the vendor name ?

(MyVendor):MyVendor        ←入力する

What is the project name ?

(MyProject):HtmxMemo       ←入力する
...
Do you want to remove the existing VCS (.git, .svn..) history? [Y,n]? ←Enterを押す
```

作成したプロジェクトディレクトリに入り、使用するライブラリを追加します。

```
cd MyVendor.HtmxMemo
composer require --dev bear/devtools:^1.0
composer require -W ray/aura-sql-module
composer require bear/qiq-module
```

# データベースの準備

データベースはSQLite3を使用します。

セットアップスクリプト `bin/setup.php` の内容を次のようにします。

```php:bin/setup.php
<?php

declare(strict_types=1);

chdir(dirname(__DIR__));
passthru('rm -rf ./var/tmp/*');

// データベースディレクトリ作成
$dbdir = dirname(__DIR__) . '/var/db';
if (!is_dir($dbdir)) {
    mkdir($dbdir);
}

// データベース設定
$dbpath = $dbdir . '/database.sqlite3';
$pdo = new \PDO('sqlite:' . $dbpath);

// テーブルを削除
$pdo->exec('DROP TABLE IF EXISTS memos');

// テーブルを作成
$pdo->exec('CREATE TABLE memos (id INTEGER PRIMARY KEY, title TEXT)');

// 初期値を登録
$pdo->exec('INSERT INTO memos (title) VALUES ("ねずみ")');
$pdo->exec('INSERT INTO memos (title) VALUES ("うし")');
$pdo->exec('INSERT INTO memos (title) VALUES ("とら")');
```

実行するとデータベースが作成され、サンプルデータが3件登録されます。

```
php bin/setup.php
```

setup.phpを実行するといつでもデータを初期化できます。

# Appリソースの作成

BEAR.SundayにはAppとPageの2種類のリソースがあります。Appリソースはサブドメインの問題解決やドメインストレージへのアクセスを担当します。([なぜ PageリソースとAppリソースがあるのか](https://gist.github.com/koriym/4f31290ae6e852ffe7211728c85117e5))

Appリソースとして次の2種類を作成します。

* Memos: メモ全体を表す
* Memo: 単一メモを表す

## AppModule

AppModuleはAppリソースで使うリソースを設定します。`src/Module/AppModule.php` を修正し、作成したデータベースをAppリソースで使えるようにします。

```diff:src/Module/AppModule.php
 <?php

 declare(strict_types=1);

 namespace MyVendor\HtmxMemo\Module;

 use BEAR\Dotenv\Dotenv;
 use BEAR\Package\AbstractAppModule;
 use BEAR\Package\PackageModule;
+use Ray\AuraSqlModule\AuraSqlModule;

 use function dirname;

 class AppModule extends AbstractAppModule
 {
     protected function configure(): void
     {
         (new Dotenv())->load(dirname(__DIR__, 2));
+        $dbpath = dirname(__DIR__) . '/../var/db/database.sqlite3';
+        $this->install(
+            new AuraSqlModule(
+                'sqlite:' . $dbpath
+            )
+        );
         $this->install(new PackageModule());
     }
 }
```

データベースへのアクセスには [Ray.AuraSqlModule](https://bearsunday.github.io/manuals/1.0/ja/database_aura.html) を使用しています。

## Memos

Memosはメモ一覧の取得、新規メモの追加をするAppリソースです。

* GET時: メモの一覧を取得します
* POST時: 新規メモを追加します

`src/Resource/App/Memos.php` を作成します。

```php:src/Resource/App/Memos.php
<?php

declare(strict_types=1);

namespace MyVendor\HtmxMemo\Resource\App;

use BEAR\Resource\ResourceObject;
use Ray\AuraSqlModule\AuraSqlInject;

class Memos extends ResourceObject
{
    use AuraSqlInject;

    public function onGet(): static
    {
        $memos = $this->pdo->fetchAll('SELECT * FROM memos');
        $this->body = $memos;

        return $this;
    }

    public function onPost(string $title): static
    {
        $sql = 'INSERT INTO memos (title) VALUES (:title)';
        $values = ['title' => $title];
        $this->pdo->perform($sql, $values);
        $id = $this->pdo->lastInsertId();
        $this->code = 201;
        $this->headers['location'] = '/memo?id=' . $id;

        return $this;
    }
}
```

今回はインフラストラクチャのレイヤーを分けず、PDO ([AuraSql](https://github.com/auraphp/Aura.Sql)のExtendedPdo) を使ってデータベースとやりとりしています。

クラス内で `use AuraSqlInject` することで、AppModuleで設定したデータベースに接続した状態のPDOオブジェクトが `$this->pdo` に格納されます。

GET時に`onGet`が、POST時に`onPost`が呼び出されます。`$this->body`がレスポンスボディを表します。`onPost`ではレスポンスコード(`$this->code`)に201を指定し、ヘッダのLocation (`$this->headers['location']`)に新しく作成されたメモリソースのURIを指定することで、作成したメモ・リソースを指し示しています。

## Memo

Memoは指定されたメモの取得、メモの修正、メモの削除をするAppリソースです。

* GET時: メモを取得します
* PUT時: メモを修正します
* DELETE時: メモを削除します

`src/Resource/App/Memo.php` を作成します。

```php:src/Resource/App/Memo.php
<?php

declare(strict_types=1);

namespace MyVendor\HtmxMemo\Resource\App;

use BEAR\Resource\ResourceObject;
use Ray\AuraSqlModule\AuraSqlInject;

class Memo extends ResourceObject
{
    use AuraSqlInject;

    public function onGet(int $id): static
    {
        $sql = 'SELECT * FROM memos WHERE id=:id';
        $bind = ['id' => $id];
        $memo = $this->pdo->fetchOne($sql, $bind);
        $this->body = $memo;

        return $this;
    }

    public function onPut(int $id, string $title): static
    {
        $sql = 'UPDATE memos SET title=:title WHERE id=:id';
        $bind = ['id' => $id, 'title' => $title];
        $this->pdo->perform($sql, $bind);

        return $this;
    }

    public function onDelete(int $id): static
    {
        $sql = 'DELETE FROM memos WHERE id=:id';
        $bind = ['id' => $id];
        $this->pdo->perform($sql, $bind);

        return $this;
    }
}
```

GET時に`onGet`が、POST時に`onPost`が、DELETE時に`onDelete`が呼び出されます。

## 動作テスト

PHP内蔵サーバを起動します。

```
php -S 127.0.0.1:8080 bin/app.php
```

ブラウザで`http://127.0.0.1:8080/memos`にアクセスするとデータベースに格納されたメモがhal/json形式で表示されます。(Dockerなど外部からアクセスする場合にはアドレスを`http://0.0.0.0:8080/memos`にする必要があるかもしれません)

![htmxmemo-memos-json.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25938/d62ae5f1-2156-a7d7-ae23-5e11eb05822d.png)

コマンドラインからcurlを使うとリソースを操作できます。たとえば次のようにするとABCというメモを追加できます。

```
curl -i -X POST http://127.0.0.1:8080/memos -d 'title
=ABC'
```

# HTML化の準備

ここまででAppリソースの内容をhal/json形式で返すようになりました。次にPageリソースの内容をHTML形式で返すための準備をします。

## HtmlModule

HtmlModuleはPageリソースで使うリソースを設定します。`src/Module/HtmlModule.php` を作成します。

```php:src/Module/HtmlModule
<?php

declare(strict_types=1);

namespace MyVendor\HtmxMemo\Module;

use BEAR\Package\AbstractAppModule;
use BEAR\QiqModule\QiqModule;

class HtmlModule extends AbstractAppModule
{
    protected function configure(): void
    {
        $this->install(new QiqModule($this->appMeta->appDir . '/var/qiq/template'));
    }
}
```

HTMLを作成するテンプレートライブラリとして[BEAR.QiqModule](https://bearsunday.github.io/manuals/1.0/ja/html-qiq.html)を使用しています。これはテンプレートエンジンQiqをBEAR.SundayのDIで使えるようにする働きをします。

## テンプレートエンジンQiq

[Qiq](https://qiqphp-ja.github.io/)は2021年にリリースされた新しいテンプレートエンジンです。PHP構文を基本としていて、PHPのコードを書くことができます。

Qiqのテンプレートをvendor以下からコピーします。

```
cp -r vendor/bear/qiq-module/var/qiq var
```

レイアウトファイル`var/qiq/template/layout/base.php`を次の内容に変更します。

```html:var/qiq/template/layout/base.php
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>HtmxMemo</title>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
        <style>
            .div-box {
                width: 50em;
                margin: auto;
            }
            .table-memo {
                width: 100%;
            }
            .td-button {
                width: 0;
                white-space: nowrap;
            }
            tr.htmx-swapping td {
                opacity: 0;
                transition: opacity 0.5s ease-out;
            }
        </style>
        <script src="https://unpkg.com/htmx.org@1.8.4"></script>
    </head>
    <body>
        {{= getContent() }}
    </body>
</html>
```

テーブルやボタンの見栄えをよくするために [Bulma](https://bulma.io/) を使用しています。BulmaはFlexboxをベースにしたCSSフレームワークです。JavaScriptを含まない純粋なCSSのみのフレームワークとなっています。

次の行でBulmaを読み込んでいます。

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
```

また、次の行でHTMXを読み込んでいます。

```html
<script src="https://unpkg.com/htmx.org@1.8.4"></script>
```

## コンテキストをHTMLにする

`index.php`でコンテキストを[HAL](https://en.wikipedia.org/wiki/Hypertext_Application_Language)からHTMLに変更します。これによって`/index`にアクセスした時にhal-jsonではなくHTMLが返されるようになります。

```diff:public/index.php
 <?php

 declare(strict_types=1);

 use MyVendor\HtmxMemo\Bootstrap;

 require dirname(__DIR__) . '/autoload.php';
-exit((new Bootstrap())(PHP_SAPI === 'cli-server' ? 'hal-app' : 'prod-hal-app', $GLOBALS, $_SERVER));
+exit((new Bootstrap())(PHP_SAPI === 'cli-server' ? 'html-app' : 'prod-html-app', $GLOBALS, $_SERVER));
```

コンテキストはハイフンで区切られ、右から順番に適用されます。`html`は`HtmlModule`を、`app`は`AppModule`を表し、`html-app`は`AppModule`→`HtmlModule`の順に適用することを意味しています。

# Pageリソースの作成

PageリソースはAppリソースを操作して情報を集め、クライアントが要求する形式(HTML)で情報を返します。

Pageリソースとして次の4種類を作成します。

* Index: メモアプリのページを表す
* AddMemo: メモを追加入力する行を表す
* EditMemo: メモを編集する行を表す
* Memo: メモを表示する行を表す

## Index

Indexはメモアプリの基本となるページを表示するPageリソースです。

`src/Resource/Page/Index.php` を作成します。

```php:src/Resource/Page/Index.php
<?php

declare(strict_types=1);

namespace MyVendor\HtmxMemo\Resource\Page;

use BEAR\Resource\ResourceObject;
use BEAR\Sunday\Inject\ResourceInject;

class Index extends ResourceObject
{
    use ResourceInject;

    public function onGet(): static
    {
        $memos = $this->resource->get('app://self/memos');
        $this->body['memos'] = $memos;

        return $this;
    }
}
```

対応するメソッドはGETだけです。GET時に`onGet`が呼び出されます。`onGet`の中ではAppリソースであるMemosをGETして、その結果をレスポンスボディの`memos`キーに格納しています。

クラス内で `use ResourceInject` することで、他のリソースを`$this->resource->get('app://self/memos')`のように呼び出すことができるようになります。ここでは`app://self/memos`がAppリソースのMemosを表します。

次にテンプレートファイルを作成します。あるPageリソースに対応するQiqテンプレートファイルは`var/qiq/template/Page`以下に同じファイル名で置きます。今回はIndexリソースですので、テンプレートファイルは`var/qiq/template/Page/Index`になります。

```html:var/qiq/template/Page/Index
{{ $this->setLayout('layout/base') }}
<div class="box div-box">
    <h1 class="title">1行メモ</h1>
    <table class="table table-memo">
        <thead>
            <tr>
                <th>タイトル</th>
                <th></th>
            </tr>
        </thead>
        <tbody hx-target="closest tr" hx-swap="outerHTML">
            <!-- 通常行の表示 -->
            {{ foreach ($this->memos as $memo): }}
                {{ $id = (string) $memo['id'] }}
                {{ $title = $memo['title'] }}
                <tr>
                    <td>
                        <input type="hidden" name="id" value="{{h $id }}"/>
                        {{h $title }}
                    </td>
                    <td class="td-button">
                        <button class="button is-warning" hx-get="/edit-memo?id={{h $id }}">編集</button>
                        <button class="button is-danger" hx-delete="/memo" hx-include="closest tr" hx-swap="outerHTML swap:1s">削除</button>
                    </td>
                </tr>
            {{ endforeach }}
            <!-- 追加入力行の表示 -->
            <tr>
                <td>
                    <input class="input" name="title" type="text"/>
                </td>
                <td class="td-button">
                    <button class="button is-primary" hx-post="/add-memo" hx-include="closest tr">追加</button>
                </td>
            </tr>
        </tbody>
    </table>
</div>
```

Qiqでは`{{ }}`内をPHPコードとして実行します。リソースで`$this->body['memos']`で格納した値はQiqテンプレート内では`$this->memos`で取得することができます。

`{{ }}`は実行のみで結果を表示しません。HTMLとして表示するためには`{{h }}`という構文を使います。`{{h $id }}`で変数`$id`の中身がテンプレート内に出力されます。

## 動作テスト

PHP内蔵サーバを起動します。Appリソースの時と異なり、`bin/app.php`ではなく`-t public`と指定します。

```
php -S 127.0.0.1:8080 -t public
```

ブラウザで`http://127.0.0.1:8080`にアクセスするとメモアプリが表示されます。(表示だけでまだ動作はしません)

![htmxmemo-page.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25938/f21aa788-8708-ddfb-224d-175bda6d5de3.png)

## HTMXへの指示

先ほど追加したQiqテンプレートファイル `var/qiq/template/Page/Index` を見てください。次のような行があります。

```html
<tbody hx-target="closest tr" hx-swap="outerHTML">
```

HTMXでは`hx-〜`という属性で動作を制御します。

* `hx-target`: サーバから送られてきたHTMLで置き換える対象を指定する
* `hx-swap`: `hx-target`で指定したタグのどの部分を置き換えるか指定する

`hx-〜`は子要素に受け継がれます。`hx-target="closest tr"`は「最も近い`<tr>`タグ」を表します。テーブルの子要素の`<tr>`内のボタンなどでHTTPリクエストが発生すると、ボタンを囲む`<tr>`が置き換え対象になります。

`hx-swap="outerHTML"`は`<tr>`タグそのものを置き換えるという指定になります。もし`hx-swap="innerHTML"`とすると`<tr>`タグの子要素のみが置き換わります。

下の方の追加入力行のあたりに「追加」ボタンを表す次のような行があります。

```html
<button class="button is-primary" hx-post="/add-memo" hx-include="closest tr">追加</button>
```

ここで指定されている`hx-〜`属性は次の2つです。

* `hx-post`: 指定したURIにPOSTすることを指定する
* `hx-include`: 同時に送るタグを指定する

`hx-post="/add-memo"`は`/add-memo`というURIにPOSTすることを意味しています。

`hx-include="closest tr"`は同じ`<tr>`タグの子要素にある`<input>`タグを送ることを意味しています。少し上にメモを入力する`<input>`タグがありますので、この内容も一緒に送られます。

`/add-memo`はAddMemoリソースを指し示します。

## AddMemo

それではAddMemoリソースを作りましょう。`src/Resource/Page/AddMemo.php`を作成します。

```php:src/Resource/Page/AddMemo.php
<?php

declare(strict_types=1);

namespace MyVendor\HtmxMemo\Resource\Page;

use BEAR\Resource\ResourceObject;
use BEAR\Sunday\Inject\ResourceInject;

class AddMemo extends ResourceObject
{
    use ResourceInject;

    public function onPost(string $title): static
    {
        $ro = $this->resource->post('app://self/memos', ['title' => $title]);
        $uri = 'app://self' . $ro->headers['location'];
        $memo = $this->resource->get($uri);
        $this->body['memo'] = $memo;
        $this->code = 200;

        return $this;
    }
}
```

対応するメソッドはPOSTだけです。POST時に`onPost`が呼び出されます。`onPost`の中ではAppリソースであるMemosにメモ内容をPOSTします。そのレスポンスに含まれるLocationヘッダの値を使い、追加したメモ・リソースをGETした内容をレスポンスボディの`memo`キーに格納しています。レスポンスコードを200にしているのは、新規作成を表す201ではHTMXがうまく動かなかったためです。

対応するQiqテンプレートも作成します。

```html:var/qiq/template/Page/AddMemo.php
{{ $id = (string) $this->memo['id'] }}
{{ $title = $this->memo['title'] }}
<!-- 追加した通常行の表示 -->
<tr>
    <td>
        <input type="hidden" name="id" value="{{h $id }}"/>
        {{h $title }}
    </td>
    <td class="td-button">
        <button class="button is-warning" hx-get="/edit-memo?id={{h $id }}">編集</button>
        <button class="button is-danger" hx-delete="/memo" hx-include="closest tr" hx-swap="outerHTML swap:1s">削除</button>
    </td>
</tr>
<!-- 追加入力行の表示 -->
<tr>
    <td>
        <input class="input" name="title" type="text"/>
    </td>
    <td class="td-button">
        <button class="button is-primary" hx-post="/add-memo" hx-include="closest tr">追加</button>
    </td>
</tr>
```

追加したメモを表示する通常行と、新たに入力するための追加入力行が返されます。Indexテンプレートにあった追加入力行の`<tr>`タグは、この2行分のタグで置き換えられます。

## 動作テスト

PHP内蔵サーバを起動します。

```
php -S 127.0.0.1:8080 -t public
```

ブラウザで`http://127.0.0.1:8080`にアクセスするとメモアプリが表示されます。追加入力行のテキストボックスに文字列を入力し「追加」ボタンをクリックすると、新しいメモが追加されます。

![htmxmemo-addmemo1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25938/09a8b07f-54e2-1fab-f74e-9e42b3a50cd0.png)
↓
![htmxmemo-addmemo2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25938/c963b701-3146-573e-f625-e929afb7800d.png)

## EditMemo

Indexテンプレートに編集ボタンを表す次のような行があります。

```html
<button class="button is-warning" hx-get="/edit-memo?id={{h $id }}">編集</button>
```

このボタンを押すと`hx-get`によってGETリクエストを発行します。URIは`/edit-memo`に`id=1`のような引数をつけたものです。`/edit-memo`はEditMemoリソースを表します。

EditMemoリソースを作成します。

```php:src/Resource/Page/EditMemo.php
<?php

declare(strict_types=1);

namespace MyVendor\HtmxMemo\Resource\Page;

use BEAR\Resource\ResourceObject;
use BEAR\Sunday\Inject\ResourceInject;

class EditMemo extends ResourceObject
{
    use ResourceInject;

    public function onGet(int $id): static
    {
        $memo = $this->resource->get('app://self/memo', ['id' => $id]);
        $this->body['memo'] = $memo;

        return $this;
    }

    public function onPut(int $id, string $title): static
    {
        $this->resource->put('app://self/memo', ['id' => $id, 'title' => $title]);
        $this->code = 303;
        $this->headers['location'] = "/memo?id=$id";
        $this->body['memo'] = null;

        return $this;
    }
}
```

GET時に`onGet`が、PUT時に`onPut`が呼び出されます。`onGet`の中では指定されたメモをAppリソースであるMemoからGETし、レスポンスボディの`memo`キーに格納しています。

`onPut`の中では指定されたメモの内容をAppリソースであるMemoにPUTしています。その後、レスポンスコード303と`Location`レスポンスヘッダによってそのメモを表すURIにリダイレクトしています。

対応するテンプレートも作成します。

```html:var/qiq/template/Page/EditMemo.php
{{ if (isset($this->memo)): }}
    {{ $id = (string) $this->memo['id'] }}
    {{ $title = $this->memo['title'] }}
    <!-- 編集行の表示 -->
    <tr>
        <td>
            <input type="hidden" name="id" value="{{h $id }}"/>
            <input class="input" type="text" name="title" value="{{h $title }}"/>
        </td>
        <td class="td-button">
            <button class="button is-primary" hx-put="/edit-memo" hx-include="closest tr">登録</button>
            <button class="button" hx-get="/memo?id={{h $id }}">キャンセル</button>
        </td>
    </tr>
{{ endif }}
```

このテンプレートによってメモの編集行が返されます。

次の行が「登録」ボタンを表しています。

```html
<button class="button is-primary" hx-put="/edit-memo" hx-include="closest tr">登録</button>
```

ボタンのクリック時に`hx-put`によって`/edit-memo`にPUTします。`hx-include="closest tr"`が指定されているため、同じ`<tr>`タグの小要素である次の2行の`<input>`タグのデータもサーバに送られます。

```html
<input type="hidden" name="id" value="{{h $id }}"/>
<input class="input" type="text" name="title" value="{{h $title }}"/>
```

PUT後には`/memo`にリダイレクトしますが、その際にもQiqテンプレートはレンダリングされます。この無駄なレンダリングを避けるため、EditMemoリソースの`onPut`の中で次のように`memo`キーの中身をNULLにしています。

```php
$this->body['memo'] = null;
```

そして、EditMemoテンプレートの先頭で、次の行によって`memo`がセットされていない場合にレンダリングをスキップしています。

```
{{ if (isset($this->memo)): }}
```

編集行で「編集」ボタンではなく「キャンセル」ボタンがクリックされた場合は、元の通常行を表示します。

```html
<button class="button" hx-get="/memo?id={{h $id }}">キャンセル</button>
```

## Memo

最後に通常行を表すMemoリソースを作成します。

```php:src/Resource/Page/Memo.php
<?php

declare(strict_types=1);

namespace MyVendor\HtmxMemo\Resource\Page;

use BEAR\Resource\ResourceObject;
use BEAR\Sunday\Inject\ResourceInject;

class Memo extends ResourceObject
{
    use ResourceInject;

    public function onGet(int $id): static
    {
        $memo = $this->resource->get('app://self/memo', ['id' => $id]);
        $this->body['memo'] = $memo;

        return $this;
    }

    public function onDelete(int $id): static
    {
        $this->resource->delete('app://self/memo', ['id' => $id]);
        $this->body['memo'] = null;

        return $this;
    }
}
```

GET時に`onGet`が、DELETE時に`onDelete`が呼び出されます。`onGet`の中では指定されたメモをAppリソースであるMemoからGETし、レスポンスボディの`memo`キーに格納しています。

`onDelete`の中ではAppリソースであるMemoに対してDELETEを発行しています。

対応するMemoテンプレートも作成します。

```html:var/qiq/template/Page/Memo.php
{{ if (isset($this->memo)): }}
    {{ $id = (string) $this->memo['id'] }}
    {{ $title = $this->memo['title'] }}
    <!-- 通常行の表示 -->
    <tr>
        <td>
            <input type="hidden" name="id" value="{{h $id }}"/>
            {{h $title }}
        </td>
        <td class="td-button">
            <button class="button is-warning" hx-get="/edit-memo?id={{h $id }}">編集</button>
            <button class="button is-danger" hx-delete="/memo" hx-include="closest tr" hx-swap="outerHTML swap:1s">削除</button>
        </td>
    </tr>
{{ endif }}
```

メモの内容を表示し、「編集」「削除」のボタンを作成しています。

「削除」ボタンでは`hx-delete`を使ってDELETEリクエストを発行しています。

```html
<button class="button is-danger" hx-delete="/memo" hx-include="closest tr" hx-swap="outerHTML swap:1s">削除</button>
```

`hx-swap="outerHTML swap:1s"`は`<tbody>`から継承した`hx-swap`指定を上書きしています。`swap:1s`は1秒後に行を削除する指定です。削除時の効果は`var/qiq/template/layout/base.php`で次のように「スーッと消える」ように設定しています。

```css
tr.htmx-swapping td {
    opacity: 0;
    transition: opacity 0.5s ease-out;
}
```

## 動作テスト

PHP内蔵サーバを起動します。

```
php -S 127.0.0.1:8080 -t public
```

ブラウザで`http://127.0.0.1:8080`にアクセスするとメモアプリが表示されます。追加、編集、削除ができます。HTMXによってページ遷移せずに一部が書き換わるSPAアプリケーションになっています。

![htmxmemo-full.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25938/68c3569e-d7c8-6a01-f664-530bd18a798f.png)

# おわりに

今回はわかりやすくするために似たようなHTMLを何回も書いていますが、Qiqテンプレートの[パーシャル](https://qiqphp-ja.github.io/1.x/partials.html)を使うと同じ記述をまとめて共通ファイルに抽出することができます。また、Appリソース自体が直接データベースアクセスをしていますが、インフラストラクチャ・レイヤーを担当する様々な[データベースモジュール](https://bearsunday.github.io/manuals/1.0/ja/database.html)をDIでインジェクトして使うことができます。

BEAR.Sundayではすべてがリソースであり、リソース間をHTTPのメソッド(GET/PUT/POST/DELETE)で接続しています。また、リソースやテンプレートの構造がそのまま配置に反映されるためすっきりとした構成になっています。そのことをサンプルプログラムで示せたのではないかと思います。

HTMXを使うとブラウザからGET/POSTだけでなくPUT/DELETEも発行できます。サーバから送られてきたHTMLで画面の一部を置き換える方式のため、JavaScriptを書かなくてもSPAのようなインタラクティブなアプリケーションを作成することができます。また、サーバから選択肢としてリンクURIを送ることができるため、ハイパーメディアコントロールを維持できます。これらの点でHTMXはBEAR.Sundayとの相性が非常によいと思います。

最後に、私がBEAR.Sundayを理解するまで辛抱強く様々な質問に答えてくださいましたBEAR.Sunday作者の郡山さん(@koriym)、ならびにこの素晴らしいフレームワークの構築に協力されている皆さんに感謝いたします。ありがとうございます。