Skip to content

Instantly share code, notes, and snippets.

@guessthepw
Last active April 15, 2024 18:50
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save guessthepw/4e2637938be06157b3fa936d34fe8895 to your computer and use it in GitHub Desktop.
Save guessthepw/4e2637938be06157b3fa936d34fe8895 to your computer and use it in GitHub Desktop.

LiveView Forms

Application.put_env(:sample, Example.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install([
  {:plug_cowboy, "~> 2.5"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7"},
  {:phoenix_ecto, "~> 4.5.1"},
  # please test your issue using the latest version of LV from GitHub!
  {:phoenix_live_view, "~> 0.20.14"}
])

# build the LiveView JavaScript assets (this needs mix and npm available in your path!)
path = Phoenix.LiveView.__info__(:compile)[:source] |> Path.dirname() |> Path.join("../")
System.cmd("mix", ["deps.get"], cd: path, into: IO.binstream())
System.cmd("npm", ["install"], cd: Path.join(path, "./assets"), into: IO.binstream())
System.cmd("mix", ["assets.build"], cd: path, into: IO.binstream())

Intro

This livebook gives examples on how to use Liveview forms with and without a Changeset.

An important note before we begin from the docs linked below:

  • LiveView can better optimize your code if you access the form fields using @form[:field] rather than through the let-variable form

Resources

Core Components

The CoreComponents file below is included with new phoenix projects once a generator command is used and is why I need to include the code below.

defmodule ExampleWeb.CoreComponents do
  @moduledoc """
  Provides core UI components.

  At first glance, this module may seem daunting, but its goal is to provide
  core building blocks for your application, such as modals, tables, and
  forms. The components consist mostly of markup and are well-documented
  with doc strings and declarative assigns. You may customize and style
  them in any way you want, based on your application growth and needs.

  The default components use Tailwind CSS, a utility-first CSS framework.
  See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
  how to customize them or feel free to swap in another framework altogether.

  Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
  """
  use Phoenix.Component

  alias Phoenix.LiveView.JS

  @doc """
  Renders a button.

  ## Examples

      <.button>Send!</.button>
      <.button phx-click="go" class="ml-2">Send!</.button>
  """
  attr(:type, :string, default: nil)
  attr(:class, :string, default: nil)
  attr(:rest, :global, include: ~w(disabled form name value))

  slot(:inner_block, required: true)

  def button(assigns) do
    ~H"""
    <button
      type={@type}
      class={[
        "phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
        "text-sm font-semibold leading-6 text-white active:text-white/80",
        @class
      ]}
      {@rest}
    >
      <%= render_slot(@inner_block) %>
    </button>
    """
  end

  @doc """
  Renders an input with label and error messages.

  A `Phoenix.HTML.FormField` may be passed as argument,
  which is used to retrieve the input name, id, and values.
  Otherwise all attributes may be passed explicitly.

  ## Types

  This function accepts all HTML input types, considering that:

    * You may also set `type="select"` to render a `<select>` tag

    * `type="checkbox"` is used exclusively to render boolean values

    * For live file uploads, see `Phoenix.Component.live_file_input/1`

  See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
  for more information.

  ## Examples

      <.input field={@form[:email]} type="email" />
      <.input name="my-input" errors={["oh no!"]} />
  """
  attr(:id, :any, default: nil)
  attr(:name, :any)
  attr(:label, :string, default: nil)
  attr(:value, :any)

  attr(:type, :string,
    default: "text",
    values: ~w(checkbox color date datetime-local email file hidden month number password
               range radio search select tel text textarea time url week)
  )

  attr(:field, Phoenix.HTML.FormField,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"
  )

  attr(:errors, :list, default: [])
  attr(:checked, :boolean, doc: "the checked flag for checkbox inputs")
  attr(:prompt, :string, default: nil, doc: "the prompt for select inputs")
  attr(:options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2")
  attr(:multiple, :boolean, default: false, doc: "the multiple flag for select inputs")

  attr(:rest, :global,
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
                multiple pattern placeholder readonly required rows size step)
  )

  slot(:inner_block)

  def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, Enum.map(field.errors, &elem(&1, 0)))
    |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
    |> assign_new(:value, fn -> field.value end)
    |> input()
  end

  def input(%{type: "checkbox"} = assigns) do
    assigns =
      assign_new(assigns, :checked, fn ->
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
      end)

    ~H"""
    <div phx-feedback-for={@name}>
      <label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
        <input type="hidden" name={@name} value="false" />
        <input
          type="checkbox"
          id={@id}
          name={@name}
          value="true"
          checked={@checked}
          class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
          {@rest}
        />
        <%= @label %>
      </label>
      <.error :for={msg <- @errors}><%= msg %></.error>
    </div>
    """
  end

  def input(%{type: "select"} = assigns) do
    ~H"""
    <div phx-feedback-for={@name}>
      <.label for={@id}><%= @label %></.label>
      <select
        id={@id}
        name={@name}
        class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
        multiple={@multiple}
        {@rest}
      >
        <option :if={@prompt} value=""><%= @prompt %></option>
        <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
      </select>
      <.error :for={msg <- @errors}><%= msg %></.error>
    </div>
    """
  end

  def input(%{type: "textarea"} = assigns) do
    ~H"""
    <div phx-feedback-for={@name}>
      <.label for={@id}><%= @label %></.label>
      <textarea
        id={@id}
        name={@name}
        class={[
          "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
          "min-h-[6rem] phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
          @errors == [] && "border-zinc-300 focus:border-zinc-400",
          @errors != [] && "border-rose-400 focus:border-rose-400"
        ]}
        {@rest}
      ><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
      <.error :for={msg <- @errors}><%= msg %></.error>
    </div>
    """
  end

  # All other inputs text, datetime-local, url, password, etc. are handled here...
  def input(assigns) do
    ~H"""
    <div phx-feedback-for={@name}>
      <.label for={@id}><%= @label %></.label>
      <input
        type={@type}
        name={@name}
        id={@id}
        value={Phoenix.HTML.Form.normalize_value(@type, @value)}
        class={[
          "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
          "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
          @errors == [] && "border-zinc-300 focus:border-zinc-400",
          @errors != [] && "border-rose-400 focus:border-rose-400"
        ]}
        {@rest}
      />
      <.error :for={msg <- @errors}><%= msg %></.error>
    </div>
    """
  end

  @doc """
  Renders a label.
  """
  attr(:for, :string, default: nil)
  slot(:inner_block, required: true)

  def label(assigns) do
    ~H"""
    <label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
      <%= render_slot(@inner_block) %>
    </label>
    """
  end

  @doc """
  Generates a generic error message.
  """
  slot(:inner_block, required: true)

  def error(assigns) do
    ~H"""
    <p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600 phx-no-feedback:hidden">
      <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
      <%= render_slot(@inner_block) %>
    </p>
    """
  end

  @doc """
  Renders a [Heroicon](https://heroicons.com).

  Heroicons come in three styles – outline, solid, and mini.
  By default, the outline style is used, but solid and mini may
  be applied by using the `-solid` and `-mini` suffix.

  You can customize the size and colors of the icons by setting
  width, height, and background color classes.

  Icons are extracted from the `deps/heroicons` directory and bundled within
  your compiled app.css by the plugin in your `assets/tailwind.config.js`.

  ## Examples

      <.icon name="hero-x-mark-solid" />
      <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
  """
  attr(:name, :string, required: true)
  attr(:class, :string, default: nil)

  def icon(%{name: "hero-" <> _} = assigns) do
    ~H"""
    <span class={[@name, @class]} />
    """
  end
end

Nested Embedded Schemas with Changesets

Next we will walk through using a form with a Changeset.

When using a changeset, it should never be stored into assigns and a new one should be generated on every validation attempt.

Ecto changesets are meant to be single use. By never storing the changeset in the assign, you will be less tempted to use it across operations - Source

timeline
    %% Section denotes the lifecycle of Phoenix LiveView
    section LiveView Changeset Form Lifecycle
    Mount: Initalize HTML.Form struct from Ecto.Changeset, only store the Form struct in assigns.
    Validate: Create a new changeset with the form params as attrs, validate and convert back to HTML.Form struct
    Submit: Create a new changeset with the form params as attrs and insert.

A more complex example uses nested embeded schema's in a liveview form.

defmodule Tag do
  use Ecto.Schema

  @primary_key false
  embedded_schema do
    field(:description, :string)
  end

  def changeset(struct, params) do
    Ecto.Changeset.cast(struct, params, ~w(description)a)
  end
end

defmodule Person do
  use Ecto.Schema

  embedded_schema do
    field(:name, :string)
    field(:email, :string)

    embeds_many(:tags, Tag, on_replace: :delete)
  end

  @allowed ~w(name email)a

  def changeset(struct, params) do
    struct
    |> Ecto.Changeset.cast(params, @allowed)
    |> Ecto.Changeset.cast_embed(:tags,
      required: true,
      sort_param: :tags_sort,
      drop_param: :tags_drop
    )
    |> Ecto.Changeset.validate_required(@allowed)
  end
end
defmodule EmbeddedSchemaLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}
  import Phoenix.Component
  import ExampleWeb.CoreComponents

  @form_as :person

  def mount(_params, _session, socket) do
    default_form_values = %{"name" => "", "email" => "", "tags" => []}
    form = validate_params(default_form_values) |> to_form(as: @form_as)

    addl_assigns = %{
      form: form,
      form_as: @form_as
    }

    {:ok, assign(socket, addl_assigns)}
  end

  defp validate_params(params) do
    Person.changeset(%Person{}, params)
  end

  def handle_event("validate", %{"person" => params}, socket) do
    form =
      validate_params(params)
      |> to_form(as: @form_as, action: :validate)

    {:noreply, assign(socket, :form, form)}
  end

  def handle_event("submit", %{"person" => params}, socket) do
    case validate_params(params) do
      %{valid?: true} ->
        {:noreply, Phoenix.LiveView.put_flash(socket, :info, "EmbeddedSchema form submitted.")}

      changeset ->
        form = to_form(changeset, action: :validate)

        {:noreply, assign(socket, :form, form)}
    end
  end

  def render(assigns) do
    ~H"""
    <h2>Embedded Schema</h2>

    <p id="info-embedded" class="text-base text-lime-700"><%= Phoenix.Flash.get(@flash, :info) %></p>

    <.form for={@form} id="embedded-schema-form" phx-change="validate" phx-submit="submit" as={@form_as}>
      <.input label="name" type="text" field={@form[:name]} />
      <.input label="email" type="email" field={@form[:email]} />

      <.inputs_for :let={f_nested} field={@form[:tags]} >
        <div>
          <input type="checkbox" class="hidden" name={"#{@form_as}[tags_sort][]"} value={f_nested.index} />
          <.input type="text" field={f_nested[:description]} label={"Tag Description #{f_nested.index}"} />

          <button
            name={"#{@form_as}[tags_drop][]"}
            value={f_nested.index}
            phx-click={Phoenix.LiveView.JS.dispatch("change")}
            class="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-2 rounded text-sm absolute top-0 right-0"
          >
            X
          </button>
          
        </div>
      </.inputs_for>

      <input type="checkbox" class="hidden" name={"#{@form_as}[tags_drop][]"} value="" />

      <div class="flex justify-between items-center">
        <button 
          class="h-11 bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 px-4 rounded text-sm" 
          type="button" 
          name={"#{@form_as}[tags_sort][]"}
          value="new" 
          phx-click={Phoenix.LiveView.JS.dispatch("change")}>
            add tag
        </button>
          
        <.submit_button form={@form} />
      </div>
    </.form>
    """
  end

  defp submit_button(%{form: %{errors: []}} = assigns), do: ~H{<.button>submit</.button>}
  defp submit_button(assigns), do: ~H{<.button disabled="true">submit</.button>}
end

Forms Backed by Structs

First we have to define the struct we are going to back our changeset with.

defmodule User do
  defstruct [:name, :email]
end
defmodule ChangesetLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}
  import Phoenix.Component
  import ExampleWeb.CoreComponents

  def mount(_params, _session, socket) do
    struct = %User{}
    default_form_values = %{}

    form =
      validate_params(struct, default_form_values)
      |> to_form(as: :user)

    {:ok, assign(socket, :form, form)}
  end

  defp validate_params(struct, params) do
    types = %{name: :string, email: :string}

    {struct, types}
    |> Ecto.Changeset.cast(params, Map.keys(types))
    |> Ecto.Changeset.validate_required([:name, :email])
  end

  def handle_event("validate", %{"user" => params}, socket) do
    form =
      validate_params(%User{}, params)
      |> to_form(as: :user, action: :validate)

    {:noreply, assign(socket, :form, form)}
  end

  def handle_event("submit", %{"user" => params}, socket) do
    case validate_params(%User{}, params) do
      %{valid?: true} ->
        {:noreply, Phoenix.LiveView.put_flash(socket, :info, "Changeset form submitted.")}

      changeset ->
        form = to_form(changeset, action: :validate)

        {:noreply, assign(socket, :form, form)}
    end
  end

  def render(assigns) do
    ~H"""
    <h2>Changeset Data</h2>

    <p id="info-changeset"- class="text-base text-lime-700"><%= Phoenix.Flash.get(@flash, :info) %></p>

    <.form for={@form} phx-change="validate" phx-submit="submit" as={:user}>
      <.input label="name" type="text" field={@form[:name]} />
      <.input label="email" type="email" field={@form[:email]} />
      <.submit_button form={@form} />
    </.form>
    """
  end

  defp submit_button(%{form: %{errors: []}} = assigns), do: ~H{<.button>submit</.button>}
  defp submit_button(assigns), do: ~H{<.button disabled="true">submit</.button>}
end

Forms Backed by map data

Sometimes when we don't have complex form validations; we don't need a changeset. Simple forms can be used without a changeset and the process becomes a little bit simplier.

timeline
    %% Section denotes the lifecycle of Phoenix LiveView
    section LiveView MAP Data Form Lifecycle
    Mount: Initalize HTML.Form struct from empty map.
    Validate: Convert params to HTML.Form struct, supplying errors and action to to_form.
    Submit: Process form params from save event
defmodule MapDataLive do
  use Phoenix.LiveView
  import ExampleWeb.CoreComponents

  import Phoenix.Component

  def mount(_params, _session, socket) do
    map_data = %{"name" => "", "email" => ""}
    form = to_form(map_data)

    {:ok, assign(socket, :form, form)}
  end

  def handle_event("validate", params, socket) do
    errors = validate_params(params)
    form = to_form(params, errors: errors, action: :validate)

    {:noreply, assign(socket, :form, form)}
  end

  def handle_event("submit", params, socket) do
    case validate_params(params) do
      [] ->
        {:noreply, Phoenix.LiveView.put_flash(socket, :info, "Map Data form submitted.")}

      errors ->
        form = to_form(params, errors: errors, action: :validate)

        {:noreply, assign(socket, :form, form)}
    end
  end

  defp validate_params(%{"name" => ""}) do
    Keyword.put([], :name, {"can't be blank", []})
  end

  defp validate_params(_params), do: []

  def render(assigns) do
    ~H"""
    <h2>MAP Data</h2>

    <p id="info-map" class="text-base text-lime-700"><%= Phoenix.Flash.get(@flash, :info) %></p>

    <.form id="map-data-form" for={@form} phx-change="validate" phx-submit="submit">
      <.input label="name" type="text" field={@form[:name]} />
      <.submit_button form={@form} />
    </.form>
    """
  end

  defp submit_button(%{form: %{errors: []}} = assigns), do: ~H{<.button>submit</.button>}
  defp submit_button(assigns), do: ~H{<.button disabled="true">submit</.button>}
end

App Setup

Because we don't use a normal Phoenix Install, which includes the custom tailwind (or bulma) classes; we have to define the rule to hide errors for inputs that haven't been interacted with yet. This shouldn't have to be done anywhere besides this livebook.

defmodule Example.HomeLive do
  use Phoenix.LiveView, layout: {Example.Layout, :live}

  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <style>
      #embedded-schema-form div {
        position: relative;
        margin-top: .5rem;
      }

      .phx-no-feedback.phx-no-feedback\:hidden,
      .phx-no-feedback .phx-no-feedback\:hidden {
        display: none!important;
      }

      form button {
        margin-top: 1rem;
      }

      button[disabled="true"], button[disabled="true"]:hover {
        background: gray;
      }
    </style>
      
    <div class="bg-slate-100 p-5">
      <h1 class="text-center">Liveview Forms</h1>
      <div class="mx-auto max-w-600 flex justify-center items-start gap-2">
        <div class="p-5 m-5 border-2 border-slate-400">
          <%= live_render(@socket, EmbeddedSchemaLive, id: "embedded_schema") %>
        </div>
        <div class="p-5 m-5 border-2 border-slate-400">
          <%= live_render(@socket, ChangesetLive, id: "changeset") %>
        </div>
        <div class="p-5 m-5 border-2 border-slate-400">
          <%= live_render(@socket, MapDataLive, id: "map_data") %>
        </div>
      </div>
    </div>
    """
  end
end

Start App

Now you can start the server and visit http://localhost:5001 to interact with the forms.

defmodule Example.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug(:accepts, ["html"])
  end

  scope "/", Example do
    pipe_through(:browser)

    live("/", HomeLive, :index)
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)

  plug(Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix")
  plug(Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view")

  plug(Example.Router)
end

defmodule Example.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule Example.Layout do
  use Phoenix.LiveView

  def render("live.html", assigns) do
    ~H"""
    <script src="/assets/phoenix/phoenix.js"></script>
    <script src="/assets/phoenix_live_view/phoenix_live_view.js"></script>

    <script src="https://cdn.tailwindcss.com"></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <style>
      * { font-size: 1.1em; }
    </style>
    <%= @inner_content %>
    """
  end

  def render(assigns) do
    ~H""
  end
end

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment