Skip to content

The Evolution of a Web MX Inspector

Kenneth Tilton edited this page Apr 3, 2023 · 16 revisions

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.

The drill

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.

Tag Zero: qs-cleanup-with-css-inlined

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:

  1. 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 HTML class 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
  2. just break out code into functions. Note that in some cases this will be HTML chunks, and in others individual cell formulas.

Changes made

  • 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.

Tag One: hello-inspector

Now we start on the inspector proper.

Objective

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&trade;<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]")))

Results

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.

Tag: tree-expand-collapse

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.

Plan

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 to false for obvious reasons. Eventually we may add a feature to open the first N levels;
  • next we need an onclick handler to toggle expanded?; and
  • we modify the kids, or last, form to create kids only if expanded? 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.

Progress

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]]}

Result

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?.

Tag: model-show-properties

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.

Plan

  1. Change expanded? to show-kids?;
  2. add a show-props? custom property, initialized to (cIn false);
  3. include a new DIV for properties, above the DIV we have for kids;
  4. populate the props DIV with one row for non-internals model properties;
  5. show the prop name and value for each property; and
  6. maybe show the code for formulaic cells, just to show off.

Progress

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

Tag: retarget-refactor

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.

The Plan

Our plan is a typical MX refactoring:

  1. use an input cell for the target: :target (cI mx);
  2. have mxi-md-view internal formulas get the target from (:target (fasc :mxi me)) if they need it, meaning we eliminate the mx 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
  3. add a dblclick handler on inspector node views that mset!s the inspector :target.

Progress

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:

  1. find the main inspector component, named :mxi; and
  2. 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.)

The result

Hot reloading may not work here, so:

  1. git checkout retarget-refactor;
  2. reload the page;
  3. in the inspector, click div::quickstart:2 to see its two children; and
  4. 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.

Microtag: inspect-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 Plan

The steps:

  1. Have the inspector register an option-cmd-click handler on the window;
  2. have the handler identify the MX proxy managing the clicked DOM;
  3. navigate to the inspector root proxy :mxi; and
  4. mset! the :mxi :target to the MX proxy origin of the click event.