Build Table Views with Phoenix LiveView

Building Table Views with Phoenix LiveViewを買って、手を動かしながら読みました。

LiveView にはだいぶ慣れてきてたのですが、ちょうどセールもやっていてよさそうだったの買ってみました。

特に良かったのはLiveViewとLiveComponentの棲み分け方で、インタラクションはLiveComponentに任せて、LiveViewはナビゲーションに専念する。 こうしておくことでLiveViewの処理がごちゃごちゃせず、かつURLフレンドリーなインタラクションが実現できてよかったです。このパターンは覚えて利用していこうと思いました。

残念な点は、利用されているコードが少し古いのでしばしばそのままでは動かず修正が必要でした。 以降はその修正点について書いていきます。

また修正済みのコードはこちらにあります。 https://github.com/yosu/meow

Schemaless changeset

パラメータのバリデーションにEctoのSchemaless changeset を利用するのですが、フィールド定義でEcto.Enum型を利用するためのコードがエラーで動きませんでした。

before

  sort_by: {:parametriezed, Ecto.Enum, Ecto.Enum.init(values: [:id, :name]

Ectoのドキュメントを参考に以下のようにすれば大丈夫でした。

after

  sort_by: Ecto.ParameterizedType.init(Ecto.Enum, values: [:id, :name]),

URLへのパラメータ埋め込み

本書では以下のようにパラメータ付きURLをセットしてナビゲーションしていました。

def handle_info({:update, opts}, socket) do
  path = Routes.live_path(socket, __MODULE__, opts)
  {:noreply, push_patch(socket, to: path, replace: true}
end

Phoenix 1.7 以降ではルーターヘルパーがなくなり~pベースになったので以下のようになります。

def handle_info({:update, opts}, socket) do
  path = ~p"/meow/?#{opts}"
  {:noreply, push_patch(socket, to: path, replace: true}
end

Formの記述

フォームに関しても古い記述のため、CoreComponents の <.simple_form><.input> を使う形に置き換える必要がありました。

その際 <.input> のfield属性が Phoenix.HTML.Form を期待するため Schemaless changeset そのままでは渡せません。通常のEctoスキーマであれば Phoenix.Component.to_form/2 で変換できるのですがこちらはSchemaless changesetには対応してません。

このため、まずフォームと連携できるようにパラメータの構造をSchemaless changeset(tuple)からEmbedded Schema(struct)に変更します。

before

defmodule MeowWeb.Forms.FilterForm do
  import Ecto.Changeset

  @fields %{
    id: :integer,
    name: :string
  }

  @default_values %{
    id: nil,
    name: nil,
  }

  def parse(params) do
    {@default_values, @fields}
    |> cast(params, Map.keys(@fields))
    |> apply_action(:insert)
  end
end

after

defmodule MeowWeb.Forms.FilterForm do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :id, :integer
    field :name, :string
  end

  def parse(params) do
    %__MODULE__{}
    |> cast(params, [:id, :name])
    |> apply_action(:insert)
  end
end

定義したEmbbed schema を Ecto.Changeset.cast/4 を使ってchangesetに変換後、前述の to_form を使って<.input> に渡せるform変数を作ります。

before

    <div>
      <.form :let={f} for={@changeset} as="filter" phx-submit="search" phx-target={@myself}>
        <div>
          <div>
            {label f, :id}
            {text_input f, :id}
            { error_tag f, :id}
          </div>
          <div>
            {label f, :name}
            {text_input f, :name}
            { error_tag f, :name}
          </div>
        </div>
        {submit "Search"}
        </.form>
    </div>

after

    <div>
      <.simple_form for={@form} as="filter" phx-submit="search" phx-target={@myself}>
        <.input field={@form[:id]} label="Id" />
        <.input field={@form[:name]} label="Name" />
        <.action>
          <.button>Search</.button>
        </.actions>
      </.simple_form>
    </div>

ここまででフォームの表示、サブミットができるようになります。

ただ、パラメータの定義をEmmbed Schemaにした関係でユーザー入力をパースした際に、struct が返ってくるようになります。 その後、パラメータをURLに含める処理で複数のパラメータを合成、フィルタリング(nilのパラメータを除く)処理をしますが、structはEnumerableでないためフィルタリングの処理がうまくいません。

結果、structで渡ってきたパラメータを一度Map.from_struct()でMapに変換する処理を挟みます。

  defp merge_and_sanitize_params(socket, overrides \\ %{}) do
    %{sorting: sorting, filter: filter} = socket.assigns

    %{}
    |> Map.merge(sorting)
    |> Map.merge(filter)
    |> Map.merge(overrides)
    |> Map.drop([:total_count])
    |> Map.from_struct() # 追記
    |> Enum.reject(fn {_key, value} -> is_nil(value) end)
    |> Map.new()
  end

これでようやく期待する動作になりました。

その他

無限スクロールの例については必要な時に試そうと思いコードは書いていないのですが、要素を追加していく処理をstreamを使った形に変える必要がありそうでした(本書ではphx-updateにappendを指定していますが現在この指定はないため)。