7 GUIs in ClojureScript: Temperature Converter, Flight Booker
This post is part 2 of a series:
- 7 GUIs in ClojureScript: Setup and Counter.
- 7 GUIs in ClojureScript: Temperature Converter, Flight Booker
- 7 GUIs in ClojureScript: Timer, CRUD, Circle Drawer
Temperature Converter
The next task in the series is the Temperature Converter. This app is more complex than the counter in a couple of ways: not only changing one value in the state might change another piece of data, but we must also handle user input.
First, some structure
I considered it necessary to organise the code in multiple namespaces for this task. I followed the lead of “Learning Reagent” and created a handful of namespaces:
temp-converter.db
: holds the app state atom.temp-converter.events
: contains all the triggers to modify the state and any helper functions.temp-converter.views
: has all the UI components.temp-converter.temperatures
: with the app-specific logic. In this case, the namespace contains all the functions for converting and validating temperatures.
I expect this pattern to be useful for small apps. For larger apps, it could make sense to repeat this pattern in different namespaces (one for each app module).
Time for some automated tests
The functions for converting temperatures and verifying user input can benefit from having some unit tests, so I spent some time setting this up.
I used cljs.test
, included with
ClojureScript, to run the tests. I just wanted to run very simple “is the result
of this function call X” type tests, and cljs.test
was more than enough.
I chose to have the test files in the same directory as the files they test
instead of having an alternate mirror tree of namespaces under src/test
. These
directory trees tend to get out of sync quickly, and in my experience, you
forget about the tests when they are tucked away somewhere else.
Configuring shadow-cljs
to run the tests wasn’t tricky, and the
documentation
was straightforward. I didn’t need to run them under a browser environment
which, in my experience, is always more complicated.
I had problems running the tests under my editor setup (Neovim + Conjure). I kept getting “missing JS environment” errors. Restarting the test watcher and dev compiler seemed to help. Eventually, I figured out that I was working with namespaces that were not yet required by the app, so they were not evaluated automatically. The JS environment issue was solved by reloading the browser whenever I restarted the dev server.
Getting a testing suite (even a small one) running was a good learning experience, and I’m glad that I did it even if it wasn’t required for the main task.
While testing the temperature conversion functions, I ran into a few type issues. I thought about temperatures as numbers, but user input will be “number as a string”. I had to iterate a couple of times over this; trim here, parse there, round it all up at the end. This process was similar to what I am used to in JavaScript.
A silly mistake
Next were the functions to modify the state. I added a couple more namespaces and got to work. Suddenly, I was getting errors about circular dependencies between these namespaces. Then I had some errors about Reagent functions not being available. Nothing made sense.
It took me a while to notice that I had used .clj
extensions on my files
(meaning they are Clojure files, so compiled to run on the JVM) instead of
.cljs
(compiled to JavaScript). Most of the code does some JavaScript interop,
hence the errors about things not being available.
Once I fixed the file extensions, everything was working. Maybe I didn’t pay enough attention to the error messages, but at the time, it wasn’t clear at all where the problem was.
Bidirectional data flow
Eventually, I got to the core of the exercise: coordinating between the two different temperatures in the state. The specification dictated that if a user-entered temperature was valid, the equivalent in the other unit should also change. If not, we still need to store the user-entered value in the state and register the error while leaving the second temperature as it was.
I chose to represent the state as a map keyed by unit (:fahrenheit
and
:celsius
), each pointing to a map containing the current value and an optional
error message:
(defonce state (r/atom {:fahrenheit {:value "" :error nil}
:celsius {:value "" :error nil}}))
I saw very unexpected behaviour when I tried to modify db/state
from the
different text inputs. One of the inputs would go blank; I couldn’t change the
other. It took me a few minutes before realising my mistake: I was operating on the
db/state
atom twice, but I was using the original value each time.
It’s probably easier to explain with some more code. I started with:
(swap! db/state (fn [state]
(assoc state :fahrenheit value})
(when valid? (assoc state :celsius (f->c value)))))
which will modify the current state
, discard the result, and then (maybe)
modify the current state
and return the new value. If the temperature was
invalid, the state is changed to nil
!
I discarded the code and started again, testing each step in the REPL until I found the problem. What I needed was:
(swap! db/state (fn [state]
(-> state
(assoc :fahrenheit value)
(conj (when valid? [:celsius (f->c value)])))))
I hope that I’m now in the immutable frame of mind!
Duplication
As I was adding the two events to update the value of either :fahrenheit
or :celsius
, and then again when coding the input controls, I noticed that the code
was pretty much the same. I’m usually against trying to generalise everything
prematurely, but in this case, I think it was worth it.
I created an event and input field that can be configured via a few parameters and made them private. Then I export a few functions that customise the generalised components.
You can see the result here and here.
Finishing up
I re-used the error value to change the appearance of the input field with an invalid temperature and give the user some feedback.
The requirement for this task was to “make the bidirectional dependency very clear with minimal boilerplate code”. I think I managed to do that, even though the generalisation of the function that changes the state might obscure this a bit. Here are the code and the app.
Flight Booker
The Flight Booker is next. This app not only has to handle user input, but it deals with dates (always a tricky thing in the browser!).
Dates, time, summertime
I didn’t want to bring in a dependency to deal with parsing and formatting dates, so I decided to try to solve the problem without any external code. I managed to do it, but I still had some issues and spent more time than I wanted.
The JavaScript Date
object
can parse date strings, but it is “strongly discouraged” because of browser
inconsistencies (I tried it anyway).
Chrome v99 running on Linux parsed “12/04/2022” to the 4th of December 2022. It did this even though my locale is set to “en_GB”. At least it didn’t take long to find out how broken the parsing functionality is!
I went with a regular expression that can handle a few different separators and optional leading zeroes on the day and month. Anything else proved too tricky and time-consuming to get right.
I noticed that, at least on Chrome, the Date
objects were created with the
timezone set to “UTC”. JavaScript does not have a date-only object, so all dates
also include time. The Date
constructor sets the time to 00:00 if it’s not
specified in the date string.
The UK had switched to summer time a few days before I wrote the code, so the dates created were actually the day before, at 23:00 (as they were UTC and BST is UTC+1). At this point, I regretted my initial decision to swear off dependencies.
I went with a simple fix that I’d used before: set the time to noon and make a mental note to find a small library to do this in the future.
I added a few tests for the parser function, which helped me uncover a few more
edge cases (I didn’t know that the Date
parser would handle the “30th of
February” as the 2nd of March, for example).
Finally, I needed a simple formatter to populate the initial state with today’s
date. I didn’t want a date like “3/4/2022” there, but I also didn’t want to do
the zero-padding myself. Eventually, I discovered the very powerful
cl-format
function, ported
from Common Lisp and included in ClojureScript.
In hindsight, I should have used an external dependency for handling dates. I’m not dissatisfied with the result, though, and in the process, I learned about a formatter that will be handy in the future.
Modelling the dates
I used a pattern similar to the previous task to store the dates in the state.
This time around, I had a couple of additions: a :valid?
entry (so that I
didn’t have to overload the :error
to check for a valid date) and an :epoch
entry to make comparing dates easier:
(def a-date {:value ""
:valid? false
:epoch nil
:error nil})
Handling changes
Every time the user changes one of the dates, the value is parsed and used to
instantiate a Date
object, used to check the input’s validity. If it is valid,
an epoch is generated. All values are then stored in the state.
A check on whether the flight can be booked or not is run when any of the dates
or the flight type changes. The result of this check is then stored in the
state. Initially, the bookable?
function
dereferenced @db/state
directly. This happened in the middle of the swap!
call, so the atom wasn’t updated yet. Passing the current value of the state as
a parameter fixes this issue.
Testing the events
I wanted to test the constraints, as they are central to the exercise. I use the
global state atom directly in the event functions, but I need a fresh state
every time to have reproducible tests,. I asked in the Clojurians Slack what was
good practice, and someone mentioned
with-redefs
, which did the
trick. I thought I could have something like a “before each” function and
redefine the state there, but with-redefs
only changes the ref while executing
the body, so I ended up with the same wrapper on every test.
I want to do some more research on best practices around this kind of test.
Final thoughts
I chose to have almost all of the constraints in the state because this task required to “make the constraints clear, succinct and explicit in the source code and not hidden behind a lot of scaffolding.” I didn’t want to have the “can I book the flight?” logic sitting on a property of the “Book” button. The only business logic in the views is the code to toggle the “return” text input, which is enabled for return flights only.