LiveView: Stoic API

En este tutorial se realizará un formulario para ingresar más citas de filósofos estóicos para nuestra API creada en el tutorial anterior.

Paso 1: Agregar nuestra ruta de LiveView

Vamos al router y agregamos nuestra nueva página en lib/stoic_quotes_web/router.ex.

Debemos modificar el scope principal donde se muestra la página del navegador y cambiar el PageController por un nuevo módulo que crearemos después. Usamos la siguiente función live("/", Live.QuotesForm, :live).

router.ex antes
scope "/", StoicQuotesWeb do
  pipe_through(:browser)

  get("/", PageController, :home)
end

A diferencia de las páginas normales que utilizan los mismos verbos HTTP como get y post. LiveView solo utiliza la función live para indicar que esto es una página de LiveView.

router.ex después
scope "/", StoicQuotesWeb do
  pipe_through(:browser)

  live("/", Live.QuotesForm, :live)
end

Paso 2: Crear nuestro controlador de LiveView

Creamos un nuevo directorio dentro de stoic_quotes_web que se llame live. Este será el directorio que usaremos para almacenar todas nuestras páginas de LiveView. Lo llamaremos live/quotes_form.ex

live/quotes_form.ex
defmodule StoicQuotesWeb.Live.QuotesForm do
  use StoicQuotesWeb, :live_view

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

Se recomienda una división adicional, con un nombre del directorio como la página y el controlador y la vista llamados como page. Esto permite estandarizar la estructura, aunque por motivos pedagógicos solo usaremos un archivo de controlador y otro de vista llamados quotes_form.*.

live/
  quotes_form/
    page.ex
    page.html.heex

La línea use StoicQuotesWeb, :live_view nos indica que usaremos el macro asociado a un LiveView, esto esta definido en el archivo lib/stoic_quotes_web/stoic_quotes_web.ex.

lib/stoic_quotes_web/stoic_quotes_web.ex
def live_view do
  quote do
    use Phoenix.LiveView

    unquote(html_helpers())
  end
end

La línea def mount(params, session, socket) nos indica que esta función será la ejecutada al momento de montar la página. Se nos dan tres argumentos, el primero son los parámetros del request (definidos en la ruta dentro del router), el segundo son los elementos dentro de la sesión (cookies) y finalmente la estructura socket, la cual es usada a lo largo de todo el ciclo de vida del LiveView para almacenar distintos datos que pueden ser compartidos de padres a hijos.

La línea {:ok, socket} es obligatoria para que el ciclo de vida pueda continuar, estamos diciendo a Phoenix que todo ha resultado correctamente y puede seguir con el ciclo de vida. Podemos modificar el socket en esta función para almacenar datos.

Luego crearemos nuestro archivo HTML que tendrá nuestra vista y formulario, lo llamaremos de la misma forma que el archivo del controlador live, pero utilizando el sufijo *.html.heex, de esta forma: quotes_form.html.heex.

Por el momento solo mostraremos un mensaje para validar que se haya configurado correctamente.

live/quotes_form.html.heex
<p>Formulario Estóico</p>

Si ejecutamos el proyecto deberíamos ver el HTML en la dirección http://localhost:4000.

$ mix phx.server
form1

Si analizamos el HTML generado en la página, descubriremos que Phoenix ha añadido código adicional. ¿Dónde están definidos estos HTML?.

<!-- <StoicQuotesWeb.Layouts.root> lib/stoic_quotes_web/components/layouts/root.html.heex:1 (stoic_quotes) --><!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="HDofMiYGdwwcPRwgPiB8JDNsKiRRNAAbwHTdSeZnpI_BdMQBU-mg8_8Y">
    <!-- @caller lib/stoic_quotes_web/components/layouts/root.html.heex:7 (stoic_quotes) --><!-- <Phoenix.Component.live_title> lib/phoenix_component.ex:2195 (phoenix_live_view) --><title data-default="StoicQuotes" data-suffix=" · Phoenix Framework">StoicQuotes · Phoenix Framework</title><!-- </Phoenix.Component.live_title> -->
    <link phx-track-static rel="stylesheet" href="/assets/css/app.css">
    <script defer phx-track-static type="text/javascript" src="/assets/js/app.js">
    </script>
    <script>
      (() => {
        const setTheme = (theme) => {
          if (theme === "system") {
            localStorage.removeItem("phx:theme");
            document.documentElement.removeAttribute("data-theme");
          } else {
            localStorage.setItem("phx:theme", theme);
            document.documentElement.setAttribute("data-theme", theme);
          }
        };
        if (!document.documentElement.hasAttribute("data-theme")) {
          setTheme(localStorage.getItem("phx:theme") || "system");
        }
        window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
        window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
      })();
    </script>
  </head>
  <body>
    <div id="phx-GGl-vRc7pJr_fwWB" data-phx-main data-phx-session="SFMyNTY.g2gDaAJhBnQAAAAIdwJpZG0AAAAUcGh4LUdHbC12UmM3cEpyX2Z3V0J3B3Nlc3Npb250AAAAAHcGcm91dGVydxxFbGl4aXIuU3RvaWNRdW90ZXNXZWIuUm91dGVydwR2aWV3dyVFbGl4aXIuU3RvaWNRdW90ZXNXZWIuTGl2ZS5RdW90ZXNGb3JtdwpwYXJlbnRfcGlkdwNuaWx3CXJvb3Rfdmlld3clRWxpeGlyLlN0b2ljUXVvdGVzV2ViLkxpdmUuUXVvdGVzRm9ybXcRbGl2ZV9zZXNzaW9uX25hbWV3B2RlZmF1bHR3CHJvb3RfcGlkdwNuaWxuBgBE9CCRmQFiAAFRgA.ezDcn_NTue6_ZWfuSQeErOe4BX6hYh6GJ-P3XpwnoN8" data-phx-static="SFMyNTY.g2gDaAJhBnQAAAADdwJpZG0AAAAUcGh4LUdHbC12UmM3cEpyX2Z3V0J3BWZsYXNodAAAAAB3CmFzc2lnbl9uZXdqbgYARfQgkZkBYgABUYA.VDBvnUZgQNL8phJlkpF3O80LDbxKVcezEXZCpjF1SYQ"><!-- <StoicQuotesWeb.Live.QuotesForm.render> lib/stoic_quotes_web/live/quotes_form.html.heex:1 (stoic_quotes) --><p>Formulario Estóico</p><!-- </StoicQuotesWeb.Live.QuotesForm.render> --></div>
  <iframe hidden height="0" width="0" src="/phoenix/live_reload/frame"></iframe></body>
</html><!-- </StoicQuotesWeb.Layouts.root> -->

Estos HTML adicionales están definidos en el directorio layouts/. Son heredados desde el archivo root.html.heex el cual se encuentra en el directorio lib/stoic_quotes_web/components/layouts/. Los contenidos de este archivo son transversales para todas las vistas.

Si se desea utilizar otro archivo se debe modificar el router.

router.ex
plug(:put_root_layout, html: {StoicQuotesWeb.Layouts, :root})
lib/stoic_quotes_web/components/layouts/root.html.heex
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <.live_title default="StoicQuotes" suffix=" · Phoenix Framework">
      {assigns[:page_title]}
    </.live_title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
    </script>
    <script>
      (() => {
        const setTheme = (theme) => {
          if (theme === "system") {
            localStorage.removeItem("phx:theme");
            document.documentElement.removeAttribute("data-theme");
          } else {
            localStorage.setItem("phx:theme", theme);
            document.documentElement.setAttribute("data-theme", theme);
          }
        };
        if (!document.documentElement.hasAttribute("data-theme")) {
          setTheme(localStorage.getItem("phx:theme") || "system");
        }
        window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
        window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
      })();
    </script>
  </head>
  <body>
    {@inner_content}
  </body>
</html>
  • {assigns[:page_title]}: Imprime el contenido que puede ser modificado usando la estructura sockets en la función mount usando socket = assign(socket, page_title: 'Mi Título').

  • {@inner_content}: Imprime un texto que puede ser reemplazado por una vista de un LiveView.

Paso 3: Implementar el formulario HTML

Editamos nuestro formulario (lib/stoic_quotes_web/quotes_form.html.heex) con el HTML necesario.

lib/stoic_quotes_web/quotes_form.html.heex
<div class="min-h-full">

  <header class="relative bg-gray-800 after:pointer-events-none after:absolute after:inset-x-0 after:inset-y-0 after:border-y after:border-white/10">
    <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
      <h1 class="text-3xl font-bold tracking-tight text-white">Stoic Quotes Form</h1>
    </div>
  </header>
  <main>
    <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">

    <form>
      <div class="space-y-12">
        <%# Alert Section %>
        <div role="alert" class="alert">
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
          </svg>
          <span>12 unread messages. Tap to see.</span>
        </div>

        <div class="border-b border-white/10 pb-12">

          <h2 class="text-base/7 font-semibold text-white">Stoic Quote Information</h2>
          <p class="mt-1 text-sm/6 text-gray-400">Use this form to add a new Stoic Quote</p>

          <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
            <div class="sm:col-span-3">
              <label for="author" class="block text-sm/6 font-medium text-white">
              Author
              </label>
              <div class="mt-2">
                <input id="author" type="text" name="author" autofocus="true" class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6" />
              </div>
            </div>

            <div class="sm:col-span-3">
              <label for="source" class="block text-sm/6 font-medium text-white">
              Source
              </label>
              <div class="mt-2">
                <input id="source" type="text" name="source" class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6" />
              </div>
            </div>

            <div class="col-span-full">

              <label
                for="quote"
                class="block text-sm/6 font-medium text-white">
                Quote
              </label>

              <div class="mt-2">
                <textarea
                  id="quote"
                  type="text"
                  rows="5"
                  name="quote"
                  class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"></textarea>

              </div>
            </div>

          </div>
        </div>

      </div>

      <div class="mt-6 flex items-center justify-end gap-x-6">
        <button type="reset" class="btn text-sm/6 font-semibold text-white">
        Reset
        </button>

        <button type="submit" class="rounded-md btn btn-xl btn-wide bg-indigo-500 px-3 py-2 text-sm font-semibold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500">
        Save
        </button>
      </div>
    </form>

    </div>
  </main>
</div>

Lo que mostrará una página similar a lo siguiente:

form2

Paso 4: Conectar el formulario al controlador

Para esto utilizaremos las herramientas proporcionadas por LiveView la cual permite enviar eventos y valores hacia el controlador.

Configuramos el valor de cada input para que sea enviado al controlador. Para esto creamos una nueva estructura que almacenará los valores, utilizamos una función llamada empty_form() que utiliza la función de Phoenix to_form() para entregar la estructura que usaremos en el formulario.

defp empty_form() do
  to_form(%{"author" => "", "quote" => "", "source" => ""})
end

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(form: empty_form())}
end

También añadiremos dos eventos "validate" y "save" que por el momento solamente devuelven los valores del formulario. Luego serán mejorados.

quotes_form.ex
def handle_event("validate", params, socket) do
  IO.inspect(params, label: :validate)
  form = to_form(params)
  {:noreply,
    socket
    |> assign(form: form)
  }
end

def handle_event("save", params, socket) do
  IO.inspect(params, label: :save)
  form = to_form(params)
  {:noreply,
    socket
    |> assign(form: form)
  }
end

Quedando el archivo de la siguiente forma:

quotes_form.ex
defmodule StoicQuotesWeb.Live.QuotesForm do
  use StoicQuotesWeb, :live_view

  defp empty_form() do
    to_form(%{"author" => "", "quote" => "", "source" => ""})
  end

  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(form: empty_form())}
  end

  def handle_event("validate", params, socket) do
    IO.inspect(params, label: :validate)
    form = to_form(params)
    {:noreply,
      socket
      |> assign(form: form)
    }
  end

  def handle_event("save", params, socket) do
    IO.inspect(params, label: :save)
    form = to_form(params)
    {:noreply,
      socket
      |> assign(form: form)
    }
  end
end

También es necesario utilizar el elemento .form para asociar el formulario al controlador. Notar los eventos que se manejaran, phx-change y phx-submit.

quotes_form.html.heex
<.form for={@form} phx-change="validate" phx-submit="save">
...
</.form>

Ahora es turno de asociar los elementos para que sean enviados en los eventos del formulario. Para esto utilizamos los elementos .input.

Author

quotes_form.html.heex
<.input
  autofocus="true"
  placeholder="Marcus Aurelius"
  phx-debounce="blur"
  field={@form[:author]}
  class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
/>

Source

quotes_form.html.heex
<.input
  placeholder="Meditations"
  phx-debounce="blur"
  field={@form[:source]}
  class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
/>

Quote

quotes_form.html.heex
<.input
  type="textarea"
  rows="5"
  placeholder="Lorem Ipsum"
  phx-debounce="blur"
  field={@form[:quote]}
  class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
/>

¿Dónde están estos elementos de <.form> e <.input>?

Estos elementos están definidos en lib/stoic_quotes_web/components/core_components.ex donde corresponden a una función que devuelve un html según los parámetros. Estos son componentes que vienen predefinidos en Phoenix y son opcionales de utilizar, pero recomendados.

lib/stoic_quotes_web/components/core_components.ex
...
def input(%{type: "textarea"} = assigns) do
  ~H"""
  <fieldset class="fieldset mb-2">
    <label>
      <span :if={@label} class="label mb-1">{@label}</span>
      <textarea
        id={@id}
        name={@name}
        class={[
          @class || "w-full textarea",
          @errors != [] && (@error_class || "textarea-error")
        ]}
        {@rest}
      >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
    </label>
    <.error :for={msg <- @errors}>{msg}</.error>
  </fieldset>
  """
end
...

Alertas

Para generar alertas utilizaremos el componente <Layouts.flash_group> el cual está dentro del archivo lib/stoic_quotes_web/components/layouts.ex.

Este es un mensaje de alerta que cambia de color dependiendo del tipo de alerta (éxito o error).

Se utiliza la función put_flash() en el socket para enviar mensajes.

quotes_form.html.heex
<Layouts.flash_group flash={@flash} />

Quedando el formulario como lo siguiente:

quotes_form.html.heex
<div class="min-h-full">

  <header class="relative bg-gray-800 after:pointer-events-none after:absolute after:inset-x-0 after:inset-y-0 after:border-y after:border-white/10">
    <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
      <h1 class="text-3xl font-bold tracking-tight text-white">Stoic Quotes Form</h1>
    </div>
  </header>
  <main>
    <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">

    <.form for={@form} phx-change="validate" phx-submit="save">
      <div class="space-y-12">

        <Layouts.flash_group flash={@flash} />

        <div class="border-b border-white/10 pb-12">

          <h2 class="text-base/7 font-semibold text-white">Stoic Quote Information</h2>
          <p class="mt-1 text-sm/6 text-gray-400">Use this form to add a new Stoic Quote</p>

          <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
            <div class="sm:col-span-3">
              <label for="author" class="block text-sm/6 font-medium text-white">
              Author
              </label>
              <div class="mt-2">
                <.input
                autofocus="true"
                required="true"
                placeholder="Marcus Aurelius"
                phx-debounce="blur"
                field={@form[:author]}
                class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
                />
              </div>
            </div>

            <div class="sm:col-span-3">
              <label for="source" class="block text-sm/6 font-medium text-white">
              Source
              </label>
              <div class="mt-2">
                <.input
                placeholder="Meditations"
                required="true"
                phx-debounce="blur"
                field={@form[:source]}
                class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
                />
              </div>
            </div>

            <div class="col-span-full">

              <label
                for="quote"
                class="block text-sm/6 font-medium text-white">
                Quote
              </label>

              <div class="mt-2">
                <.input
                  type="textarea"
                  required="true"
                  rows="5"
                  placeholder="Lorem Ipsum"
                  phx-debounce="blur"
                  field={@form[:quote]}
                  class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
                />

              </div>
            </div>

          </div>
        </div>

      </div>

      <div class="mt-6 flex items-center justify-end gap-x-6">
        <button type="reset" class="btn text-sm/6 font-semibold text-white">
        Reset
        </button>

        <.button type="submit" class="rounded-md btn btn-xl btn-wide bg-indigo-500 px-3 py-2 text-sm font-semibold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500">
        Save
        </.button>
      </div>
    </.form>

    </div>
  </main>
</div>

Paso 5: Implementar validación del formulario

Ahora se realizará la validación del formulario, para que muestre errores si se envía un valor que no sea correcto. Para esto modificaremos la función def handle_event("validate", params, socket), donde crearemos un nuevo changeset, el cual será la estructura usada para realizar todas las validaciones. Como ya tenemos un esquema podemos reutilizarlo, sin embargo también existen los changeset sin esquemas (por ejemplo un formulario de contacto) que pemiten realizar validaciones a formularios no asociados a una base de datos o también cuando sea necesario validar múltiples valores no relacionados en la misma tabla.

Primero añadimos el módulo y el Logger.

quotes_form.ex
defmodule StoicQuotesWeb.Live.QuotesForm do
  use StoicQuotesWeb, :live_view

  alias StoicQuotes.Quotes
  alias StoicQuotes.Quotes.Quote
  require Logger
  ...

Luego modificamos la función para usar el módulo. Notemos que añadimos una nueva función llamada Quote.new que inicia una validación con los parámetros que le hemos dado. Para esto debemos añadir la función al esquema correspondiente.

quotes_form.ex
def handle_event("validate", params, socket) do
  changeset =
    Quote.new(params)

  form = to_form(params, errors: changeset.errors)

  Logger.debug(form)

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

Notar que separamos el changeset del formulario. Esto es por que un formulario puede tener distintos campos que no necesariamente tienen relación con el esquema y sus validaciones. Por lo que siempre es recomendable tener entidades separadas para mayor mantenibilidad y bajo acoplamiento.

Ahora modicamos el esquema para que tenga la función new.

@doc false
def new(attrs \\ %{"author" => "", "quote" => "", "source" => ""}) do
  case changeset(%__MODULE__{}, attrs) do
    {_, changeset} -> changeset
    changeset -> changeset
  end
end
lib/stoic_quotes/quotes/quote.ex
defmodule StoicQuotes.Quotes.Quote do
  use Ecto.Schema
  import Ecto.Changeset

  @optional_fields [:id, :inserted_at, :updated_at]

  schema "quotes" do
    field(:quote, :string)
    field(:author, :string)
    field(:source, :string)

    timestamps(type: :utc_datetime)
  end

  def fields() do
    __MODULE__.__schema__(:fields)
  end

  def required_fields() do
    fields() -- @optional_fields
  end

  @doc false
  def changeset(quote, attrs) do
    quote
    |> cast(attrs, fields())
    |> validate_required(required_fields())
    |> unsafe_validate_unique(:quote, StoicQuotes.Repo)
    |> unique_constraint(:quote)
  end

  @doc false
  def new(attrs \\ %{"author" => "", "quote" => "", "source" => ""}) do
    case changeset(%__MODULE__{}, attrs) do
      {_, changeset} -> changeset
      changeset -> changeset
    end
  end
end
  • MODULE: Este elemento permite utilizar el módulo dentro del mismo. Siempre apuntara al nombre del módulo, por lo que es buena práctica usarlo para reducir el acomplamiento.

  • changeset(%MODULE{}, attrs): Llamamos a la función existente pasando los parámetros adecuados, como un nuevo struct del módulo.

  • {_, changeset} → changeset: La validación unique_constraint(:quote) entrega una tupla {:error, changeset}, por lo cual debemos estandarizar para simplificar el manejo de errores.

  • changeset → changeset: Si la validación entrega el formato estándar entonces la devolvemos tal cual es.

Paso 6: Implementar el guardado en la base de datos

Si las validaciones son exitosas, entonces podemos enviarlo para su almacenamiento en la base de datos. Para esto modificamos la función def handle_event("save", params, socket). Debemos evaluar los casos: las validaciones son correcta o no, se guardo exitosamente o no, como también considerar un caso excepcional donde no se retornó el valor esperado al guardar (éxito o fracaso).

quotes_form.ex
def handle_event("save", params, socket) do
  changeset =
    Quote.new(params)

  form = to_form(params, errors: changeset.errors)

  Logger.debug(form)

  socket =
    case changeset.valid? do
      true ->
        case Quotes.create_quote(params) do
          {:ok, result} ->
            Logger.debug("Insert completed")
            Logger.debug(result)

            socket
            |> assign(form: empty_form())
            |> put_flash(:info, "Created new Quote")

          {:error, error} ->
            Logger.debug("Insert failed")
            Logger.debug(error)

            socket
            |> assign(form: form)
            |> put_flash(:error, "There was an error saving the Quote")

          unknown ->
            Logger.debug("Insert operation with unknown state")
            Logger.debug(unknown)

            socket
            |> assign(form: form)
            |> put_flash(:error, "There was an error saving the Quote")
        end

      false ->
        Logger.debug("Changeset with errors can not be saved")
        Logger.debug(changeset.errors)

        socket
        |> assign(form: form)
        |> put_flash(:error, "There was an error saving the Quote")
    end

  {:noreply, socket}
end

Paso 7: Botón Guardar

Ahora que tenemos las validaciones listas se modificará un poco el botón guardar para que solo esté activo si el formulario tiene valores válidos.

Para esto le añadimos la propiedad disabled que estará en verdadero si no se puede guardar. disabled={@can_save? == false}.

<button
  type="submit"
  class="rounded-md btn btn-xl btn-wide bg-indigo-500 px-3 py-2 text-sm font-semibold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
  disabled={@can_save? == false}
  >
Save
</button>

Ahora debemos añadir esta nueva variable en nuestro socket y función de validación. Para esto creamos la variable con su valor inicial en mount.

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(can_save?: false)
   |> assign(form: empty_form())}
end

Y modificamos tanto la función de validación, como la función de guardado. En la función de validación debemos obtener el valor del changeset de validaciones para determinar si el botón puede ser habilitado.

def handle_event("validate", params, socket) do
  changeset =
    Quote.new(params)

  form = to_form(params, errors: changeset.errors)

  Logger.debug(form)

  {:noreply,
   socket
   |> assign(can_save?: changeset.valid?)
   |> assign(form: form)}
end
def handle_event("save", params, socket) do
...
case Quotes.create_quote(params) do
  {:ok, result} ->
    Logger.debug("Insert completed")
    Logger.debug(result)

    socket
    |> assign(can_save?: false)
    |> assign(form: empty_form())
    |> put_flash(:info, "Created new Quote")
...

Se mostrará el código final de cada archivo.

defmodule StoicQuotesWeb.Live.QuotesForm do
  use StoicQuotesWeb, :live_view

  alias StoicQuotes.Quotes.Quote
  alias StoicQuotes.Quotes

  require Logger

  defp empty_form() do
    to_form(%{"author" => "", "quote" => "", "source" => ""})
  end

  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(can_save?: false)
     |> assign(form: empty_form())}
  end

  def handle_event("validate", params, socket) do
    changeset =
      Quote.new(params)

    form = to_form(params, errors: changeset.errors)

    Logger.debug(form)

    {:noreply,
     socket
     |> assign(can_save?: changeset.valid?)
     |> assign(form: form)}
  end

  def handle_event("save", params, socket) do
    changeset =
      Quote.new(params)

    form = to_form(params, errors: changeset.errors)

    Logger.debug(form)

    socket =
      case changeset.valid? do
        true ->
          case Quotes.create_quote(params) do
            {:ok, result} ->
              Logger.debug("Insert completed")
              Logger.debug(result)

              socket
              |> assign(can_save?: false)
              |> assign(form: empty_form())
              |> put_flash(:info, "Created new Quote")

            {:error, error} ->
              Logger.debug("Insert failed")
              Logger.debug(error)

              socket
              |> assign(form: form)
              |> put_flash(:error, "There was an error saving the Quote")

            unknown ->
              Logger.debug("Insert operation with unknown state")
              Logger.debug(unknown)

              socket
              |> assign(form: form)
              |> put_flash(:error, "There was an error saving the Quote")
          end

        false ->
          Logger.debug("Changeset with errors can not be saved")
          Logger.debug(changeset.errors)

          socket
          |> assign(form: form)
          |> put_flash(:error, "There was an error saving the Quote")
      end

    {:noreply, socket}
  end
end
<div class="min-h-full">

  <header class="relative bg-gray-800 after:pointer-events-none after:absolute after:inset-x-0 after:inset-y-0 after:border-y after:border-white/10">
    <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
      <h1 class="text-3xl font-bold tracking-tight text-white">Stoic Quotes Form</h1>
    </div>
  </header>
  <main>
    <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">

    <.form for={@form} phx-change="validate" phx-submit="save">
      <div class="space-y-12">

        <Layouts.flash_group flash={@flash} />

        <div class="border-b border-white/10 pb-12">

          <h2 class="text-base/7 font-semibold text-white">Stoic Quote Information</h2>
          <p class="mt-1 text-sm/6 text-gray-400">Use this form to add a new Stoic Quote</p>

          <div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
            <div class="sm:col-span-3">
              <label for="author" class="block text-sm/6 font-medium text-white">
              Author
              </label>
              <div class="mt-2">
                <.input
                autofocus="true"
                required="true"
                placeholder="Marcus Aurelius"
                phx-debounce="blur"
                field={@form[:author]}
                class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
                />
              </div>
            </div>

            <div class="sm:col-span-3">
              <label for="source" class="block text-sm/6 font-medium text-white">
              Source
              </label>
              <div class="mt-2">
                <.input
                placeholder="Meditations"
                required="true"
                phx-debounce="blur"
                field={@form[:source]}
                class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
                />
              </div>
            </div>

            <div class="col-span-full">

              <label
                for="quote"
                class="block text-sm/6 font-medium text-white">
                Quote
              </label>

              <div class="mt-2">
                <.input
                  type="textarea"
                  required="true"
                  rows="5"
                  placeholder="Lorem Ipsum"
                  phx-debounce="blur"
                  field={@form[:quote]}
                  class="block w-full rounded-md bg-white/5 px-3 py-1.5 text-base text-white outline-1 -outline-offset-1 outline-white/10 placeholder:text-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-500 sm:text-sm/6"
                />

              </div>
            </div>

          </div>
        </div>

      </div>

      <div class="mt-6 flex items-center justify-end gap-x-6">
        <button type="reset" class="btn text-sm/6 font-semibold text-white">
        Reset
        </button>

        <button
          type="submit"
          class="rounded-md btn btn-xl btn-wide bg-indigo-500 px-3 py-2 text-sm font-semibold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
          disabled={@can_save? == false}
          >
        Save
        </button>
      </div>
    </.form>

    </div>
  </main>
</div>

Paso 9: Siguientes pasos

Ahora se debería tener un formulario que realiza validaciones, muestra mensajes de éxito y error y permite insertar registros en la base de datos. El siguiente tutorial verá cómo realizar pruebas unitarias al código realizado.