Home Posts Post Search Tag Search

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)