Elixir Phoenix form field one liner
Tags: elixir, phoenix, css, bulma
Published: 2022-05-15 16:22:55


[`mix phx.gen.auth`]: https://hexdocs.pm/phoenix/mix_phx_gen_auth.html
[Elixir]: https://elixir-lang.org/docs.html
[Phoenix]: https://hexdocs.pm/phoenix/Phoenix.html
[José Valim]: https://github.com/josevalim
[Dynamic forms with Phoenix]: http://blog.plataformatec.com.br/2016/09/dynamic-forms-with-phoenix/
[`Phoenix.HTML.Form`]: https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html
[Bulma]: https://bulma.io/
[HTML 属性]: https://developer.mozilla.org/ja/docs/Web/HTML/Attributes
[IEx]: https://hexdocs.pm/iex/IEx.html

> Necessity is the mother of invention.

フォームのスタイリングを抽象化する方法はいくつかありますが、個人的にフォームフィールドはヘルパー関数を自作するのが一番シンプルな気がしており、実際にやってみた結果も気に入ってます。

## やりたいこと

- 各フォームフィールドを一行で書けるようにしたい。
- 共通のCSSクラスは自動的に適用したい。

例えば、[`mix phx.gen.auth`]コマンドで生成される以下のようなフォームフィールドがあります。

```elixir
<%= label f, :email %>
<%= email_input f, :email, required: true %>
<%= error_tag f, :email %>
```

それをこのように共通のCSSクラスも含め一行で簡潔に記述したいのです。

```elixir
<%= bulma_input f, :email %>
```

## 動作環境

```
elixir          1.13.4-otp-24
erlang          24.3.4
```

```
❯ mix phx.new --version
Phoenix installer v1.6.8
```

## アイデア

カスタムビューヘルパーを書くために必要な知識とアイデアは[Elixir]言語の作者[José Valim]さんがこの記事（[Dynamic forms with Phoenix]）の中で丁寧に解説してくれています。
ですのでそれを読めば大体わかります。

また、[Phoenix]自体が[`Phoenix.HTML.Form`]等ビルトインのヘルパー関数を多数持っているのでそれらを大いに活用することも大事だと思います。

## やってみる

`lib/my_app_web/views/input_helpers.ex`ファイルを作成。

```elixir
defmodule MyAppWeb.InputHelpers do
  use Phoenix.HTML

  # TODO: define my custom view helper functions
end
```

`lib/my_app_web.ex`の`view_helpers`関数でそれを忘れずにインポートしておく。

```diff_elixir
   defp view_helpers do
     quote do
       # Use all HTML functionality (forms, tags, etc)
       use Phoenix.HTML

       # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
       import Phoenix.LiveView.Helpers
       import MyAppWeb.LiveHelpers

       # Import basic rendering functionality (render, render_layout, etc)
       import Phoenix.View

+      import MyAppWeb.InputHelpers
       import MyAppWeb.ErrorHelpers
       import MyAppWeb.Gettext
       alias MyAppWeb.Router.Helpers, as: Routes
     end
   end
```

あとは`MyAppWeb.InputHelpers`に好きなようにヘルパー関数を定義するだけ。
先日たまたま[Bulma] CSSフレームワークを使って遊んでいた時に、それ用に一つ作ってみました。
一つのサンプルコードになるかもしれません。

```elixir
defmodule MyAppWeb.InputHelpers do
  use Phoenix.HTML

  def bulma_input(form, field, opts \\ []) do
    label_opts = Keyword.take(opts, ~w[required label]a)
    input_opts = Keyword.drop(opts, ~w[required label]a)

    content_tag :div, class: "field" do
      [
        build_label(form, field, label_opts),
        build_input(form, field, input_opts),
        MyAppWeb.ErrorHelpers.error_tag(form, field)
      ]
    end
  end

  def bulma_checkbox(form, field, opts \\ []) do
    content_tag :label, class: "checkbox" do
      [
        checkbox(form, field, opts),
        ' ',
        opts[:label] || field |> to_string() |> Phoenix.Naming.humanize()
      ]
    end
  end

  defp build_label(form, field, opts) do
    required = opts[:required] || Keyword.get(input_validations(form, field), :required)
    label_text = (opts[:label] || humanize(field)) <> if required, do: " *", else: ""

    Phoenix.HTML.Form.label(form, field, label_text, class: "label")
  end

  defp build_input(form, field, opts) do
    input_fun_name = opts[:using] || Phoenix.HTML.Form.input_type(form, field)
    permitted_attributes = Keyword.drop(opts, [:using])

    input_class =
      case input_fun_name do
        :textarea -> "textarea "
        _ -> "input "
      end <> form_state_class(form, field)

    input_opts =
      [{:class, input_class} | permitted_attributes]
      |> Enum.reject(&is_nil(elem(&1, 1)))

    content_tag :div, class: "control" do
      apply(Phoenix.HTML.Form, input_fun_name, [form, field, input_opts])
    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-danger"
      true -> "is-success"
    end
  end
end
```

`lib/my_app_web/views/error_helpers.ex`に[Phoenix]が生成した`error_tag`があるので、そこのCSSクラスも必要に応じて変更します。

```diff_elixir
   def error_tag(form, field) do
     Enum.map(Keyword.get_values(form.errors, field), fn error ->
       content_tag(:span, translate_error(error),
-       class: "invalid-feedback",
+       class: "invalid-feedback help is-danger",
         phx_feedback_for: input_name(form, field)
       )
     end)
```

[Bulma]でスタイリングされたフォームフィールドを生成する`bulma_input`関数ができました。

```elixir
bulma_input f, :email
```

使用する[`Phoenix.HTML.Form`]の関数を切り替えるオプションも受け付けます。

```elixir
# Phoenix.HTML.Form.text_input/3の代わりにPhoenix.HTML.Form.textarea/3を使用したい場合
bulma_input f, :email, using: :textarea
```

必要に応じて[HTML 属性]を追加できるようにしました。

```elixir
bulma_input f, :email, placeholder: "E-mail", autocomplete: "off"
```

## IExで検証

テンプレート上のフォームで実際に実装した方が早いですが、興味があったので[IEx]上でランできる方法を探しました。

[Phoenixのソースコードのテスト](https://github.com/phoenixframework/phoenix_html/blob/4dc4e45b0e06c6fe26f2e91136fe581171e65e98/test/phoenix_html/form_test.exs#L71)の中にヒントがあったので、その知識で適当にフォームを生成します。

```elixir
iex

alias MyAppWeb.Accounts
alias MyAppWeb.Accounts.User

changeset = Accounts.change_user_registration(%User{})
form = Phoenix.HTML.Form.form_for(changeset, "/registration", [])

bulma_input(form, :email, placeholder: "E-mail", autocomplete: "off")
|> Phoenix.HTML.Safe.to_iodata()
|> to_string()
|> IO.puts()
```

```html
<div class="field">
  <label class="label" for="user_email">Email *</label>
  <div class="control">
    <input autocomplete="off" class="input " id="user_email" name="user[email]" placeholder="E-mail" type="email">
  </div>
</div>
```

:tada:

# <u><b>Elixirコミュニティに初めて接する方は下記がオススメです</b></u>

**Elixirコミュニティ の歩き方 －国内オンライン編－**<br>
https://speakerdeck.com/elijo/elixirkomiyunitei-falsebu-kifang-guo-nei-onrainbian

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/f891b7ad-d2c4-3303-915b-f831069e28a4.png)](https://speakerdeck.com/elijo/elixirkomiyunitei-falsebu-kifang-guo-nei-onrainbian)

**日本には28箇所のElixirコミュニティがあります**<br>
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/7fdc5db7-dfad-9d10-28f8-1e0b8830a587.png)

## 日程からイベントを探すならElixirイベントカレンダー:calendar:

** Elixirイベントカレンダー **<br>
[https://elixir-jp-calendar.fly.dev/](https://elixir-jp-calendar.fly.dev/)

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109744/985acaa4-50c9-da42-ae32-50fbf9119e61.png)](https://elixir-jp-calendar.fly.dev/)