We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Chapter 8: Build a Interactive Dashboard
We now have a lot user generated content within the site but we don’t have a way to see things through the lense of an admin. We can’t see things as they come in so let’s work on that now. We will build an interactive Dashboard that will allow us to very the data.
The Plan
We want to be able to:
View the health of out products
Survey Component
Real Time list of users
We will want to start off with the average rating of a product and a chart to show that. It will be it’s own liveview.
Start by using as CRC pattern to define the core module
Wrap it in a live view and then use SVG graphics to chart the results.
Make the chart interactive so that we can get a better and more refined results for the graph.
Then use the __using__ macro to make our chart helper easier to use.
We will need 3 things to make this happen:
First the Admin.DashboardLive.
The route
The Survey data that will come from Admin.SurveyResultsLive
Define the Admin.Dashboard Concepts with Components
First let’s create the liveview with the file pento/lib/pento_web/live/admin/dashboard_live.ex
defmodule PentoWeb.Admin.DashboardLive do
use PentoWeb, :live_view
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:survey_results_component_id, "survey-results")}
end
end
Then we can create the route
live("/admin/dashboard", Admin.DashboardLive)
You want to make sure that this is within the “/“ scope and that we are withing a pipe that requires browser and the :require_authenticated_user, we can go into the requirements for an admin later. Next we can add that into our site menu.
<a href="/admin/dashboard" class="btn btn-ghost"> Dashboard </a>
Now that we have this I think we should talk about when we add in the Admin id what we would want to have in place to differentiate.
A new plug that will authorize an admin or redirects if not.
A new live_session block that will use a different on_mount that authorizes an admin. Maybe even an other UserAuth.on_mount/4 that will use the :admin to pattern match.
Now we can add in the html.heex but again in the current version on this we will use the live_view file that we just created and a render function.
def render(assigns) do
~H```elixir
<section class="row">
<h1 class="font-heavy text-3x1"> Admin Dashboard </h1>
</section>
'''
end
Now we can start up the server and head to the new dashboard.
Represent Dashboard Concepts with Components
We can model each part of the dashboard with a component.
Create a Component Module
We can start off with the survey results module. We will create a new file with the :live_component and the render it with the :survey_results_component_id, this is because we will need a live component not just a functional one as we will need the state of the parent to be able to make it inter-actable. Create this file pento/lib/pento_web/live admin/survey_results_live.ex
defmodule PentoWeb.Admin.SurveyResultsLive do
use PentoWeb, :live_component
def render(assigns) do
~H"""
<section class="ml-8">
<h2 class="font-light text-2xl">Survey Results</h2>
</section>
"""
end
end
Now we can add that to the dashboard. Replace the contents of the render of html.heex with
def render(assigns) do
~H"""
<div class="container mx-auto p-6">
<h1 class="text-3xl font-bold mb-6">Admin Dashboard</h1>
<.live_component
module={PentoWeb.Admin.SurveyResultsLive}
id={@survey_results_component_id}
/>
</div>
"""
end
Fetch Survey Results Data
Okay so now that we have the basic version of the layout let’s start to get data from the demographics and ratings. We will want to create a new function for the Catalog context that will use SQL.
Shape the Data with Ecto
For now we just need to know that we want the data to come to the charts looking like a set of Tuples
[
{"Tic-Tac-Toe", 3.4285714285714284},
{"Table Tennis", 2.5714285714285716},
{"Chess", 2.625}
]
For this we want it to return things that we know will work and can be repeatable. So this is a job for the Core. Use the file that we created pento/lib/pento/catalog/product query.ex
def with_average_ratings(query \\ base()) do
query
|> join_ratings
|> average_ratings
end
defp join_ratings(query) do
query
|> join(:inner, [p], r in Rating, on: r.product_id == p.id)
end
defp average_ratings(query) do
query
|> group_by([p], p.id)
|> select([p, r], {p.name, fragment("?::float", avg(r.stars))})
|> order_by([p, r], [{:asc, p.name}])
end
Test Drive the Query
iex -S mix
iex> alias Pento.Catalog.Product
Pento.Catalog.Product
iex> alias Pento.Repo
Pento.Repo
iex> Product.Query.with_average_ratings() |> Repo.all()
...
[
{"Chess", 5.0}
{"Table Tennis", 2.0},
{"Tic-Tac-Toe", 1.0},
]
This is what we want and as such it should always work as long as the database is setup as we want.
Extend the Catalog Context
Now we get into the part that might not always work or will need some work, the boundary. We might get a {:ok, result} or {:error, reason}. So we have a way to leverage the new query so let’s put it to use within the catalog.ex
@doc """
Returns all the products with a user rating and organizes them by
their rating, this will return their average
## Examples
iex> products_with_average_ratings()
[{"Backgammon", 3.0},
{"Checkers", 4.0},
{"Chess", 5.0},
{"Go", 2.0}]
"""
def products_with_average_ratings do
Product.Query.with_average_ratings()
|> Repo.all()
end
Initialize the Admin.SurveyResultsLive Component State
Okay so now that we have the query and the way in which to use it within the Catalog we can now use it to set the products for our dashboard.
Head to pento/lib/pento_web/live/admin/survey_results_live.ex
alias Pento.Catalog
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_products_with_average_ratings()}
end
def assign_products_with_average_ratings(socket) do
socket
|> assign(
:products_with_average_ratings,
Catalog.products_with_average_ratings()
)
end
Here is a bit of code that you could add to your render (html.heex) that will show the products and rating for the products that you have with ratings in the db.
<.table
id="products"
rows={@products_with_average_ratings}
>
<:col :let={{product, _rating}} label="Name">{product}</:col>
<:col :let={{_product, rating}} label="Rating">{rating}</:col>
</.table>
Render SVG Charts with Contex
Now we want to turn this into a better version with a bar graph. For this we would like to make it server side with a new dependency so let’s head to the mix.exs and add a new one for this very purpose.
defp deps do
[
...,
{:contex, "~> 0.4.0"},
...
]
Then ofc get the new dependencies
mix deps.get
Resolving Hex dependencies...
...
New:
contex 0.4.0
nimble_strftime 0.1.1
* Getting contex (Hex package)
* Getting nimble_strftime (Hex package)
...
Initialize the Dataset
Context (using a CRC) provides us with a Dataset module to produce structs describing the state of the chart. Then the reducer manipulates the data, and the converter will make it into different charts.
Let’s head back to survey_results_live.ex and start to leverage the new data and library.
defmodule PentoWeb.Admin.SurveyResultsLive do
use PentoWeb, :live_component
alias Pento.Catalog
...
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_products_with_average_ratings()
|> assign_dataset()}
end
def assign_products_with_average_ratings(socket) do
socket
|> assign(
:products_with_average_ratings,
Catalog.products_with_average_ratings()
)
end
def assign_dataset(
%{
assigns: %{
products_with_average_ratings: products_with_average_ratings
}
} = socket
) do
socket
|> assign(
:dataset,
make_bar_chart_dataset(products_with_average_ratings)
)
end
defp make_bar_chart_dataset(data) do
Contex.Dataset.new(data)
end
end
We have now used the new query that we made before to assigns the dataset to the socket, with the mar_bar_chart_dataset function that we also just made.
Initialize the BarChart
Now we can use that new data set into a bar chart with an other Contex function.
defp assign_chart(%{assigns: %{dataset: dataset}} = socket) do
socket
|> assign(:chart, make_bar_chart(dataset))
end
defp make_bar_chart(dataset) do
Context.BarChart.new(dataset)
end
This is how the data will look like
%Contex.Dataset{
data: [
{"Tic-Tac-Toe", 3.4285714285714284},
{"Table Tennis", 2.5714285714285716},
{"Chess", 2.625}
],
headers: nil,
title: nil
}
Now we can add those new datasets to the assigns.
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_products_with_average_ratings()
|> assign_dataset()
|> assign_chart}
end
The dataset for the BarChart will have columns that will look like
column_map: %{category_col: 0, value_cols: [1]}
This will help to make the columns for the bar chart.
Transform the Chart to SVG
Now we can add some SVG (Scalable Vector Graphics) This will involve some new helper functions as well as some Contex.Plot built-in functions. We will start off with the helper and plotters.
defmodule PentoWeb.Admin.SurveyResultsLive do
...
alias Contex.Plot
...
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_products_with_average_ratings()
|> assign_dataset()
|> assign_chart
|> assign_chart_svg()}
end
...
defp assign_chart(%{assigns: %{dataset: dataset}} = socket) do
socket
|> assign(:chart, make_bar_chart(dataset))
end
defp assign_chart_svg(%{assigns: %{chart: chart}} = socket) do
socket
|> assign(:chart_svg, render_bar_chart(chart))
end
defp make_bar_chart(dataset) do
Contex.BarChart.new(dataset)
end
defp render_bar_chart(chart) do
# This will make a
Plot.new(500, 400, chart)
|> Plot.titles(title(), subtitle())
|> Plot.axis_labels(x_axis(), y_axis())
|> Plot.to_svg()
end
defp title do
"Product Ratings"
end
defp subtitle do
"average star ratings per product"
end
defp x_axis do
"products"
end
defp y_axis do
"stars"
end
end
This is all that we need in order to get the data ready for the chart. We will need to now render the chart on our page with the render or html.heex Put this at the bottom of the section block.
<div id="survey-results-chart" class="w-full overflow-x-auto">
{@chart_svg}
</div>
They also created some style for the chart that we can put into the assets/app.css, put this in its entirety into that file
survey-component-container {
background-color: #fefefe;
padding: 20px;
border: 1px solid #888;
width: 80%;
margin-bottom: 20px;
}
.survey-component-container label {
padding: 10px;
}
.survey-component-container input {
margin-right: 10px;
}
.survey-component-container select {
margin-right: 10px;
}
.survey-component-container h4 {
font-weight: bold;
}
.fa.fa-star.checked {
color: orange;
}
.fa.fa-star {
padding: 3px;
}
.survey-component-container ul li {
list-style: none;
}
.fa.fa-check.survey {
color: green;
}
.exc-tick {
stroke: grey;
}
.exc-tick text {
fill: grey;
stroke: none;
font-size: 1.0rem;
}
.exc-grid {
stroke: lightgrey;
}
.exc-legend {
stroke: black;
}
.exc-legend text {
fill: grey;
font-size: 1.5rem;
stroke: none;
}
.exc-title {
fill: darkslategray;
font-size: 1.5rem;
stroke: none;
padding-bottom: 10px;
}
.exc-subtitle {
fill: darkgrey;
font-size: 1rem;
stroke: none;
}
.exc-domain {
stroke: rgb(207, 207, 207);
}
.exc-barlabel-in {
fill: white;
font-size: 1.0rem;
}
.exc-barlabel-out {
fill: grey;
font-size: 0.7rem;
}
.float-container {
padding: 20px;
}
.float-child {
width: 33%;
float: left;
padding: 20px;
}
#survey-results-component {
border: 1px solid;
}
#survey-results-chart {
padding-right: 200px;
}
.survey-results-filters {
padding-left: 2000px;
}
.user-activity-component,
.product-sales-component {
border: 1px solid;
padding: 10px;
margin-top: 30px;
margin-bottom: 30px;
}
.user-activity-component h2,
h3 {
background: rebeccapurple;
color: white;
padding: 10px;
}
.user-activity-component ul,
p {
padding-left: 20px;
}
Add Filters to Make Charts Interactive
Now we get to move on to make it more interactive. We will work on ways of filtering out data within our data set.
Filter By Age Group
We will want to build a form that will test for various age groups so that we can filter out data that we don’t wan’t. It will be in the form of a dropdown that we can choose from.
Establish Test Data
For this to work better we will want to build a large variety of data that we can use for this. In this case we go back to the pento/priv/repo/rating_seeds.exs (You will need to make this file),
# ---
# Excerpted from "Programming Phoenix LiveView",
# published by The Pragmatic Bookshelf.
# Copyrights apply to this code. It may not be used to create training material,
# courses, books, articles, and the like. Contact us if you are in doubt.
# We make no guarantees that this code is fit for any purpose.
# Visit https://pragprog.com/titles/liveview for more book information.
# ---
# Run this script with: mix run priv/repo/rating_seeds.exs
alias Pento.{Repo, Accounts, Survey}
alias Pento.Accounts.{User, Scope}
alias Pento.Survey.{Demographic, Rating}
alias Pento.Catalog.Product
users =
for i <- 1..43,
do:
Accounts.register_user(%{
email: "user#{i}@example.com",
password: "passwordpassword"
})
user_ids = Repo.all(User) |> Enum.map(& &1.id)
product_ids = Repo.all(Product) |> Enum.map(& &1.id)
genders = ["male", "female", "other", "prefer not to say"]
years = 1950..2005 |> Enum.to_list()
stars = 1..5 |> Enum.to_list()
for user_id <- user_ids do
scope = %Scope{user: %User{id: user_id}}
gender = Enum.random(genders)
year_of_birth = Enum.random(years)
Survey.create_demographic(scope, %{
gender: gender,
year_of_birth: year_of_birth
})
end
# Create ratings for users and products
for user_id <- user_ids,
product_id <- product_ids,
Enum.random([true, false, false]) do
# Check if rating already exists
existing = Repo.get_by(Rating, user_id: user_id, product_id: product_id)
unless existing do
scope = %Scope{user: %User{id: user_id}}
%Rating{}
|> Rating.changeset(
%{
user_id: user_id,
product_id: product_id,
stars: Enum.random(stars)
},
scope
)
|> Repo.insert()
end
end
Then you can run the script with
mix run priv/repo/rating_seeds.exs
Build the Age Group Query Filters
Now we can start to use more of the query.ex to add more filters to data set and then get the right users, with a variable for the age group.
def join_users(query \\ base()) do
query
|> join(:left, [p, r], u in User, on: r.user_id == u.id)
end
def join_demographics(query \\ base()) do
query
|> join(:left, [p, r, u], d in Demographic, on: d.user_id == u.id)
end
def filter_by_age_group(query \\ base(), filter) do
query
|> apply_age_group_filter(filter)
end
To go over this a bit we have:
p for product, r for results, u for user and d for demographics
join(:left, [p,r,u, d], d in Demographic, on: d.user_id == u.id)
means:
a :left join
that returns [products, results, users, and demographics]
where the id on the user is the same as the user_id on the demographic
Now we can build the reducer to filter by the age group.
def filter_by_age_group(query \\ base(), filter) do
query
|> apply_age_group_filter(filter)
end
defp apply_age_group_filter(query, "18 and under") do
birth_year = DateTime.utc_now().year - 18
query
|> where([p, r, u, d], d.year_of_birth >= ^birth_year)
end
defp apply_age_group_filter(query, "18 to 25") do
birth_year_max = DateTime.utc_now().year - 18
birth_year_min = DateTime.utc_now().year - 25
query
|> where(
[p, r, u, d],
d.year_of_birth >= ^birth_year_min and d.year_of_birth <= ^birth_year_max
)
end
defp apply_age_group_filter(query, "25 to 35") do
birth_year_max = DateTime.utc_now().year - 25
birth_year_min = DateTime.utc_now().year - 35
query
|> where(
[p, r, u, d],
d.year_of_birth >= ^birth_year_min and d.year_of_birth <= ^birth_year_max
)
end
defp apply_age_group_filter(query, "35 and up") do
birth_year = DateTime.utc_now().year - 35
query
|> where([p, r, u, d], d.year_of_birth <= ^birth_year)
end
defp apply_age_group_filter(query, _filter) do
query
end
This is a lot of the same code but we are doing it based off the different age groups that we want to filter by. Now we can go back to the catalog.ex and add in the new queries.
@doc """
Returns all the ratings for a set of products based off the age group
that we filter it with.
## Examples
iex> products_with_average_ratings(%{age_group_filter: "18-25"})
[{"Backgammon", 3.0},
{"Checkers", 4.0},
{"Chess", 5.0},
{"Go", 2.0}]
"""
def products_with_average_ratings(%{
age_group_filter: age_group_filter
}) do
Product.Query.with_average_ratings()
|> Product.Query.join_users()
|> Product.Query.join_demographics()
|> Product.Query.filter_by_age_group(age_group_filter)
|> Repo.all()
end
Your Turn: Test Drive the Query
iex -S mix
iex(1)> alias Pento.Catalog
Pento.Catalog
iex(2)> Catalog.products_with_average_ratings(%{age_group_filter: "18-25"})
[debug] QUERY OK source="products" db=1.2ms decode=1.3ms queue=1.6ms idle=1173.7ms
SELECT p0."name", avg(r1."stars")::float FROM "products" AS p0 INNER JOIN "ratings" AS r1 ON r1."product_id" = p0."id" LEFT OUTER JOIN "users" AS u2 ON r1."user_id" = u2."id" LEFT OUTER JOIN "demographics" AS d3 ON d3."user_id" = u2."id" GROUP BY p0."id" ORDER BY p0."name" []
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:386
[
{"Backgammon", 3.375},
{"Checkers", 3.2666666666666666},
{"Chess", 3.857142857142857},
{"Go", 2.6315789473684212}
]
That should be how the data is returned to you with the new function.
Add the Age Group Filter to Component State
Now that we have this query and function setup we will need to be able to use it to filter data. With that being said we will want to set the initial state of the query for when we first join the page and then update it as we change the form. We will want to do the following:
Set the initial state to "all"
Display a drop down for the different filters.
Respond to the event that it changes with an other call to Pento.Catalog.products_with_average_ratings(filter)
Let’s add the age_group_filter to the assigns. pento/lib/pento_web/live/admin survey_results_live.ex
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_age_group_filter()
|> assign_products_with_average_ratings()
|> assign_dataset()
|> assign_chart()
|> assign_chart_svg()}
end
def assign_age_group_filter(socket) do
socket
|> assign(:age_group_filter, "all")
end
def assign_products_with_average_ratings(
%{assigns: %{age_group_filter: age_group_filter}} = socket
) do
socket
|> assign(
:products_with_average_ratings,
Catalog.products_with_average_ratings(%{age_group_filter: age_group_filter})
)
end
This took the standard way to assign the ratings and then made it so we can leverage the filter that we will set. This will not change anything on the page until we have dealt with more events and then set the form for the age-group.
Send Age Group Filter Events
Let’s start out by building the html for the form that we will build.
<section class="ml-8">
<h2 class="font-light text-2xl">Survey Results</h2>
<div id="survey-results-component">
<div class="container">
<div class="form-control w-full">
<.form
for={%{}}
as={:age_group_filter}
phx-change="age_group_filter"
phx-target={@myself}
id="age-group-form"
>
<label class="label">
<span class="label-text text-sm">By age group:</span>
</label>
<select
name="age_group_filter"
id="age_group_filter"
class="select select-bordered select-sm w-full"
>
<%= for age_group <-
["all", "18 and under", "18 to 25", "25 to 35", "35 and up"] do %>
<option value={age_group} selected={@age_group_filter == age_group}>
{age_group}
</option>
<% end %>
</select>
</.form>
</div>
</div>
</div>
<div id="survey-results-chart">
{@chart_svg}
</div>
</section>
For this we are not having to run a changeset for each change and as such we are asking it to build a map for us. We also didn’t need to create a form for the filter as again there is no need. We do need to worry about what kind of data that we are getting from the form. Open up an iex session with these inputs.
iex
iex> i %{}
Term
%{}
...
Implemented protocols:
Collectable, Enumerable, IEx.Info, ... Phoenix.HTML.FormData, ...
iex> i Pento.Catalog.change_product(%Pento.Catalog.Product{}, %{id: 1})
Term
#Ecto.Changeset<...>
Implemented protocols
IEx.Info, Inspect, Jason.Encoder, ..., Phoenix.HTML.FormData, ...
Notice how both the %{} and the change_product both implement the Phoenix.HTML.FormData
Okay now that we have the right data set and we are leveraging the phx-change to deal with any change to the form we will not be able to start the event-handler for the “age_group_filer” event.
@impl true
def handle_event("age_group_filter", %{"age_group_filter" => age_group}, socket) do
{:noreply,
socket
|> assign_age_group_filter(age_group)
|> assign_products_with_average_ratings()
|> assign_dataset()
|> assign_chart()
|> assign_chart_svg()}
end
def assign_age_group_filter(socket, age_group) do
socket
|> assign(:age_group_filter, age_group)
end
Okay last but not least there is an event that will crash the page and that is when nil results are returned as the Context will break. So we need to add in some logic for that. First we head to
#query.ex
def with_zero_ratings(query \\ base()) do
query
|> select([p], {p.name, 0})
end
# catalog.ex
@doc """
Returns all the products when we pass in a set of values that will not
return any products
## Examples
iex> products_with_zero_ratings()
[{"Backgammon", 3.0},
{"Checkers", 4.0},
{"Chess", 5.0},
{"Go", 2.0}]
"""
def products_with_zero_ratings do
Product.Query.with_zero_ratings()
|> Repo.all()
end
# survey_results_live.ex
def assign_products_with_average_ratings(
%{assigns: %{age_group_filter: age_group_filter}} =
socket
) do
assign(
socket,
:products_with_average_ratings,
get_products_with_average_ratings(%{age_group_filter: age_group_filter})
)
end
defp get_products_with_average_ratings(filter) do
case Catalog.products_with_average_ratings(filter) do
[] ->
Catalog.products_with_zero_ratings()
products ->
products
end
end
Refactor Chart Code with Macros
Here is where the good stuff happens. We are going to start to use macros to make even more happen. We will can start to pull out some code in a using macro.
Refactor with using
At the very top od the every live view we have the line use PentoWeb, :liveview. The use directive calls the _using macro that in turns injects code into our live view modules. That is calling the pento/lib/pento_web.ex
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
...
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
You can see at the end there is a defmacro using, again this is a way to inject code into a file or block of code.
use PentoWeb, :live_view
# calls
__using__ function with a which value of :live_view
# calls
live_view
# calls
quote do
use Phoenix.LiveView
unquote(html_helpers())
Extract Common Helpers
First we want to define a new file that will hold everything that we want in the code. pento/lib/pento_web/bar_chart.ex
defmodule PentoWeb.BarChart do
alias Contex.{Dataset, BarChart, Plot}
def make_bar_chart_dataset(data) do
Dataset.new(data)
end
def make_bar_chart(dataset) do
BarChart.new(dataset)
end
def render_bar_chart(chart, title, subtitle, x_axis, y_axis) do
Plot.new(500, 400, chart)
|> Plot.titles(title, subtitle)
|> Plot.axis_labels(x_axis, y_axis)
|> Plot.to_svg()
end
end
This took all our chart-specific functions and added them all into one. There might be some things that we don’t want hard coded within this so we didn’t include the x_axis and y_axis names etc.
Import the Charting Module
Okay so now we need to deal with the code that will import the code to live. Quote is responsible for injecting code, so we can head to pento_web.ex and add in a function chart_helpers.
defp chart_helpers do
quote do
import PentoWeb.BarChart
end
end
This is a way to be sure that when we call the macro we will use the right import
Inject the Code with using
Now we can make the using definition so that it will call the chart_helpers and the other code we need.
def chart_live do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
unquote(chart_helpers())
end
end
This just added a way to inject the code that is within the bar_chart.ex
Use the Macro
Now we can leverage that within the survey_results_live.ex add this line to the top
use PentoWeb, :chart_live
We now can remove the make_bar_chart_dataset/1, make_bar_chart/2 and render_bar_chart/5 so that the assign_chart_svg/1 looks like this.
defp assign_chart_svg(%{assigns: %{chart: chart}} = socket) do
socket
|> assign(:chart_svg, render_bar_chart(chart, title(), subtitle(), x_axis(), y_axis()))
end
That is all you need, you were able to make a new macro that injects all the code needed to deal with a bar graph.