Skip to content

An awesome calculator built in Phoenix LiveView

License

Notifications You must be signed in to change notification settings

dwyl/phoenix-liveview-calculator

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

30 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PhxCalculator - WORK IN PROGRESS

Brief Phoenix Overview

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!

Calculator Logic

The calculation logic implementation was made incredibly simple thanks to the elixir package Abacus.

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:

  1. Add abacus to your list of dependencies in mix.exs:
def deps do
  [
    ...
    {:abacus, "~> 2.1.0"},
    ...
  ]
end
  1. 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!

Event handling

phx-click and phx-value

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>

Mount

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 LiveView
  • mode 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 shortly
  • history will be used to store the calculation history of the session and render it to the history tab

The number event

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.

The backspace event

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.

Why pass operator \\ ""?

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.

The operator event

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 the operator to the calc string and update the socket, like we've just seen before.

    • i.e if calc = "1" and operator="+" the updated socket contains calc = "1+"
  • operator, meaning the last input to the calc string was another operator, we remove the previous operator using the backspace() helper function and then concatenate the new operator as we do not want invalid inputs.

    • i.e if calc = "1+" and operator="-" the updated socket contains calc = "1-"
  • _, 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 the numbers 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()`.

The clear event

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 equals event

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.

Tests

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.

File and code structure

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.

Helper function

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 click event and its corresponding value
    • view: is the current LiveView of the process, the same one we create we live(conn, "/")
    • equals?: is the boolean that determines whether we want to call the equals event
  • We loop through the sequence list with Enum.each()

    • We pass (sequence, fn map -> to Enum.each which allows us to run a function on each entry (map) of sequence list
    • Using pattern matching we destruct the map into its components event and value
    • Then call render_click() with that data
  • If equals? is true we also render the equals 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!

Connection test

Addition

Multiplication

Subtraction

Division

Rendering logic

Brackets