We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
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