-
Notifications
You must be signed in to change notification settings - Fork 0
The Evolution of a Web MX Inspector
Our first workshop will produce a series of tagged commits evolving a live inspector for Web/MX apps. We will use the Web/MX Quickstart as our target, so we have something to inspect.
I will:
- work for a day or so, commenting heavily;
- reach some milestone;
- rebase commits made during the day so one commit has everything;
- tag that commit with a helpful name for the milestone; and
- push.
Folks can then git diff
before and after to see the work side-by-side.
And now on to the first tag.
Our first tag will not actually tackle the inspector. We just wanted to get the ball rolling, and cleaning up the Quickstart main controller was a ripe target. It happens also to demonstrate the easy refactoring possible when widgets handle state communication themselves. So on with the cleanup.
The Web/MX Quickstart (QS) was never meant to be anything other than a throwaway; we just needed it to present the dozen or so QS lessons. No serious evolution was anticipated, so it is a bit of a mess.
Aside from that, one cleanup we have in mind is getting copious in-line CSS out of the way.
Ergo, two main goals to get the source mess under control:
- move in-line CSS literals into CLJS functions, in part to get them out of the way, but also to start a conversation about separate CSS not necessarily being effective. For one, that totally breaks the co-location win. For another, Web/MX supports dynamic
:style
formulas, which cannot live in .css files, so CSS will end up partly in formulas and partly in CSS files, combined by HTMLclass
attributes. CLJS functional composition is more powerful and predictable than CSS class manipulation, so unless we are doing CSS animations, we will stay away from class-based CSS authoring. Over time. We have some classes now and can leave them until we really dive into CLJS->CSS; and - just break out code into functions. Note that in some cases this will be HTML chunks, and in others individual cell formulas.
- Break out the router starter as separate function 'qs-router-starter', and
- ... break out the app-wide key handler (for lesson navigation) as
qs-keydown-handler
. - Move most CSS into functions in core/style.cljs.
- DRY a reusable 'text-block' component for sequences of text paragraphs.
- Break out the toolbar and right-side entire lesson display into componentsin a new core.widget NS.
- Move the glossary display logic into the core.glossary NS.
- Move the global lessons array hardcode into the lessons NS.
Now we start on the inspector proper.
Our first goal is to just have some user gesture pop open a new DIV right alongside the QS app, and display as much of the QS structure as possible in a day's effort.
Ideally we would open a new browser tab and workout communication between the two, but that will take some head-scratching, and again we want to dig into w/mx leaning more than we want to get sophisticated with browser tech.
So we will cheat and modify index.html
to have a second "inspector" placeholder DIV alongside the "app" DIV, and when the "open inspector" gesture is received, populate the "inspector" DIV with structure mirroring the "app" structure, but with little inspector widgets.
This, btw, will give w/mx noobs a feel for how w/mx code initially connects with the DOM. Speaking of which:
(defn main [mx-builder]
(let [app (gdom/getElement "app")
app-matrix (mx-builder)
app-dom (tag-dom-create app-matrix)]
(reset! matrix app-matrix)
(set! (.-innerHTML app) nil)
(gdom/appendChild app app-dom)
(when-let [router-starter (mget? app-matrix :router-starter)]
(router-starter))))
(main #(quick-start "Web/MX™<br>Quick Start"
(lesson/qs-lessons)))
The above main
function gets kicked off by the top-level form below it, on page load.
We just need to write a wmx-inspector
function to generate the initial DOM for the inspector, then emulate the code above to get the inspector loaded.
Step #1 Trap option-cmd-x
First extend the raw index.html
:
<body>
<div id="root"
style="display:flex;flex-direction:row">
<div id="app"></div>
<div id="inspector"></div>
</div>
<script src="../cljs-out/qs-main.js"></script>
</body>
...then add this to the QS window event handler.
(let [key-evt (wmx/jso-select-keys evt
[:type :keyCode :metaKey :altKey :shiftKey :ctrlKey])]
;; step #1: parse and recognize option-cmd-X keychord
(case key-evt
{:type "keydown" :keyCode 88
:metaKey true :altKey true
:shiftKey false :ctrlKey false}
(i/inspector-install)
(do #_ (prn :unknown-key-evt!!! key-evt)))
nil)
Step #2 Find the new "inspector" tag and modify
First just see if we can get a dummy span
installed in the right place.
(defn inspector-install []
(let [app (gdom/getElement "inspector")]
(set! (.-innerHTML app) nil)
(gdom/appendChild app
(tag-dom-create
(span "Your inspector here!"))))
Step #3 Knock together a primitive inspector hard-coded to display the MX under the "app" DIV just three levels deep, with just a spab showing the MX :name, or :tag if unnamed.
(defn mxi-md-view [md depth]
(when (pos? depth)
(cond
(string? md) (span md)
(md-ref? md) (do
(div {}
{:md md}
(span (mget? md :name
(mget? md :tag "noname")))
;; ^^^ no tag should not occur, wmx builds that in
(div {:style {:padding-left "1em"}} {}
(mapv #(mxi-md-view % (dec depth)) (mget? md :kids)))))
:else (span "not string or md"))))
(defn inspector-toolbar []
(div {:style {:justify-content :space-between
:margin "2em"
:gap "1em"
:display :flex}}
(span "inspector toolbar")
(span {:onclick (fn [evt]
; todo break this out as inspector-uninstall then make keychord a toggle
(let [mxi (fasc :mxi (evt-md evt))
dom (gdom/getElement "inspector")]
(md-quiesce mxi)
(set! (.-innerHTML dom) nil)))}
;; todo find a nice "close" icon?
"[X]")))
Run the app, make the window twice as wide as the content shown, then hit the key chord option-cmd-x. We should see:
inspector toolbar [X]
:quick-start
div
span
i
span
div
div
div
Click the [x] to clear the inspector.
With this tag we begin a new policy of smaller commits, each (most?) demonstrating how to solve some typical U/X task. Our first will be a classic, expanding/collapsing the elements of a tree.
Here is how we tackle sth like this:
- Since this is a user action, we will need an input cell, perhaps named
expanded?
. We will default tofalse
for obvious reasons. Eventually we may add a feature to open the first N levels; - next we need an
onclick
handler to toggleexpanded?
; and - we modify the
kids
, or last, form to create kids only ifexpanded?
is true.
By the way, this is a good example of something where a separate store is overkill and tricky. We have a state dependency that matters only to the widget, and is controlled ony by the widget. A global store does not offer value, and now we must work to differentiate multiple expanded?
nodes.
Rookie mistake: forgot to make expanded?
an input cell!
mxerr> MXAPI_ILLEGAL_MUTATE_NONCELL> invalid mswap!/mset!/md-reset! to the property ':expanded?', which is not mediated by any cell.
...> if such post-make mutation is in fact required, wrap the initial argument to model.core/make in 'cI'. eg: (make... :answer (cI 42)).
...> look for MXAPI_ILLEGAL_MUTATE_NONCELL in the Errors documentation for more details.
...> FYI: intended new value is [true]; initial value was [false].
...> FYI: instance is of type :web-mx.base/tag.
...> FYI: full instance is {:attr-keys (:id :onclick :style), :parent #object[cljs.core.Atom {:val {:parent nil, :tag "div", :id "div-332", :attr-keys #, :kids #, :name :mxi, :target #object[cljs.core.Atom #]}}], :expanded? false, :name :md-view, :onclick #object[Function], :style nil, :kids (#object[cljs.core.Atom {:val #}] #object[cljs.core.Atom {:val #}]), :id "div-334", :tag "div", :md #object[cljs.core.Atom {:val {:attr-keys #, :selected-lesson #, :parent nil, :name :quick-start, :keydowner #object[Function], :route :intro, :style #, :kids #, :id "div-226", :lessons #, :tag "div", :router-starter #object[Function], :show-glossary? false}}]}
...> FYI: instance meta is {:tiltontec.cell.base/state :awake, :mx-sid 733, :mx-type :web-mx.base/tag, :on-quiesce nil, :cz {:kids nil, :style nil}, :cells-flushed ([:kids :val # :pulse 293] [:style :val nil :pulse 293]), :dom-x #object[HTMLDivElement [object HTMLDivElement]]}
Pretty simple once I remembered how browser events work:
(div
{:onclick (cF (fn [e]
(let [ck (evt-md e)]
(.stopPropagation e)
(mswap! me :expanded? not))))
:style (cF (when (mget me :expanded?)
;; just showin' off
{:color :red}))}
{:name :md-view
:md md
:expanded? (cI false)}
(span {:style {:user-select :none}}
(mget? md :name
(mget? md :tag "noname")))
(div {:style {:padding-left "1em"}} {}
;; careful! "me" is this DIV, we want its parent. And rather than hard code
;; that relationship by saying "(mpar me)", we go flexible with an "fasc" search ascendants.
(when (mget (fasc :md-view me) :expanded?)
;; so we make one child for this local DIV for every chikd of the "md" being inspected.
(mapv #(mxi-md-view %) (mget? md :kids)))))
Next we will shift to optionally showing properties of a model, using an option key command modifier to indicate that. And our expanded?
will become show-kids?
and this new property will be show-props?
.
This feature will let us drill down into the properties of a model, and involve a slightly different gesture: option-clicking.
Here is a list of expected properties, first for an input cell, then a formulaic cell.
(def +valid-input-options+
#{:watch :prop :ephemeral? :unchanged-if
:value :input? :debug :on-quiesce})
(def +valid-formula-options+
#{:watch :prop :input? :lazy :optimize :ephemeral? :unchanged-if
:model :synaptic? :synapse-id
:code :value :rule :async? :and-then :debug :on-quiesce})
We may also show properties supporting cells internals, such as the cell "state" and several "pulses" tracked per cell.
Pulse? MX data integrity works off a monotonically increasing *datapulse*
integer. Prolly not worth seeing, but we're just having fun so far with the inspector.
- Change
expanded?
toshow-kids?
; - add a
show-props?
custom property, initialized to(cIn false)
; - include a new DIV for properties, above the DIV we have for kids;
- populate the props DIV with one row for non-internals model properties;
- show the prop name and value for each property; and
- maybe show the code for formulaic cells, just to show off.
First we made iterative development easier by starting with the inspector visible.
Then we executed the plan, as is, and spent a little time on CSS to make it presentable.
Here is the code to display a property-value pair:
(defn mxi-md-prop-view [md prop-key]
(div {:style {:display :flex
:flex-direction :row
:align-items :center
:padding "6px"}}
; prop name as label:
(span {:style (assoc {:min-width "8em"}
:background (when-let [pv (prop-key (:cz (meta md)))]
(case (mx-type pv)
:tiltontec.cell.base/c-formula :aqua
:tiltontec.cell.base/cell :lime
nil)))
:title (when-let [pv (prop-key (:cz (meta md)))]
;; we cannot show code for cI input cells because
;; cI is a function and thus evaluates the value parameter
(case (mx-type pv)
:tiltontec.cell.base/c-formula
(with-out-str
(binding [*print-level* false]
(pp/pprint (first (:code @pv)))))
nil))}
prop-key)
; prop value:
(span {:style {:background :white
:padding "0.5em"}
:title (str (prop-key @md))}
(subs (str (prop-key @md)) 0 40))))
The tag, as planned: https://github.com/kennytilton/web-mx-workshop/releases/tag/model-show-properties
This next exercise covers a classic development use case. So far we can open an inspector on the full app matrix, which is fine, but once we start playing with it we realize it would be nice to focus on one sub-component, just to keep our viewer uncluttered. (And once we conceive this, we can imagine a viewer with multiple tabs, each focused on a different node. Or maybe "back" and "forward" options. But we have a problem: the inspector as originally implemented just focused on the root node, fetched from a global matrix
atom:
(defn main [mx-builder & [and-then]]
(let [app (gdom/getElement "app")
app-matrix (mx-builder)
app-dom (tag-dom-create app-matrix)]
(reset! matrix app-matrix) ;; <===== stash MX app root proxy
(set! (.-innerHTML app) nil)
(gdom/appendChild app app-dom)
...snip...
(defn inspector-install []
(let [ins (gdom/getElement "inspector")]
(set! (.-innerHTML ins) nil)
(gdom/appendChild ins
(tag-dom-create
(inspector-mx @matrix))))) ;; <====== launch inspector from global atom
(defn inspector-mx [mx]
(div {:style {:background :linen}}
{:name :mxi
:target mx} ;; <====== not wrapped by a cell, so cannot be changed. (Runtime exception thrown if we try.)
(inspector-toolbar)
(ul
(li (i "click to show/hide children"))
(li (i "option click to show/hide properties")))
(div {:style {:padding "0 12px 12px 12px"}}
(mxi-md-view mx)))) ;; <======= closes over function parameter
It is fine capturing the root MX proxy in an atom, but it is also optional, because MX formulas work over internal wiring, if you will.
The problem, as we see above, is that the inspector builder takes the mx
parameter and builds a DIV with that value as a custom :target
value, not wrapped by a Cell. If we want to re-target the inspector, we cannot, because at run time MX enforces a rule that mutated values must be wrapped in an input cell, so MX knows to track any readers. Failing that, an exception is thrown if we try to mset!/mswap!
the property.
Why the rule? MX gains a significant optimization from not tracking which formulas read properties it sees will never change, and we think a more programmer-friendly default for properties is "will never change", since that is true of most properties.
A second problem: in the last line of code we see the mx-md-view
closing over the mx
parameter to inspector-mx
. Even after we arrange for changes to the :target
property, the mxi-md-view
function will not learn of those changes and not update.
Our plan is a typical MX refactoring:
- use an input cell for the target:
:target (cI mx)
; - have
mxi-md-view
internal formulas get the target from(:target (fasc :mxi me))
if they need it, meaning we eliminate themx
parameter. This is an example of how letting properties find their own data means we do not end up with brittle parameter lists to adjust; and - add a
dblclick
handler on inspector node views thatmset!
s the inspector:target
.
Steps #1 and #2:
(defn inspector-mx [mx]
(div {:style {:background :linen}}
{:name :mxi
:target (cI mx)} ;; <===== input cell
(inspector-toolbar)
(ul
(li (i "click to show/hide children"))
(li (i "option click to show/hide properties")))
(div {:style {:padding "0 12px 12px 12px"}}
(mxi-md-view (mget (fasc :mxi me) :target))))) ;; <===== use `mget` to establish dependency and re-run on changes
Steo #3: Now we can simply have any event handler change the target in two steps:
- find the main inspector component, named
:mxi
; and - change the target to the model being inspected by the DIV owning the handler.
(defn mxi-md-view-rich [md]
(div
{:onclick (mxi-md-view-on-click md)
:ondblclick (fn [e]
(.stopPropagation e)
(let [me (evt-md e)]
(mset! (fasc :mxi me) :target md)))}
...snip...
Note that the one trick here is not confusing the model with its inspector. When coding the reflective inspector, we have to keep this in mind, because the inspector itself is another model. (And, yes, with a small effort we could have the inspector inspect itself. Fun, but left as an exercise.)
Hot reloading may not work here, so:
-
git checkout retarget-refactor
; - reload the page;
- in the inspector, click
div::quickstart:2
to see its two children; and - double-click one of them and confirm it is now the inspector target.
Next up, we will implement re-targeting from the running app as a shortcut to jump the inspector directly to any visible widget.
Problem: As implemented, the MX inspector starts by inspecting the whole app, by focusing on the root proxy model. It can be a nuisance to navigate then to some deeply selected node. Quick fix: We will concoct an oddball click that will focus the inspector on the clicked widget.
Note that this an awkward solution, which we include only for didactic reasons, as with many aspects of this inspector tool. It assumes the inspector has already been opened, and it would shadow, or be shadowed by, the app trying to use the same modified click.
The steps:
- Have the inspector register an option-cmd-click handler on the window;
- have the handler identify the MX proxy managing the clicked DOM;
- navigate to the inspector root proxy
:mxi
; and -
mset!
the :mxi:target
to the MX proxy origin of the click event.