-
Notifications
You must be signed in to change notification settings - Fork 0
Tutorial 1: Writing an Axiom Client
Assuming you successfully set-up your Axiom environment and created a project, in this tutorial we will write our first Axiom app.
For simplicity, this app will be client-only, meaning that we will only create client-side code.
However, Axiom will still be there to store our state and to share it among clients.
As starting point, please make sure your Axiom is running
(run lein axiom run
from the top-level directory of the project you created using lein new axiom-clj ...
),
and Figwheel (lein figwheel
) too.
Axiom is based on logic programming. It is based on the notion of facts and rules. In Axiom, facts are pieces of data, kind of records in relational databases. Similar to a record, a fact is a tuple of a certain arity (a certain number of elements). Its first element, however, is always a name, represented as a Clojure keyword. For example, the fact:
[:my-app/task "alice" "Need to create a new app" 1000]
has the name :my-app/task
, and the data elements "alice"
, "Need to create a new app"
and 1000
.
This fact uses strings and numbers as its data elements, but any valid EDN expression is valid in a fact, so you can use vectors, maps, sets etc. to create facts with rich content.
The first data element of a fact ("alice"
in the example above), is the fact's key.
Facts are stored in the database that Axiom uses, organized by their key.
Fetching facts can be done only by their key.
If we want to create compound keys (e.g., keys that include a user and a date), we can use compound data structures (e.g., a vector containing the key component).
Facts have other attributes, such as a writers set and a readers set, but we will not discuss them in this tutorial.
In Axiom, unlike relational databases, there is no schema that defines the structure of facts. Similar to many NoSQL database, facts are just created. The client-side is responsible for the lifecycle of facts -- creating them, updating them and deleting them. In this tutorial we will see how this is done.
Axiom advocates declarative programming. As we will see in the following tutorials, the application code that runs on the server is purely-declerative. The client-side, however, needs to be somewhat imperative, because this is where we do stuff. This is where we change the state of the application by creating new facts, updating facts and deleting facts -- all imperative operations.
But everything else in the client code can still be declarative. Specifically, defining the structure of a fact and rendering UI based on facts can both be done declaratively. In this section we will discuss the former, and in the next section -- the latter.
In the project you created in the Getting Started guide,
open the file src/your_project/core_test.cljs
(note the s
at the end).
Now delete everything except for the first two s-expressions.
(ns your-project.core
(:refer-clojure :exclude [uuid?])
(:require [reagent.core :as r]
[axiom-cljs.core :as ax])
(:require-macros [axiom-cljs.macros :refer [defview defquery user]]))
;; Remove this before going to production...
(enable-console-print!)
In the ns
declaration we :require
some stuff that we need, including the axiom-cljs.core
library, which allows us to connect to Axiom, and some macros from the axiom-cljs.macros
library.
The (enable-console-print!)
command makes printing function (e.g., prn
and println
) take effect as Javascript's console.log()
.
Now, write your first view:
(defview task-view [user]
[:your-project/task user task ts])
defview allows us to define views, which are bidirectional links between collections of facts in the database and on the client.
The view definition, similar to a defn
, starts with a name and a vector of arguments.
This defines a function named (in our case), task-view
, which takes one argument -- user
.
The body of the view definition is a fact template.
Like any fact, it is a tuple (vector) that starts with a name (keyword), and has some number of arguments.
The first one (user
, in this case) is the key.
The fact template also includes other symbols giving names to the other data elements in the fact, like task
and ts
(timestamp) in our case.
A view definition can also include keyword arguments (key/value pairs that come after the fact template). These will be discussed later.
The Clojure ecosystem includes some really amazing projects, and in my opinion, Reagent is definitely one of the best.
It is a relatively small ClojureScript library that wraps over React, which is by itself one of the best things that happened to the client.
But Reagent isn't just a mapping of React's API.
The approach its developers took was to leverage the power of Clojure to overcome some of the challenges React leaves to its developers.
For example, React uses JSX -- a special language (with a special parser) for mixing HTML with Javascript code to create the render()
methods. Reagent simply uses Clojure vectors and maps to represent HTML fragments.
Another example is that in React you refer to state explicitly, by using the setState()
method of your component.
In Reagent you use atoms to store state.
The only difference between that and what you'll normally do is that you need to use Reagent's own r/atom
instead of a "normal" clojure.core/atom
.
All the rest is the same.
If you consult such atoms when rendering components, Reagent (with React's help) will guarantee that whenever one of these atoms is updated, the component will be re-rendered.
To use Reagent we need to have an entry-point in the index.html
.
This file looks like this (resource/public/index.html
):
<!DOCTYPE html>
<html>
<head></head>
<body>
<div id="app"></div>
<script src="cljs/main.js" type="text/javascript"></script>
</body>
</html>
Notice the line: <div id="app"></div>
. This is our entry-point.
This is where our ClojureScript program will render its content.
To do that, write the following code at the end of your core.cljs
file:
(defn app []
[:h1 "Boo"])
(defn render []
(let [elem (js/document.getElementById "app")]
(when elem
(r/render [app] elem))))
(render)
If you still have lein figwheel
working (from the getting-started tutorial),
you'll notice the text "Boo" appearing on in the browser (directed to http://localhost:3449/index.html?#?_identity=foo, for example).
What we did is created a pure ClojureScript function for rendering the app.
In our case, the app is an <h1>
element with the text Boo
.
Then, in the function render
, we identify our entry-point in the HTML by using the browser's document.getElementById()
function.
If this element exists, we use Reagent's top-level function: r/render
, giving it the app
function and the element we chose as our entry-point.
Eventually, we call the render
function to make this all happen.
But we wanted a button...
So let's create a component function for the task-list:
(defn task-list []
[:button {:on-click #(println "New Task!")} "New Task"])
(Place this code before the app
function).
In case you are not familiar with the #()
syntax, this is a convenient way in Clojure to create functions.
The expression (println "New Task!")
is a call to the println
function to print (via console.log()
) "New Task!"
.
If we leave it like that in the task-list
function, "New Task!"
will be printed when task-list
is called, that is, when the button is being rendered,
and not when it is clicked.
The #()
syntax makes this an anonymous function that prints when it is called.
By setting the :on-click
attribute to #(println "New Task!")
we give it a function that will run (and print) only when the button is pressed.
To integrate the task-list
component in our app we have to simply call task-list
from app
.
Modify the app
function to use task-list
:
(defn app []
(task-list))
Once you save the file, you should see a button with the caption "New Task" in the browser.
Clicking this button will print New Task!
to the console (depending on the browser you use, you'll need to open the developer tools / console to see this).
In order to store and retrieve facts to and from Axiom, we need to connect to Axiom.
Add the following code before the definition of the app
function:
(def host (ax/default-connection r/atom))
This code defines host
as a connection map, containing attributes that allow views and queries (which we will discuss in future tutorials) to send and receive data to and from the Axiom back-end.
The ax/default-connection
function uses some default settings to create this connection.
We pass it r/atom
as parameter to tell it to use a Reagent atom to store its internal state (e.g., the connected user's identity).
This is important if we want to, for example, display the user's identity.
Let's do just that.
Update the app
function to the following:
(defn app []
[:div
[:p "Hi, " (user host)]
(task-list)])
This will add the caption "Hi, foo" (or whatever you chose as the _identity
value in your URL) above the button.
We made two changes to the function.
First, we wrapped the (task-list)
with a :div
block.
A Reagent component function must return a single HTML element represented as a ClojureScript vector, so this is our way of creating multiple elements.
Then, we added a :p
element with "Hi, "
and (user host)
.
user
is a macro (defined in axiom-cljs.macros
) that takes the host
connection map and returns the identity of the user.
When refreshing the browser you'll notice that the name of the user comes at a certain delay. This is because the user's identity comes from the back-end, in a special event. The value is therefore not known until WebSocket connection is made with the back-end, and until this special event passes through.
Our task-list
component function so far doesn't do much.
Let's change this...
We want it to list the existing tasks, and allow the "New Task" button to add a new task.
First, we need to tell our view to capture state (the facts stored on the client) in a Reagent atom.
To do this, we will add the keyword parameter :store-in
to our view definition:
(defview task-view [user]
[:your-project/task user task ts]
:store-in (r/atom nil))
This will make sure that changes in state (creation, update or deletion of facts) will trigger a UI change.
Next, we will modify the task-list
function to the following:
(defn task-list [host]
(let [tasks (task-view host (user host))
{:keys [add]} (meta tasks)]
[:div
[:ul
(for [task tasks]
[:li "<task>"])]
[:button {:on-click #(add {:ts ((:time host))
:task ""})} "New Task"]]))
Here we made a few changes.
First, we call our view function (the function defined by our view definition) with the host
connection map as its first argument
(the host
must always come as the first argument when calling the view function),
and the user ID as the second argument (this is corresponding to the user
parameter in the view definition).
The view function returns a sequence of maps, each representing a fact.
We bind it to variable tasks
.
We iterate over this sequence and for each task we generate a :li
(list item) element with the caption <tasks>
as placeholder for the real content.
The sequence returned by the view function has a meta-field named add
.
This is a function that creates new facts, by providing it maps of the values we wish them to have.
We use this function in the :on-click
callback of the "New Task"
button.
When the button is clicked, a new fact is created, with a :ts
(timestamp) value of the current time
(the function (:time host)
returns the current time in milliseconds since Unix Epoch),
and an empty string as the value of :task
.
The two keys (:task
and :ts
) correspond to the second and third data elements in the fact template we provided in the view definition.
The first data element (user
) is implicit here, as we provided it as parameter when calling task-view
.
If you play around with it, you'll see that pushing the button creates new tasks, but we cannot edit or delete them yet.
Furthermore, if you look at the console output you'll see a warning stating that our :li
element does not have a :key
.
We'll fix both problems next.
We saw how we can add new tasks, but how can we edit them? To do so we will create a new component -- a task editor.
Create the following function above task-list
:
(defn edit-task [task-entry]
(let [{:keys [task ts swap! del!]} task-entry]
[:li {:key ts}
[:input {:value task
:on-change #(swap! assoc :task (.-target.value %))}]
[:button {:on-click del!} "X"]]))
This function takes a task-entry
(an element in the tasks
sequence) as parameter and extracts a few fields out of it.
In addition to task
and ts
we already know, it also extracts swap!
and del!
, which are functions that will allow us to
edit the content of the fact and delete it, respectively.
This function returns a :li
element containing an :input
box and a delete button.
The first thing to note about the returned :li
element is that it has a :key
attribute.
This attribute is used by React to identify the different element.
When state (stored in a Reagent atom) gets update, React re-generates the whole page.
What it actually does is apply the funtion (app
in our case), and then runs some processing on the virtual DOM it generated
(which is nothing but some nested Javascript objects).
The result of this processing is the knowledge of what changed in the UI, that is, what in the browser's actual DOM needs to be updated.
React is famous in updating only those parts that need updating.
The :key
we supply allows React to know that regardless of the change inside our :li
element (e.g., when we edit the task's text),
the identity of the element is maintained.
If we, for example, choose the wrong field for it (e.g., use task
instead of ts
), React may re-generate the whole :li
element as we are editing it, and we will end up with an awful user experience.
You can try replacing the ts
with task
as the value for the :key
attribute, and see what happens.
The :input
element represents a text-box.
This :input
is bound to the task
field of the fact we are editing.
This binding is bidirectional, and the two directions are represented by the two attributes the :input
element has.
The :value
attribute simply takes its value from the task
field.
The binding in the oposite direction requires a bit more explaining.
When content of the :input
box changes on the browser, and :on-change
event is triggerred.
This event has a target
attribute which is the actual DOM element corresponding to the :input
element specified here.
In turn, this DOM element has a value
attribute containing the updated value.
We provide :on-change
the function #(swap! assoc :task (.-target.value %))
.
Let's try to understand it part by part.
First, it uses Clojure's reader-macro syntax for anonymous functions -- #()
, as we saw before.
This time, the symbol %
used in this expression represents the argument to the function,
which in the case of a DOM event is the Javascript Event
object.
Second, the swap!
function we use here is the one we extracted from the record.
Similar to clojure.core/swap!
, which works on atoms, this swap!
function takes a function and arguments for this function,
and applies the function to the state.
Here, this is not the state of an atom, but rather the state of the current fact.
The function we use is the assoc
function, which sets value to a field of a map (recall that our fact is represented as a map).
assoc
takes the map as its first argument, which is implicit here (as it were if we used clojure.core/swap!
).
Its second argument is the key in the map (:task
, in our case), and the third is the value we wish to associate to the key.
The value we take would be event.target.value
in Javascript, or (.-target.value %)
in ClojureScirpt.
Last but not least -- the deletion button.
Its :on-click
callback is simply the del!
function for this fact.
To make this take effect you'll need to update the task-list
function to the following:
(defn task-list [host]
(let [tasks (task-view host (user host))
{:keys [add]} (meta tasks)]
[:div
[:ul
(for [task tasks]
(edit-task task))]
[:button {:on-click #(add {:ts ((:time host))
:task ""})} "New Task"]]))
We simply replaced the :li
vector inside the for
loop with a call to edit-task
.
Save your changes and let Figwheel update the browser page.
You'll see that what used to be placeholders are now edit boxes placed next to buttons marked with X
.
Try editing tasks, adding new ones and deleting them.
Try refreshing the browser.
You should see the content is retained.
One thing you may notice is that as you edit tasks, they change their respective order.
This happens because we did not specify a sort order.
We can do so by adding an :order-by
parameter to the view definition:
(defview task-view [user]
[:your-project/task user task ts]
:store-in (r/atom nil)
:order-by ts)
You'll need to refresh the browser to have this change take effect.
This will sort them by their creation time -- oldest first.
You can decide to sort them in reverse order -- newest first, by speficying :order-by (- ts)
.
The complete core.cljs
file should look like this:
(ns your-project.core
(:refer-clojure :exclude [uuid?])
(:require [reagent.core :as r]
[axiom-cljs.core :as ax])
(:require-macros [axiom-cljs.macros :refer [defview defquery user]]))
;; Remove this before going to production...
(enable-console-print!)
(defview task-view [user]
[:your-project/task user task ts]
:store-in (r/atom nil)
:order-by ts)
(defn edit-task [task-entry]
(let [{:keys [task ts swap! del!]} task-entry]
[:li {:key ts}
[:input {:value task
:on-change #(swap! assoc :task (.-target.value %))}]
[:button {:on-click del!} "X"]]))
(defn task-list [host]
(let [tasks (task-view host (user host))
{:keys [add]} (meta tasks)]
[:div
[:ul
(for [task tasks]
(edit-task task))]
[:button {:on-click #(add {:ts ((:time host))
:task ""})} "New Task"]]))
(def host (ax/default-connection r/atom))
(defn app []
[:div
[:p "Hi, " (user host)]
(task-list host)])
(defn render []
(let [elem (js/document.getElementById "app")]
(when elem
(r/render [app] elem))))
(render)
Now, copy the URL from your browser and paste it to another browser tab/window. You'll see that changes you make in one browser window take effect in the other. This is the effect of Axiom acting as an event storage and distribution system. Axiom can, of-course, do much more than this, but those things will be dicussed in the following tutorials.