Home Posts Tags Post Search Tag Search

Post 95

LiveView 10 - Chapter 5: Forms and Changesets

Published on: 2025-12-13 Tags: elixir, Blog, State, LiveView, Ecto, Html/CSS, Phoenix, schema, embedded_schema
Part II - LiveView Composition

Chapter 5 Forms and Changesets
    We might want to have a way to update a page as a user fills out a form we will use changesets and forms to accomplish this. Let's start by revisiting changesets.

    Model Change with Changesets
        First, consider Ecto changesets. Changesets are policies for changing data and they play these roles:
            • Changesets cast unstructured user data into a known, structured form—most commonly, an Ecto database schema, ensuring data safety
            • Changesets capture differences between safe, consistent data and a pro- posed change, allowing efficiency.
            • Changesets validate data using known consistent rules, ensuring data consistency.
            • Changesets provide a contract for communicating error states and valid states, ensuring a common interface for change

        Looking back at the changeset we have for the product resource pento/lib/pento/catalog/product.ex
            @doc false
            def changeset(product, attrs, user_scope) do
                product
                |> cast(attrs, [:name, :description, :unit_price, :sku])
                |> validate_required([:name, :description, :unit_price, :sku])
                |> validate_number(:unit_price, greater_than: 0.0)
                |> unique_constraint(:sku)
                |> put_change(:user_id, user_scope.user.id)
            end

        Important parts of this are the cast where it only allows the keys that we want, and the changeset itself. The changeset can take as many things as you want but its usually a struct of some kind and the the map of the changes you want made. 

        Right now this is a database backed validation we want to move to a schemaless, then we can move to an embedded one that is completely free of the database.

        Then last we can work with live uploads. This will allows us to upload images. 

    Model Change with Embedded Schemas
        Right now all the data that a form takes in is meant to be persistent. But there might be times that we don't want to keep the data withing a server. Maybe a search form where we just want to see the output and not save the search.

        Build Database-Free Schemas from Structs
            So we can use some of the functionality of an ecto with a data that will not be persistent. We will just need to use the Ecto.Changeset.cast/4 to start the validation.

            Let's open up an iex session and test some things out.
                iex -S mix
                iex(1)> defmodule Player do
                ...(1)>     defstruct [:username, :age]
                ...(1)> end
                {:module, Player,
                <<70, 79, 82, 49, 0, 0, 8, 248, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 249,
                0, 0, 0, 22, 13, 69, 108, 105, 120, 105, 114, 46, 80, 108, 97, 121, 101, 114,
                8, 95, 95, 105, 110, 102, 111, 95, 95, ...>>,
                %Player{username: nil, age: nil}}
                iex(2)> player = %Player{}
                %Player{username: nil, age: nil}

            We have a struct and want to use changesets to validate an update to the struct. Let's now look at the docs for the Ecto.Changeset.cast/4
                iex(3)> h Ecto.Changeset.cast/4

                                def cast(data, params, permitted, opts \\ [])                  

                @spec cast(
                        Ecto.Schema.t() | t() | {data(), types()},
                        %{required(binary()) => term()}
                        | %{required(atom()) => term()}
                        | :invalid,
                        [atom()],
                        Keyword.t()
                        ) :: t()

                Applies the given params as changes on the data according to the set of
                permitted keys. Returns a changeset.

                data may be either a changeset, a schema struct or a {data, types} tuple.

            Looks at this we can see a types() that can be used to be sure that we take those values.
                iex(4)> types = %{username: :string, age: :integer}
                %{username: :string, age: :integer}
                iex(5)> attrs = %{username: "player1", age: 20}
                %{username: "player1", age: 20}
                iex(6)> changeset = {player, types} \
                ...(6)> |> Ecto.Changeset.cast(attrs, Map.keys(types))
                #Ecto.Changeset<
                action: nil,
                changes: %{username: "player1", age: 20},
                errors: [],
                data: #Player<>,
                valid?: true,
                ...
                >

            Great we have a changeset that kept track of the changes and is ready to be sent to a function. Now let's look at how we might do the same thing with an embedded version.
                iex> defmodule Player do
                ...> use Ecto.Schema
                ...> @primary_key false
                ...> embedded_schema do
                ...> field :user_name, :string
                ...> field :age, :integer
                ...> end
                ...> end
                {:module, Player, ...}
                iex> %Player{}
                %Player{user_name: nil, age: nil}

            The difference here is the fact that we have the primary_key false to avoid the id part. And that we use the embedded_schema. Now we can use the same schema style validates and casts.
                iex> import Ecto.Changeset
                Ecto.Changeset
                iex> changes = %{age: 16, name: "Mario"}
                %{name: "Mario", age: 16}
                iex> allowed_fields = [:user_name, :age]
                [:user_name, :age]
                
                # Now we can use the cast as we imported the Ecto.Changeset

                iex> changeset = cast(player, changes, allowed_fields)
                #Ecto.Changeset<
                action: nil,
                changes: %{age: 16},
                errors: [],
                data: #Player<>,
                valid?: true
                >

                We have the changeset and then we can even validate with a check
                    iex> validate_number(changeset, :age, greater_than: 16)
                    #Ecto.Changeset<
                    action: nil,
                    changes: %{age: 16},
                    errors: [
                    age: {"must be greater than %{number}",
                    [validation: :number, kind: :greater_than, number: 16]}
                    ],
                    data: #Player<>,
                    valid?: false
                    >

                Now that we have theses tools let's build something.

    Use Embedded Schemas in LiveView
        We want to celebrate and we want a user to be able to go the /promo route and enter a friends email and we can send them a code for 10% off the game!!! 

        For this we will need a form but we don't want to persist the data as this is a friends email and they didn't give us the okay to store their data. 

        We will need a new route for the form. Let's start with the core the thing that will always work because it's meant to get the right information all the time. Promo.Recipient

        Promo Boundary and Core
            We have to create some files here as we at least need to have the form set and then we will need the embedded_schema, let's start there. pento/lib/pento/promo/recipient.ex
                defmodule Pento.Promo.Recipient do
                    use Ecto.Schema
                    import Ecto.Changeset

                    embedded_schema do
                        field :email, :string
                        field :first_name, :string
                    end

                    @doc false
                    def changeset(recipient, attrs) do
                        recipient
                        |> cast(attrs, [:first_name, :email])
                        |> validate_required([:first_name, :email])
                        |> validate_format(:email, ~r/^[\w.!#$%&’*+=?^`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/)
                    end
                end

            You can test this if you want by starting up an other iex session or recompile the current session.
                iex(7)> recompile
                Compiling 1 file (.ex)
                Generated pento app
                :ok
                iex(8)> alias Pento.Promo.Recipient
                Pento.Promo.Recipient
                iex(9)> user = %Recipient{}
                %Pento.Promo.Recipient{id: nil, email: nil, first_name: nil}

            We also have the changeset so let's leverage that as well.
                iex(10)> valid = %{first_name: "Mario", email: "super@mushroom.com"
                ...(10)> }
                %{email: "super@mushroom.com", first_name: "Mario"}
                iex(11)> changeset = Recipient.changeset(user, valid)
                #Ecto.Changeset<
                action: nil,
                changes: %{email: "super@mushroom.com", first_name: "Mario"},
                errors: [],
                data: #Pento.Promo.Recipient<>,
                valid?: true,
                ...
                >}

            We can even use the i command to learn more about the changeset
                i changeset
                Term
                #Ecto.Changeset<..., valid?: true, ...>
                Data type
                Ecto.Changeset
                Description
                This is a struct. Structs are maps with a __struct__ key.
                Reference modules
                Ecto.Changeset, Map
                Implemented protocols
                IEx.Info, Inspect, Jason.Encoder, Phoenix.HTML.FormData, Phoenix.Param, ...

            There is even functionality for the Phoenix.HTML.FormData. So that is what we can use to submit and create forms. Let's take a second to make sure that bad values still fail.
                iex(12)> invalid = %{email: "joe@emai.com", first_name: 1234}
                %{email: "joe@emai.com", first_name: 1234}
                iex(13)> Recipient.changeset(user, invalid)
                #Ecto.Changeset<
                action: nil,
                changes: %{email: "joe@emai.com"},
                errors: [first_name: {"is invalid", [type: :string, validation: :cast]}],
                data: #Pento.Promo.Recipient<>,
                valid?: false,
                ...
                >

            We can test to see what happens with a break of the custom rules too.
                iex(14)> invalid_email = %{email: "joe email", first_name: "joe"}
                %{email: "joe email", first_name: "joe"}
                iex(15)> Recipient.changeset(user, invalid_email)
                #Ecto.Changeset<
                action: nil,
                changes: %{email: "joe email", first_name: "joe"},
                errors: [email: {"has invalid format", [validation: :format]}],
                data: #Pento.Promo.Recipient<>,
                valid?: false,
                ...
                >

            Now let's move onto the Promo context. This is where the interface will take place. pento/lib/pento/promo.ex
                defmodule Pento.Promo do
                    alias Pento.Promo.Recipient

                    def change_recipient(%Recipient{} = recipient, attrs \\ %{}) do
                        Recipient.changeset(recipient, attrs)
                    end

                    def send_promo(recipient, attrs) do
                        recipient
                        |> change_recipient(attrs)
                        |> Ecto.Changeset.apply_action(:update)
                    end
                end


            This is all we need, the change_recipient takes a form value and creates a changeset for us, the send_promo will trigger and email to be sent. As you can see it's almost the exact same as we would have to a context for a database.

        The Promo Live View
            This is where the Live View will come into play the location will be pento/lib/pento_web/live/promo_live.ex start by creating that file.
                defmodule PentoWeb.PromoLive do
                    use PentoWeb, :live_view

                    alias Pento.Promo
                    alias Pento.Promo.Recipient

                    def render(assigns) do
                        ~H"""
                        <.header>
                        Send Your Promo Code to a Friend
                        <:subtitle>
                        Use this form to send a 10% off promo code for their first game purchase!
                        </:subtitle>
                        </.header>
                        """
                    end

                    def mount(_params, _session, socket) do
                        {:ok, socket}
                    end
                end
            
            Now let's create the route for the /promo route. pento/lib/pento_web/router.ex
                live("/promo", PromoLive)

            Now you can start up the session and test the page by heading to the /promo route. Let's start to add in all the functionality we want. Let's add the recipient struct to the socket and then clear the form. pento/lib/pento_web/live/promo_live.ex
                defmodule PentoWeb.PromoLive do
                    use PentoWeb, :live_view

                    alias Pento.Promo
                    alias Pento.Promo.Recipient

                    def render(assigns) do
                        ~H"""
                        <.header>
                        Send Your Promo Code to a Friend
                        <:subtitle>
                            Use this form to send a 10% off promo code for their first game purchase!
                        </:subtitle>
                        </.header>
                        """
                    end

                    def mount(_params, _session, socket) do
                        {:ok,
                        socket
                        |> assign_recipient()
                        |> clear_form()}
                    end

                    def assign_recipient(socket) do
                        assign(socket, :recipient, %Recipient{})
                    end

                    def clear_form(socket) do
                        changeset =
                        socket.assigns.recipient
                        |> Promo.change_recipient()

                        socket |> assign_form(changeset)
                    end

                    def assign_form(socket, changeset) do
                        assign(socket, :form, to_form(changeset))
                    end
                end

            Lot's to unpack here so here we go. The first mount was changed to do 2 things add in a blank recipient to the socket and then clear_form will be used to build the form that we will use.

            Then we have the assign_recipient which simply builds a clear Recipient

            Then we have the clear_form that will take the socket and build the form in the socket.

            Then the assign_form will add the form to the socket. Let's talk about the Phoenix.Component.to_form/2. This is built in to the phoenix lib and is meant to take values from and to a form and render them or pull them from a page. It utilizes the .form component to do this. So as long as you are leveraging the .form it should work fine. Let's add that now.
                <.form
                    for={@form}
                    id="promo-form"
                    phx-change="validate"
                    phx-submit="save"
                >
                    <.input field={@form[:first_name]} type="text" label="First Name" />
                    <.input field={@form[:email]} type="email" label="Email" />

                    <.button phx-disable-with="Sending...">Send Promo</.button>
                </.form>

            With all this set we need to work on the events and we should be good. We know that changes will trigger the validate event and the submit will trigger the save event. 
                def handle_event("validate", %{"recipient" => recipient_params}, socket) do
                    changeset =
                    socket.assigns.recipient
                    |> Promo.change_recipient(recipient_params)
                    |> Map.put(:action, :validate)

                    {:noreply, assign_form(socket, changeset)}
                end

            This will take any change to the form and run it through the changeset to see if it is valid, then the last line is making sure that the live view can be notified if there is an issue with the desired input.

            Real quick let's head to the core_components.ex and look at the input(assigns) for the .form
                def input(assigns) do
                    ~H"""
                        ...
                        <.label for={@id}><%= @label %></.label>
                        <input type={@type} name={@name} ... />
                        <.error :for={msg <- @errors}><%= msg %></.error>
                        ...
                    """
                    end

                def input(assigns) do
                    ~H"""
                        <.label for={@id}><%= @label %></.label>
                        <input type={@type} name={@name} ... />
                        <.error :for={msg <- @errors}><%= msg %></.error>
                    """
                end

            This again shows us that there is now a way to express issues with the inputs. 

            Now the only thing left is to take care of the "save" event
                def handle_event("save", %{"recipient" => recipient_params}, socket) do
                    case Promo.send_promo(socket.assigns.recipient, recipient_params) do
                    {:ok, _recipient} ->
                        {:noreply,
                        socket
                        |> put_flash(:info, "Promo email sent successfully!")
                        |> clear_form()}

                    {:error, %Ecto.Changeset{} = changeset} ->
                        {:noreply, assign_form(socket, changeset)}
                    end
                end