This is a log
of the steps taken
to build the MVP. 🚀
It took us hours
to write it,
but you can
speedrun
it in 20 minutes. 🏁
Note: we have referenced sections in our more extensive tutorials/examples to keep this doc DRY.
You don't have to follow every step in the other tutorials/examples, but they are linked in case you get stuck.
In this log we have written the "CRUD" functions first
and then built the UI.
We were able to do this because we had a good idea
of which functions we were going to need.
If you are reading through this
and scratching your head
wondering where a particular function will be used,
simply scroll down to the UI section
where (hopefully) it will all be clear.
At the end of each step, remember to run the tests:
mix test
This will help you keep track of where you are and retrace your steps if something is not working as expected.
We suggest keeping two terminal tabs/windows running
one for the server mix phx.server
and the other for the tests.
That way you can also see the UI as you progress.
We created a separate
document detailing the implementation of the API
.
Please see:
API.md
.
With that in place, let's get building!
- Build Log 👩💻
- 1. Create a New
Phoenix
App - 2. Create Schemas to Store Data
- 3. Input
items
- 4. Create
Timer
- 5.
items
withtimers
- 6. Add Authentication
- 7. Create
LiveView
Functions - 8. Implement the
LiveView
UI Template - 9. Update the
LiveView
Template - 10. Filter Items
- 11. Tags
- 12. Editing timers
- 13. Tracking changes of
items
in database - 14. Adding a dashboard to track metrics
- 15. Implementing Enhanced Tag Details and Sorting
- 16.
People
in Different Timezones 🌐 - 17.
Lists
- 18. Reordering
items
Using Drag & Drop - 19. Run the Finished MVP App!
- Thanks!
Open your terminal and
create a new Phoenix
app
with the following command:
mix phx.new app --no-mailer --no-dashboard --no-gettext
When asked to install the dependencies,
type Y
and [Enter]
(to install everything).
The MVP won't
send emails,
display dashboards
or translate to other languages
(sorry).
All of those things
will be in the main
dwyl/app.
We're excluding them here
to reduce complexity/dependencies.
Run the Phoenix
app with the command:
cd app
mix phx.server
You should see output similar to the following in your terminal:
Generated app app
[info] Running AppWeb.Endpoint with cowboy 2.9.0 at 127.0.0.1:4000 (http)
[info] Access AppWeb.Endpoint at http://localhost:4000
[debug] Downloading esbuild from https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.29.tgz
[watch] build finished, watching for changes...
That's a good sign, esbuild
was downloaded
and the assets were compiled successfully.
Visit
localhost:4000
from your browser.
You should see something similar to the following
(default Phoenix
homepage):
To run the tests with
You should see output similar to:
...
Finished in 0.1 seconds (0.07s async, 0.07s sync)
3 tests, 0 failures
That tells us everything is working as expected. 🚀
If you prefer to see test coverage - we certainly do -
then you will need to add a few lines to the
mix.exs
file. You need to do two things:
add a coveralls.json
file (copy the contents)
and change mix.exs
file according to
commit #d6ab5ef7c
(add alias in defp aliases do
list with the mix c
command).
This is just standard Phoenix
project setup for us,
so we don't duplicate any of the steps here.
For more detail, please see:
Automated Testing
in the
dwyl/phoenix-chat-example
and specifically
What is not tested?
With that setup, run:
mix c
You should see output similar to the following:
With the release of
Phoenix 1.7
,
Tailwind CSS
is automatically built-in.
So if you are starting the project with the new
phx.new
generator,
you don't need to follow any steps.
Otherwise,
please refer to:
Tailwind in Phoenix.
Setting up should only take ~1 minute
.
By the end of this step you should have Tailwind
working.
When you visit
localhost:4000
in your browser,
you should see:
If you got stuck in this section, please retrace the steps and open an issue: learn-tailwind/issues
Create the lib/app_web/live
directory
and the controller at lib/app_web/live/app_live.ex
:
defmodule AppWeb.AppLive do
use AppWeb, :live_view
def mount(_params, _session, socket) do
{:ok, socket}
end
end
Create the lib/app_web/views/app_view.ex
file:
defmodule AppWeb.AppView do
use AppWeb, :view
end
Next, create the
lib/app_web/live/app_live.html.heex
file
and add the following line of HTML
:
<h1 class="">LiveView App Page!</h1>
Finally, to make the root layout simpler,
open the
lib/app_web/templates/layout/root.html.heex
file and
update the contents of the <body>
to:
<body>
<header>
<section class="container">
<h1>App MVP Phoenix</h1>
</section>
</header>
<%= @inner_content %>
</body>
Now that you've created the necessary files,
open the router
lib/app_web/router.ex
replace the default route PageController
controller:
get "/", PageController, :index
with AppLive
controller:
scope "/", AppWeb do
pipe_through :browser
live "/", AppLive
end
Now if you refresh the page you should see the following:
At this point we have made a few changes that mean our automated test suite will no longer pass ... Run the tests in your command line with the following command:
mix test
You should see the tests fail:
..
1) test GET / (AppWeb.PageControllerTest)
test/app_web/controllers/page_controller_test.exs:4
Assertion with =~ failed
code: assert html_response(conn, 200) =~ "Hello TailWorld!"
left: "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">
<main class=\"container\">
<h1 class=\"text-6xl text-center\">LiveView App Page!</h1>\n</main></div>
</body>\n</html>"
right: "Hello TailWorld!"
stacktrace:
test/app_web/controllers/page_controller_test.exs:6: (test)
Finished in 0.1 seconds (0.06s async, 0.1s sync)
3 tests, 1 failure
Create a new directory: test/app_web/live
Then create the file:
test/app_web/live/app_live_test.exs
With the following content:
defmodule AppWeb.AppLiveTest do
use AppWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, "/")
assert html_response(conn, 200) =~ "LiveView App Page!"
end
end
Save the file,
delete the test/app_web/controllers/page_controller_test.exs
(it has the failing test on an unused component)
and re-run the tests: mix test
You should see output similar to the following:
Generated app app
The database for App.Repo has been dropped
...
Finished in 0.1 seconds (0.08s async, 0.1s sync)
3 tests, 0 failures
Randomized with seed 796477
Since we won't be using the page
in our App,
we can delete the default files created by Phoenix
:
lib/app_web/views/page_view.ex
lib/app_web/controllers/page_controller.ex
lib/app_web/templates/page/index.html.heex
With those files deleted,
our Phoenix + LiveView
project
is now fully setup
and ready to start building!
Create database schemas
to store the data
with the following
mix phx.gen.schema
commands:
mix phx.gen.schema Item items person_id:integer status:integer text:string
mix phx.gen.schema Timer timers item_id:references:items start:naive_datetime stop:naive_datetime
At the end of this step, we have the following database Entity Relationship Diagram (ERD):
We created 2 database tables;
items
and timers
.
Let's run through them.
This is a quick breakdown of the schemas created above:
An item
is the most basic unit of content.
An item
is just a String
of text
.
Later we will be able to
e.g: a "note", "task", "reminder", etc.
The name item
is deliberately generic
as it maintains complete flexibility
for what we are building later on.
id
:Int
- the auto-incrementingid
.text
:Binary
(encrypted) - the free text you want to capture.person_id
:Integer
the "owner" of theitem
)status
:Integer
thestatus
of theitem
e.g:3
=active
inserted_at
:NaiveDateTime
- created/managed byPhoenix
updated_at
:NaiveDateTime
A timer
is associated with an item
to track how long it takes to complete.
id
:Int
item_id
(Foreign Keyitem.id
)start
:NaiveDateTime
- start time for the timerstop
:NaiveDateTime
- stop time for the timerinserted_at
:NaiveDateTime
- record insertion timeupdated_at
:NaiveDateTime
An item
can have zero or more timers
.
Each time an item
(task
) is worked on
a new timer
is created/started (and stopped).
Meaning a person
can split the completion
of an item
(task
) across multiple sessions.
That allows us to get a running total
of the amount of time that has
been taken.
Once we've created the required schemas, several new files are created. If we run the tests with coverage:
mix c
We note that the test coverage has dropped considerably:
Finished in 0.1 seconds (0.08s async, 0.09s sync)
3 tests, 0 failures
----------------
COV FILE LINES RELEVANT MISSED
0.0% lib/app/item.ex 19 2 2
0.0% lib/app/timer.ex 20 2 2
100.0% lib/app_web/live/app_live.ex 11 2 0
100.0% lib/app_web/router.ex 18 2 0
100.0% lib/app_web/views/error_view.ex 16 1 0
[TOTAL] 38.5%
----------------
Specifically the files:
lib/app/item.ex
and
lib/app/timer.ex
have zero test coverage.
We will address this test coverage shortfall in the next section. Yes, we know this is not "TDD" because we aren't writing the tests first. But by creating database schemas, we have a scaffold for the next stage. See: https://en.wikipedia.org/wiki/Scaffold_(programming)
We're going to
Create the directory test/app
and file:
test/app/item_test.exs
with the following code:
defmodule App.ItemTest do
use App.DataCase
alias App.{Item, Timer}
describe "items" do
@valid_attrs %{text: "some text", person_id: 1, status: 2}
@update_attrs %{text: "some updated text"}
@invalid_attrs %{text: nil}
test "get_item!/1 returns the item with given id" do
{:ok, item} = Item.create_item(@valid_attrs)
assert Item.get_item!(item.id).text == item.text
end
test "create_item/1 with valid data creates a item" do
assert {:ok, %Item{} = item} = Item.create_item(@valid_attrs)
assert item.text == "some text"
inserted_item = List.first(Item.list_items())
assert inserted_item.text == @valid_attrs.text
end
test "create_item/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} =
Item.create_item(@invalid_attrs)
end
test "list_items/0 returns a list of items stored in the DB" do
{:ok, _item1} = Item.create_item(@valid_attrs)
{:ok, _item2} = Item.create_item(@valid_attrs)
assert Enum.count(Item.list_items()) == 2
end
test "update_item/2 with valid data updates the item" do
{:ok, item} = Item.create_item(@valid_attrs)
assert {:ok, %Item{} = item} = Item.update_item(item, @update_attrs)
assert item.text == "some updated text"
end
end
end
The first five tests are basic CRUD.
If you run these tests:
mix test test/app/item_test.exs
You will see all the testes fail. This is expected as the code is not there yet!
Open the
lib/app/item.ex
file and replace the contents
with the following code:
defmodule App.Item do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias App.Repo
alias __MODULE__
schema "items" do
field :person_id, :integer
field :status, :integer
field :text, :string
timestamps()
end
@doc false
def changeset(item, attrs) do
item
|> cast(attrs, [:person_id, :status, :text])
|> validate_required([:text])
end
@doc """
Creates a item.
## Examples
iex> create_item(%{text: "Learn LiveView"})
{:ok, %Item{}}
iex> create_item(%{text: nil})
{:error, %Ecto.Changeset{}}
"""
def create_item(attrs) do
%Item{}
|> changeset(attrs)
|> Repo.insert()
end
@doc """
Gets a single item.
Raises `Ecto.NoResultsError` if the Item does not exist.
## Examples
iex> get_item!(123)
%Item{}
iex> get_item!(456)
** (Ecto.NoResultsError)
"""
def get_item!(id), do: Repo.get!(Item, id)
@doc """
Returns the list of items where the status is different to "deleted"
## Examples
iex> list_items()
[%Item{}, ...]
"""
def list_items do
Item
|> order_by(desc: :inserted_at)
|> where([i], is_nil(i.status) or i.status != 6)
|> Repo.all()
end
@doc """
Updates an `item`.
## Examples
iex> update_item(item, %{field: new_value})
{:ok, %Item{}}
iex> update_item(item, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_item(%Item{} = item, attrs) do
item
|> Item.changeset(attrs)
|> Repo.update()
end
# soft delete an item:
def delete_item(id) do
get_item!(id)
|> Item.changeset(%{status: 6})
|> Repo.update()
end
end
Once you have saved the file, re-run the tests. They should now pass.
Open the test/app/timer_test.exs
file and add the following tests:
defmodule App.TimerTest do
use App.DataCase
alias App.{Item, Timer}
describe "timers" do
@valid_item_attrs %{text: "some text", person_id: 1}
test "Timer.start/1 returns timer that has been started" do
{:ok, item} = Item.create_item(@valid_item_attrs)
assert Item.get_item!(item.id).text == item.text
started = NaiveDateTime.utc_now()
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: started})
assert NaiveDateTime.diff(timer.start, started) == 0
end
test "Timer.stop/1 stops the timer that had been started" do
{:ok, item} = Item.create_item(@valid_item_attrs)
assert Item.get_item!(item.id).text == item.text
{:ok, started} =
NaiveDateTime.new(Date.utc_today, Time.add(Time.utc_now, -1))
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: started})
assert NaiveDateTime.diff(timer.start, started) == 0
ended = NaiveDateTime.utc_now()
{:ok, timer} = Timer.stop(%{id: timer.id, stop: ended})
assert NaiveDateTime.diff(timer.stop, timer.start) == 1
end
test "stop_timer_for_item_id(item_id) should stop the active timer (happy path)" do
{:ok, item} = Item.create_item(@valid_item_attrs)
{:ok, seven_seconds_ago} =
NaiveDateTime.new(Date.utc_today, Time.add(Time.utc_now, -7))
# Start the timer 7 seconds ago:
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago})
# stop the timer based on it's item_id
Timer.stop_timer_for_item_id(item.id)
stopped_timer = Timer.get_timer!(timer.id)
assert NaiveDateTime.diff(stopped_timer.start, seven_seconds_ago) == 0
assert NaiveDateTime.diff(stopped_timer.stop, stopped_timer.start) == 7
end
test "stop_timer_for_item_id(item_id) should not explode if there is no timer (unhappy path)" do
zero_item_id = 0 # random int
Timer.stop_timer_for_item_id(zero_item_id)
assert "Don't stop believing!"
end
test "stop_timer_for_item_id(item_id) should not melt down if item_id is nil (sad path)" do
nil_item_id = nil # random int
Timer.stop_timer_for_item_id(nil_item_id)
assert "Keep on truckin'"
end
end
end
Open the lib/app/timer.ex
file
and replace the contents with the following code:
defmodule App.Timer do
use Ecto.Schema
import Ecto.Changeset
# import Ecto.Query
alias App.Repo
alias __MODULE__
require Logger
schema "timers" do
field :item_id, :id
field :start, :naive_datetime
field :stop, :naive_datetime
timestamps()
end
@doc false
def changeset(timer, attrs) do
timer
|> cast(attrs, [:item_id, :start, :stop])
|> validate_required([:item_id, :start])
end
@doc """
`get_timer/1` gets a single Timer.
Raises `Ecto.NoResultsError` if the Timer does not exist.
## Examples
iex> get_timer!(123)
%Timer{}
"""
def get_timer!(id), do: Repo.get!(Timer, id)
@doc """
`start/1` starts a timer.
## Examples
iex> start(%{item_id: 1, })
{:ok, %Timer{start: ~N[2022-07-11 04:20:42]}}
"""
def start(attrs \\ %{}) do
%Timer{}
|> changeset(attrs)
|> Repo.insert()
end
@doc """
`stop/1` stops a timer.
## Examples
iex> stop(%{id: 1})
{:ok, %Timer{stop: ~N[2022-07-11 05:15:31], etc.}}
"""
def stop(attrs \\ %{}) do
get_timer!(attrs.id)
|> changeset(%{stop: NaiveDateTime.utc_now})
|> Repo.update()
end
@doc """
`stop_timer_for_item_id/1` stops a timer for the given item_id if there is one.
Fails silently if there is no timer for the given item_id.
## Examples
iex> stop_timer_for_item_id(42)
{:ok, %Timer{item_id: 42, stop: ~N[2022-07-11 05:15:31], etc.}}
"""
def stop_timer_for_item_id(item_id) when is_nil(item_id) do
Logger.debug("stop_timer_for_item_id/1 called without item_id: #{item_id} fail.")
end
def stop_timer_for_item_id(item_id) do
# get timer by item_id find the latest one that has not been stopped:
sql = """
SELECT t.id FROM timers t
WHERE t.item_id = $1
AND t.stop IS NULL
ORDER BY t.id
DESC LIMIT 1;
"""
res = Ecto.Adapters.SQL.query!(Repo, sql, [item_id])
if res.num_rows > 0 do
# IO.inspect(res.rows)
timer_id = res.rows |> List.first() |> List.first()
Logger.debug("Found timer.id: #{timer_id} for item: #{item_id}, attempting to stop.")
stop(%{id: timer_id})
else
Logger.debug("No active timers found for item: #{item_id}")
end
end
end
The first few functions are simple again.
The more advanced function is stop_timer_for_item_id/1
.
The reason for the function is,
as it's name suggests,
to stop a timer
for an item
by its' item_id
.
We have written the function using "raw" SQL
so that it's easier for people who are new
to Phoenix
, and specifically Ecto
to understand.
The interesting thing we are UX-testing in the MVP
is the combination of (todo list) items
and timers
.
So we need a way of:
a. Selecting all the timers
for a given item
b. Accumulating the timers
for the item
Note: We would have loved to find a single
Ecto
function to do this, but we didn't. If you know of one, please share!
This might feel like we are working in reverse,
that's because we are!
We are working back from our stated goal
of accumulating all the timer
for a given item
so that we can display a single elapsed time
when an item
has had more than one timer.
Open the
test/app/item_test.exs
file and add the following block of test code:
describe "accumulate timers for a list of items #103" do
test "accumulate_item_timers/1 to display cumulative timer" do
# https://hexdocs.pm/elixir/1.13/NaiveDateTime.html#new/2
# "Add" -7 seconds: https://hexdocs.pm/elixir/1.13/Time.html#add/3
{:ok, seven_seconds_ago} =
NaiveDateTime.new(Date.utc_today, Time.add(Time.utc_now, -7))
# this is the "shape" of the data that items_with_timers/1 will return:
items_with_timers = [
%{
stop: nil,
id: 3,
start: nil,
text: "This item has no timers",
timer_id: nil
},
%{
stop: ~N[2022-07-17 11:18:10.000000],
id: 2,
start: ~N[2022-07-17 11:18:00.000000],
text: "Item #2 has one active (no end) and one complete timer should total 17sec",
timer_id: 3
},
%{
stop: nil,
id: 2,
start: seven_seconds_ago,
text: "Item #2 has one active (no end) and one complete timer should total 17sec",
timer_id: 4
},
%{
stop: ~N[2022-07-17 11:18:31.000000],
id: 1,
start: ~N[2022-07-17 11:18:26.000000],
text: "Item with 3 complete timers that should add up to 42 seconds elapsed",
timer_id: 2
},
%{
stop: ~N[2022-07-17 11:18:24.000000],
id: 1,
start: ~N[2022-07-17 11:18:18.000000],
text: "Item with 3 complete timers that should add up to 42 seconds elapsed",
timer_id: 1
},
%{
stop: ~N[2022-07-17 11:19:42.000000],
id: 1,
start: ~N[2022-07-17 11:19:11.000000],
text: "Item with 3 complete timers that should add up to 42 seconds elapsed",
timer_id: 5
}
]
# The *interesting* timer is the *active* one (started seven_seconds_ago) ...
# The "hard" part to test in accumulating timers are the *active* ones ...
acc = Item.accumulate_item_timers(items_with_timers)
item_map = Map.new(acc, fn item -> {item.id, item} end)
item1 = Map.get(item_map, 1)
item2 = Map.get(item_map, 2)
item3 = Map.get(item_map, 3)
# It's easy to calculate time elapsed for timers that have an stop:
assert NaiveDateTime.diff(item1.stop, item1.start) == 42
# This is the fun one that we need to be 17 seconds:
assert NaiveDateTime.diff(NaiveDateTime.utc_now(), item2.start) == 17
# The diff will always be 17 seconds because we control the start in the test data above.
# But we still get the function to calculate it so we know it works.
# The 3rd item doesn't have any timers, it's the control:
assert item3.start == nil
end
end
This is a large test but most of it is the test data (items_with_timers
) in the format we will be returning from
items_with_timers/1
in the next section.
With that test in place, we can write the function.
Open the
lib/app/item.ex
file and add the following function:
@doc """
`accumulate_item_timers/1` aggregates the elapsed time
for all the timers associated with an item
and then subtracs that time from the start value of the *current* active timer.
This is done to create the appearance that a single timer is being started/stopped
when in fact there are multiple timers in the backend.
For MVP we *could* have just had a single timer ...
and given the "ugliness" of this code, I wish I had done that!!
But the "USP" of our product [IMO] is that
we can track the completion of a task across multiple work sessions.
And having multiple timers is the *only* way to achieve that.
If you can think of a better way of achieving the same result,
please share: https://github.com/dwyl/app-mvp-phoenix/issues/103
This function *relies* on the list of items being ordered by timer_id ASC
because it "pops" the last timer and ignores it to avoid double-counting.
"""
def accumulate_item_timers(items_with_timers) do
# e.g: %{0 => 0, 1 => 6, 2 => 5, 3 => 24, 4 => 7}
timer_id_diff_map = map_timer_diff(items_with_timers)
# e.g: %{1 => [2, 1], 2 => [4, 3], 3 => []}
item_id_timer_id_map = Map.new(items_with_timers, fn i ->
{ i.id, Enum.map(items_with_timers, fn it ->
if i.id == it.id, do: it.timer_id, else: nil
end)
# stackoverflow.com/questions/46339815/remove-nil-from-list
|> Enum.reject(&is_nil/1)
}
end)
# this one is "wasteful" but I can't think of how to simplify it ...
item_id_timer_diff_map = Map.new(items_with_timers, fn item ->
timer_id_list = Map.get(item_id_timer_id_map, item.id, [0])
# Remove last item from list before summing to avoid double-counting
{_, timer_id_list} = List.pop_at(timer_id_list, -1)
{ item.id, Enum.reduce(timer_id_list, 0, fn timer_id, acc ->
Map.get(timer_id_diff_map, timer_id) + acc
end)
}
end)
# creates a nested map: %{ item.id: %{id: 1, text: "my item", etc.}}
Map.new(items_with_timers, fn item ->
time_elapsed = Map.get(item_id_timer_diff_map, item.id)
start = if is_nil(item.start), do: nil,
else: NaiveDateTime.add(item.start, -time_elapsed)
{ item.id, %{item | start: start}}
end)
# Return the list of items without duplicates and only the last/active timer:
|> Map.values()
# Sort list by item.id descending (ordered by timer_id ASC above) so newest item first:
|> Enum.sort_by(fn(i) -> i.id end, :desc)
end
There's no getting around this, the function is huge and not very pretty. But hopefully the comments clarify it.
If anything is unclear, we're very happy to expand/explain.
We're also very happy for anyone else
to refactor it!
Please open an issue
so we can discuss. 🙏
Open the
test/app/item_test.exs
file and the following test to the bottom:
test "Item.items_with_timers/1 returns a list of items with timers" do
{:ok, item1} = Item.create_item(@valid_attrs)
{:ok, item2} = Item.create_item(@valid_attrs)
assert Item.get_item!(item1.id).text == item1.text
started = NaiveDateTime.utc_now()
{:ok, timer1} =
Timer.start(%{item_id: item1.id, person_id: 1, start: started})
{:ok, _timer2} =
Timer.start(%{item_id: item2.id, person_id: 1, start: started})
assert NaiveDateTime.diff(timer1.start, started) == 0
# list items with timers:
item_timers = Item.items_with_timers(1)
assert length(item_timers) > 0
end
Open the
lib/app/item.ex
file and add the following code to the bottom:
@doc """
`items_with_timers/1` Returns a List of items with the latest associated timers.
## Examples
iex> items_with_timers()
[
%{text: "hello", person_id: 1, status: 2, start: 2022-07-14 09:35:18},
%{text: "world", person_id: 2, status: 7, start: 2022-07-15 04:20:42}
]
"""
#
def items_with_timers(person_id \\ 0) do
sql = """
SELECT i.id, i.text, i.status, i.person_id, t.start, t.stop, t.id as timer_id FROM items i
FULL JOIN timers as t ON t.item_id = i.id
WHERE i.person_id = $1 AND i.status IS NOT NULL AND i.status != 6
ORDER BY timer_id ASC;
"""
Ecto.Adapters.SQL.query!(Repo, sql, [person_id])
|> map_columns_to_values()
|> accumulate_item_timers()
end
@doc """
`map_columns_to_values/1` takes an Ecto SQL query result
which has the List of columns and rows separate
and returns a List of Maps where the keys are the column names and values the data.
Sadly, Ecto returns rows without column keys so we have to map them manually:
ref: https://groups.google.com/g/elixir-ecto/c/0cubhSd3QS0/m/DLdQsFrcBAAJ
"""
def map_columns_to_values(res) do
Enum.map(res.rows, fn(row) ->
Enum.zip(res.columns, row)
|> Map.new |> AtomicMap.convert()
end)
end
@doc """
`map_timer_diff/1` transforms a list of items_with_timers
into a flat map where the key is the timer_id and the value is the difference
between timer.stop and timer.start
If there is no active timer return {0, 0}.
If there is no timer.stop return Now - timer.start
## Examples
iex> list = [
%{ stop: nil, id: 3, start: nil, timer_id: nil },
%{ stop: ~N[2022-07-17 11:18:24], id: 1, start: ~N[2022-07-17 11:18:18], timer_id: 1 },
%{ stop: ~N[2022-07-17 11:18:31], id: 1, start: ~N[2022-07-17 11:18:26], timer_id: 2 },
%{ stop: ~N[2022-07-17 11:18:24], id: 2, start: ~N[2022-07-17 11:18:00], timer_id: 3 },
%{ stop: nil, id: 2, start: seven_seconds_ago, timer_id: 4 }
]
iex> map_timer_diff(list)
%{0 => 0, 1 => 6, 2 => 5, 3 => 24, 4 => 7}
"""
def map_timer_diff(list) do
Map.new(list, fn item ->
if is_nil(item.timer_id) do
# item without any active timer
{ 0, 0}
else
{ item.timer_id, timer_diff(item)}
end
end)
end
@doc """
`timer_diff/1` calculates the difference between timer.stop and timer.start
If there is no active timer OR timer has not ended return 0.
The reasoning is: an *active* timer (no end) does not have to
be subtracted from the timer.start in the UI ...
Again, DRAGONS!
"""
def timer_diff(timer) do
# ignore timers that have not ended (current timer is factored in the UI!)
if is_nil(timer.stop) do
0
else
NaiveDateTime.diff(timer.stop, timer.start)
end
end
Once again, there is quite a lot going on here. We have broken down the functions into chunks and added inline comments to clarify the code. But again, if anything is unclear please let us know!!
This section borrows heavily from: dwyl/phoenix-liveview-chat-example
Open the mix.exs
file and add auth_plug
to the deps
section:
{:auth_plug, "~> 1.4.14"},
Once the file is saved, run:
mix deps.get
Follow the steps in the
docs
to get your AUTH_API_KEY
environment variable. (1 minute)
Create a new file with the path:
lib/app_web/controllers/auth_controller.ex
and add the following code:
defmodule AppWeb.AuthController do
use AppWeb, :controller
import Phoenix.LiveView, only: [assign_new: 3]
def on_mount(:default, _params, %{"jwt" => jwt} = _session, socket) do
{:cont, AuthPlug.assign_jwt_to_socket(socket, &Phoenix.Component.assign_new/3, jwt)}
end
def on_mount(:default, _params, _session, socket) do
socket = assign_new(socket, :loggedin, fn -> false end)
{:cont, socket}
end
def login(conn, _params) do
redirect(conn, external: AuthPlug.get_auth_url(conn, "/"))
end
def logout(conn, _params) do
conn
|> AuthPlug.logout()
|> put_status(302)
|> redirect(to: "/")
end
end
Finally we have all the "CRUD" functions we're going to need
we can focus on the LiveView
code that will be the actual UI/UX!
Opent the
test/app_web/live/app_live_test.exs
file and replace the contents with the following test code:
defmodule AppWeb.AppLiveTest do
use AppWeb.ConnCase
alias App.{Item, Timer}
import Phoenix.LiveViewTest
test "disconnected and connected render", %{conn: conn} do
{:ok, page_live, disconnected_html} = live(conn, "/")
assert disconnected_html =~ "done"
assert render(page_live) =~ "done"
end
test "connect and create an item", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert render_submit(view, :create,
%{text: "Learn Elixir", person_id: 1}) =~ "Learn Elixir"
end
test "toggle an item", %{conn: conn} do
{:ok, item} =
Item.create_item(%{text: "Learn Elixir", status: 2, person_id: 0})
{:ok, _item2} =
Item.create_item(%{text: "Learn Elixir", status: 4, person_id: 0})
assert item.status == 2
started = NaiveDateTime.utc_now()
{:ok, _timer} =
Timer.start(%{item_id: item.id, start: started})
# See: https://github.com/dwyl/useful/issues/17#issuecomment-1186070198
# assert Useful.typeof(:timer_id) == "atom"
assert Item.items_with_timers(1) > 0
{:ok, view, _html} = live(conn, "/")
assert render_click(view, :toggle,
%{"id" => item.id, "value" => "on"}) =~ "line-through"
updated_item = Item.get_item!(item.id)
assert updated_item.status == 4
end
test "(soft) delete an item", %{conn: conn} do
{:ok, item} = Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2})
assert item.status == 2
{:ok, view, _html} = live(conn, "/")
assert render_click(view, :delete, %{"id" => item.id}) =~ "done"
updated_item = Item.get_item!(item.id)
assert updated_item.status == 6
end
test "start a timer", %{conn: conn} do
{:ok, item} = Item.create_item(%{text: "Get Fancy!", person_id: 0, status: 2})
assert item.status == 2
{:ok, view, _html} = live(conn, "/")
assert render_click(view, :start, %{"id" => item.id}) =~ "stop"
end
test "stop a timer", %{conn: conn} do
{:ok, item} = Item.create_item(%{text: "Get Fancy!", person_id: 0, status: 2})
assert item.status == 2
started = NaiveDateTime.utc_now()
{:ok, timer} = Timer.start(%{item_id: item.id, start: started})
{:ok, view, _html} = live(conn, "/")
assert render_click(view, :stop,
%{"id" => item.id, "timerid" => timer.id}) =~ "done"
end
# This test is just to ensure coverage of the handle_info/2 function
# It's not required but we like to have 100% coverage.
# https://stackoverflow.com/a/60852290/1148249
test "handle_info/2 start|stop", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
{:ok, item} = Item.create_item(%{text: "Always Learning", person_id: 0, status: 2})
started = NaiveDateTime.utc_now()
{:ok, _timer} = Timer.start(%{item_id: item.id, start: started})
send(view.pid, %{
event: "start|stop",
payload: %{items: Item.items_with_timers(1)}
})
assert render(view) =~ item.text
end
test "handle_info/2 update", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
{:ok, item} = Item.create_item(%{text: "Always Learning", person_id: 0, status: 2})
send(view.pid, %{
event: "update",
payload: %{items: Item.items_with_timers(1)}
})
assert render(view) =~ item.text
end
test "handle_info/2 delete", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
{:ok, item} = Item.create_item(%{text: "Always Learning", person_id: 0, status: 6})
send(view.pid, %{
event: "delete",
payload: %{items: Item.items_with_timers(1)}
})
refute render(view) =~ item.text
end
test "edit-item", %{conn: conn} do
{:ok, item} = Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2})
{:ok, view, _html} = live(conn, "/")
assert render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) =~
"<form phx-submit=\"update-item\" id=\"form-update\""
end
test "update an item", %{conn: conn} do
{:ok, item} = Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2})
{:ok, view, _html} = live(conn, "/")
assert render_submit(view, "update-item", %{"id" => item.id, "text" => "Learn more Elixir"}) =~
"Learn more Elixir"
updated_item = Item.get_item!(item.id)
assert updated_item.text == "Learn more Elixir"
end
test "timer_text(start, stop)" do
timer = %{
start: ~N[2022-07-17 09:01:42.000000],
stop: ~N[2022-07-17 13:22:24.000000]
}
assert AppWeb.AppLive.timer_text(timer) == "04:20:42"
end
test "get / with valid JWT", %{conn: conn} do
data = %{email: "[email protected]", givenName: "Alex", picture: "this", auth_provider: "GitHub", id: 2}
jwt = AuthPlug.Token.generate_jwt!(data)
{:ok, view, _html} = live(conn, "/?jwt=#{jwt}")
assert render(view)
end
test "Logout link displayed when loggedin", %{conn: conn} do
data = %{email: "[email protected]", givenName: "Alex", picture: "this", auth_provider: "GitHub", id: 2}
jwt = AuthPlug.Token.generate_jwt!(data)
conn = get(conn, "/?jwt=#{jwt}")
assert html_response(conn, 200) =~ "logout"
end
test "get /logout with valid JWT", %{conn: conn} do
data = %{
email: "[email protected]",
givenName: "Alex",
picture: "this",
auth_provider: "GitHub",
sid: 1,
id: 2
}
jwt = AuthPlug.Token.generate_jwt!(data)
conn =
conn
|> put_req_header("authorization", jwt)
|> get("/logout")
assert "/" = redirected_to(conn, 302)
end
test "test login link redirect to auth.dwyl.com", %{conn: conn} do
conn = get(conn, "/login")
assert redirected_to(conn, 302) =~ "auth.dwyl.com"
end
end
These tests are written in the order we created them. Feel free to comment out all but one at a time to implement the functions gradually.
Open the
lib/app_web/live/app_live.ex
file and replace the contents with the following code:
defmodule AppWeb.AppLive do
use AppWeb, :live_view
alias App.{Item, Timer}
# run authentication on mount
on_mount AppWeb.AuthController
alias Phoenix.Socket.Broadcast
@topic "live"
defp get_person_id(assigns) do
if Map.has_key?(assigns, :person) do
assigns.person.id
else
0
end
end
@impl true
def mount(_params, _session, socket) do
# subscribe to the channel
if connected?(socket), do: AppWeb.Endpoint.subscribe(@topic)
person_id = get_person_id(socket.assigns)
items = Item.items_with_timers(person_id)
{:ok, assign(socket, items: items, editing: nil, filter: "active")}
end
@impl true
def handle_event("create", %{"text" => text}, socket) do
person_id = get_person_id(socket.assigns)
Item.create_item(%{text: text, person_id: person_id, status: 2})
AppWeb.Endpoint.broadcast(@topic, "update", :create)
{:noreply, socket}
end
@impl true
def handle_event("toggle", data, socket) do
# Toggle the status of the item between 3 (:active) and 4 (:done)
status = if Map.has_key?(data, "value"), do: 4, else: 3
# need to restrict getting items to the people who own or have rights to access them!
item = Item.get_item!(Map.get(data, "id"))
Item.update_item(item, %{status: status})
Timer.stop_timer_for_item_id(item.id)
AppWeb.Endpoint.broadcast(@topic, "update", :toggle)
{:noreply, socket}
end
@impl true
def handle_event("delete", %{"id" => item_id}, socket) do
Item.delete_item(item_id)
AppWeb.Endpoint.broadcast(@topic, "update", :delete)
{:noreply, socket}
end
@impl true
def handle_event("start", data, socket) do
item = Item.get_item!(Map.get(data, "id"))
person_id = get_person_id(socket.assigns)
{:ok, _timer} =
Timer.start(%{
item_id: item.id,
person_id: person_id,
start: NaiveDateTime.utc_now()
})
AppWeb.Endpoint.broadcast(@topic, "update", :start)
{:noreply, socket}
end
@impl true
def handle_event("stop", data, socket) do
timer_id = Map.get(data, "timerid")
{:ok, _timer} = Timer.stop(%{id: timer_id})
AppWeb.Endpoint.broadcast(@topic, "update", :stop)
{:noreply, socket}
end
@impl true
def handle_event("edit-item", data, socket) do
{:noreply, assign(socket, editing: String.to_integer(data["id"]))}
end
@impl true
def handle_event("update-item", %{"id" => item_id, "text" => text}, socket) do
current_item = Item.get_item!(item_id)
Item.update_item(current_item, %{text: text})
AppWeb.Endpoint.broadcast(@topic, "update", :update)
{:noreply, assign(socket, editing: nil)}
end
@impl true
def handle_info(%Broadcast{event: "update", payload: _message}, socket) do
person_id = get_person_id(socket.assigns)
items = Item.items_with_timers(person_id)
{:noreply, assign(socket, items: items)}
end
# only show certain UI elements (buttons) if there are items:
def has_items?(items), do: length(items) > 1
# 2: uncategorised (when item are created), 3: active
def active?(item), do: item.status == 2 || item.status == 3
def done?(item), do: item.status == 4
def archived?(item), do: item.status == 6
# Check if an item has an active timer
def started?(item) do
not is_nil(item.start) and is_nil(item.stop)
end
# An item without an end should be counting
def timer_stopped?(item) do
not is_nil(item.stop)
end
def timers_any?(item) do
not is_nil(item.timer_id)
end
# Convert Elixir NaiveDateTime to JS (Unix) Timestamp
def timestamp(naive_datetime) do
DateTime.from_naive!(naive_datetime, "Etc/UTC")
|> DateTime.to_unix(:millisecond)
end
# Elixir implementation of `timer_text/2`
def leftPad(val) do
if val < 10, do: "0#{to_string(val)}", else: val
end
def timer_text(item) do
if is_nil(item) or is_nil(item.start) or is_nil(item.stop) do
""
else
diff = timestamp(item.stop) - timestamp(item.start)
# seconds
s =
if diff > 1000 do
s = (diff / 1000) |> trunc()
s = if s > 60, do: Integer.mod(s, 60), else: s
leftPad(s)
else
"00"
end
# minutes
m =
if diff > 60000 do
m = (diff / 60000) |> trunc()
m = if m > 60, do: Integer.mod(m, 60), else: m
leftPad(m)
else
"00"
end
# hours
h =
if diff > 3_600_000 do
h = (diff / 3_600_000) |> trunc()
leftPad(h)
else
"00"
end
"#{h}:#{m}:#{s}"
end
end
# Filter element by status (active, archived & done; default: all)
# see https://hexdocs.pm/phoenix_live_view/live-navigation.html
@impl true
def handle_params(params, _uri, socket) do
# person_id = get_person_id(socket.assigns)
# items = Item.items_with_timers(person_id)
filter = params["filter_by"] || socket.assigns.filter
{:noreply, assign(socket, filter: filter)}
end
defp filter_items(items, filter) do
case filter do
"active" ->
Enum.filter(items, &active?(&1))
"done" ->
Enum.filter(items, &done?(&1))
"archived" ->
Enum.filter(items, &archived?(&1))
_ ->
items
end
end
def class_footer_link(filter_name, filter_selected) do
if filter_name == filter_selected do
"px-2 py-2 h-9 mr-1 bg-teal-500 text-white rounded-md"
else
"""
py-2 px-4 bg-transparent font-semibold
border rounded border-teal-500 text-teal-500
hover:text-white hover:bg-teal-500 hover:border-transparent
"""
end
end
end
Again, a bunch of code here. Please work through each function to understand what is going on.
Finally we have all the LiveView
functions,
Open the
lib/app_web/templates/layout/root.html.heex
file and replace the contents with the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="csrf-token" content={csrf_token_value()}>
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self' dwyl.com https://*.cloudflare.com plausible.io;
connect-src 'self' wss://mvp.fly.dev plausible.io;
form-action 'self';
img-src *; child-src 'none';
script-src 'self' https://cdnjs.cloudflare.com plausible.io 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
"
/>
<%= live_title_tag assigns[:page_title] || "dwyl mvp"%>
<%= render "icons.html" %>
<link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
<script defer phx-track-static type="text/javascript"
src={Routes.static_path(@conn, "/assets/app.js")}></script>
<!-- see: https://github.com/dwyl/learn-alpine.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.10.2/cdn.js" defer></script>
</head>
<body>
<nav x-data="{ openMenu: false }" class="bg-gray-800">
<div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
<div class="relative flex h-16 items-center justify-between">
<div class="absolute inset-y-0 left-0 flex items-center pr-2">
<%= if @loggedin do %>
<!-- Profile dropdown -->
<div
x-data="{showProfileDropdown: false}"
x-on:click="showProfileDropdown = !showProfileDropdown"
class="relative ml-3"
>
<div>
<button
type="button"
class="flex rounded-full bg-gray-800 text-sm focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
id="user-menu-button"
aria-expanded="false"
aria-haspopup="true"
>
<span class="sr-only">Open user menu</span>
<img
src={@person.picture}
class="h-8 w-8 rounded-full"
alt="avatar image"
/>
</button>
</div>
<!-- Dropdown menu, show/hide based on menu state. -->
<div
x-show="showProfileDropdown"
x-on:click.away="showProfileDropdown = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute left-0 z-10 mt-2 w-48 origin-top-left rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
role="menu"
aria-orientation="vertical"
aria-labelledby="user-menu-button"
tabindex="-1"
>
<!-- Active: "bg-gray-100", Not Active: "" -->
<%= link to: "/logout" , class: "block px-4 py-2 text-sm text-gray-700" do %>
Sign out
<% end %>
</div>
</div>
<% else %>
<div class="flex-shrink-0">
<%= link to: "/login" , class: "relative inline-flex items-center gap-x-1.5 rounded-md bg-[#0F766E] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500" do %>
Login
<% end %>
</div>
<% end %>
</div>
<div class="flex flex-1 items-center justify-center">
<div class="flex flex-shrink-0 items-center">
<a href="/" class="flex items-center">
<img
src="https://dwyl.com/img/common/dwyl-heart-only-logo.png"
height="32"
width="32"
alt="dwyl logo"
/>
</a>
</div>
</div>
<div class="absolute inset-y-0 right-0 flex items-center">
<!-- Mobile menu button-->
<button
x-on:click="openMenu = !openMenu"
type="button"
class="inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-white focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
aria-controls="mobile-menu"
aria-expanded="false"
>
<span class="sr-only">Open main menu</span>
<!--
Icon when menu is closed.
Menu open: "hidden", Menu closed: "block"
-->
<svg
class="block h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
<!--
Icon when menu is open.
Menu open: "block", Menu closed: "hidden"
-->
<svg
class="hidden h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile menu, show/hide based on menu state. -->
<div x-show="openMenu" id="mobile-menu">
<div class="space-y-1 px-2 pb-3 pt-2 text-right">
<!-- <a href="#" class="bg-gray-900 text-white rounded-md px-3 py-2 text-sm font-medium" aria-current="page">Current</a> -->
<%= link("Tags",
to: "/tags",
class:
"text-gray-300 hover:bg-gray-700 hover:text-white rounded-md px-3 py-2 text-sm font-medium"
) %>
</div>
</div>
</nav>
<%= @inner_content %>
</body>
</html>
Note that we are defining a content security policy with:
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self' dwyl.com https://*.cloudflare.com plausible.io;
connect-src 'self' wss://mvp.fly.dev plausible.io;
form-action 'self';
img-src *; child-src 'none';
script-src 'self' https://cdnjs.cloudflare.com plausible.io 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
"
/>
This defines who can run scripts, forms, style css and images on the browser.
The default-src
value is used by default when the fetch directives
are not specified.
For scripts we want to allow cloudfare
(used for cdn) and plausible
used
as an alternative to Google Analytics, to run javascript scripts.
The self
value allows the server itself (the Phoenix application) to run scripts.
Read more about content security policy at https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
To make the App more Mobile-friendly, we define a bunch of iOS/Android related icons.
Create a new file with the path
lib/app_web/templates/layout/icons.html.heex
and add the following code to it:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="dwyl is a worldwide community of people using technology to solve real problems.">
<meta name="robots" content="noarchive">
<link rel="shortcut-icon" href="https://dwyl.com/img/favicon.ico">
<link rel="apple-touch-icon" sizes="57x57" href="https://dwyl.com/img/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="https://dwyl.com/img/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="https://dwyl.com/img/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="https://dwyl.com/img/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="https://dwyl.com/img/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="https://dwyl.com/img/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="https://dwyl.com/img/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="https://dwyl.com/img/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="https://dwyl.com/img/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="https://dwyl.com/img/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="https://dwyl.com/img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="https://dwyl.com/img/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="https://dwyl.com/img/favicon-16x16.png">
<link rel="manifest" href="https://dwyl.com/img/manifest.json">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="https://dwyl.com/img/ms-icon-144x144.png">
This is static and very repetitive, hence creating a partial to hide it from the root layout.
Finally ...
Open the app_live.html.heex
file and replace the contents
with the following template code:
<div class="h-90 w-full font-sans">
<form phx-submit="create" class="w-full lg:w-3/4 lg:max-w-lg text-center mx-auto">
<!-- textarea so that we can have multi-line capturing
help wanted auto re-sizing: https://github.com/dwyl/learn-alpine.js/issues/3 -->
<textarea
class="w-full py-1 px-1 text-slate-800 text-3xl
bg-white bg-clip-padding
resize-none
max-h-80
transition ease-in-out
border border-b border-slate-200
focus:border-none focus:outline-none"
name="text"
placeholder="What needs to be done?"
autofocus=""
required="required"
x-data="{resize() {
$el.style.height = '80px';
$el.style.height = $el.scrollHeight + 'px';
}
}"
x-init="resize"
x-on:input="resize"
></textarea>
<!-- Want to help "DRY" this? see: https://github.com/dwyl/app-mvp-phoenix/issues/105 -->
<!-- https://tailwindcss.com/docs/justify-content#end -->
<div class="flex justify-end mr-1">
<button class="inline-flex items-center px-2 py-1 mt-1 h-9
bg-green-500 hover:bg-green-600 text-white rounded-md">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z"
/>
</svg>
Save
</button>
</div>
</form>
<!-- List of items with inline buttons and controls -->
<ul class="w-full">
<%= for item <- @items do %>
<li data-id={item.id} class="mt-2 flex w-full border-t border-slate-200 py-2">
<!-- if item is "done" (status: 4) strike-through and show "Archive" button -->
<%= if done?(item) do %>
<input type="checkbox" phx-value-id={item.id} phx-click="toggle"
class="flex-none p-4 m-2 form-checkbox text-slate-400"
checked />
<label class="w-full text-slate-400 m-2 line-through">
<%= item.text %>
</label>
<div class="flex flex-col">
<div class="flex flex-col justify-end mr-1">
<!-- "Archive" button with icon see: https://github.com/dwyl/app-mvp-phoenix/issues/101 -->
<button class="inline-flex items-center px-2 py-1 mr-2 h-9
bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md"
phx-click="delete" phx-value-id={item.id}>
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
Archive
</button>
<p>
<span class="text-sm font-mono font-semibold flex flex-col justify-end text-right mr-2 mt-1">
<%= timer_text(item) %>
</span>
</p>
</div>
</div>
<!-- else render the buttons for start|stop timer -->
<% else %>
<!-- Show checkbox so the item can be marked as "done" -->
<input type="checkbox" phx-value-id={item.id} phx-click="toggle"
class="flex-none p-4 m-2 form-checkbox hover:text-slate-600" />
<!-- Editing renders the textarea and "save" button - near identical (duplicate) code from above
Help wanted DRY-ing this ... see: https://github.com/dwyl/app-mvp-phoenix/issues/105 -->
<%= if item.id == @editing do %>
<form phx-submit="update-item" id="form-update" class="w-full mr-2">
<textarea
id="editing"
class="w-full flex-auto text-slate-800
bg-white bg-clip-padding
transition ease-in-out
border border-b border-slate-200
focus:border-none focus:outline-none"
name="text"
placeholder="What is on your mind?"
autofocus
required="required"
value={item.text}
><%= item.text %></textarea>
<input type="hidden" name="id" value={item.id}/>
<div class="flex justify-end mr-1">
<button class="inline-flex items-center px-2 py-1 mt-1 h-9
bg-green-500 hover:bg-green-600 text-white rounded-md">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z"
/>
</svg>
Save
</button>
</div>
</form>
<% else %>
<!-- Render item.text as click-able label -->
<label class="w-full flex-auto text-slate-800 m-2"
phx-click="edit-item" phx-value-id={item.id}>
<%= item.text %>
</label>
<% end %>
<%= if timers_any?(item) do %>
<!-- always display the time elapsed in the UI https://github.com/dwyl/app-mvp-phoenix/issues/106 -->
<%= if timer_stopped?(item) do %>
<div class="flex flex-col">
<div class="flex flex-col justify-end mr-1">
<!-- render "continue" button -->
<button phx-click="start" phx-value-id={item.id}
class="inline-flex items-center px-2 py-2 h-9 mr-1
bg-teal-600 hover:bg-teal-800 text-white rounded-md">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-1" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Resume
</button>
<p>
<span class="text-sm font-mono font-semibold flex flex-col justify-end text-right mr-2 mt-1">
<%= timer_text(item) %>
</span>
</p>
</div>
</div>
<% else %>
<%= if started?(item) do %>
<!-- render the counting timer with Apline.js! see: github.com/dwyl/learn-alpine.js -->
<div class="flex flex-col"
x-data="{
start: parseInt($refs.timer_start.innerHTML, 10),
current: null,
stop: null,
interval: null
}"
x-init="
start = parseInt($refs.timer_start.innerHTML, 10);
current = start;
interval = setInterval(() => { current = Date.now() }, 500)
"
>
<!-- this is how we pass the start|stop time from Elixir (server) to Alping (client) -->
<span x-ref="timer_start" class="hidden"><%= timestamp(item.start) %></span>
<div class="flex flex-col justify-end mr-1">
<button phx-click="stop" phx-value-id={item.id} phx-value-timerid={item.timer_id}
class="inline-flex items-center px-2 py-2 h-9 mr-1
bg-red-500 hover:bg-red-700 text-white rounded-md">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-1" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Stop
</button>
<p><span x-text="timer_text(start, current || stop)"
class="text-sm font-mono font-semibold text-right mr-1">00:00:00</span></p>
</div>
</div>
<% end %> <!-- end if started?(item) -->
<% end %>
<% else %>
<!-- render start button -->
<button phx-click="start" phx-value-id={item.id}
class="inline-flex items-center px-2 py-2 h-9 mr-1
bg-teal-500 hover:bg-teal-700 text-white rounded-md">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-1" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Start
</button>
<% end %><!-- end timers_any?(item) -->
<% end %>
</li>
<% end %><!-- end for item <- @items -->
</ul>
</div>
<script>
// Render the *counting* timer using JavaScript
// (Stopped timers are rendered by Elixir)
function leftPad(val) {
return val < 10 ? '0' + String(val) : val;
}
function timer_text(start, current) {
console.log("timer_text(start, current)", start, current)
let h="00", m="00", s="00";
const diff = current - start;
// seconds
if(diff > 1000) {
s = Math.floor(diff / 1000);
s = s > 60 ? s % 60 : s;
s = leftPad(s);
}
// minutes
if(diff > 60000) {
m = Math.floor(diff/60000);
m = m > 60 ? m % 60 : m;
m = leftPad(m)
}
// hours
if(diff > 3600000) {
h = Math.floor(diff/3600000);
h = leftPad(h)
}
return h + ':' + m + ':' + s;
}
</script>
The bulk of the App is contained in this one template file.
Work your way through it and if anything is unclear,
let us know!
On this section we want to add LiveView links to filter items by status.
We first update the template to add the following footer
in the lib/app_web/live/app_live.html.heex
file:
<%= if has_items?(@items) do %>
<footer>
<div class="flex flex-row justify-center p-2 border-t">
<div class="px-8 py-2"><%= live_patch "All", to: Routes.live_path(@socket, AppWeb.AppLive, %{filter_by: "all"}), class: class_footer_link("all", @filter) %></div>
<div class="px-8 py-2"><%= live_patch "Active", to: Routes.live_path(@socket, AppWeb.AppLive, %{filter_by: "active"}), class: class_footer_link("active",@filter) %></div>
<div class="px-8 py-2"><%= live_patch "Done", to: Routes.live_path(@socket, AppWeb.AppLive, %{filter_by: "done"} ), class: class_footer_link("done", @filter) %></div>
<div class="px-8 py-2"><%= live_patch "Archived", to: Routes.live_path(@socket, AppWeb.AppLive, %{filter_by: "archived"} ), class: class_footer_link("archived", @filter) %></div>
</div>
</footer>
<% end %>
<script>
...
We are creating four live_patch
links: "All", "Active", "Done" and "Archived".
When a linked is clicked LiveView
will search for the handle_params
function
in our AppWeb.AppLive
module. If we check the
app_live.ex
file, we will notice these functions
(don't copy this code, it's already inside the file):
# only show certain UI elements (buttons) if there are items:
def has_items?(items), do: length(items) > 1
@impl true
def handle_params(params, _uri, socket) do
person_id = get_person_id(socket.assigns)
filter = params["filter_by"] || socket.assigns.filter
items =
Item.items_with_timers(person_id)
|> filter_items(filter)
{:noreply, assign(socket, items: items, filter: filter)}
end
defp filter_items(items, filter) do
case filter do
"active" ->
Enum.filter(items, &active?(&1))
"done" ->
Enum.filter(items, &done?(&1))
"archived" ->
Enum.filter(items, &archived?(&1))
_ ->
items
end
end
For each of the possible filters the function assigns to the socket the filtered
list of items. Similar to our done?
function we have created the active?
and
archived?
functions which check the status value of an item
(don't copy this code, it's already inside the file):
def active?(item), do: item.status == 2 || item.status == 3
def done?(item), do: item.status == 4
def archived?(item), do: item.status == 6
Now that we have the new filtered list of items assigned to the socket, we need
to make sure archived
items are displayed. Let's update our template
app_live.html.heex
with:
<!-- List of items with inline buttons and controls -->
<ul class="w-full">
<%= for item <- @items do %>
<li data-id={item.id} class="mt-2 flex w-full border-t border-slate-200 py-2">
<%= if archived?(item) do %>
<input type="checkbox" phx-value-id={item.id} phx-click="toggle"
class="flex-none p-4 m-2 form-checkbox text-slate-400 cursor-not-allowed"
checked disabled />
<label class="w-full text-slate-400 m-2 line-through">
<%= item.text %>
</label>
<div class="flex flex-col">
<div class="flex flex-col justify-end mr-1">
<button disabled class="cursor-not-allowed inline-flex items-center px-2 py-1 mr-2 h-9
bg-gray-200 text-gray-800 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
Archived
</button>
</div>
</div>
<% else %>
<!-- if item is "done" (status: 4) strike-through and show "Archive" button -->
<%= if done?(item) do %>
...
<% end %> <!-- don't forget to add this one!!! -->
</li>
<% end %><!-- end for item <- @items -->
For each items we first check if the status is archived
.
If it is we then displayed the checkbox checked and disabled and we also displayed
an arhived
disabled button to make it obvious the item is archived.
Finally we can add the following test to make sure our filtering feature is working as we expect:
test "filter items", %{conn: conn} do
{:ok, item} =
Item.create_item(%{text: "Item to do", person_id: 0, status: 2})
{:ok, item_done} =
Item.create_item(%{text: "Item done", person_id: 0, status: 4})
{:ok, item_archived} =
Item.create_item(%{text: "Item archived", person_id: 0, status: 6})
{:ok, view, _html} = live(conn, "/?filter_by=all")
assert render(view) =~ "Item to do"
assert render(view) =~ "Item done"
assert render(view) =~ "Item archived"
{:ok, view, _html} = live(conn, "/?filter_by=active")
assert render(view) =~ "Item to do"
refute render(view) =~ "Item done"
refute render(view) =~ "Item archived"
{:ok, view, _html} = live(conn, "/?filter_by=done")
refute render(view) =~ "Item to do"
assert render(view) =~ "Item done"
refute render(view) =~ "Item archived"
{:ok, view, _html} = live(conn, "/?filter_by=archived")
refute render(view) =~ "Item to do"
refute render(view) =~ "Item done"
assert render(view) =~ "Item archived"
end
We are creating 3 items and testing depending on the filter selected that the items are properly displayed and removed from the view.
See also the Live Navigation
Phoenix documentation for using live_patch
In this section we're going to add tags to items.
Tags
belong to a person
(i.e. different people
can create the same tag
name).
A person
can't create tag
duplicates (case insensitive).
We first want to create a new tags
table in our database.
We can use the mix ecto.gen.migration add_tags
command to create a new
migration and then create manually a App.Tag
schema, or we can directly use
the mix phx.gen.schema
command to create the schema and the migration in one step:
mix phx.gen.schema Tag tags person_id:integer text:string
You should see a similar response:
* creating lib/app/tags.ex
* creating priv/repo/migrations/20220922084231_create_tags.exs
Remember to update your repository by running migrations:
$ mix ecto.migrate
We can repeat this process to create a items_tags
table and ItemTag
schema. This join table
is used to link items and tags together.
mix phx.gen.schema ItemTag items_tags item_id:references:items tag_id:references:tags
We are using the references
attribute to link the item_id
field to the items
table and tag_id
to tags
.
Here's how the ER diagram should look so far.
Before running our migrations file, we need to add a few changes to them.
In our create_tags
migration, update the file
priv/repo/migrations/XXXXXXXXX_create_tags.exs
to:
def change do
create table(:tags) do
add(:person_id, :integer)
add(:text, :string)
timestamps()
end
create(unique_index(:tags, ["lower(text)", :person_id], name: tags_text_person_id_index))
end
We have added a unique index on the fields text
and person_id
.
We have specified the name tags_text_person_id_index
to the index to make
sure later on to use it in the Tag
changeset.
This means a person can't create duplicated tags.
The "lower(text)"
function also makes sure the tags are case insensitive,
for example if a tag UI
has been created, the person then won't be able to create
the ui
tag.
Another solution for case insensitive with Postgres is to use the
citext
extension. Update the migration with:
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext"
create table(:tags) do
add(:person_id, :integer)
add(:text, :citext)
timestamps()
end
create(unique_index(:tags, [:text, :person_id], name: tags_text_person_id_index))
end
And that's all, Postgres will take care of checking the text value case-sensitivity for us.
see also for some information about lower
and citext
:
- https://hexdocs.pm/ecto/Ecto.Changeset.html#unique_constraint/3-case-sensitivity
- https://elixirforum.com/t/case-insensitive-column-in-ecto/2062/5
- https://www.postgresql.org/docs/current/citext.html
- https://nandovieira.com/using-insensitive-case-columns-in-postgresql-with-citext
In our create_items_tags
migration, update the file with:
def change do
create table(:items_tags, primary_key: false) do
add(:item_id, references(:items, on_delete: :delete_all))
add(:tag_id, references(:tags, on_delete: :delete_all))
timestamps()
end
create(unique_index(:items_tags, [:item_id, :tag_id]))
end
-
We have added the
primary_key: false
option. This to avoid having theid
column created automatically by the migration. -
We've updated the
on_delete
option todelete_all
. This means that if an item or a tag is deleted, we then remove the rows linked to this item/tag in the join tableitems_tags
. However if for example an item is deleted the references in the join table will be removed but the tags linked to the deleted item won't be removed.
The on_delete
values
can be
-
:nothing
(default), Postgres raises an error if the deleted data is still linked in the join table -
:delete_all
, delete the data and the references in the join table -
:nilify_all
, delete the data and change the id to nil in the join table -
:restrict
, similar to:nothing
, see https://stackoverflow.com/questions/60043008/when-to-use-nothing-or-restrict-for-on-delete-with-ecto -
Finally we create a unique index on the
item_id
andtag_id
fields to make sure that the same tag can't be added multiple times to an item.
We can now run our migrations with mix ecto.migrate
:
Compiling 2 files (.ex)
Generated app app
10:16:42.276 [info] == Running 20220922091606 App.Repo.Migrations.CreateTags.change/0 forward
10:16:42.279 [info] create table tags
10:16:42.284 [info] == Migrated 20220922091606 in 0.0s
10:16:42.307 [info] == Running 20220922091636 App.Repo.Migrations.CreateItemsTags.change/0 forward
10:16:42.307 [info] create table items_tags
10:16:42.313 [info] create index items_tags_item_id_index
10:16:42.315 [info] create index items_tags_tag_id_index
10:16:42.316 [info] == Migrated 20220922091636 in 0.0s
Now that our database is setup for tags, we can update our schemas.
In lib/app/tag.ex
, update the file to:
defmodule App.Tag do
use Ecto.Schema
import Ecto.Changeset
alias App.{Item, ItemTag}
schema "tags" do
field :text, :string
field :person_id, :integer
many_to_many(:items, Item, join_through: ItemTag)
timestamps()
end
@doc false
def changeset(tag, attrs) do
tag
|> cast(attrs, [:person_id, :text])
|> validate_required([:person_id, :text])
|> unique_constraint([:person_id, :text], name: :tags_text_person_id_index)
end
end
We have added the many_to_many function.
We've also added in the changeset
the unique_constraint
for the person_id
and text
values.
We have defined the name of the unique constraint to match the one defined
in our migration.
In lib/app/item.ex
, add also the many_to_many
function to the schema
schema "items" do
field :person_id, :integer
field :status, :integer
field :text, :string
many_to_many(:tags, Tag, join_through: ItemTag)
timestamps()
end
Finally in lib/app/item_tag.ex
:
@primary_key false
schema "items_tags" do
belongs_to(:item, Item)
belongs_to(:tag, Tag)
timestamps()
end
Because we have define our items_tags
migration to not use the default id
for the primary we want to reflect this change on the schema by using the
primary_key false
schema attribute.
If we don't add this attribute if we attempt
to insert or to get one of the item_tag
value from the database,
the query will fail as the schema will try to retrieve the non existent id
column.
We also use the belongs_to
function to define the association with the Item
and
Tag
schemas.
Let's use iex
to create some items and tags and to check our constraints
are working on the tags
To make our life easier when using iex
we're going to first create a .iex.exs
file containing any aliases you want to have when starting a session:
alias App.{Repo, Item, Tag, ItemTag}
So when running the Phoenix application iex -S mix
you will have access
directly to Repo
, Item
, Tag
and ItemTag
!
see also: https://alchemist.camp/episodes/iex-exs
now run iex -S mix
and let's create a few items and tags:
item1 = Repo.insert!(%Item{person_id: 1, text: "item1"})
item2 = Repo.insert!(%Item{person_id: 1, text: "item2"})
tag1 = Repo.insert!(%Tag{person_id: 1, text: "Tag1"})
tag2 = Repo.insert!(%Tag{person_id: 1, text: "Tag2"})
We've created two items and two tags, now if we attempt to create "tag1" with the same person id:
Repo.insert!(%Tag{person_id: 1, text: "tag1"})
** (Ecto.ConstraintError) constraint error when attempting to insert struct:
* tags_text_person_id_index (unique_constraint)
We can see that the citext
type is working as "Tag1" and "tag1" can't coexist.
However if we change the person id we can still create the tag:
Repo.insert!(%Tag{person_id: 2, text: "tag1"})
[debug] QUERY OK db=5.8ms queue=0.1ms idle=1767.0ms
We can manually link the tag and the item:
Repo.insert!{%ItemTag{item_id: item1.id, tag_id: tag1.id})
Repo.delete(item1)
Repo.all(ItemTag)
We are creating a link then we delete the item and finally we verify the list
of ItemTag
is empty. However if we check the list of tags we can see the tag
with id 1 still exist
Finally we can check that we can't add duplicate tags to an item:
Repo.insert!{%ItemTag{item_id: item2.id, tag_id: tag2.id})
Repo.insert!{%ItemTag{item_id: item2.id, tag_id: tag2.id})
** (Ecto.ConstraintError) constraint error when attempting to insert struct:
* items_tags_item_id_tag_id_index (unique_constraint)
Typing all of this in iex is a slow and if we want to add data to our database
we can use the priv/repo/seeds.exs
file:
alias App.{Repo, Item, Tag, ItemTag}
# reset
Repo.delete_all(Item)
Repo.delete_all(Tag)
item1 = Repo.insert!(%Item{person_id: 1, text: "task1"})
item2 = Repo.insert!(%Item{person_id: 1, text: "task2"})
tag1 = Repo.insert!(%Tag{person_id: 1, text: "tag1"})
tag2 = Repo.insert!(%Tag{person_id: 1, text: "tag2"})
Repo.insert!(%ItemTag{item_id: item1.id, tag_id: tag1.id})
Repo.insert!(%ItemTag{item_id: item1.id, tag_id: tag2.id})
Repo.insert!(%ItemTag{item_id: item2.id, tag_id: tag2.id})
Then running mix run priv/repo/seeds.exs
command will populate our database
with our items and tags.
We have just tested manually our schemas using Iex, we can also write tests,
for example we can test the changeset for Tag
:
describe "Test constraints and requirements for Tag schema" do
test "valid tag changeset" do
changeset = Tag.changeset(%Tag{}, %{person_id: 1, text: "tag1"})
assert changeset.valid?
end
test "invalid tag changeset when person_id value missing" do
changeset = Tag.changeset(%Tag{}, %{text: "tag1"})
refute changeset.valid?
end
test "invalid tag changeset when text value missing" do
changeset = Tag.changeset(%Tag{}, %{person_id: 1})
refute changeset.valid?
end
end
see https://hexdocs.pm/phoenix/1.3.2/testing_schemas.html for more information about testing schemas.
We want to create the tags at the same time as the item is created. The tags are represented as string where tag values are separated by comma: "tag1, tag2, ..."
So we need first to parse the tags string value, create any new tags in Postgres, then associate the list of tags to the item.
We'll first update our Item
schema to add the on_replace
option to the
many_to_many
function:
many_to_many(:tags, Tag, join_through: ItemTag, on_replace: :delete)
The :delete
value will remove any associations between the item and the tags that
have been removed, see https://hexdocs.pm/ecto/Ecto.Schema.html#many_to_many/3.
We now create a new changeset:
def changeset_with_tags(item, attrs) do
changeset(item, attrs)
|> put_assoc(:tags, Tag.parse_and_create_tags(attrs))
end
The put_assoc
creates the association between the item and the list of tags.
The Tag.parse_and_create_tags
function is defined as:
def parse_and_create_tags(attrs) do
(attrs[:tags] || "")
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> create_tags(attrs[:person_id])
end
def create_tag(attrs) do
%Tag{}
|> changeset(attrs)
|> Repo.insert()
end
The function makes to parse the tags properly by removing any unwanted value
(ex: empty strings) then it called create_tags
:
@spec create_tags(tag_name :: list(String.t()), person_id: integer) :: map()
def create_tags([], _person_id), do: []
def create_tags(tag_names, person_id) do
timestamp =
NaiveDateTime.utc_now()
|> NaiveDateTime.truncate(:second)
placeholders = %{timestamp: timestamp}
maps =
Enum.map(
tag_names,
&%{
text: &1,
person_id: person_id,
inserted_at: {:placeholder, :timestamp},
updated_at: {:placeholder, :timestamp}
}
)
Repo.insert_all(
Tag,
maps,
placeholders: placeholders,
on_conflict: :nothing
)
Repo.all(
from t in Tag, where: t.text in ^tag_names and t.person_id == ^person_id
)
end
This function uses Repo.insert_all
to only send one request to insert all the tags.
We need to "build" the tags timestamp as insert_all
doesn't do this automatically
unlike Repo.insert
.
The other important information is the on_conlict
option defined to :nothing
in insert_all
. This means that if we attempt to create a tag which already
exists in the database then we tell Ecto to not raise any error: insert only non
existing tags.
This function is heavily inspired by: https://hexdocs.pm/ecto/constraints-and-upserts.html
Learn more about Ecto with the guides documentation, especially the How to section: https://hexdocs.pm/ecto/getting-started.html (taken from: https://dashbit.co/ebooks/the-little-ecto-cookbook)
In this section we are going to add the ability to edit timers
when editing items. The timer has to follow a specific format
(%Y-%m-%d %H:%M:%S
) to be persisted.
As you might have noticed, we are using NaiveDateTime
when persisting the timer's datetime.
One would be inclined to use from_iso8601/2
to parse the input string and convert it to a datetime object.
However, if we were to think on a long-term perspective,
we would want to be able to parse any string format,
not just ISO6601
.
Currently, Elixir doesn't have a way to create a datetime object
from any string format. For this, we are going use
Timex
.
In mix.exs
, add the following piece of code in the deps
section.
{:timex, "~> 3.7"},
and run mix deps.get
.
This will download and install the package so we can use it.
With this library, we have access to parse/3
where we can
create a DateTime object from a string according to
a given format. We are going to be using this later on.
So far we can only start, stop and fetch timers.
We need a way to directly update a specific timer through their id
.
With this in mind, let's add the update method to
lib/app/timer.ex
.
def update_timer(attrs \\ %{}) do
get_timer!(attrs.id)
|> changeset(attrs)
|> Repo.update()
end
In addition to this, we also need a function to fetch
all the timers associated with a specific timer id
.
Firstly, let's specify the associations between Timer
and Item
.
In lib/app/timer.ex
, add:
alias App.Item
import Ecto.Query
and inside the Timers
schema, change the scema to the following.
This will properly reference Timer
to the Item
object.
schema "timers" do
field :start, :naive_datetime
field :stop, :naive_datetime
belongs_to :item, Item, references: :id, foreign_key: :item_id
timestamps()
end
In the same file, let us add a way to list all the timer changesets associated
with a certain item
id.
We are returning changesets because of form validation.
In case an error occurs, we want to provide feedback to the person.
To do this, we use these changesets and add errors to them,
which will later be displayed on the UI.
Paste the following.
def list_timers_changesets(item_id) do
from(v in Timer, where: [item_id: ^item_id], order_by: [asc: :id])
|> Repo.all()
|> Enum.map(fn t ->
Timer.changeset(t, %{
id: t.id,
start: t.start,
stop: t.stop,
item_id: t.item_id
})
end)
end
We need a way to show the timers related to an item
in the UI.
Currently, in lib/app_web/live/app_live.ex
, every time the person
edits an item, an edit-timer
event is propped up, setting the
socket assigns accordingly.
We want to fetch the timers of an item ad-hoc. Instead of loading all the timers on mount, it's best to dynamically fetch the timers whenever we want to edit a timer. For this, we are going to add an array of timer changesets to the socket assigns and show these when editing a timer. Let's do that.
In lib/app_web/live/app_live.ex
, in the mount
function, add
editing_timers: []
to the list of changesets.
assign(socket,
items: items,
editing_timers: [],
editing: nil,
filter: "active",
filter_tag: nil
...
Let's change the handle_event
handler for the edit-item
event
to fetch the timer changesets when editing an item. Change the function
to the following:
def handle_event("edit-item", data, socket) do
item_id = String.to_integer(data["id"])
timers_list_changeset = Timer.list_timers_changesets(item_id)
{:noreply,
assign(socket, editing: item_id, editing_timers: timers_list_changeset)}
end
Likewise, inside the handle_event
handler for the update-item
event,
change the last line to reset the editing_timers
array to empty. This
is after a successful item edit.
{:noreply, assign(socket, editing: nil, editing_timers: [])}
Now we need to have an handler for an event that will be created when editing a timer. For this, create the following function in the same file.
@impl true
def handle_event(
"update-item-timer",
%{
"timer_id" => id,
"index" => index,
"timer_start" => timer_start,
"timer_stop" => timer_stop
},
socket
) do
timer_changeset_list = socket.assigns.editing_timers
index = String.to_integer(index)
timer = %{
id: id,
start: timer_start,
stop: timer_stop
}
case Timer.update_timer_inside_changeset_list( timer, index, timer_changeset_list) do
{:ok, _list} ->
# Updates item list and broadcast to other clients
AppWeb.Endpoint.broadcast(@topic, "update", :update)
{:noreply, assign(socket, editing: nil, editing_timers: [])}
{:error, updated_errored_list} ->
{:noreply, assign(socket, editing_timers: updated_errored_list)}
end
end
Let's do a rundown of what we just added.
From the form, we receive an index
of the timer inside the editing_timers
socket assign array. We use this index
to replace the changeset being edited
in case there's an error with the string format or the dates.
We are calling a function update_timer_inside_changeset_list/5
that we will implement shortly, This function will either
update the timer successfully or return an error,
with an updated list of timer changesets to display the error on the UI.
We want the people
to be able to update timers
even when
there's an ongoing timer and have the people
still
see the list of timers
.
For this, we ought to update the events that are created
when clicking Resume
or Stop
.
Therefore, we need to these handlers and the broadcast
update
event that is sent to all connected clients.
Let's check the start
and stop
event handlers inside app_live.ex
.
Let's add information to the event with the item.id
that is being edited.
Change these event handlers so they look like this.
@impl true
def handle_event("start", data, socket) do
item = Item.get_item!(Map.get(data, "id"))
person_id = get_person_id(socket.assigns)
{:ok, _timer} =
Timer.start(%{
item_id: item.id,
person_id: person_id,
start: NaiveDateTime.utc_now()
})
AppWeb.Endpoint.broadcast(@topic, "update", {:start, item.id})
{:noreply, socket}
end
@impl true
def handle_event("stop", data, socket) do
timer_id = Map.get(data, "timerid")
{:ok, _timer} = Timer.stop(%{id: timer_id})
AppWeb.Endpoint.broadcast(@topic, "update", {:stop, Map.get(data, "id")})
{:noreply, socket}
end
Now we need to update the handle_info/2
event handler
that deals with this broadcasting event that is used
everytime Start/Resume
or Stop
is called.
@impl true
def handle_info(%Broadcast{event: "update", payload: payload}, socket) do
person_id = get_person_id(socket.assigns)
items = Item.items_with_timers(person_id)
is_editing_item = socket.assigns.editing
# If the item is being edited, we update the timer list of the item being edited.
if is_editing_item do
case payload do
{:start, item_id} ->
timers_list_changeset = Timer.list_timers_changesets(item_id)
{:noreply,
assign(socket,
items: items,
editing: item_id,
editing_timers: timers_list_changeset
)}
{:stop, item_id} ->
timers_list_changeset = Timer.list_timers_changesets(item_id)
{:noreply,
assign(socket,
items: items,
editing: item_id,
editing_timers: timers_list_changeset
)}
_ ->
{:noreply, assign(socket, items: items)}
end
# If not, just update the item list.
else
{:noreply, assign(socket, items: items)}
end
end
Now, everytime the update
event is broadcasted,
we update the timer list if the item is being edited.
If not, we update the timer list, as normally.
What this does is that every person will have the socket.assigns
properly updated everytime a timer is edited.
Let's create the unimplemented function that we
previously added.
In the timer.ex
file, add the following.
def update_timer_inside_changeset_list(
%{
id: timer_id,
start: timer_start,
stop: timer_stop
},
index,
timer_changeset_list
) when timer_stop == "" or timer_stop == nil do
# Getting the changeset to change in case there's an error
changeset_obj = Enum.at(timer_changeset_list, index)
try do
# Parsing the dates
{start_op, start} =
Timex.parse(timer_start, "%Y-%m-%dT%H:%M:%S", :strftime)
# Error guards when parsing the date
if start_op === :error do
throw(:error_invalid_start)
end
# Getting a list of the other timers (the rest we aren't updating)
other_timers_list = List.delete_at(timer_changeset_list, index)
# If there are other timers, we check if there are no overlap
if(length(other_timers_list) > 0) do
# Latest timer end
max_end =
other_timers_list |> Enum.map(fn chs -> chs.data.stop end) |> Enum.max()
case NaiveDateTime.compare(start, max_end) do
:gt ->
timer = get_timer(timer_id)
update_timer(timer, %{start: start, stop: nil})
{:ok, []}
_ ->
throw(:error_not_after_others)
end
# If there are no other timers, we can update the timer safely
else
timer = get_timer(timer_id)
update_timer(timer, %{start: start, stop: nil})
{:ok, []}
end
catch
:error_invalid_start ->
updated_changeset_timers_list =
Timer.error_timer_changeset(
timer_changeset_list,
changeset_obj,
index,
:id,
"Start field has an invalid date format.",
:update
)
{:error, updated_changeset_timers_list}
:error_not_after_others ->
updated_changeset_timers_list =
Timer.error_timer_changeset(
timer_changeset_list,
changeset_obj,
index,
:id,
"When editing an ongoing timer, make sure it's after all the others.",
:update
)
{:error, updated_changeset_timers_list}
end
end
def update_timer_inside_changeset_list(
%{
id: timer_id,
start: timer_start,
stop: timer_stop
},
index,
timer_changeset_list
) do
# Getting the changeset to change in case there's an error
changeset_obj = Enum.at(timer_changeset_list, index)
try do
# Parsing the dates
{start_op, start} =
Timex.parse(timer_start, "%Y-%m-%dT%H:%M:%S", :strftime)
{stop_op, stop} = Timex.parse(timer_stop, "%Y-%m-%dT%H:%M:%S", :strftime)
# Error guards when parsing the dates
if start_op === :error do
throw(:error_invalid_start)
end
if stop_op === :error do
throw(:error_invalid_stop)
end
case NaiveDateTime.compare(start, stop) do
:lt ->
# Creates a list of all other timers to check for overlap
other_timers_list = List.delete_at(timer_changeset_list, index)
# Timer overlap verification ---------
for chs <- other_timers_list do
chs_start = chs.data.start
chs_stop = chs.data.stop
# If the timer being compared is ongoing
if chs_stop == nil do
compareStart = NaiveDateTime.compare(start, chs_start)
compareEnd = NaiveDateTime.compare(stop, chs_start)
# The condition needs to FAIL so the timer doesn't overlap
if compareStart == :lt && compareEnd == :gt do
throw(:error_overlap)
end
# Else the timer being compared is historical
else
# The condition needs to FAIL (StartA <= EndB) and (EndA >= StartB)
# so no timer overlaps one another
compareStartAEndB = NaiveDateTime.compare(start, chs_stop)
compareEndAStartB = NaiveDateTime.compare(stop, chs_start)
if(
(compareStartAEndB == :lt || compareStartAEndB == :eq) &&
(compareEndAStartB == :gt || compareEndAStartB == :eq)
) do
throw(:error_overlap)
end
end
end
update_timer(%{id: timer_id, start: start, stop: stop})
{:ok, []}
:eq ->
throw(:error_start_equal_stop)
:gt ->
throw(:error_start_greater_than_stop)
end
catch
:error_invalid_start ->
updated_changeset_timers_list =
Timer.error_timer_changeset(
timer_changeset_list,
changeset_obj,
index,
:id,
"Start field has an invalid date format.",
:update
)
{:error, updated_changeset_timers_list}
:error_invalid_stop ->
updated_changeset_timers_list =
Timer.error_timer_changeset(
timer_changeset_list,
changeset_obj,
index,
:id,
"Stop field has an invalid date format.",
:update
)
{:error, updated_changeset_timers_list}
:error_overlap ->
updated_changeset_timers_list =
Timer.error_timer_changeset(
timer_changeset_list,
changeset_obj,
index,
:id,
"This timer interval overlaps with other timers. Make sure all the timers are correct and don't overlap with each other",
:update
)
{:error, updated_changeset_timers_list}
:error_start_equal_stop ->
updated_changeset_timers_list =
Timer.error_timer_changeset(
timer_changeset_list,
changeset_obj,
index,
:id,
"Start or stop are equal.",
:update
)
{:error, updated_changeset_timers_list}
:error_start_greater_than_stop ->
updated_changeset_timers_list =
Timer.error_timer_changeset(
timer_changeset_list,
changeset_obj,
index,
:id,
"Start is newer that stop.",
:update
)
{:error, updated_changeset_timers_list}
end
end
That is a lot of code! But it's fairly simple.
Firstly, these two functions are called according to
pattern matching of the timer_stop
field.
If timer_stop
field is empty, we assume it's an
ongoing timer being edited.
If both timer_start
and timer_stop
is being edited,
it's because the person is changing an old timer.
Inside both functions, the flow is the same.
We first get the timer changeset being edited
by using the index
parameter and the passed changeset list.
After this, we try to parse the field using Timex
.
If this doesn't work, we throw an error.
All of errors thrown are later caught.
If the parse is successful, we compare the
start
and stop
fields and check if the start
is newer than stop
or if they're equal.
This is not allowed, so we throw an error if this is the case.
If these verifications are passed, in the case of
ongoing timers, we check if the timer start
being edited
is after all the timers.
In the case of old timer being updated,
we check if there is an overlap with the rest of the timers.
If all of these validations are successful,
the timer is updated.
If not, the error that was thrown is caught
using catch
.
Depending on the error, we add a different error text
to be displayed on the form and then return the error.
In each error, we make use of the error_timer_changeset/6
function, which just replaces the timer inside the list
with a custom error to be displayed on the form.
Let's add this function.
def error_timer_changeset(
timer_changeset_list,
changeset_to_error,
changeset_index,
error_key,
error_message,
action
) do
# Clearing and adding error to changeset
cleared_changeset = Map.put(changeset_to_error, :errors, [])
errored_changeset =
Ecto.Changeset.add_error(
cleared_changeset,
error_key,
error_message
)
{_reply, errored_changeset} =
Ecto.Changeset.apply_action(errored_changeset, action)
# Updated list with errored changeset
List.replace_at(timer_changeset_list, changeset_index, errored_changeset)
end
And now all that's left is to change the UI! Let's do that.
Now let's focus on showing the timers in the UI. Head over to
lib/app_web/live/app_live.html.heex
and make the following changes.
We are showing each timer whenever an item
is being edited.
<%= if item.id == @editing do %>
<!-- Replace starts here -->
<div class="flex flex-col grow">
<form
phx-submit="update-item"
id={"form-update-item-#{item.id}"}
class="w-full pr-2"
>
<textarea
id={"textarea-editing-of-item-#{item.id}"}
class="w-full flex-auto text-slate-800
bg-white bg-clip-padding
transition ease-in-out
border border-b border-slate-200
focus:border-none focus:outline-none"
name="text"
placeholder="What is on your mind?"
autofocus
required="required"
value={item.text}
><%= item.text %></textarea>
<input
id={"tag-of-item-#{item.id}"}
type="text"
name="tags"
value={tags_to_string(item.tags)}
placeholder="tag1, tag2..."
/>
<input type="hidden" name="id" value={item.id} />
<div
class="flex justify-end mr-1"
id={"save-button-item-#{item.id}"}
>
<button class="inline-flex items-center px-2 py-1 mt-1 h-9
bg-green-700 hover:bg-green-800 text-white rounded-md">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z"
/>
</svg>
Save
</button>
</div>
</form>
<div>
<%= if (length @editing_timers) > 0 do %>
<h1 class="text-4xl font-bold">Timers</h1>
<% else %>
<h1 class="text-2xl text-center font-semibold text-slate-400">
No timers associated with this item.
</h1>
<% end %>
<div class="flex flex-col w-full mt-2">
<%= @editing_timers |> Enum.with_index |> Enum.map(fn({changeset, index}) -> %>
<.form
:let={f}
for={changeset}
phx-submit="update-item-timer"
id={"form-update-timer-#{changeset.data.id}"}
class="w-full pr-2"
>
<div class="flex flex-row w-full justify-between">
<div class="flex flex-row items-center">
<h3 class="mr-3">Start:</h3>
<input
type="text"
required="required"
name="timer_start"
id={"#{changeset.data.id}_start"}
value={
NaiveDateTime.add(
changeset.data.start,
@hours_offset_fromUTC,
:hour
)
}
/>
</div>
<div class="flex flex-row items-center">
<h3 class="mr-3">Stop:</h3>
<input
type="text"
name="timer_stop"
id={"#{changeset.data.id}_stop"}
value={
if is_nil(changeset.data.stop) do
changeset.data.stop
else
NaiveDateTime.add(
changeset.data.stop,
@hours_offset_fromUTC,
:hour
)
end
}
/>
</div>
<input
type="hidden"
name="timer_id"
value={changeset.data.id}
/>
<input type="hidden" name="index" value={index} />
<button
type="submit"
id={"button_timer-update-#{changeset.data.id}"}
class="text-white bg-blue-700
hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300
font-medium rounded-full text-sm px-5 py-2.5 text-center
mr-2 mb-2
dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Update
</button>
</div>
<span class="text-red-700">
<%= error_tag(f, :id) %>
</span>
</.form>
<% end) %>
</div>
</div>
</div>
<!-- Replace ends here -->
<% else %>
<!-- Render item.text as click-able label -->
As you can see from the snippet above,
for each timer related to an item
,
we are creating a form.
When the changes from the form are submitted, a
update-item-timer
event is created.
With this event, all the fields added inside
the form is passed on (the timer id
,
index
inside the timer changesetlist,
timer_start
and timer_stop
)
If we run source .env_sample
and
MIX_ENV=test mix coveralls.html ; open cover/excoveralls.html
we will see how coverage dropped.
We need to test the new handler we created when updating a timer,
as well as the update_timer
function added inside timer.ex
.
Paste the following test in test/app/timer_test.exs
.
test "update_timer(%{id: id, start: start, stop: stop}) should update the timer" do
start = ~N[2022-10-27 00:00:00]
stop = ~N[2022-10-27 05:00:00]
{:ok, item} = Item.create_item(@valid_item_attrs)
{:ok, seven_seconds_ago} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7))
# Start the timer 7 seconds ago:
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago})
# Stop the timer based on its item_id
Timer.stop_timer_for_item_id(item.id)
# Update timer to specific datetimes
Timer.update_timer(%{id: timer.id, start: start, stop: stop})
updated_timer = Timer.get_timer!(timer.id)
assert updated_timer.start == start
assert updated_timer.stop == stop
end
We now test the newly created update-item-timer
event.
In test/app_web/live/app_live_test.exs
, add the following test.
test "update an item's timer", %{conn: conn} do
start = "2022-10-27T00:00:00"
stop = "2022-10-27T05:00:00"
start_datetime = ~N[2022-10-27 00:00:00]
stop_datetime = ~N[2022-10-27 05:00:00]
{:ok, item} =
Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2})
{:ok, seven_seconds_ago} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7))
# Start the timer 7 seconds ago:
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago})
# Stop the timer based on its item_id
Timer.stop_timer_for_item_id(item.id)
{:ok, view, _html} = live(conn, "/")
# Update successful
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
assert render_submit(view, "update-item-timer", %{
"timer_id" => timer.id,
"index" => 0,
"timer_start" => start,
"timer_stop" => stop
})
updated_timer = Timer.get_timer!(timer.id)
assert updated_timer.start == start_datetime
assert updated_timer.stop == stop_datetime
# Trying to update with equal values on start and stop
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
assert render_submit(view, "update-item-timer", %{
"timer_id" => timer.id,
"index" => 0,
"timer_start" => start,
"timer_stop" => start
}) =~ "Start or stop are equal."
# Trying to update with equal start greater than stop
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
assert render_submit(view, "update-item-timer", %{
"timer_id" => timer.id,
"index" => 0,
"timer_start" => stop,
"timer_stop" => start
}) =~ "Start is newer that stop."
# Trying to update with start as invalid format
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
assert render_submit(view, "update-item-timer", %{
"timer_id" => timer.id,
"index" => 0,
"timer_start" => "invalid",
"timer_stop" => stop
}) =~ "Start field has an invalid date format."
# Trying to update with stop as invalid format
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
assert render_submit(view, "update-item-timer", %{
"timer_id" => timer.id,
"index" => 0,
"timer_start" => start,
"timer_stop" => "invalid"
}) =~ "Stop field has an invalid date format."
end
We also need to change the test "edit-timer"
test because it's failing.
We have changed the id of the form when changing the .heex
template.
Change the test to the following.
test "edit-item", %{conn: conn} do
{:ok, item} =
Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2})
{:ok, view, _html} = live(conn, "/")
assert render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) =~
"<form phx-submit=\"update-item\" id=\"form-update"
end
Let's add more tests for the edge cases.
Let's test ongoing timers and overlapping 😄
In the same test_live_test.exs
file,
add the following tests.
test "update timer timer with ongoing timer ", %{conn: conn} do
{:ok, item} =
Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2})
{:ok, seven_seconds_ago} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7))
{:ok, now} = NaiveDateTime.new(Date.utc_today(), Time.utc_now())
{:ok, four_seconds_ago} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -4))
{:ok, ten_seconds_after} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), 10))
# Start the timer 7 seconds ago:
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago})
# Stop the timer based on its item_id
Timer.stop_timer_for_item_id(item.id)
# Start a second timer
{:ok, timer2} = Timer.start(%{item_id: item.id, person_id: 1, start: now})
# Stop the timer based on its item_id
Timer.stop_timer_for_item_id(item.id)
{:ok, view, _html} = live(conn, "/")
# Update fails because of overlap timer -----------
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
four_seconds_ago_string =
NaiveDateTime.truncate(four_seconds_ago, :second)
|> NaiveDateTime.to_string()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map(fn {value, index} ->
if index == 10 do
"T"
else
value
end
end)
|> List.to_string()
now_string =
NaiveDateTime.truncate(now, :second)
|> NaiveDateTime.to_string()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map(fn {value, index} ->
if index == 10 do
"T"
else
value
end
end)
|> List.to_string()
error_view =
render_submit(view, "update-item-timer", %{
"timer_id" => timer2.id,
"index" => 1,
"timer_start" => four_seconds_ago_string,
"timer_stop" => ""
})
assert error_view =~ "When editing an ongoing timer"
# Update fails because of format -----------
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
error_format_view =
render_submit(view, "update-item-timer", %{
"timer_id" => timer2.id,
"index" => 1,
"timer_start" => "invalidformat",
"timer_stop" => ""
})
assert error_format_view =~ "Start field has an invalid date format."
# Update successful -----------
ten_seconds_after_string =
NaiveDateTime.truncate(ten_seconds_after, :second)
|> NaiveDateTime.to_string()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map(fn {value, index} ->
if index == 10 do
"T"
else
value
end
end)
|> List.to_string()
ten_seconds_after_datetime =
NaiveDateTime.truncate(ten_seconds_after, :second)
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
view =
assert render_submit(view, "update-item-timer", %{
"timer_id" => timer2.id,
"index" => 1,
"timer_start" => ten_seconds_after_string,
"timer_stop" => ""
})
updated_timer2 = Timer.get_timer!(timer2.id)
assert updated_timer2.start == ten_seconds_after_datetime
end
test "timer overlap error when updating timer", %{conn: conn} do
{:ok, item} =
Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2})
{:ok, seven_seconds_ago} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7))
{:ok, now} = NaiveDateTime.new(Date.utc_today(), Time.utc_now())
{:ok, four_seconds_ago} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -4))
# Start the timer 7 seconds ago:
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago})
# Stop the timer based on its item_id
Timer.stop_timer_for_item_id(item.id)
# Start a second timer
{:ok, timer2} = Timer.start(%{item_id: item.id, person_id: 1, start: now})
# Stop the timer based on its item_id
Timer.stop_timer_for_item_id(item.id)
{:ok, view, _html} = live(conn, "/")
# Update fails because of overlap -----------
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
four_seconds_ago_string =
NaiveDateTime.truncate(four_seconds_ago, :second)
|> NaiveDateTime.to_string()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map(fn {value, index} ->
if index == 10 do
"T"
else
value
end
end)
|> List.to_string()
now_string =
NaiveDateTime.truncate(now, :second)
|> NaiveDateTime.to_string()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map(fn {value, index} ->
if index == 10 do
"T"
else
value
end
end)
|> List.to_string()
assert render_submit(view, "update-item-timer", %{
"timer_id" => timer2.id,
"index" => 0,
"timer_start" => four_seconds_ago_string,
"timer_stop" => now_string
}) =~ "This timer interval overlaps with other timers."
end
test "timer overlap error when updating historical timer with ongoing timer",
%{conn: conn} do
{:ok, item} =
Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2})
{:ok, seven_seconds_ago} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7))
{:ok, now} = NaiveDateTime.new(Date.utc_today(), Time.utc_now())
{:ok, twenty_seconds_future} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), 20))
# Start the timer 7 seconds ago:
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago})
# Stop the timer based on its item_id
Timer.stop_timer_for_item_id(item.id)
# Start a second timer
{:ok, timer2} = Timer.start(%{item_id: item.id, person_id: 1, start: now})
{:ok, view, _html} = live(conn, "/")
# Update fails because of overlap -----------
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
seven_seconds_ago_string =
NaiveDateTime.truncate(seven_seconds_ago, :second)
|> NaiveDateTime.to_string()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map(fn {value, index} ->
if index == 10 do
"T"
else
value
end
end)
|> List.to_string()
twenty_seconds_string =
NaiveDateTime.truncate(twenty_seconds_future, :second)
|> NaiveDateTime.to_string()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map(fn {value, index} ->
if index == 10 do
"T"
else
value
end
end)
|> List.to_string()
assert render_submit(view, "update-item-timer", %{
"timer_id" => timer.id,
"index" => 0,
"timer_start" => seven_seconds_ago_string,
"timer_stop" => twenty_seconds_string
}) =~ "This timer interval overlaps with other timers."
end
Let us not forget we also changed the way
the update
event is broadcasted.
It now updates the socket assigns depending
on whether an item is being edited or not.
Let's add tests in the same file to cover these scenarios
test "handle_info/2 update with editing open (start)", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
{:ok, item} =
Item.create_item(%{text: "Always Learning", person_id: 0, status: 2})
{:ok, now} = NaiveDateTime.new(Date.utc_today(), Time.utc_now())
now_string =
NaiveDateTime.truncate(now, :second)
|> NaiveDateTime.to_string()
|> String.graphemes()
|> Enum.with_index()
|> Enum.map(fn {value, index} ->
if index == 10 do
"T"
else
value
end
end)
|> List.to_string()
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
render_click(view, "start", %{"id" => Integer.to_string(item.id)})
# The editing panel is open and showing the newly created timer on the 'Start' text input field
assert render(view) =~ now_string
end
test "handle_info/2 update with editing open (stop)", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
{:ok, item} =
Item.create_item(%{text: "Always Learning", person_id: 0, status: 2})
{:ok, seven_seconds_ago} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7))
# Start the timer 7 seconds ago:
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago})
# Stop the timer based on its item_id
Timer.stop_timer_for_item_id(item.id)
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
render_click(view, "start", %{"id" => Integer.to_string(item.id)})
render_click(view, "stop", %{"timerid" => timer.id, "id" => item.id})
num_timers_rendered =
(render(view) |> String.split("Update") |> length()) - 1
# Checking if two timers were rendered
assert num_timers_rendered == 2
end
test "handle_info/2 update with editing open (delete)", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
{:ok, item} =
Item.create_item(%{text: "Always Learning", person_id: 0, status: 2})
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
send(view.pid, %Broadcast{
event: "update",
payload: :delete
})
assert render(view) =~ item.text
end
Having a page to track metrics regarding app usage is important two-fold:
- if you are a developer, it's crucial to know if and how the app is being used, to better implement features in the future.
- if you are a
person
, you want to view aggregate stats of how manyitems
andtimers
you created so you know how to improve your personal effectiveness.
Let's create a simple /stats
dashboard
to display the number of items
and timers
each person
has created
in a simple table.
Let's roll!
Open
lib/app_web/router.ex
and add the new route inside the "/"
scope.
...
get "/login", AuthController, :login
get "/logout", AuthController, :logout
live "/stats", StatsLive
Now create the StatsLive
file
with the path:
lib/app_web/live/stats_live.ex
and template at:
lib/app_web/live/stats_live.html.heex
.
In stats_live.ex
,
paste the following code:
defmodule AppWeb.StatsLive do
require Logger
use AppWeb, :live_view
alias App.Item
alias Phoenix.Socket.Broadcast
# run authentication on mount
on_mount(AppWeb.AuthController)
@stats_topic "stats"
@impl true
def mount(_params, _session, socket) do
# subscribe to the channel
if connected?(socket), do: AppWeb.Endpoint.subscribe(@stats_topic)
metrics = Item.person_with_item_and_timer_count()
{:ok,
assign(socket,
metrics: metrics
)}
end
@impl true
def handle_info(
%Broadcast{topic: @stats_topic, event: "item", payload: payload},
socket
) do
metrics = socket.assigns.metrics
case payload do
{:create, payload: payload} ->
updated_metrics = Enum.map(metrics, fn row -> add_row(row, payload, :num_items) end)
{:noreply, assign(socket, metrics: updated_metrics)}
_ ->
{:noreply, socket}
end
end
@impl true
def handle_info(
%Broadcast{topic: @stats_topic, event: "timer", payload: payload},
socket
) do
metrics = socket.assigns.metrics
case payload do
{:create, payload: payload} ->
updated_metrics = Enum.map(metrics, fn row -> add_row(row, payload, :num_timers) end)
{:noreply, assign(socket, metrics: updated_metrics)}
_ ->
{:noreply, socket}
end
end
def add_row(row, payload, key) do
row =
if row.person_id == payload.person_id do
Map.put(row, key, Map.get(row, key) + 1)
else
row
end
row
end
def person_link(person_id) do
"https://auth.dwyl.com/people/#{person_id}"
end
end
Let's break this down.
On mount
, we are retrieving
an array containing the number of items and timers of each person (id
and name
).
We are calling Item.person_with_item_and_timer_count()
for this
(we will implement this right after this, don't worry!).
This LiveView
is subscribed to a channel called stats
,
and has two handlers which increment the number of timers
or items
in real-time whenever a person
joins.
For this to actually work,
we need to broadcast to this channel.
person_link/1
is merely used to display their
profile in auth.dwyl.com
We will do this shortly!
But first, let's implement Item.person_with_item_and_timer_count()
.
In lib/app/stats.ex
,
add the following function.
def person_with_item_and_timer_count() do
sql = """
SELECT i.person_id,
COUNT(distinct i.id) AS "num_items",
COUNT(distinct t.id) AS "num_timers"
FROM items i
LEFT JOIN timers t ON t.item_id = i.id
GROUP BY i.person_id
ORDER BY i.person_id
"""
Ecto.Adapters.SQL.query!(Repo, sql)
|> map_columns_to_values()
end
We are simply executing an SQL query expression
and retrieving it.
This function should yield a list of objects
containing person_id
, name
, num_items
and num_timers
.
[
%{name: nil, num_items: 3, num_timers: 8, person_id: 0}
%{name: username, num_items: 1, num_timers: 3, person_id: 1}
]
We've created lib/app_web/live/stats_live.html.heex
but haven't implemented anything.
Let's fix that now.
Add this code to the file.
<main class="font-sans container mx-auto">
<div class="relative overflow-x-auto mt-12">
<h1 class="mb-12 text-xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
Usage metrics
</h1>
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
Id
</th>
<th scope="col" class="px-6 py-3">
Number of items
</th>
<th scope="col" class="px-6 py-3">
Number of timers
</th>
</tr>
</thead>
<tbody>
<%= for metric <- @metrics do %>
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td class="px-6 py-4">
<a href={person_link(metric.person_id)}>
<%= metric.person_id %>
</a>
</td>
<td class="px-6 py-4">
<%= metric.num_items %>
</td>
<td class="px-6 py-4">
<%= metric.num_timers %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</main>
We are simply creating a table with four columns,
one for person_id
, person name
, number of items
and number of timers
.
We are accessing the @metrics
array
that is fetched and assigned on mount/3
inside stats_live.ex
.
The only thing that is left to implement
is broadcasting events from lib/app_web/live/app_live.ex
so stats_live.ex
handles them and updates stats_live.html.heex
accordingly.
In lib/app_web/live/app_live.ex
,
let's create a new constant value
for the stats
channel.
@topic "live"
@stats_topic "stats" # add this
Whenever an item
or timer
is created,
an event is going to be broadcasted to this channel.
Let's subscribe to the stats
channel on mount,
similarly to what happens with live
.
In mount/3
,
subscribe to the stats
channel
like we are doing to the live
one.
if connected?(socket), do:
AppWeb.Endpoint.subscribe(@topic)
AppWeb.Endpoint.subscribe(@stats_topic)
Now, in the
handle_event("create", %{"text" => text}, socket)
function,
broadcast an event to the stats
channel
whenever an item
is created.
def handle_event("create", %{"text" => text}, socket) do
person_id = get_person_id(socket.assigns)
Item.create_item_with_tags(%{
text: text,
person_id: person_id,
status: 2,
tags: socket.assigns.selected_tags
})
AppWeb.Endpoint.broadcast(@topic, "update", :create)
AppWeb.Endpoint.broadcast(@stats_topic, "item", {:create, payload: %{person_id: person_id}}) # add this
{:noreply, assign(socket, text_value: "", selected_tags: [])}
end
Similarly, do the same for timers
.
def handle_event("start", data, socket) do
item = Item.get_item!(Map.get(data, "id"))
person_id = get_person_id(socket.assigns)
{:ok, _timer} =
Timer.start(%{
item_id: item.id,
person_id: person_id,
start: NaiveDateTime.utc_now()
})
AppWeb.Endpoint.broadcast(@topic, "update", {:start, item.id})
AppWeb.Endpoint.broadcast(@stats_topic, "timer", {:create, payload: %{person_id: person_id}}) # add this
{:noreply, socket}
end
In the same file,
app_live.ex
also needs to have a handler
of these new event broadcasts,
or else an error is thrown.
no function clause matching in AppWeb.AppLive.handle_info/2
app_live.ex
doesn't really care about these events,
so we do nothing with them.
Add the following function for this.
@impl true
def handle_info(%Broadcast{topic: @stats_topic, event: _event, payload: _payload}, socket) do
{:noreply, socket}
end
Let's add the tests to cover the use case we just created.
Firstly, let's create a file:
test/app_web/live/stats_live_test.exs
and add the following code to it.
defmodule AppWeb.StatsLiveTest do
use AppWeb.ConnCase
alias App.{Item, Person, Timer, Tag}
import Phoenix.LiveViewTest
alias Phoenix.Socket.Broadcast
@person_id 55
setup [:create_person]
test "disconnected and connected render", %{conn: conn} do
{:ok, page_live, disconnected_html} = live(conn, "/stats")
assert disconnected_html =~ "Usage metrics"
assert render(page_live) =~ "Usage metrics"
end
test "display metrics on mount", %{conn: conn} do
# Creating two items
{:ok, item} =
Item.create_item(%{text: "Learn Elixir", status: 2, person_id: @person_id})
{:ok, _item2} =
Item.create_item(%{text: "Learn Elixir", status: 4, person_id: @person_id})
assert item.status == 2
# Creating one timer
started = NaiveDateTime.utc_now()
{:ok, _timer} = Timer.start(%{item_id: item.id, start: started})
{:ok, page_live, _html} = live(conn, "/stats")
assert render(page_live) =~ "Stats"
# two items and one timer expected
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n2\n </td><td class=\"px-6 py-4 text-center\">\n1\n </td>"
end
test "handle broadcast when item is created", %{conn: conn} do
# Creating an item
{:ok, _item} =
Item.create_item(%{text: "Learn Elixir", status: 2, person_id: @person_id})
{:ok, page_live, _html} = live(conn, "/stats")
assert render(page_live) =~ "Stats"
# num of items
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n1\n </td><td class=\"px-6 py-4 text-center\">\n0\n </td>"
# Creating another item.
AppWeb.Endpoint.broadcast(
"stats",
"item",
{:create, payload: %{person_id: @person_id}}
)
# num of items
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n2\n </td><td class=\"px-6 py-4 text-center\">\n0\n </td>"
# Broadcasting update. Shouldn't effect anything in the page
AppWeb.Endpoint.broadcast(
"stats",
"item",
{:update, payload: %{person_id: @person_id}}
)
# num of items
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n2\n </td><td class=\"px-6 py-4 text-center\">\n0\n </td>"
end
test "handle broadcast when timer is created", %{conn: conn} do
# Creating an item
{:ok, _item} =
Item.create_item(%{text: "Learn Elixir", status: 2, person_id: @person_id})
{:ok, page_live, _html} = live(conn, "/stats")
assert render(page_live) =~ "Stats"
# num of timers
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n1\n </td><td class=\"px-6 py-4 text-center\">\n0\n </td>"
# Creating a timer.
AppWeb.Endpoint.broadcast(
"stats",
"timer",
{:create, payload: %{person_id: @person_id}}
)
# num of timers
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n1\n </td><td class=\"px-6 py-4 text-center\">\n1\n </td>"
# Broadcasting update. Shouldn't effect anything in the page
AppWeb.Endpoint.broadcast(
"stats",
"timer",
{:update, payload: %{person_id: @person_id}}
)
# num of timers
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n1\n </td><td class=\"px-6 py-4 text-center\">\n1\n </td>"
end
test "add_row/3 adds 1 to row.num_timers" do
row = %{person_id: 1, num_items: 1, num_timers: 1}
payload = %{person_id: 1}
# expect row.num_timers to be incremented by 1:
row_updated = AppWeb.StatsLive.add_row(row, payload, :num_timers)
assert row_updated == %{person_id: 1, num_items: 1, num_timers: 2}
# no change expected:
row2 = %{person_id: 2, num_items: 1, num_timers: 42}
assert row2 == AppWeb.StatsLive.add_row(row2, payload, :num_timers)
end
end
We've now covered the stats_live.ex
file thoroughly.
The last thing we need to do
is to add a test for the person_with_item_and_timer_count/0
function that was implemented inside lib/app/item.ex
.
Open test/app/stats_test.exs
and add this test.
test "Item.person_with_item_and_timer_count/0 returns a list of count of timers and items for each given person" do
{:ok, item1} = Item.create_item(@valid_attrs)
{:ok, item2} = Item.create_item(@valid_attrs)
started = NaiveDateTime.utc_now()
{:ok, timer1} =
Timer.start(%{
item_id: item1.id,
person_id: item1.person_id,
start: started,
stop: started
})
{:ok, _timer2} =
Timer.start(%{item_id: item2.id, person_id: item2.person_id, start: started})
# list person with number of timers and items
person_with_items_timers = Item.person_with_item_and_timer_count()
assert length(person_with_items_timers) == 1
first_element = Enum.at(person_with_items_timers, 0)
assert Map.get(first_element, :num_items) == 2
assert Map.get(first_element, :num_timers) == 2
end
And you're done!
Awesome job! 🎉
If you run mix phx.server
,
you should see /stats
being updated live
when creating timers
or items
.
Note: more stats docs in: dwyl.github.io/book/mvp/stats
Tracking changes that each item
is subjected to
over time is important not only for statistics
but to also implement features
that such as rolling back changes.
For this, we are going to be using
PaperTrail
,
which makes it easy for us to record each change
on each item
transaction occurred in the database.
In this section we are going to explain
the process of implementing this feature.
Some of these steps overlap
phoenix-papertrail-demo
's.
If you are interested in a more in-depth tutorial,
we recommend you take a gander there!
Install paper_trail
.
def deps do
[
...
{:paper_trail, "~> 0.14.3"}
]
end
And add this config to config/config.exs
.
config :paper_trail, repo: App.Repo
This will let PaperTrail
know the reference of the database repo,
so it can sabe the changes correctly.
After this, run the following commands.
mix deps.get
mix compile
mix papertrail.install
This will install dependencies, compile the project
and create a migration for creating the versions
table.
This will be the table where all the changes will be recorded.
If you open the created migration in
priv/repo/migrations/XXX_add_versions.exs
,
you will find the schema for te table.
create table(:versions) do
add :event, :string, null: false, size: 10
add :item_type, :string, null: false
add :item_id, :integer
add :item_changes, :map, null: false
add :originator_id, references(:users) # you can change :users to your own foreign key constraint
add :origin, :string, size: 50
add :meta, :map
# Configure timestamps type in config.ex :paper_trail :timestamps_type
add :inserted_at, :utc_datetime, null: false
end
You can find more information about what field means
in phoenix-papertrail-demo
.
We are going to be changing the originator_id
to the following line.
add :originator_id, :integer
The originator_id
refers to "who made this change".
In the case of our application,
we want to use the person_id
that is used when logging in
with a third-party provider.
Do note that we do not save a person's information.
So the person_id
will need to be passed each time
a CRUD operation occurs
(or not, if the operation is anonymous).
We want to track the changes when an item
is inserted or updated.
To do this,
we can use PaperTrail.insert
and PaperTrail.update
methods.
We are going to replace Repo.insert
and Repo.update
functions
with these.
Head over to lib/app/item.ex
,
add the import
and make these changes in create_item/1
,
create_item_with_tags/1
and update_item/2
.
alias PaperTrail
def create_item(attrs) do
%Item{}
|> changeset(attrs)
|> PaperTrail.insert(originator: %{id: Map.get(attrs, :person_id, 0)})
end
def create_item_with_tags(attrs) do
%Item{}
|> changeset_with_tags(attrs)
|> PaperTrail.insert(originator: %{id: Map.get(attrs, :person_id, 0)})
end
def update_item(%Item{} = item, attrs) do
item
|> Item.changeset(attrs)
|> PaperTrail.update(originator: %{id: Map.get(attrs, :person_id, 0)})
end
We are using the person_id
and using it to be placed in the originator_id
column
in the Versions
table.
If no person_id
is passed,
we default to id=0
.
We want to successfully pass the person_id
when the item is toggled, as well.
In lib/app_web/live/app_live.ex
,
in handle_event("toggle", data, socket)
,
update it so it looks like the following:
@impl true
def handle_event("toggle", data, socket) do
person_id = get_person_id(socket.assigns)
# Toggle the status of the item between 3 (:active) and 4 (:done)
status = if Map.has_key?(data, "value"), do: 4, else: 3
# need to restrict getting items to the people who own or have rights to access them!
item = Item.get_item!(Map.get(data, "id"))
Item.update_item(item, %{status: status, person_id: person_id})
Timer.stop_timer_for_item_id(item.id)
AppWeb.Endpoint.broadcast(@topic, "update", :toggle)
{:noreply, socket}
end
e.g.
lib/app_web/live/app_live.ex
We are now passing the person_id
when toggling
(which is an updating operation) an item.
If you run mix test
,
you will notice we've broken quite a few of them!
..
Finished in 1.7 seconds (0.3s async, 1.3s sync)
78 tests, 26 failures
Randomized with seed 205107
Not to worry though, we can fix them!
The reason these tests are failing is because,
unlike operations like Repo.insert
or Repo.update
,
PaperTrail.insert
and PaperTrail.update
return a struct with two fields:
- the model changeset.
PaperTrail
version object, which contains info about the changes made, versioning, etc.
The returned struct looks like so:
{:ok,
%{
model: %Item{__meta__: Ecto.Schema.Metadata<:loaded, "items"> ...},
version: %PaperTrail.Version{__meta__: Ecto.Schema.Metadata<:loaded, "versions">...}
}
}
Many tests are creating items
like:
{:ok, item} = Item.create_item(@valid_attrs)
which should be changed to:
{:ok, %{model: item, version: version}} = Item.create_item(@valid_attrs)
To fix the tests, all instances
of Item.create_item
,
Item.create_item_with_tags
and Item.update_item
should be changed according
to the example above.
Additionally, since we are using id=0
as the default person_id
,
inside test/app/item_test.exs
,
update the @update_attrs
to the following:
@update_attrs %{text: "some updated text", person_id: 1}
This will make sure the isolated tests pass successfully.
You can see the updated files in:
If you run mix phx.server
and create/update items,
you will see these events being tracked in the Versions
table.
We often use DBeaver
,
which is a PostgreSQL GUI.
If you don't have this installed,
we highly recommend you doing so.
As you can see, update/insert events are being tracked,
with the corresponding person_id
(in originator_id
),
the item_id
that is being edited
and the corresponding changes.
Having a page to track metrics regarding app usage is important two-fold:
- if you are a developer, it's crucial to know if and how the app is being used, to better implement features in the future.
- if you are a
person
using theApp
, you want to view aggregate stats of how manyitems
andtimers
you created so you know how to improve your personal effectiveness.
Let's create a simple /stats
dashboard
to display the number of items
and timers
each person
has created
in a simple table.
Let's roll!
![NOTE] If you want to see an upgraded version with more columns and sortable columns, do check our
dwyl/book
. It has a run-through of a different stats page that might interest you 😊.
Open
lib/app_web/router.ex
and add the new route inside the "/"
scope.
...
get "/login", AuthController, :login
get "/logout", AuthController, :logout
live "/stats", StatsLive
Now create the StatsLive
file
with the path:
lib/app_web/live/stats_live.ex
and template at:
lib/app_web/live/stats_live.html.heex
.
In stats_live.ex
,
paste the following code:
defmodule AppWeb.StatsLive do
require Logger
use AppWeb, :live_view
alias App.Item
alias Phoenix.Socket.Broadcast
# run authentication on mount
on_mount(AppWeb.AuthController)
@stats_topic "stats"
@impl true
def mount(_params, _session, socket) do
# subscribe to the channel
if connected?(socket), do: AppWeb.Endpoint.subscribe(@stats_topic)
metrics = Item.person_with_item_and_timer_count()
{:ok,
assign(socket,
metrics: metrics
)}
end
@impl true
def handle_info(
%Broadcast{topic: @stats_topic, event: "item", payload: payload},
socket
) do
metrics = socket.assigns.metrics
case payload do
{:create, payload: payload} ->
updated_metrics =
Enum.map(metrics, fn row -> add_row(row, payload, :num_items) end)
{:noreply, assign(socket, metrics: updated_metrics)}
_ ->
{:noreply, socket}
end
end
@impl true
def handle_info(
%Broadcast{topic: @stats_topic, event: "timer", payload: payload},
socket
) do
metrics = socket.assigns.metrics
case payload do
{:create, payload: payload} ->
updated_metrics =
Enum.map(metrics, fn row -> add_row(row, payload, :num_timers) end)
{:noreply, assign(socket, metrics: updated_metrics)}
_ ->
{:noreply, socket}
end
end
def add_row(row, payload, key) do
row =
if row.person_id == payload.person_id do
Map.put(row, key, Map.get(row, key) + 1)
else
row
end
row
end
def person_link(person_id) do
"https://auth.dwyl.com/people/#{person_id}"
end
end
Let's break this down.
On mount
, we are retrieving
an array containing the number of items and timers of each person (id
and name
).
We are calling Item.person_with_item_and_timer_count()
for this
(we will implement this right after this, don't worry!).
This LiveView
is subscribed to a channel called stats
,
and has two handlers which increment the number of timers
or items
in real-time whenever a person
joins.
For this to actually work,
we need to broadcast to this channel.
We will do this shortly!
But first, let's implement Item.person_with_item_and_timer_count()
.
In lib/app/item.ex
,
add the following function:
def person_with_item_and_timer_count() do
sql = """
SELECT i.person_id,
COUNT(distinct i.id) AS "num_items",
COUNT(distinct t.id) AS "num_timers"
FROM items i
LEFT JOIN timers t ON t.item_id = i.id
GROUP BY i.person_id
ORDER BY i.person_id
"""
Ecto.Adapters.SQL.query!(Repo, sql)
|> map_columns_to_values()
end
We are simply executing an SQL query expression
and retrieving it.
This function should yield a list of objects
containing person_id
, name
, num_items
and num_timers
.
[
%{name: nil, num_items: 3, num_timers: 8, person_id: 0}
%{name: username, num_items: 1, num_timers: 3, person_id: 1}
]
We've created lib/app_web/live/stats_live.html.heex
but haven't implemented anything.
Let's fix that now.
Add this code to the file.
<main class="font-sans container mx-auto">
<div class="relative overflow-x-auto mt-12">
<h1 class="mb-2 text-xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
Stats
</h1>
<table class="text-sm text-left text-gray-500 dark:text-gray-400 table-auto">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
<th scope="col" class="px-6 py-3">
Id
</th>
<th scope="col" class="px-6 py-3 text-center">
Items
</th>
<th scope="col" class="px-6 py-3 text-center">
Timers
</th>
</tr>
</thead>
<tbody>
<%= for metric <- @metrics do %>
<tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<td class="px-6 py-4">
<a href={person_link(metric.person_id)}>
<%= metric.person_id %>
</a>
</td>
<td class="px-6 py-4 text-center">
<%= metric.num_items %>
</td>
<td class="px-6 py-4 text-center">
<%= metric.num_timers %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</main>
We are simply creating a table with four columns,
one for person_id
, person name
, number of items
and number of timers
.
We are acessing the @metrics
array
that is fetched and assigned on mount/3
inside stats_live.ex
.
The only thing that is left to implement
is broadcasting events from lib/app_web/live/app_live.ex
so stats_live.ex
handles them and updates stats_live.html.heex
accordingly.
In lib/app_web/live/app_live.ex
,
let's create a new constant value
for the stats
channel.
@topic "live"
@stats_topic "stats" # add this
Whenever an item
or timer
is created,
an event is going to be broadcasted to this channel.
Let's subscribe to the stats
channel on mount,
similarly to what happens with live
.
In mount/3
,
subscribe to the stats
channel
like we are doing to the live
one.
if connected?(socket), do:
AppWeb.Endpoint.subscribe(@topic)
AppWeb.Endpoint.subscribe(@stats_topic)
Now, in the
handle_event("create", %{"text" => text}, socket)
function,
broadcast an event to the stats
channel
whenever an item
is created.
def handle_event("create", %{"text" => text}, socket) do
person_id = get_person_id(socket.assigns)
Item.create_item_with_tags(%{
text: text,
person_id: person_id,
status: 2,
tags: socket.assigns.selected_tags
})
AppWeb.Endpoint.broadcast(@topic, "update", :create)
AppWeb.Endpoint.broadcast(@stats_topic, "item", {:create, payload: %{person_id: person_id}}) # add this
{:noreply, assign(socket, text_value: "", selected_tags: [])}
end
Similarly, do the same for timers
.
def handle_event("start", data, socket) do
item = Item.get_item!(Map.get(data, "id"))
person_id = get_person_id(socket.assigns)
{:ok, _timer} =
Timer.start(%{
item_id: item.id,
person_id: person_id,
start: NaiveDateTime.utc_now()
})
AppWeb.Endpoint.broadcast(@topic, "update", {:start, item.id})
AppWeb.Endpoint.broadcast(@stats_topic, "timer", {:create, payload: %{person_id: person_id}}) # add this
{:noreply, socket}
end
In the same file,
app_live.ex
also needs to have a handler
of these new event broadcasts,
or else an error is thrown.
no function clause matching in AppWeb.AppLive.handle_info/2
app_live.ex
doesn't really care about these events,
so we do nothing with them.
Add the following function for this.
@impl true
def handle_info(%Broadcast{topic: @stats_topic, event: _event, payload: _payload}, socket) do
{:noreply, socket}
end
Let's add the tests to cover the use case we just created.
Firstly, let's create a file:
test/app_web/live/stats_live_test.exs
and add the following code to it:
defmodule AppWeb.StatsLiveTest do
use AppWeb.ConnCase, async: true
alias App.{Item, Timer}
import Phoenix.LiveViewTest
@person_id 55
test "disconnected and connected render", %{conn: conn} do
{:ok, page_live, disconnected_html} = live(conn, "/stats")
assert disconnected_html =~ "Stats"
assert render(page_live) =~ "Stats"
end
test "display metrics on mount", %{conn: conn} do
# Creating two items
{:ok, %{model: item, version: _version}} =
Item.create_item(%{text: "Learn Elixir", status: 2, person_id: @person_id})
{:ok, %{model: _item2, version: _version}} =
Item.create_item(%{text: "Learn Elixir", status: 4, person_id: @person_id})
assert item.status == 2
# Creating one timer
started = NaiveDateTime.utc_now()
{:ok, _timer} = Timer.start(%{item_id: item.id, start: started})
{:ok, page_live, _html} = live(conn, "/stats")
assert render(page_live) =~ "Stats"
# two items and one timer expected
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n2\n </td><td class=\"px-6 py-4 text-center\">\n1\n </td>"
end
test "handle broadcast when item is created", %{conn: conn} do
# Creating an item
{:ok, %{model: _item, version: _version}} =
Item.create_item(%{text: "Learn Elixir", status: 2, person_id: @person_id})
{:ok, page_live, _html} = live(conn, "/stats")
assert render(page_live) =~ "Stats"
# num of items
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n1\n </td><td class=\"px-6 py-4 text-center\">\n0\n </td>"
# Creating another item.
AppWeb.Endpoint.broadcast(
"stats",
"item",
{:create, payload: %{person_id: @person_id}}
)
# num of items
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n2\n </td><td class=\"px-6 py-4 text-center\">\n0\n </td>"
# Broadcasting update. Shouldn't effect anything in the page
AppWeb.Endpoint.broadcast(
"stats",
"item",
{:update, payload: %{person_id: @person_id}}
)
# num of items
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n2\n </td><td class=\"px-6 py-4 text-center\">\n0\n </td>"
end
test "handle broadcast when timer is created", %{conn: conn} do
# Creating an item
{:ok, %{model: _item, version: _version}} =
Item.create_item(%{text: "Learn Elixir", status: 2, person_id: @person_id})
{:ok, page_live, _html} = live(conn, "/stats")
assert render(page_live) =~ "Stats"
# num of timers
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n1\n </td><td class=\"px-6 py-4 text-center\">\n0\n </td>"
# Creating a timer.
AppWeb.Endpoint.broadcast(
"stats",
"timer",
{:create, payload: %{person_id: @person_id}}
)
# num of timers
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n1\n </td><td class=\"px-6 py-4 text-center\">\n1\n </td>"
# Broadcasting update. Shouldn't effect anything in the page
AppWeb.Endpoint.broadcast(
"stats",
"timer",
{:update, payload: %{person_id: @person_id}}
)
# num of timers
assert render(page_live) =~
"<td class=\"px-6 py-4 text-center\">\n1\n </td><td class=\"px-6 py-4 text-center\">\n1\n </td>"
end
test "add_row/3 adds 1 to row.num_timers" do
row = %{person_id: 1, num_items: 1, num_timers: 1}
payload = %{person_id: 1}
# expect row.num_timers to be incremented by 1:
row_updated = AppWeb.StatsLive.add_row(row, payload, :num_timers)
assert row_updated == %{person_id: 1, num_items: 1, num_timers: 2}
# no change expected:
row2 = %{person_id: 2, num_items: 1, num_timers: 42}
assert row2 == AppWeb.StatsLive.add_row(row2, payload, :num_timers)
end
end
We've now covered the stats_live.ex
file thoroughly.
The last thing we need to do
is to add a test for the person_with_item_and_timer_count/0
function that was implemented inside lib/app/item.ex
.
Open test/app/item_test.exs
and add this test:
test "Item.person_with_item_and_timer_count/0 returns a list of count of timers and items for each given person" do
{:ok, item1} = Item.create_item(@valid_attrs)
{:ok, item2} = Item.create_item(@valid_attrs)
started = NaiveDateTime.utc_now()
{:ok, timer1} =
Timer.start(%{
item_id: item1.id,
person_id: item1.person_id,
start: started,
stop: started
})
{:ok, _timer2} =
Timer.start(%{item_id: item2.id, person_id: item2.person_id, start: started})
# list person with number of timers and items
person_with_items_timers = Item.person_with_item_and_timer_count()
assert length(person_with_items_timers) == 1
first_element = Enum.at(person_with_items_timers, 0)
assert Map.get(first_element, :num_items) == 2
assert Map.get(first_element, :num_timers) == 2
end
And you're done!
Awesome job! 🎉
If you run mix phx.server
,
you should see /stats
being updated live
when creating timers
or items
.
These modifications are designed to enhance functionality and improve user experience.
We'll cover updates made to the Repo
module, changes in the Tag
schema,
alterations in the TagController
and StatsLive
modules,
and updates to LiveView files.
The toggle_sort_order
function in the Repo
module allows us to dynamically change the sorting order of our database queries.
This is useful for features where the user can sort items in ascending or descending order that will be used throughout the whole app where we need to sort it.
lib/app/repo.ex
def toggle_sort_order(:asc), do: :desc
def toggle_sort_order(:desc), do: :asc
If the current order is :asc (ascending), it changes to :desc (descending), and vice versa.
Open lib/app/tag.ex
and add new fields to the Tag
schema.
field :last_used_at, :naive_datetime, virtual: true
field :items_count, :integer, virtual: true
field :total_time_logged, :integer, virtual: true
These fields are 'virtual', meaning they're not stored in the database but calculated on the fly.
The purposes of the fields are:
last_used_at
: the date a Tag was last used
items_count
: how many items are using the Tag
total_time_logged
: the total time that was logged with this particular Tag being used by a Item
We will add a new method that will query with these new fields on the same file.
Define list_person_tags_complete/3
:
def list_person_tags_complete(
person_id,
sort_column \\ :text,
sort_order \\ :asc
) do
sort_column =
if validate_sort_column(sort_column), do: sort_column, else: :text
Tag
|> where(person_id: ^person_id)
|> join(:left, [t], it in ItemTag, on: t.id == it.tag_id)
|> join(:left, [t, it], i in Item, on: i.id == it.item_id)
|> join(:left, [t, it, i], tm in Timer, on: tm.item_id == i.id)
|> group_by([t], t.id)
|> select([t, it, i, tm], %{
t
| last_used_at: max(it.inserted_at),
items_count: fragment("count(DISTINCT ?)", i.id),
total_time_logged:
sum(
coalesce(
fragment(
"EXTRACT(EPOCH FROM (? - ?))",
tm.stop,
tm.start
),
0
)
)
})
|> order_by(^get_order_by_keyword(sort_column, sort_order))
|> Repo.all()
end
And add these new methods at the end of the file:
defp validate_sort_column(column) do
Enum.member?(
[
:text,
:color,
:created_at,
:last_used_at,
:items_count,
:total_time_logged
],
column
)
end
defp get_order_by_keyword(sort_column, :asc) do
[asc: sort_column]
end
defp get_order_by_keyword(sort_column, :desc) do
[desc: sort_column]
end
These methods are used in the previous method to validate the columns that can be searched and to transform into keywords [asc: column] to work on the query.
First, we need to remove the index page from the tag_controller.ex
because we are going to include it on a new LiveView for Tags
.
This is needed because of the sorting events of the table.
So remove these next lines of code from the lib/app_web/controllers/tag_controller.ex
def index(conn, _params) do
person_id = conn.assigns[:person][:id] || 0
tags = Tag.list_person_tags(person_id)
render(conn, "index.html",
tags: tags,
lists: App.List.get_lists_for_person(person_id),
custom_list: false
)
end
Now, let's create the LiveView that will have the table for tags
and the redirections to all other pages on the TagController
.
Create a new file on lib/app_web/live/tags_live.ex
with the following content.
defmodule AppWeb.TagsLive do
use AppWeb, :live_view
alias App.{DateTimeHelper, Person, Tag, Repo}
# run authentication on mount
on_mount(AppWeb.AuthController)
@tags_topic "tags"
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: AppWeb.Endpoint.subscribe(@tags_topic)
person_id = Person.get_person_id(socket.assigns)
tags = Tag.list_person_tags_complete(person_id)
{:ok,
assign(socket,
tags: tags,
lists: App.List.get_lists_for_person(person_id),
custom_list: false,
sort_column: :text,
sort_order: :asc
)}
end
@impl true
def handle_event("sort", %{"key" => key}, socket) do
sort_column =
key
|> String.to_atom()
sort_order =
if socket.assigns.sort_column == sort_column do
Repo.toggle_sort_order(socket.assigns.sort_order)
else
:asc
end
person_id = Person.get_person_id(socket.assigns)
tags = Tag.list_person_tags_complete(person_id, sort_column, sort_order)
{:noreply,
assign(socket,
tags: tags,
sort_column: sort_column,
sort_order: sort_order
)}
end
def format_date(date) do
DateTimeHelper.format_date(date)
end
def format_seconds(seconds) do
DateTimeHelper.format_duration(seconds)
end
end
The whole code is similar to other LiveViews created on the project.
mount
This function is invoked when the LiveView component is mounted. It initializes the state of the LiveView.
on_mount(AppWeb.AuthController)
: This line ensures that authentication is run when the LiveView component mounts.- The
if connected?(socket)
block subscribes to a topic (@tags_topic) if the user is connected, enabling real-time updates. person_id
is retrieved to identify the current user.- tags are fetched using
Tag.list_person_tags_complete(person_id)
, which retrieves all tags associated with theperson_id
and is the method that we created previously. - The socket is assigned various values, such as tags, lists, custom_list, sort_column, and sort_order, setting up the initial state of the LiveView.
handle_event
This function is called when a "sort" event is triggered by user interaction on the UI.
sort_column
is set based on the event's key, determining which column to sort by.sort_order
is determined by the current state of sort_column and sort_order. If the sort_column is the same as the one already in the socket's assigns, the order is toggled usingRepo.toggle_sort_order
. Otherwise, it defaults to ascending (:asc).- Tags are then re-fetched with the new sort order and column, and the socket is updated with these new values.
- This dynamic sorting mechanism allows the user interface to update the display order of tags based on user interaction.
format_date(date)
Uses DateTimeHelper.format_date to format a given date.
format_seconds(seconds)
Uses DateTimeHelper.format_duration to format a duration in seconds into a more human-readable format.
Create a new file on lib/app_web/live/tags_live.html.heex
that will handle the LiveView created in the previous section:
<main class="font-sans container mx-auto">
<div class="relative overflow-x-auto mt-12">
<h1 class="mb-2 text-xl font-extrabold leading-none tracking-tight text-gray-900 md:text-5xl lg:text-6xl dark:text-white">
Listing Tags
</h1>
<.live_component
module={AppWeb.TableComponent}
id="tags_table_component"
rows={@tags}
sort_column={@sort_column}
sort_order={@sort_order}
highlight={fn _ -> false end}
>
<:column :let={tag} label="Name" key="text">
<td class="px-6 py-4 text-center" data-test-id={"text_#{tag.id}"}>
<%= tag.text %>
</td>
</:column>
<:column :let={tag} label="Color" key="color">
<td class="px-6 py-4 text-center" data-test-id={"color_#{tag.id}"}>
<span
style={"background-color:#{tag.color}"}
class="max-w-[144px] text-white font-bold py-1 px-2 rounded-full
overflow-hidden text-ellipsis whitespace-nowrap inline-block"
>
<%= tag.color %>
</span>
</td>
</:column>
<:column :let={tag} label="Created At" key="inserted_at">
<td class="px-6 py-4 text-center" data-test-id={"inserted_at_#{tag.id}"}>
<%= format_date(tag.inserted_at) %>
</td>
</:column>
<:column :let={tag} label="Latest" key="last_used_at">
<td
class="px-6 py-4 text-center"
data-test-id={"last_used_at_#{tag.id}"}
>
<%= if tag.last_used_at do %>
<%= format_date(tag.last_used_at) %>
<% else %>
-
<% end %>
</td>
</:column>
<:column :let={tag} label="Items Count" key="items_count">
<td class="px-6 py-4 text-center" data-test-id={"items_count_#{tag.id}"}>
<a href={~p"/?filter_by_tag=#{tag.text}"} class="underline">
<%= tag.items_count %>
</a>
</td>
</:column>
<:column :let={tag} label="Total Time Logged" key="total_time_logged">
<td
class="px-6 py-4 text-center"
data-test-id={"total_time_logged_#{tag.id}"}
>
<%= format_seconds(tag.total_time_logged) %>
</td>
</:column>
<:column :let={tag} label="Actions" key="actions">
<td class="px-6 py-4 text-center" data-test-id={"actions_#{tag.id}"}>
<%= link("Edit", to: Routes.tag_path(@socket, :edit, tag)) %>
<span class="text-red-500 ml-10">
<%= link("Delete",
to: Routes.tag_path(@socket, :delete, tag),
method: :delete,
data: [confirm: "Are you sure you want to delete this tag?"]
) %>
</span>
</td>
</:column>
</.live_component>
<.button
link_type="a"
to={Routes.tag_path(@socket, :new)}
label="Create Tag"
class="text-2xl text-center float-left rounded-md bg-green-600 hover:bg-green-700
my-2 mt-2 ml-2 px-4 py-2 font-semibold text-white shadow-sm"
/>
</div>
</main>
The structure is similar to the stats_live.html.heex
with the new columns for tags and the events for sorting.
And it's using the TableComponent
as well.
Open the lib/app_web/router.ex
and change the following line:
...
scope "/", AppWeb do
pipe_through [:browser, :authOptional]
live "/", AppLive
resources "/lists", ListController, except: [:show]
get "/logout", AuthController, :logout
live "/stats", StatsLive
+ live "/tags", TagsLive
resources "/tags", TagController, except: [:show]
end
...
After that, you can remove the lib/app_web/templates/tag/index.html.heex
file,
since we will use the Tags
LiveView for the tags page now.
Done! The Tags Page has the new columns and everything to be enhanced, congratulations!
It's just missing tests, let's add them:
test/app/repo_test.exs
defmodule App.RepoTest do
use ExUnit.Case
alias App.Repo
describe "toggle_sort_order/1" do
test "toggles :asc to :desc" do
assert Repo.toggle_sort_order(:asc) == :desc
end
test "toggles :desc to :asc" do
assert Repo.toggle_sort_order(:desc) == :asc
end
end
end
Add new test cases to the test/app/tag_test.exs
describe "list_person_tags/1" do
test "returns an empty list for a person with no tags" do
assert [] == Tag.list_person_tags(-1)
end
test "returns a single tag for a person with one tag" do
tag = add_test_tag(%{text: "TestTag", person_id: 1, color: "#FCA5A5"})
assert [tag] == Tag.list_person_tags(1)
end
test "returns tags in alphabetical order for a person with multiple tags" do
add_test_tag(%{text: "BTag", person_id: 2, color: "#FCA5A5"})
add_test_tag(%{text: "ATag", person_id: 2, color: "#FCA5A5"})
tags = Tag.list_person_tags(2)
assert length(tags) == 2
assert tags |> Enum.map(& &1.text) == ["ATag", "BTag"]
end
end
describe "list_person_tags_complete/3" do
test "returns detailed tag information for a given person" do
add_test_tag_with_details(%{person_id: 3, text: "DetailedTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(3)
assert length(tags) > 0
assert tags |> Enum.all?(&is_map(&1))
assert tags |> Enum.all?(&Map.has_key?(&1, :last_used_at))
assert tags |> Enum.all?(&Map.has_key?(&1, :items_count))
assert tags |> Enum.all?(&Map.has_key?(&1, :total_time_logged))
end
test "sorts tags based on specified sort_column and sort_order" do
add_test_tag_with_details(%{person_id: 4, text: "CTag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 4, text: "ATag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 4, text: "BTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(4, :text, :asc)
assert tags |> Enum.map(& &1.text) == ["ATag", "BTag", "CTag"]
end
test "sorts tags with desc sort_order" do
add_test_tag_with_details(%{person_id: 4, text: "CTag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 4, text: "ATag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 4, text: "BTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(4, :text, :desc)
assert tags |> Enum.map(& &1.text) == ["CTag", "BTag", "ATag"]
end
test "uses default sort_order when none are provided" do
add_test_tag_with_details(%{person_id: 5, text: "SingleTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(5, :text)
assert length(tags) == 1
end
test "uses default parameters when none are provided" do
add_test_tag_with_details(%{person_id: 5, text: "SingleTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(5)
assert length(tags) == 1
end
test "handles invalid column" do
add_test_tag_with_details(%{person_id: 6, text: "BTag", color: "#FCA5A5"})
add_test_tag_with_details(%{person_id: 6, text: "AnotherTag", color: "#FCA5A5"})
tags = Tag.list_person_tags_complete(6, :invalid_column)
assert length(tags) == 2
assert tags |> Enum.map(& &1.text) == ["AnotherTag", "BTag"]
end
end
defp add_test_tag(attrs) do
{:ok, tag} = Tag.create_tag(attrs)
tag
end
defp add_test_tag_with_details(attrs) do
tag = add_test_tag(attrs)
{:ok, %{model: item}} = Item.create_item(%{
person_id: tag.person_id,
status: 0,
text: "some item",
tags: [tag]
})
seconds_ago_date = NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -10))
Timer.start(%{item_id: item.id, person_id: tag.person_id, start: seconds_ago_date})
Timer.stop_timer_for_item_id(item.id)
tag
end
Remove these next lines from the tag_controller_test.exs
since we don't have this page anymore:
describe "index" do
test "lists all tags", %{conn: conn} do
conn = get(conn, Routes.tag_path(conn, :index))
assert html_response(conn, 200) =~ "Listing Tags"
end
test "lists all tags and display logout button", %{conn: conn} do
conn =
conn
|> assign(:jwt, AuthPlug.Token.generate_jwt!(%{id: 1, picture: ""}))
|> get(Routes.tag_path(conn, :index))
assert html_response(conn, 200) =~ "logout"
end
end
test/app_web/live/tags_live_test.exs
defmodule AppWeb.TagsLiveTest do
use AppWeb.ConnCase, async: true
alias App.{Item, Timer, Tag}
import Phoenix.LiveViewTest
@person_id 0
test "disconnected and connected render", %{conn: conn} do
{:ok, page_live, disconnected_html} = live(conn, "/tags")
assert disconnected_html =~ "Tags"
assert render(page_live) =~ "Tags"
end
test "display tags on table", %{conn: conn} do
tag1 = add_test_tag_with_details(%{person_id: @person_id, text: "Tag1", color: "#000000"})
tag2 = add_test_tag_with_details(%{person_id: @person_id, text: "Tag2", color: "#000000"})
tag3 = add_test_tag_with_details(%{person_id: @person_id, text: "Tag3", color: "#000000"})
{:ok, page_live, _html} = live(conn, "/tags")
assert render(page_live) =~ "Tags"
assert page_live
|> element("td[data-test-id=text_#{tag1.id}")
|> render() =~
"Tag1"
assert page_live
|> element("td[data-test-id=text_#{tag2.id}")
|> render() =~
"Tag2"
assert page_live
|> element("td[data-test-id=text_#{tag3.id}")
|> render() =~
"Tag3"
end
@tag tags: true
test "sorting column when clicked", %{conn: conn} do
add_test_tag_with_details(%{person_id: @person_id, text: "a", color: "#000000"})
add_test_tag_with_details(%{person_id: @person_id, text: "z", color: "#000000"})
{:ok, page_live, _html} = live(conn, "/tags")
# sort first time
result =
page_live |> element("th[phx-value-key=text]") |> render_click()
[first_element | _] = Floki.find(result, "td[data-test-id^=text_]")
assert first_element |> Floki.text() =~ "z"
# sort second time
result =
page_live |> element("th[phx-value-key=text]") |> render_click()
[first_element | _] = Floki.find(result, "td[data-test-id^=text_]")
assert first_element |> Floki.text() =~ "a"
end
defp add_test_tag_with_details(attrs) do
{:ok, tag} = Tag.create_tag(attrs)
{:ok, %{model: item}} = Item.create_item(%{
person_id: tag.person_id,
status: 0,
text: "some item",
tags: [tag]
})
seconds_ago_date = NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -10))
Timer.start(%{item_id: item.id, person_id: tag.person_id, start: seconds_ago_date})
Timer.stop_timer_for_item_id(item.id)
tag
end
end
After these tests, you are ready to run the application and see your new changes!
Our application works not only for ourselves
but in a collaborative environment.
Not everyone lives within the same timezone.
We might have a person living in South Korea (UTC+9
)
and others living in the US UTC-5
if they each start a timer at the same time
and we don't record the timezone info
there will be a mismatch in the elapsed time ...
For a good visual understanding of the different timezones see: everytimezone.com
You can see that 09:00
in London
is 04:00
in NYC
and 20:00
in Auckland
.
This is fine when all the people
collaborating together
are working on the same day.
Consider the person
starting a timer
at 09:00
on the Friday
Morning in Auckland
:
For their colleague in LA
(UTC-7
) it's still
14:00
on the Thursday
i.e. the previous day!
This can rapidly get confusing for people collaborating around the world.
The point is:
we clearly need to deal with people
living in different timezones.
As it stands, the server-side of the MVP
saves every timer
according to the
UTC
timezone
and this is shown to the person
as well.
This, of course, only makes sense to people living
within this timezone.
If you were to live on another one,
the value of the Datetime
of the timer
wouldn't make sense to you.
The easiest way to solve this is
to only change how the timers
are displayed
according to the timezone of the client.
The datetime will still be saved as UTC
within the database.
This makes it much easier not only to maintain consistency
across collaborative environments
but to also better trail activity.
Since we are going to be adjusting the dates shown on the client-side only, we need a way for the server to know the timezone as well, so we can adjust the updated timer values back to UTC before persisting into the database.
We can leverage
get_connect_params/1
to pass information from the client
to the LiveView
server during the mounting phase.
Open assets/js/app.js
and locate the
let live_socket = new LiveSocket()
variable.
We are going to be changing the params
attribute.
Change it to the following:
params: {
_csrf_token: csrf_token,
hours_offset_fromUTC: -new Date().getTimezoneOffset()/60
}
We are passing a parameter called hours_offset_fromUTC
that represents the amount of hours the client
is from the UTC
.
By multiplying this value by -1
,
we are calculating our timezone's offset from UTC
.
For more information,
read:
stackoverflow.com/questions/13/determine-a-users-timezone/1809974
To use this within LiveView,
we are going to be assigning this value to the socket.
For this, open lib/app_web/live/app_live.ex
and locate the mount/3
function at the top of the file.
We are going to be adding this new value to the socket assigns,
like so:
{:ok,
assign(socket,
items: items,
editing_timers: [],
editing: nil,
filter: "active",
filter_tag: nil,
tags: tags,
selected_tags: selected_tags,
text_value: draft_item.text || "",
# Offset from the client to UTC. If it's "1", it means we are one hour ahead of UTC.
hours_offset_fromUTC: get_connect_params(socket)["hours_offset_fromUTC"] || 0
)}
If hours_offset_fromUTC
is not defined,
we assume the user is in the UTC timezone
(it has a value of 0).
Awesome! 🎉
Now we can use this value to adjust the timezone to adjust the timezone every time we update a timer! But before that, let's adjust how it is displayed to the person.
Open lib/app_web/live/app_live.html.heex
and locate the line
<%= @editing_timers |> Enum.with_index |> Enum.map(fn({changeset, index}) -> %>
.
We are going to be changing the Start and Stop value
of the timer
.
Inside this loop,
locate the <input>
tags
pertaining to the timer_start
and timer_stop
and change them.
For timer_start
, change it to:
<input
type="text"
required="required"
name="timer_start"
id={"#{changeset.data.id}_start"}
value={NaiveDateTime.add(changeset.data.start, @hours_offset_fromUTC, :hour)}
/>
And for timer_stop
, make these changes:
<input
type="text"
name="timer_stop"
id={"#{changeset.data.id}_stop"}
value={if is_nil(changeset.data.stop) do changeset.data.stop else NaiveDateTime.add(changeset.data.stop, @hours_offset_fromUTC, :hour) end}
/>
We are changing the number of hours displayed
according to the @hours_offset_fromUTC
socket assigns
we've declared previously.
In the timer_stop
case,
we are checking if the value is nil
(ongoing timers have the stop
field as nil
)
so they are displayed properly.
To see the changes needed,
please check
lib/app_web/live/app_live.html.heex
.
Now that we are displaying the correct timezones, we need to make sure the adjusted updated timer is converted back to UTC before persisting into the database.
For this,
we simply need to do this operation
inside the update_timer_inside_changeset_list/3
function
inside lib/app/timer.ex
.
This function will now receive the adjusted timezone
and change the updated timer value(s) back to UTC.
Now the function will receive a new parameter
with the hours_offset_fromUTC
.
Locate the two pattern match functions called update_timer_inside_changeset_list
and add a new parameter:
def update_timer_inside_changeset_list(
%{
id: timer_id,
start: timer_start,
stop: timer_stop
},
index,
timer_changeset_list,
hours_offset_fromUTC # add this line
)
...
In the first function
(with the guard when timer_stop == "" or timer_stop == nil
),
add the following line.
def update_timer_inside_changeset_list(
%{
id: timer_id,
start: timer_start,
stop: timer_stop
},
index,
timer_changeset_list,
hours_offset_fromUTC
)
when timer_stop == "" or timer_stop == nil do
changeset_obj = Enum.at(timer_changeset_list, index)
try do
{start_op, start} =
Timex.parse(timer_start, "%Y-%m-%dT%H:%M:%S", :strftime)
if start_op === :error do
throw(:error_invalid_start)
end
# Add this new line
start = NaiveDateTime.add(start, -hours_offset_fromUTC, :hour)
other_timers_list = List.delete_at(timer_changeset_list, index)
On the other pattern-matched function, add the following lines.
def update_timer_inside_changeset_list(
%{
id: timer_id,
start: timer_start,
stop: timer_stop
},
index,
timer_changeset_list,
hours_offset_fromUTC
) do
changeset_obj = Enum.at(timer_changeset_list, index)
try do
{start_op, start} =
Timex.parse(timer_start, "%Y-%m-%dT%H:%M:%S", :strftime)
{stop_op, stop} = Timex.parse(timer_stop, "%Y-%m-%dT%H:%M:%S", :strftime)
if start_op === :error do
throw(:error_invalid_start)
end
if stop_op === :error do
throw(:error_invalid_stop)
end
# Add these two lines
start = NaiveDateTime.add(start, -hours_offset_fromUTC, :hour)
stop = NaiveDateTime.add(stop, -hours_offset_fromUTC, :hour)
If you want to see the changes you need to make,
please check
lib/app/timer.ex
.
The last thing we need to do is
pass this new parameter inside
lib/app_web/live/app_live.ex
that calls this function.
Locate
def handle_event("update-item-timer"
and pass the hours_offset_fromUTC
assign from the socket
to the update_timer_inside_changeset_list/4
.
case Timer.update_timer_inside_changeset_list(
timer,
index,
timer_changeset_list,
socket.assigns.hours_offset_fromUTC # add this new line
) do
If you're curious to see the change you ought to make,
please check
lib/app_web/live/app_live.ex
.
Let's add a test case that will check if the datetime
is shown with an offset that is mocked during testing.
We expect the datetime to show a different datetime
than the Timer
object that is persisted in the database.
Let's test it.
Open test/app_web/live/app_live_test.exs
and add the following unit test.
test "item\'s timer shows correct value (adjusted timezone)", %{conn: conn} do
{:ok, %{model: item, version: _version}} =
Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2})
{:ok, seven_seconds_ago} =
NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7))
# Start the timer 7 seconds ago:
{:ok, timer} =
Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago})
# Stop the timer based on its item_id
Timer.stop_timer_for_item_id(item.id)
# Adding timezone socket assign to simulate we're one hour ahead of UTC
hours_offset_fromUTC = 1
conn =
put_connect_params(conn, %{"hours_offset_fromUTC" => hours_offset_fromUTC})
{:ok, view, _html} = live(conn, "/")
edit_timer_view =
render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)})
# `Start` and `stop` of the timer in the database (in UTC)
# We expect the `start` and `stop` to be shown with one hour more in the view
timer = Timer.get_timer!(timer.id)
expected_start_in_view =
NaiveDateTime.add(timer.start, hours_offset_fromUTC, :hour)
|> NaiveDateTime.to_iso8601()
expected_stop_in_view =
NaiveDateTime.add(timer.stop, hours_offset_fromUTC, :hour)
|> NaiveDateTime.to_iso8601()
# Check if timers are being shown correctly.
# They should be adjusted to timezone.
assert edit_timer_view =~ expected_start_in_view
assert edit_timer_view =~ expected_stop_in_view
# Now let's update the timer with a new value.
# This is the value the user inputs in the client-side.
# Since the user is in UTC+1, the persisted value should be adjusted
start = "2022-10-27T01:00:00"
stop = "2022-10-27T01:30:00"
{:ok, persisted_start} = NaiveDateTime.from_iso8601("2022-10-27T00:00:00")
{:ok, persisted_stop} = NaiveDateTime.from_iso8601("2022-10-27T00:30:00")
render_submit(view, "update-item-timer", %{
"timer_id" => timer.id,
"index" => 0,
"timer_start" => start,
"timer_stop" => stop
})
updated_timer = Timer.get_timer!(timer.id)
# The persisted datetime in the database should be one hour less
# than what the user has input.
assert NaiveDateTime.compare(updated_timer.start, persisted_start) == :eq
assert NaiveDateTime.compare(updated_timer.stop, persisted_stop) == :eq
end
As you can see,
since we are using get_connect_params
when mounting
in the LiveView to get the timezone,
we are using
put_connect_params
in our unit test to mock this value.
We are passing with a value of 1
.
With this, we expect the timer values
in the view to be shown
the timer value with one hour incremented.
We are also checking the value of the persisted timer within the database. Given the timer value is adjusted back according to the person's timezone, we expect the persisted value to be one hour less than what the person inputted.
In preparation for the next set of features in the MVP
,
we added lists
which are simply a collection of items
.
Please see: book/mvp/lists
We didn't add a lot of code for lists
there is currently no way for the person
to create a new list
or "move" items
between lists
.
If you want to help with defining the interface, please comment on the issue: dwyl/mvp#365
At present people
using the App
can only add new items
to a stack
where the newest is on top; no ordering.
people
that tested the MVP
noted that the ability to reorder items
was an essential feature:
dwyl/mvp#145
So in this step we are going to
add the ability to organize items
.
We will implement reordering using
drag and drop!
And by using Phoenix LiveView
,
other people will also be able
to see the changes in real time!
For all the detail implementing this feature, please see: book/mvp/reordering
With all the code saved, let's run the tests one more time.
In your terminal window, run:
mix c
Note: this alias was defined above
You should see output similar to the following:
Finished in 1.5 seconds (1.4s async, 0.1s sync)
117 tests, 0 failures
Randomized with seed 947856
----------------
COV FILE LINES RELEVANT MISSED
100.0% lib/api/item.ex 218 56 0
100.0% lib/api/tag.ex 101 24 0
100.0% lib/api/timer.ex 152 40 0
100.0% lib/app/color.ex 90 1 0
100.0% lib/app/item.ex 415 62 0
100.0% lib/app/item_tag.ex 12 1 0
100.0% lib/app/tag.ex 108 18 0
100.0% lib/app/timer.ex 452 84 0
100.0% lib/app_web/controllers/auth_controller. 26 4 0
100.0% lib/app_web/controllers/init_controller. 41 6 0
100.0% lib/app_web/controllers/tag_controller.e 77 25 0
100.0% lib/app_web/live/app_live.ex 476 132 0
100.0% lib/app_web/live/stats_live.ex 77 21 0
100.0% lib/app_web/router.ex 49 9 0
100.0% lib/app_web/views/error_view.ex 59 12 0
0.0% lib/app_web/views/profile_view.ex 3 0 0
0.0% lib/app_web/views/tag_view.ex 3 0 0
[TOTAL] 100.0%
----------------
All tests pass and we have 100%
Test Coverage.
This reminds us just how few relevant lines of code there are in the MVP!
In your second terminal tab/window, run:
mix phx.server
Open the app
localhost:4000
in two or more web browsers
(so that you can see the realtime sync)
and perform the actions
listed in the README.md
.
e.g:
Thanks for reading this far. If you found it interesting, please let us know by starring the repo on GitHub! ⭐