Continuing on from yesterday's post, I continued learning more about re-frame
and DataLog and how it related to the issue I'm working on with @adrien and @nthd3gr33 in my cohort.
Learning re-frame
was useful in understanding the general nature of the function. DataLog turned out to be less relevant but I think it still gave me some useful insights and will be useful in the long run.
So what is re-frame
? From the re-frame
repo -
re-frame is a ClojureScript framework for building user interfaces. It is has a data-oriented, functional design.
That doesn't seem like a particularly useful description. Athens' internal docs have a better comparison - re-frame
is to reagent
what redux
is to React
. It's basically a state and event management abstraction.
Athens uses it quite heavily to manage internal state in a declarative way. re-frame
is combined with posh
and DataScript to make a really powerful combo! posh
lets you use a DataScript database to store app state and has functions that allow components to access relevant data and update global state if needed.
Now let's take a look at the issue code (the code was located ever so quickly by @adrien saving us quite some time). The function that we're looking at starts like this -
;; TODO: no-op when indenting as the right-most child
(reg-event-fx ;; register an event handler
:indent ;; for events with this name
(fn [_ [_ uid]] ;; get the cofx and destructure the event
...
If you're familiar with re-frame
, you'll know that reg-event-fx
is how you register an event handler for re-frame
. Here we're registering an :indent
event which takes in two args, the Co-Effects (which is being elided here since we're not interested in it) and the Event vector which is destructured to get the uid of the block being indented.
There's quite a hefty let
expression that follows -
(let [
block (get-block [:block/uid uid]) ;; get current block
parent (get-parent [:block/uid uid]) ;; get parent
older-sib (->> parent
:block/children
(filter #(= (dec (:block/order block)) (:block/order %)))
first
:db/id
get-block) ;; older sibling of current block
new-block {:db/id (:db/id block) :block/order (count (:block/children older-sib))} ;; where the block goes after indent
reindex-blocks (->> (d/q '[:find ?ch ?new-o
:in $ % ?p ?at
:where (dec-after ?p ?at ?ch ?new-o)]
@db/dsdb rules (:db/id parent) (:block/order block))
(map (fn [[id order]] {:db/id id :block/order order})))]
When I first saw this function, I was a little taken aback. I didn't really recognize what was going in the reindex-blocks
binding especially in this odd looking function call -
(d/q '[:find ?ch ?new-o
:in $ % ?p ?at
:where (dec-after ?p ?at ?ch ?new-o)]
@db/dsdb rules (:db/id parent) (:block/order block))
That was the section that prompted me to learn DataLog since I really wanted to understand what was going on.
Some quick DataScript info:
q
queries the DataScript db (referenced here by@db/dsdb
) with a query'[:find ?ch ?new-to]...
with some args ((:db/id parent)
,(:block/order block
).- In-component data queries only run when the database is updated with the relevant values.
After learning some DataLog, reindex-blocks
started to make sense. It makes sure that after the current block is indented, the order for the other blocks makes sense.
However, the more I thought about what the function did, this little snippet didn't seem quite relevant to the issue at hand.
.
└── Blah
├── This is top node
├── Hello
└── Iron Man was not good
The issue here is that, currently Athens lets you indent a block that is at the top of it's level. Looking at the diagram above, we could indent the block "This is top node". But that doesn't really make sense. That block doesn't have any blocks directly above it that could become it's parent.
Maybe the older-sib
expression has some clues...
older-sib (->> parent
:block/children
(filter #(= (dec (:block/order block)) (:block/order %)))
first
:db/id
get-block)
Let's break this down. older-sib
is bound to the result of a threader macro. This macro starts off with the parent block for the current block, gets the children of the parent block, filters the list to find the block whose block order is one less than the block order of the current block, gets the id and then gets the block object.
However, there's a little issue here. What if the current block did not have an older sibling at all.filter
would return an empty list. first
on an empty list returns nil
. And clearly the db is not going to give us a valid response when asked for the id of the nil block. And it doesn't seem like any of the rest of the function is handling this issue.
So the million-dollar question is - "How do we indent a block that does not have an older sibling?". The answer is that we don't.
Each block has an attribute called block order. The top-most block at a given indentation level starts off at 0. And as you add blocks below, the block order number increments for each block.
.
├── 0
├── 1
├── 2
│ ├── 0
│ └── 1
└── 3
└── 0
└── 0
The above diagram illustrates how Athens assigns block orders for blocks. I ended up staring at a bunch of hand-drawn diagrams of nested blocks and how different levels of indentation could affect how the blocks are rendered.
Ultimately I realized that the "don't indent a block that doesn't have an older sibling" could be simplified to "don't indent a block which has block order 0".
Now handling the indent issue becomes as simple as figuring out how to check if block order equals 0 for the given block and not performing the re-ordering transaction if so. In fact, we can do this in a way where we don't even have to evaluate the full beefy let
expression.
(fn [_ [_ uid]]
(let [block (get-block [:block/uid uid])
parent (get-parent [:block/uid uid])]
(if (= (:block/order block) 0) ;; check block order = 0
{} ;; return no-op if so
(let [older-sib (->> parent
:block/children
(filter #(= (dec (:block/order block)) (:block/order %)))
first
:db/id
get-block)])))
;; rest of the original function
)
Adding the if form here makes sure that we only pull data and perform other calculations if we need to. When we don't need to, our :indent
event handler returns a no-op.
Turns out that studying DataLog to understand this issue wasn't really needed (except to understand the query in reindex-blocks
). But the time spent on that was still valuable since I poked around a bunch of different files in the codebase to understand how the state of the application is initialized. This has definitely deepened my understanding of Athens and ClojureScript in general.
I also solved some of the exercises in LearnDataLog.com which I'm sure will come in handy when I need to decipher some queries or when I write queries myself.
It feels pretty great to leap into a codebase blind and pick up the pieces by digging through files and the documentation for various libraries. This is the kind of problem solving that gets me excited!
It was also fun to discuss this issue with @adrien. We ended up coming to the same conclusion on what the issue was and how to solve it.
Next Steps - Figure out how to solve the :unindent
issue.
P.S - Looking at the re-frame
event handlers and the code got me thinking about what end-to-end testing for declarative state could look like. This might be another interesting topic to discuss on the Athens discord.