Home Posts Post Search Tag Search

LiveView 12 - Chapter 5: Your Turn
Published on: 2025-12-17 Tags: elixir, Blog, Side Project, files, LiveView, Ecto, Html/CSS, Phoenix, embedded_schema, upload cancel

Your Turn

    You did a lot here to get different forms of forms. Changeset was a big part of this. Once you have that under control you were able to add in images to the page. 

    Give It a Try
        Add a Custom Validation
            First, add a custom validation to the Product schema’s changeset that validates that :sku is a 6-digit number.
                This one is pretty easy as you just need a new validate to the products schema. I also needed to add a helper function that will take the :changeset, :sku and the value and determine what the issue is.
                        @doc false
                        def changeset(product, attrs, user_scope) do
                            product
                            |> cast(attrs, [:name, :description, :unit_price, :sku, :image_upload])
                            |> validate_required([:name, :description, :unit_price, :sku])
                            |> validate_number(:unit_price, greater_than: 0.0)
                            |> validate_change(:sku, &validate_sku_length/2)
                            |> unique_constraint(:sku)
                            |> put_change(:user_id, user_scope.user.id)
                        end

                        defp validate_sku_length(:sku, value) when is_integer(value) do
                            length = value |> Integer.digits() |> length()

                            cond do
                            length < 6 ->
                                [sku: "must be at least 6 digits"]

                            length > 10 ->
                                [sku: "must be at most 10 digits"]

                            true ->
                                []
                            end
                        end

                        defp validate_sku_length(:sku, _value), do: []
            Then, visit /products/new and try to create a new product with an invalid SKU. 
                What happens when you start typing into the SKU field? 
                    Every time you add a new character for the sku field it will run the validate event within the form code. 
                What happens if you submit the form with an invalid SKU? 
                    The changeset will not go through and populate the errors with a size error because of the return value of the validator.
                Can you trace through the code flow for each of these scenarios and identify when and how the template is updated to display the validation error?
                    Said before but I'll go through the steps. 
                        You start to type and the validator starts to make a changeset for the current values in the form ("validate" event)
                        When you finally get to a valid for you are then going to submit the changeset to the save_product/3 function. 
                        save_product/3 will take the changeset and send it to the Catalog.create_product/3
                        create_product will then add the product to the database and then send out broadcasts for the people that are subscribed to the product.

        Use Embedded Schemas
            We will need to add in a new route for this but we want to be able to search for a product based off the sku. The route should be /search PentoWeb.SearchLive.
                pento/lib/pento_web/route.ex (Add a new route to the router)
                        scope "/", PentoWeb do
                        pipe_through([:browser])

                        live_session :current_user,
                        on_mount: [{PentoWeb.UserAuth, :mount_current_scope}] do
                        live("/users/register", UserLive.Registration, :new)
                        live("/users/log-in", UserLive.Login, :new)
                        live("/users/log-in/:token", UserLive.Confirmation, :new)

                        live("/guess", WrongLive)
                        live("/promo", PromoLive)
                        live("/search", SearchLive)
                    end
                pento/lib/pento_web/live/search_live.ex (will handle the form and display the results, Will also need a handle_params to make it so a user can copy the url)
                        defmodule PentoWeb.SearchLive do
                            use PentoWeb, :live_view

                            alias Pento.Catalog
                            alias Pento.Search.SearchQuery

                            @impl true
                            def render(assigns) do
                                ~H"""
                                <Layouts.app flash={@flash} current_scope={@current_scope}>
                                <.header>
                                    Product Search
                                    <:subtitle>
                                    Search for products by their SKU number.
                                    </:subtitle>
                                </.header>

                                <.form
                                    for={@form}
                                    id="search-form"
                                    phx-change="validate"
                                    phx-submit="search"
                                >
                                    <.input
                                    field={@form[:query]}
                                    type="number"
                                    label="SKU Number"
                                    placeholder="Enter SKU number (max 6 digits)"
                                    />

                                    <.button>Search</.button>
                                </.form>

                                <%= if @streams.products != [] do %>
                                    <.table
                                    id="products"
                                    rows={@streams.products}
                                    row_click={fn {_id, product} -> JS.navigate(~p"/products/#{product}") end}
                                    >
                                    <:col :let={{_id, product}} label="Name">{product.name}</:col>
                                    <:col :let={{_id, product}} label="Description">{product.description}</:col>
                                    <:col :let={{_id, product}} label="Unit price">{product.unit_price}</:col>
                                    <:col :let={{_id, product}} label="Sku">{product.sku}</:col>
                                    <:action :let={{_id, product}}>
                                        <div class="sr-only">
                                        <.link navigate={~p"/products/#{product}"}>Show</.link>
                                        </div>
                                        <.link navigate={~p"/products/#{product}/edit"}>Edit</.link>
                                    </:action>
                                    <:action :let={{id, product}}>
                                        <.link
                                        phx-click={JS.push("delete", value: %{id: product.id}) |> hide("##{id}")}
                                        data-confirm="Are you sure?"
                                        >
                                        Delete
                                        </.link>
                                    </:action>
                                    </.table>
                                <% end %>
                                </Layouts.app>
                                """
                            end

                            @impl true
                            def mount(_params, _session, socket) do
                                {:ok,
                                socket
                                |> assign_search_query()
                                |> clear_form()
                                |> stream(:products, [])}
                            end

                            @impl true
                            @impl true
                            def handle_event("search", %{"search_query" => search_query_params}, socket) do
                                query = Map.get(search_query_params, "query")

                                {:noreply,
                                socket
                                |> push_patch(to: ~p"/search?q=#{query}")}
                            end

                            @impl true
                            def handle_event("validate", %{"search_query" => search_query_params}, socket) do
                                changeset =
                                %SearchQuery{}
                                |> SearchQuery.changeset(search_query_params)
                                |> Map.put(:action, :validate)

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

                            @impl true
                            def handle_params(params, _uri, socket) do
                                search_query_params = %{"query" => Map.get(params, "q")}

                                changeset =
                                %SearchQuery{}
                                |> SearchQuery.changeset(search_query_params)

                                products =
                                if changeset.valid? do
                                    query = Ecto.Changeset.get_field(changeset, :query)
                                    Catalog.get_products_by_sku_partial(socket.assigns.current_scope, query)
                                else
                                    []
                                end

                                {:noreply,
                                socket
                                |> assign(:search_query, changeset)
                                |> assign_form(changeset)
                                |> stream(:products, products)}
                            end

                            defp clear_form(socket) do
                                changeset =
                                socket.assigns.search_query
                                |> SearchQuery.changeset(%{})

                                socket |> assign_form(changeset)
                            end

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

                            defp assign_search_query(socket) do
                                assign(socket, :search_query, %SearchQuery{})
                            end
                        end
                pento/lib/pento/catalog.ex (need a new get_product_by_sku that will allows us to get skus that are within the searched value.)
                        @doc """
                        Gets products matching a partial SKU. Also scoped to the user.

                        ## Examples

                            iex> get_products_by_sku_partial(scope, 123)
                            [%Product{}, ...]

                            iex> get_products_by_sku_partial(scope, 999)
                            []
                        """
                        def get_products_by_sku_partial(%Scope{} = scope, query) when is_integer(query) do
                            query_str = Integer.to_string(query)

                            from(p in Product,
                            where:
                                p.user_id == ^scope.user.id and
                                like(fragment("CAST(? AS TEXT)", p.sku), ^"%#{query_str}%")
                            )
                            |> Repo.all()
                        end
                pento/search/search_query.ex (will handle the embedded_schema)
                        # This will take care of the search_query, I needed to deal with string
                        # or integer searches. 
                        defmodule Pento.Search.SearchQuery do
                            use Ecto.Schema
                            import Ecto.Changeset

                            embedded_schema do
                                field :query, :integer
                            end

                            @doc false
                            def changeset(search_query, attrs) do
                                search_query
                                |> cast(attrs, [:query])
                                |> validate_change(:query, &validate_sku_length/2)
                                |> validate_required([:query])
                            end

                            defp validate_sku_length(:query, value) when is_integer(value) do
                                len = Integer.digits(value) |> length()

                                cond do
                                len < 3 -> [query: "must be at least 3 digits"]
                                len > 6 -> [query: "must be at most 6 digits"]
                                true -> []
                                end
                            end

                            defp validate_sku_length(:query, value) when is_binary(value) do
                                len = String.length(value)

                                cond do
                                len < 3 -> [query: "must be at least 3 digits"]
                                len > 6 -> [query: "must be at most 6 digits"]
                                true -> []
                                end
                            end

                            # fallback for nil or unexpected types
                            defp validate_sku_length(:query, _), do: []
                        end
                pento/lib/pento_web/components/layouts.ex (add in the new route to the top bar)
                        def app(assigns) do
                            ~H"""
                            <header class="navbar px-4 sm:px-6 lg:px-8">
                            <div class="flex-1">
                                <a href="/guess" class="btn btn-ghost"> Guessing Game
                                </a>
                                <a href="/products" class="btn btn-ghost"> Products
                                </a>
                                <a href="/questions" class="btn btn-ghost"> FAQ
                                </a>
                                <a href="/promo" class="btn btn-ghost"> Promo
                                </a>
                                <a href="/search" class="btn btn-ghost"> Search
                                </a>
                            </div>
            Make sure to use an embedded_schema for this and get the right results.
                Notes / Highlights:
                    The search form uses an embedded schema (SearchQuery) so it doesn’t persist in the DB.
                    SKU validation ensures 3–6 digits.
                    get_products_by_sku_partial/2 scopes results to the current user.
                    URL uses q param, handled in handle_params/3, so users can share/copy URLs.
                    push_patch/2 is used in handle_event("search") to update the URL without a full page reload.


        Implement a Notifier
            This one will require the use of an mail delivery service. I went over what to do with the other one but didn't have access to a free tier service so might wait on this one.

        Customize Your File Uploader
            Check out this site to get more information about cancels [https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#cancel_upload/3]

            • Make sure you add the phx-target={@myself} attribute to your cancel button so that the event targets the form component and not the parent live view.
            • Remember to use the {} interpolation syntax for the phx-value-ref HTML attribute of your button.
            • Use a <.button> component to render your cancel button.
                    <div :for={entry <- @uploads.image.entries}>
                        <div>
                            <.button phx-click="cancel-upload" phx-value-ref={entry.ref}>
                                cancel
                            </.button>
                        </div>
                        ...
                    </div>

                    @impl true
                    def handle_event("cancel-upload", %{"ref" => ref}, socket) do
                        {:noreply, cancel_upload(socket, :image, ref)}
                    end