We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
LiveView 15 - Chapter 6: Function Components
Published on: 2025-12-24
Tags:
elixir, Blog, LiveView, Ecto, Phoenix, schema, queries
Chapter 6 Function Components
Let's start to use all the things that we have talked about so far and build a survey. This will not be something that is used completely for what we are going to say it will be used, but will show us some good examples of things that we can add to an other page.
The Survey
For this to work as well as we want for our users we want to only have the use fill out the Demographic part of the survey once and then be able to rate each product.
To do this we need a flow for how a user will see each part of the /survey route.
If a user doesn't have any Demographics we will show them the Demographic survey.
Once that is filled out then we will show them some of the demographics.
This results will also show any product that has ratings of games that they have rated themselves.
First we will setup the back end with the context and schema that support the survey.
Then we will move onto the frontend.
Set up the live view then create a component that will compartmentalize the demographics info.
Organize Your LiveView with Components
Think about the site as a whole it would be nice to display just the ratings part of the ratings within the show page. Or just the demographics within an about us page. This is where the components will come in and help us get the things that we need for each part of the survey.
Components Isolate Markup, Events, and State
Components allow us to deal with the state, events, and even the html markup within one function.
Components Share the Parent LiveView Process
Every part of the survey will be handled within the parent live view. If we crash within the child the parent will also crash.
Build the Survey Context
We want to build the Survey context with Schemas for the Demographic and Rating. There is an other way to use the generators for this as we don't want the generator to build the routes just the context and schemas. That is phx.gen.context
Generate and Customize the Context
Type this command to generate the context:
[pento] ➔ mix phx.gen.context Survey Demographic demographics gender:string \
year_of_birth:integer
* creating lib/pento/survey/demographic.ex
* creating priv/repo/migrations/20250728134555_create_demographics.exs
* creating lib/pento/survey.ex
* creating test/pento/survey_test.exs
* creating test/support/fixtures/survey_fixtures.ex
Looking at the generated text that the generator output we see that we have:
demographic Schema
new migration
Survey Context
Tests and fixtures.
Now let's head to the new migration pento/priv/repo/migrations/..._create_demographics.exs
defmodule Pento.Repo.Migrations.CreateDemographics do
use Ecto.Migration
def change do
create table(:demographics) do
add(:gender, :string)
add(:year_of_birth, :integer)
add(:user_id, references(:users, on_delete: :nothing))
timestamps(type: :utc_datetime)
end
create(unique_index(:demographics, [:user_id]))
end
end
Let's head to the demographic.ex schema and add in a line to make sure that we have unique user_id and that it belongs to the user.id for the user. Also that we are validating the right data.
defmodule Pento.Survey.Demographic do
use Ecto.Schema
import Ecto.Changeset
schema "demographics" do
field(:gender, :string)
field(:year_of_birth, :integer)
belongs_to(:user, Pento.Accounts.User)
timestamps(type: :utc_datetime)
end
@doc false
def changeset(demographic, attrs, user_scope) do
demographic
|> cast(attrs, [:gender, :year_of_birth])
|> validate_required([:gender, :year_of_birth])
|> validate_inclusion(:gender, ["Male", "Female", "Other", "Prefer not to say"])
|> validate_inclusion(:year_of_birth, 1900..2010)
|> unique_constraint(:user_id)
|> put_change(:user_id, user_scope.user.id)
end
end
We can now create the other schema for the ratings schema
mix phx.gen.context Survey Rating ratings stars:integer
* creating lib/pento/survey/rating.ex
* creating priv/repo/migrations/20251223203601_create_ratings.exs
* injecting lib/pento/survey.ex
* injecting test/pento/survey_test.exs
* injecting test/support/fixtures/survey_fixtures.ex
We see some of the same things that we saw with the last generator.
rating schema
migration
injects to the context
tests and fixtures
We should now head to the migration pento/priv/repo/migrations/...create_ratings.exs
defmodule Pento.Repo.Migrations.CreateRatings do
use Ecto.Migration
def change do
create table(:ratings) do
add(:stars, :integer)
add(:user_id, references(:users, type: :id, on_delete: :nothing))
add(:product_id, references(:products, on_delete: :nothing))
timestamps(type: :utc_datetime)
end
create(index(:ratings, [:user_id]))
create(index(:ratings, [:product_id]))
create(unique_index(:ratings, [:user_id, :product_id], name: :index_ratings_on_user_product))
end
end
now to the schema for rating pento/lib/pento/survey/rating.ex
# Need to make sure that we have unique values for this rating
|> unique_constraint([:user_id, :product_id])
# now make sure that we have the fields for the product and user id
schema "ratings" do
field(:stars, :integer)
belongs_to(:product, Pento.Catalog.Product)
belongs_to(:user, Pento.Accounts.User)
timestamps(type: :utc_datetime)
end
# Now to deal with the new values in the changeset
@doc false
def changeset(rating, attrs, user_scope) do
rating
|> cast(attrs, [:stars, :product_id])
|> validate_required([:stars, :product_id])
|> validate_inclusion(:stars, 1..5)
|> put_change(:user_id, user_scope.user.id)
# |> unique_constraint([:product_id, :user_id], name: :index_ratings_on_user_product)
|> unique_constraint(:product_id, name: :index_ratings_on_user_product)
end
Now we need to add in the other side of the relationship for the ratings, as we know that the product id that we are injecting into the rating belong to the a product. We need to say that a product will have a rating. pento/lib/pento/catalog/product.ex
has_many(:ratings, Pento.Survey.Rating)
Let's migrate
mix ecto.migrate
We now have all the needed relationships and ownership for all the new databases. Let's do something that we haven't done yet and that is deal with the tests that were made for us.
pento/test/support/fixtures/survey_fixtures.exs
defmodule Pento.SurveyFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Pento.Survey` context.
"""
@doc """
Generate a demographic.
"""
import Pento.CatalogFixtures
def demographic_fixture(scope, attrs \\ %{}) do
attrs =
Enum.into(attrs, %{
gender: "male",
year_of_birth: 1990
})
{:ok, demographic} = Pento.Survey.create_demographic(scope, attrs)
demographic
end
@doc """
Generate a rating.
"""
def rating_fixture(scope, attrs \\ %{}) do
product = product_fixture(scope)
attrs =
Enum.into(attrs, %{
stars: 4,
product_id: product.id
})
{:ok, rating} = Pento.Survey.create_rating(scope, attrs)
rating
end
end
We needed to add in the proper values for the fixtures so that we can run the tests and get the right starting values. Next we should head into the tests themselves. pento/test/pento/survey_test.exs One of the best ways to do this is to run `mix test` and trace the tests that fail. Most of the fixes should be setting a valid rating and gender.
test "create_rating/2 with valid data creates a rating" do
scope = user_scope_fixture()
product = product_fixture(scope)
valid_attrs = %{stars: 2, product_id: product.id}
assert {:ok, %Rating{} = rating} = Survey.create_rating(scope, valid_attrs)
assert rating.stars == 2
assert rating.user_id == scope.user.id
end
# before there wasn't a product_id that we could leverage and we need a scope to create a product.
test "create_demographic/2 with valid data creates a demographic" do
valid_attrs = %{gender: "Male", year_of_birth: 1942}
scope = user_scope_fixture()
assert {:ok, %Demographic{} = demographic} = Survey.create_demographic(scope, valid_attrs)
assert demographic.gender == "Male"
assert demographic.year_of_birth == 1942
assert demographic.user_id == scope.user.id
end
# This test had an invalid gender ("some other gender") and the year_of_birth was only 4 digits. Make sure to change the assert as well.
Explore the Generated Context Schema
Let's try and test out some of the new Schema and Context
iex -S mix
iex> alias Pento.Accounts
Pento.Accounts
iex> user_attrs = %{email: "cassandra@grox.io", password: "Tr0yW1llF8ll"}
%{email: "cassandra@grox.io", password: "Tr0yW1llF8ll"}
iex> {:ok, user} = Accounts.register_user(user_attrs)
...
{:ok,
#Pento.Accounts.User<email: "cassandra@grox.io",id: 1,...>}
# We added a user, and now we can create a demographic for them:
iex> alias Pento.Survey
Pento.Survey
iex> demo_attrs = %{
user_id: user.id,
gender: "prefer not to say",
year_of_birth: 1989
}
%{gender: "prefer not to say", user_id: 1, year_of_birth: 1989}
iex> Survey.create_demographic(demo_attrs)
...
iex> scope = Accounts.get_scope_for_user(user.id)
{:ok,
%Pento.Survey.Demographic{gender: "prefer not to say",id: 1,user_id: 1,...}
}
# We now have a demographic let's add in a rating.
iex> pid = Pento.Catalog.list_products |> hd |> Map.get(:id)
SELECT p0."id", p0."name", p0."description", p0."unit_price", p0."sku", p0."user_id", p0."image_upload", p0."image_url", p0."inserted_at", p0."updated_at" FROM "products" AS p0 []
1
iex> rating_attrs = %{user_id: user.id, product_id: pid, stars: 5}
%{user_id: 3, product_id: 1, stars: 5}
iex> Survey.create_rating(scope, rating_attrs)
{:ok,
%Pento.Survey.Rating{
__meta__: #Ecto.Schema.Metadata<:loaded, "ratings">,
id: 1,
stars: 5,
product_id: 1,
product: #Ecto.Association.NotLoaded<association :product is not loaded>,
user_id: 3,
user: #Ecto.Association.NotLoaded<association :user is not loaded>,
inserted_at: ~U[2025-12-24 00:40:24Z],
updated_at: ~U[2025-12-24 00:40:24Z]
}}
# Now let's try and add in an other rating for the same user.
iex> Survey.create_rating(%{user_id: user.id, product_id: 1, stars: 1})
Organize the Application Core and Boundary
Here is where we learn about custom queries so that we can be sure that we have the right products and reviews for the user.
The demographic section of the survey will need to return the demographic for a given user.
Ratings section will return all products preloaded with a ratings for a given user.
Query for User Demographics
We are going to define a module that will deal with the queries. pento/lib/pento/survey/demographic/query.ex
defmodule Pento.Survey.Demographic.Query do
import Ecto.Query
alias Pento.Survey.Demographic
def base do
from(d in Demographic)
end
def for_user(query, %{user: user}) do
where(query, [d], d.user_id == ^user.id)
end
end
The base is the query that we build from. It will return everything that we want to reduce from. The next for_user will reduce from the demographic into just the ones for the user. The ^ will take an expression and make sure that we inject the value that we want.
Next let's head into the survey.ex context so we can leverage the query. pento/lib/pento/survey.ex
@doc """
Gets a demographic for the given user scope.
Returns nil if no demographic exists for the user.
"""
def get_demographic_by_user(%Scope{} = scope) do
Repo.one(
from(demographic in Demographic,
where: demographic.user_id == ^scope.user.id
)
)
end
Query for Product Ratings
Let's start an other iex session and try to leverage Queryable that is built into the ecto lib
iex -S mix
iex(1)> import Ecto.Query
Ecto.Query
iex(2)> alias Pento.Catalog.Product
Pento.Catalog.Product
iex(3)> alias Pento.Survey.Rating.Query, as: RatingQuery
Pento.Survey.Rating.Query
iex(4)> from(p in Product)
#Ecto.Query<from p0 in Pento.Catalog.Product>
iex(5)> i
Term
#Ecto.Query<from p0 in Pento.Catalog.Product>
Data type
Ecto.Query
Description
This is a struct. Structs are maps with a __struct__ key.
Reference modules
Ecto.Query, Map
Implemented protocols
Ecto.Queryable, IEx.Info, Inspect, Jason.Encoder, Phoenix.Param, Plug.Exception, Swoosh.Email.Recipient, Timex.Protocol
Let's build an other query for the ratings pento/lib/pento/catalog/product/query.ex
defmodule Pento.Catalog.Product.Query do
import Ecto.Query
alias Pento.Catalog.Product
alias Pento.Survey.Rating.Query, as: RatingQuery
def base, do: Product
end
This is the basic version of the query to start. What is nice is that we can make the base query changeable later in the app. Like to avoid archived products or something else. Same file now let's add the part of the query so that we can only show the ones with a rating.
def with_user_ratings(query, user) do
ratings_query = RatingQuery.preload_user(user)
from(p in query, preload: [ratings: ^ratings_query])
end
Preload here is about making sure that we add in the ratings for the user, but we need define that as well. pento/lib/pento/survey/rating/query.ex
defmodule Pento.Survey.Rating.Query do
import Ecto.Query
alias Pento.Survey.Rating
def preload_user(user) do
from(r in Rating, where: r.user_id == ^user.id)
end
end
# Now we can head into the catalog.ex and use the queries.
@doc """
Returns the list of products with user ratings.
## Examples
iex> list_products_with_user_rating(user)
[%Product{}, ...]
"""
def list_products_with_user_rating(user) do
Pento.Catalog.Product.Query.base()
|> Pento.Catalog.Product.Query.with_user_ratings(user)
|> Repo.all()
end
Let's test it out. With that iex session still running
iex(6)> alias Pento.{Survey, Accounts, Catalog}
[Pento.Survey, Pento.Accounts, Pento.Catalog]
iex(7)> user = Accounts.get_user!(1)
[debug] QUERY OK source="users" db=4.2ms decode=1.1ms queue=1.0ms idle=539.6ms
SELECT u0."id", u0."username", u0."email", u0."hashed_password", u0."confirmed_at", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."id" = $1) [1]
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:386
#Pento.Accounts.User<
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
id: 1,
username: nil,
email: "seed@example.com",
confirmed_at: nil,
authenticated_at: nil,
inserted_at: ~U[2025-12-23 21:20:03Z],
updated_at: ~U[2025-12-23 21:20:03Z],
...
>
iex(9)> scope = Accounts.get_scope_for_user(user)
%Pento.Accounts.Scope{
user: #Pento.Accounts.User<
__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
id: 1,
username: nil,
email: "seed@example.com",
confirmed_at: nil,
authenticated_at: nil,
inserted_at: ~U[2025-12-23 21:20:03Z],
updated_at: ~U[2025-12-23 21:20:03Z],
...
>,
user_name_set?: false
}
iex(10)> Survey.create_rating(scope, %{user_id: user.id, product_id: 1, stars: 5})
iex(11)> Catalog.list_products_with_user_rating(user)