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

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

[2021/3/12(金)〜 2021/3/15(月)開催のautoracex #16](https://autoracex.connpass.com/event/207338/)での成果です。

## TL;DR

元ネタは[José Valimさん](https://github.com/josevalim)の[Dynamic forms with Phoenix](https://dashbit.co/blog/dynamic-forms-with-phoenix)ですが、それをもとに必須項目の印を表示させたり、[Bootstrap 4](https://getbootstrap.com/docs/4.6/getting-started/introduction/)に対応させたりしました。これは一例です。ひとそれぞれ好きなようにElixirでカスタマイズできます。

以下のようなのフォームがあったとして
![Screen Shot 2021-03-12 at 7 56 37 PM](https://user-images.githubusercontent.com/7563926/111013378-1f049500-836d-11eb-856f-09db6d4036b0.png)

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

```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の３パターンの状態が考えられます。フォームの状態に対応したCSSクラスと共にHTMLが生成されます。

フォームtypeは、フィールド名から推測して生成します。デフォルトは[Phoenix.HTML.Form.text_input/3](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#text_input/3)
です。

| フィールド名        | HTML生成に使用される関数                                                                                                            |
| ------------ | --------------------------------------------------------------------------------------------------------------- |
| `:email`     | [Phoenix.HTML.Form.email_input/3](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#email_input/3)         |
| `:password`  | [Phoenix.HTML.Form.password_input/3](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#password_input/3)   |
| `:search`    | [Phoenix.HTML.Form.search_input/3](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#search_input/3)       |
| `:url`       | [Phoenix.HTML.Form.url_input/3](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#url_input/3)             |

```html
<!-- 提出前 -->
<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`ヘルパー実装例

```elixir
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](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#input_type/3)により、input項目名をもとにtypeの決定します。仕組みはシンプルです。予め用意されたマッピングが使用されます。デフォルトのマッピングは下記のとおりです。第３引数にカスタムマッピングをしていするとデフォルトのマッピングにマージされます。

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

`:xxx_input`アトムは[Phoenix.HTML.Form](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#functions)に予め用意された関数名と一致している必要があります。

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

```elixir
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

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

## 必須項目かどうか

必須項目かどうかは[Phoenix.HTML.Form.input_validations/2](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#input_validations/2)で確認できます。

```elixir
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](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Tag.html#content_tag/2)を用い、ElixirでHTMLを組み立てることができます。他にも同様の関数が[Phoenix.HTML.Form functions](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#functions)に用意されてます。

```elixir
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](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#humanize/1)が便利です。

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

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

## 資料

- [Dynamic forms with Phoenix](https://dashbit.co/blog/dynamic-forms-with-phoenix) by José Valimさん
- [Phoenix.HTML.Form functions](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#functions)
- [Phoenix.HTML.Form.humanize/1](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#humanize/1)
- [Phoenix.HTML.Form.input_type/3](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#input_type/3)
- [Phoenix.HTML.Form.input_validations/2](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#input_validations/2)
- [Phoenix.HTML.Tag.content_tag/2](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Tag.html#content_tag/2)