This Phoenix LiveView project is being created with the aim of putting into practice the theory and knowledge gained from the many open source tutorials provided by the wonderful dwyl.
This readme will include a breakdown for anyone else starting their functional programming / elixir journey in the hopes that this will be another useful resource on that quest.
If you are looking for a complete beginner look into Phoenix and Elixir, I'd strongly suggest reviewing the basics with dwyl-learn-elixir and dwyl-learn-phoenix first, before coming back and seeing these technologies in action!
The calculation logic implementation was made incredibly simple thanks to the elixir package Abacus.
Utilizing the Abacus package kept our calculation logic extremely easy and
highly effective. It provides the Abacus.eval()
function which converts
Strings to a mathematical equation and calculates the result. It also
makes error handling simple as the returned tuple can be pattern matched to
either extract the result or handle the error. More on that later.
The installation instructions on Abacus were out of date, the corrected instructions are:
- Add
abacus
to your list of dependencies in mix.exs:
def deps do
[
...
{:abacus, "~> 2.1.0"},
...
]
end
- Include
abacus
in the extra applications
def application do
[
mod: {PhxCalculator.Application, []},
extra_applications: [:logger, :runtime_tools, :abacus] # here
]
end
We can now call Abacus.eval()
in our project!
To handle the event of clicking a button, I need my project to know that and event has been triggered and the value of button that has been pressed.
In Phoenix this is very simple, we can use
phx-click
and
phx-value
The html for our calculated is in the
lib\phx_calculator_web\components\core_components.ex
file to make our LiveView file cleaner, and in it we see phx-click
on every button which triggers the corresponding event, and if a value is
needed then the corresponding phx-value
:
<button class="bg-gray-700 text-purple-800 h-16 font-mono text-3xl
rounded-lg" phx-click="number" phx-value-number="1">1</button>
Note: not every button needs to pass a value to the handler function,
for example we can handle a "clear"
or "backspace"
event without any
data being passed
<button class="bg-gray-700 text-blue-400 h-16 font-mono text-3xl
rounded-lg" phx-click="clear">C</button>
Before we dive into talking about the event handling it is worth briefly
looking at our
mount
/3
as it details the set-up of our
socket
struct which is used in determining which inputs are permitted.
def mount(_params, _session, socket) do
socket = assign(socket, calc: "", mode: "", history: "")
{:ok, socket}
end
Ok. So in our assigns
we see we have the keys calc
, mode
and history
.
calc
will be used to store the calculation string which is auto-rendered thanks to LiveViewmode
is very useful as it allows the system to 'know' what behavior the calculator is performing. This can be utilized to prevent illegal expressions as we'll see shortlyhistory
will be used to store the calculation history of the session and render it to the history tab
All calculations start by clicking on a number (yes, or perhaps bracket..) so let's have a look at that first.
When a button is pressed with the phx-click="number"
def handle_event("number", %{"number" => number}, socket) do
case socket.assigns.mode do
"display" ->
calc = number
socket = assign(socket, calc: calc, mode: "number")
{:noreply, socket}
_ ->
calc = socket.assigns.calc <> number
socket = assign(socket, calc: calc, mode: "number")
{:noreply, socket}
end
end
Ok, first thing to notice is that we are saving the phx-value in the number
variable with %{"number" => number}
.
By implementing a case
we then either concatenate the new number to the existing calc
string saved
in the socket calc = socket.assigns.calc <> number
and then update the socket,
or if the calculator is in display mode (after clicking the equals button)
we start a new string with calc = number
and update the socket accordingly.
We'll examine the backspace event next as the helper function is also used for the
operator` event.
First let's examine the handle_event/3
:
def handle_event("backspace", _unsigned_params, socket) do
case socket.assigns.mode do
"display" ->
{:noreply, socket}
_ ->
backspace(socket)
end
end
Very simple logic thanks to our helper function. If the calculator is in display mode we tell our function to do nothing, otherwise we call the helper function to remove the last character of the calc string.
The helper function works as follows:
defp backspace(socket, operator \\ "") do
calc = String.slice(socket.assigns.calc, 0..-2//1) <> operator
socket = assign(socket, calc: calc)
{:noreply, socket}
end
Thanks to String.slice()
we can remove the last character. We specify a slice from the range of
index 0
to -2//1
. This just means the second to last index (-2
),
but we have to specify that the range is increasing with //1
for
.slice()
to be happy.
The reason we're passing an operator with a default of an empty string is so
when we call this helper function with a valid operator, we just replace
the last element of the calc string with the passed in operator
using
concatenation. We'll see why that's handy next.
Examining our handler function we see:
def handle_event("operator", %{"operator" => operator}, socket) do
case socket.assigns.mode do
"number" ->
calc = socket.assigns.calc <> operator
socket = assign(socket, calc: calc, mode: "operator")
{:noreply, socket}
"operator" ->
backspace(socket, operator)
_ ->
{:noreply, socket}
end
end
Much like the number
event we're saving the passed operator
variable
and using that to build the calculation string. But notice we are also
making use of a case which again is utilizing the socket.assigns.mode
to determine what the function should do.
If we're in mode..
-
number
, meaning the last input was a number, we simply concatenate theoperator
to thecalc
string and update the socket, like we've just seen before.- i.e if
calc = "1"
andoperator="+"
the updated socket containscalc = "1+"
- i.e if
-
operator
, meaning the last input to the calc string was another operator, we remove the previous operator using thebackspace()
helper function and then concatenate the new operator as we do not want invalid inputs.- i.e if
calc = "1+"
andoperator="-"
the updated socket containscalc = "1-"
- i.e if
-
_
, meaning if the previous input was neither a number or an operator then we tell our function to do nothing as we do not want to add an operator to the calc string unless it follows a number (brackets are handled by thenumbers
event)
In the operator
case we saw the backspace helper function being used again, this time we passed in our operator. Like we saw before, that just means we concatenate on the new operator once we've used
String.slice()`.
This ones super simple. All we need to do is set the calc string to be an empty string.
def handle_event("clear", _unsigned_params, socket) do
socket = assign(socket, calc: "")
{:noreply, socket}
end
Simples!
The actual handler function is actually very basic, thanks to another
helper function, which in turn is simple thanks to the aforementioned
Abacus.eval
.
def handle_event("equals", _unsigned_params, socket) do
case socket.assigns.mode do
"number" ->
calculate(socket)
_ ->
{:noreply, socket}
end
end
Once again we utilize a case
, which lets us only call the calculate()
function if the last input was a number which is always the case for valid inputs.
Remember that brackets are classed as a number
For example, the strings:
1+2
3-2*1
5 / (3 - 2)
would all call the calculate()
function and these strings:
1+
12/3-
would do nothing.
Let's now dive into the function that's doing all the work:
defp calculate(socket) do
case Abacus.eval(socket.assigns.calc) do
{:ok, result} ->
socket = assign(socket, calc: result, mode: "display")
{:noreply, socket}
{:error, err} ->
socket = assign(socket, calc: "ERROR", mode: "display")
{:noreply, socket}
end
end
Of course, another case.
This time, we are
pattern matching
the result of Abacus.eval(socket.assigns.socket)
with tuples:
{:ok, result}
: is returned when the.eval
is successful and extracts the result which we can pass to our socket.{:error, err}
: is returned when.eval
is unsuccessful, in cases like dividing by 0 or improper use of brackets.
In both cases we set mode: "display"
which affects certain functions
as we have seen, and we either set the calc string to the result or the
"ERROR" string.
In this section we will talk about the tests we used to obtain 100% test coverage. Instead of examining each test with a code snippet like we did with our functions, we'll examine the general test code structure, the helper function used to reduce code repetition, and then speak about the test cases in a more general manner.
The testing in this project is organized into describe blocks containing the relevant test suite, which helps separate the logic and make it more readable and easier for the developer to figure out which test is failing (along with test names).
describe "test suite name" do
# Your test suite
end
The test themselves are named, and in this project we pass a conn
instance
which we use with
live/2
to spawn a a connected LiveView process enabling us to obtain a LiveView to
test.
Note: We're using Phoenix's ConnCase
We test the view
using
render_click/3
which sends a click event to the view with value
and returns the rendered result, and then we use assert
to determine
whether the correct value has been calculated.
test "clear", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
render_click(view, "number", number: "1")
render_click(view, "clear")
# screen should be empty
assert render(view) =~ ~s(<div id="screen" class="mr-4"></div>)
end
So in this example we've simulated clicking the number 1
and then the clear
button.
We then check to see if our "screen" is empty on the calculator using
assert and accessing the screen via its id
.
Since this is a calculator, we'll be "pressing" a lot of buttons.
Obviously we don't want to be typing endless render_click()
's,
which is where the following helper function comes in:
defp apply_sequence(sequence, view, equals?) do
Enum.each(sequence, fn map ->
%{event: event, value: value} = map
render_click(view, event, %{event => value})
end)
if(equals?) do
render_click(view, "equals")
end
end
Let's break it down:
-
We pass in three parameters
sequence
: is a list of key-value maps containing the clickevent
and its corresponding valueview
: is the current LiveView of the process, the same one we create welive(conn, "/")
equals?
: is the boolean that determines whether we want to call theequals
event
-
We loop through the
sequence
list withEnum.each()
- We pass
(sequence, fn map ->
toEnum.each
which allows us to run a function on each entry (map
) ofsequence
list - Using pattern matching
we destruct the map into its components
event
andvalue
- Then call
render_click()
with that data
- We pass
-
If
equals?
istrue
we also render theequals
event after the sequence.
This function allows us to increase readability and reduce repeated code.
For example, look at how it's used an addition test:
# Addition block
test "with identity element", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
apply_sequence([
%{event: "number", value: "1"},
%{event: "operator", value: "+"},
%{event: "number", value: "0"}
], view, true)
assert render(view) =~ ~s(<div id="screen" class="mr-4">1</div>)
end
We can clearly read what the test is doing, and we didn't have to type four render_click() functions. Awesome!