Phoenixのフォームinputを共通ヘルパーとして使い回す

フォームは一度スタイリングができたら、他のページでも同じスタイリングでいくことがほとんどであると思います。inputタグを共通InputHelpersとして隠蔽すると、コーディングが楽になり、CSSクラスを気にせずに済むようになり、またテンプレートもスッキリとして読みやすくなります。

2021/3/12(金)〜 2021/3/15(月)開催のautoracex #16での成果です。

TL;DR

元ネタはJosé ValimさんDynamic forms with Phoenixですが、それをもとに必須項目の印を表示させたり、Bootstrap 4に対応させたりしました。これは一例です。ひとそれぞれ好きなようにElixirでカスタマイズできます。

以下のようなのフォームがあったとして Screen Shot 2021-03-12 at 7 56 37 PM

カスタムinput_tagヘルパーを用いラベル、スタイリング、エラーメッセージを含んだHTMLを動的に生成させます。

<%= f = form_for @changeset, "#", phx_submit: "submit-check-in-form" %>
  <%= input_tag f, :name %>
  <%= input_tag f, :phone %>
  <%= submit "Check In", phx_disable_with: "Saving ...", class: "btn btn-primary" %>
</form>

未提出、提出後GOOD、提出後BADの3パターンの状態が考えられます。フォームの状態に対応したCSSクラスと共にHTMLが生成されます。

フォームtypeは、フィールド名から推測して生成します。デフォルトはPhoenix.HTML.Form.text_input/3 です。

フィールド名HTML生成に使用される関数
:emailPhoenix.HTML.Form.email_input/3
:passwordPhoenix.HTML.Form.password_input/3
:searchPhoenix.HTML.Form.search_input/3
:urlPhoenix.HTML.Form.url_input/3
<!-- 提出前 -->
<div class="form-group">
  <label for="volunteer_name">Name *</label>
  <input type="text"
          class="form-control "
          id="volunteer_name"
          name="volunteer[name]"
          placeholder="Name">
</div>

<!-- 提出後BAD -->
<div class="form-group">
  <label for="volunteer_name">Name *</label>
  <input type="text"
          class="form-control is-invalid" <-- CSSが変化
          id="volunteer_name"
          name="volunteer[name]"
          placeholder="Name"
          value="">
  <span class="invalid-feedback d-inline-block" 
        phx-feedback-for="volunteer_name">
        can't be blank
  </span>
</div>

<!-- 提出後GOOD -->
<div class="form-group">
  <label for="volunteer_name">Name *</label>
  <input type="text"
          class="form-control is-valid" <-- CSSが変化
          id="volunteer_name"
          name="volunteer[name]"
          placeholder="Name"
          value="Masatoshi">
</div>

カスタムinput_tagヘルパー実装例

defmodule MnishiguchiWeb.InputHelpers do
  use Phoenix.HTML

  @custom_field_form_mapping %{
    "phone" => :telephone_input
  }

  @doc """
  Dynamically generates a Bootstrap 4 form input field.
  http://blog.plataformatec.com.br/2016/09/dynamic-forms-with-phoenix/

  ## Examples

      input_tag f, :name, placeholder: "Name", autocomplete: "off"
      input_tag f, :phone, using: :telephone_input, placeholder: "Phone", autocomplete: "off"

  """
  def input_tag(form, field, opts \\ []) do
    # Some input type can be inferred from the field name.
    input_fun_name = opts[:using] || Phoenix.HTML.Form.input_type(form, field, @custom_field_form_mapping)
    required = opts[:required] || form |> input_validations(field) |> Keyword.get(:required)
    label_text = opts[:label] || humanize(field)

    permitted_input_opts = Enum.filter(opts, &(elem(&1, 0) in [:id, :name, :autocomplete, :placeholder]))
    phx_attributes = Enum.filter(opts, &String.starts_with?(to_string(elem(&1, 0)), "phx_"))
    custom_class = [class: "form-control #{form_state_class(form, field)}"]

    input_opts =
      (permitted_input_opts ++ phx_attributes ++ custom_class)
      |> Enum.reject(&is_nil(elem(&1, 1)))

    content_tag :div, class: "form-group" do
      label = label_tag(form, field, label_text, required)
      input = apply(Phoenix.HTML.Form, input_fun_name, [form, field, input_opts])
      error = MnishiguchiWeb.ErrorHelpers.error_tag(form, field)

      [label, input, error]
    end
  end

  defp form_state_class(form, field) do
    cond do
      # Some forms may not use a Map as a source. E.g., :user
      !is_map(form.source) -> ""
      # Ignore Conn-based form.
      Map.get(form.source, :__struct__) == Plug.Conn -> ""
      # The form is not yet submitted.
      !Map.get(form.source, :action) -> ""
      # This field has an error.
      form.errors[field] -> "is-invalid"
      true -> "is-valid"
    end
  end
end

input type

Phoenix.HTML.Form.input_type/3により、input項目名をもとにtypeの決定します。仕組みはシンプルです。予め用意されたマッピングが使用されます。デフォルトのマッピングは下記のとおりです。第3引数にカスタムマッピングをしていするとデフォルトのマッピングにマージされます。

%{"email"    => :email_input,
  "password" => :password_input,
  "search"   => :search_input,
  "url"      => :url_input}

:xxx_inputアトムはPhoenix.HTML.Formに予め用意された関数名と一致している必要があります。

理解を深めるために、Iexで挙動を確認してみます。例では、volunteersテーブルとVolunteerスキーマがあることを想定しています。

iex> alias Mnishiguchi.Volunteers.Volunteer
iex> import Ecto.Changeset

iex> changeset = %Volunteer{} |> cast(%{}, [:name]) |> validate_required([:name])
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [
    name: {"can't be blank", [validation: :required]},
  ],
  data: #Mnishiguchi.Volunteers.Volunteer<>,
  valid?: false
>

iex> form = Phoenix.HTML.Form.form_for changeset, "#"
%Phoenix.HTML.Form{
  action: "#",
  data: %Mnishiguchi.Volunteers.Volunteer{
    __meta__: #Ecto.Schema.Metadata<:built, "volunteers">,
    id: nil,
    inserted_at: nil,
    name: nil,
    updated_at: nil
  },
  errors: [],
  hidden: [],
  id: "volunteer",
  impl: Phoenix.HTML.FormData.Ecto.Changeset,
  index: nil,
  name: "volunteer",
  options: [method: "post"],
  params: %{},
  source: #Ecto.Changeset<
    action: nil,
    changes: %{},
    errors: [
      name: {"can't be blank", [validation: :required]},
    ],
    data: #Mnishiguchi.Volunteers.Volunteer<>,
    valid?: false
  >
}

# using default mapping
iex> Phoenix.HTML.Form.input_type(form, :name)
:text_input

iex> Phoenix.HTML.Form.input_type(form, :email)
:email_input

iex> Phoenix.HTML.Form.input_type(form, :search)
:search_input

iex> Phoenix.HTML.Form.input_type(form, :password)
:password_input

iex> Phoenix.HTML.Form.input_type(form, :url)
:url_input

# 第3引数にカスタムマッピングを指定するとデフォルトにマージされます。
iex> Phoenix.HTML.Form.input_type(form, :denwa, %{"denwa" => :telephone_input})
:telephone_input

必須項目かどうか

必須項目かどうかはPhoenix.HTML.Form.input_validations/2で確認できます。

iex> form |> input_validations(:name)
[required: true]

iex> form |> input_validations(:name) |> Keyword.get(:required)
true

iex> form |> input_validations(:hello) |> Keyword.get(:required)
false

HTMLをElixirで組み立て

Phoenix.HTML.Tag.content_tag/2を用い、ElixirでHTMLを組み立てることができます。他にも同様の関数がPhoenix.HTML.Form functionsに用意されてます。

iex> Phoenix.HTML.Tag.content_tag(:p, "hello")
{:safe, [60, "p", [], 62, "hello", 60, 47, "p", 62]}

iex> Phoenix.HTML.Tag.content_tag(:p, "hello") |> Phoenix.HTML.safe_to_string
"<p>hello</p>"

humanize

Phoenix.HTML.Form.humanize/1が便利です。

iex> Phoenix.HTML.Form.humanize("name")
"Name"

iex> Phoenix.HTML.Form.humanize("hello_world")
"Hello world"

資料