Rendering

Ryan Neufeld edited this page Apr 18, 2014 · 30 revisions

To follow along with this section, start with tag v2.0.10.

In this section you will work on rendering the application. Rendering is the process of drawing something on the screen which represents the application and with which users can interact. In the previous sections you created two HTML templates which will be made use of below.

It is important to note that rendering is more general than binding data to HTML. As you will see in Part 2 of the tutorial, it is often the case that you will want to render with something other than HTML.

As you work through this section, you will be introduced to the following pedestal-app concepts:

  • Recording interactions
  • Playing recordings
  • Writing rendering code
  • Avoiding "callback hell"

Recording user interactions

Pedestal-app is designed for building interactive applications. This means that things will change in the UI based on what the local user is doing and based on what other users that you are interacting with are doing. Rendering interactive applications can be hard because it is time consuming to simulate specific interactions that you would like to render.

Pedestal-app helps by allowing you to record an interaction once, then play it many times while working on the rendering code. This allows you to easily replay the same scenario many times and to focus on rendering without having to worry about running the rest of the system.

Pedestal-app provides a way to record a sequence of deltas for later playback. You can use this feature to generate deltas which represent a typical use of your application.

In the generated project, the only aspect which allows for recording is the Data UI. This feature can be added to any aspect. For this project, using the Data UI to generate a recording will work fine.

To make a recording, go to the DataUI (http://localhost:3000/tutorial-client-data-ui.html) and then type the key combination

Alt-Shift-R

You will see an indicator that you are currently recording in the lower right-hand corner of the screen.

As you interact with the application, each rendering delta is being recorded. Press the :inc button a few times. After you have recorded a sufficient amount of activity, type the key combination

Alt-Shift-R

to stop recording.

This will open a dialog which will ask you to name the recording. The dialog asks for both a keyword and a text description. For the keyword enter :tutorial and for the description enter Tutorial Recording.

Clicking Continue will save the recording while clicking Cancel will delete it.

The recording is saved in a Clojure file as Clojure data. This recording can be found in the file

tutorial-client/tools/recordings/tutorial-client/tutorial.clj

You could write a file like this from scratch or edit this file to create alternate interactions. Being able to generate and work with this kind of data allows you to capture hard-to-simulate interactions and work on rendering them without having to drive the application. You could even create an interaction that the application cannot yet produce. This would allow you to work on rendering and the application logic simultaneously.

Playing recordings

To see a list of recordings, click on the Render link in the Tools menu or open http://localhost:3000/_tools/render. This page contains a list of each recording that you have created. Each recording will have three links. The link that is the recording's name will play through all the deltas at once. This is useful when you want to see how a specific point in time will be rendered.

The second link is named Break it will play through the deltas in chunks. For recorded data, each chunk is the output of a single transaction. This allows for the deltas to be played in the same way that the browser would receive them. A sequence of recorded deltas will contain the :break keyword to divide blocks of deltas by transaction. Breaks may be added by hand to control how this method of playback behaves.

The last link is named Step. It allows playback to proceed one delta at a time. This is the most helpful mode when working on a new renderer, which is what you will be doing throughout the remainder of this section.

Click on the Step link and then open the JavaScript console in order to see the log messages which will be printed. It may also be useful to open the recording file so that you can refer to the deltas which you are trying to render.

With focus on the main window, use the → and ← arrow keys to navigate forward and backward through the deltas. As each delta is rendered, it will be printed to the console.

You may have noticed that nothing is actually being rendered on the screen. The deltas are being fed through the renderer but it does not yet know how to render them. The rest of this section describes how to implement this rendering code. As you work on this, use the recording view to monitor your progress.

Rendering

Basic rendering is very simple. A rendering function can be provided to the application which will be called after every transaction and passed the sequence of deltas produced and the input queue. This function will have some side-effect which draws something on the screen or creates an event listener. Using the typical example for side-effects, you could even make a renderer which launches missiles.

Pedestal-app provides a function in the io.pedestal.app.render.push namespace named renderer which will create a rendering function for you. This renderer is helpful to use when working with the DOM and is called the "push renderer" because it responds to data being pushed to it from the application.

The renderer which will be used is set up in the tutorial-client.start namespace. In the create-app function the renderer is created passing a render-config. That renderer configuration is implemented in the namespace tutorial-client.rendering which is where you will do all of your work.

Rendering with the DOM

Before you start, add one additional required namespace to tutorial-client.rendering.

[io.pedestal.app.render.push.handlers :as h]

At the beginning of the recording, the first deltas to be encountered are

[:node-create [] :map]
[:node-create [:main] :map]

The first delta will only be seen once. It represents the creation of the root node of the application model tree.

The second delta represents the creation of the root node of this tutorial application. This delta will be rendered by adding the main template to the DOM, all subsequent rendering will fill in the values of this template. Unfortunately this means that the first rendering function to write will also be the most complex.

A render configuration is a vector of tuples which map rendering deltas to functions. Each vector has the op then the path and then the function to call. This is similar to the way transform functions are configured. In the example below, the render-template function will be called when the :main node is created and the library function h/default-destroy will be called to remove the template when the :main node is removed.

(defn render-config []
  [[:node-create [:main] render-template]
   [:node-destroy [:main] h/default-destroy]])

When using the push renderer, every rendering function receives three arguments: the renderer, the delta and the input queue. The renderer helps to map paths to the DOM. The delta contains all of the information which is required to make the change and the input queue is used to send messages back to the application.

(defn render-template [renderer [_ path] _]
  (let [parent (render/get-parent-id renderer path)
        id (render/new-id! renderer path)
        html (templates/add-template renderer path (:tutorial-client-page templates))]
    (dom/append! (dom/by-id parent) (html {:id id}))))

In the example above, the delta is destructured to get the information required by this function. This is a common pattern. The most common part of the delta to grab is the path.

The body of this function is as complicated as a rendering function can get. Here is what this function does:

  1. get the parent id for the current path from the renderer
  2. generate a new id for this path
  3. add the dynamic template to the renderer at this path
  4. add the template to the DOM under the parent id, providing the default values

For complex render functions, this is also a common pattern: get the parent id, create a new id, add some child content under the parent.

The functions render/get-parent-id and render/new-id! are simple to understand. See io.pedestal.app.render.push for more information.

The function templates/add-template takes all the hard work out of dealing with dynamic templates. This function associates the template with the given path and returns a function which generates the initial HTML. Calling the returned function with a map of data will return HTML which can be added to the DOM.

The dynamic template is retrieved from the templates map which is created at the top of this namespace.

(def templates (html-templates/tutorial-client-templates))

After adding this code, refresh the browser and then step through the deltas again to see the template get added to the DOM when the delta below is received.

[:node-create [:main] :map]

Press the left arrow to go backward and see the template be removed from the DOM. Testing that the renderer works correctly when going backward and forward through the deltas is important. This shows that the changes to the DOM can be built up and torn down correctly in response to changes in the application model tree.

Rendering transforms

Pedestal-app helps you to avoid "callback hell" by removing the need for events to call arbitrary functions. Every callback function in pedestal-app does one thing, it creates a sequence of messages and puts them on the input queue. All input messages, both those generated by user events and those which come from services, follow the same path through the application.

A :transform-enable delta provides a sequence of messages to send when some event occurs. What event will cause this action is up to the renderer. For this application, the transforms attached to the [:main :my-counter] node will be sent when a button is clicked.

Pedestal-app provides helper functions to wire this up in the io.pedestal.app.render.push.handlers namespace.

(defn render-config []
  [...

   [:transform-enable [:main :my-counter] (h/add-send-on-click "inc-button")]
   [:transform-disable [:main :my-counter] (h/remove-send-on-click "inc-button")]])

The add-send-on-click handler will arrange for the messages included in this :transform-enable to be sent when the element with id inc-button is clicked. The remove-send-on-click handler will remove this event listener when a :transform-disable is received.

This is one example of how small, focused handlers can lead to reusable code. It also illustrates how little the rendering code has to know about the messages that it sends. It doesn't know how many messages will be sent or even what the contents of those messages are.

Once again, refresh the browser and step through the changes. Watch for the :transform-enable delta. When this delta arrives, click the button. You should see messages printed to the console showing the messages that would have been added to the input queue. Use the left arrow to go back and generate the :transform-disable delta. Now, when you click on the button, nothing should happen. And we all know that when nothing happens, that totally confirms that it is working. :)

Changing a value in a template

By design, most of the values that will be plugged into the template have a path which ends with the template field name. This allows for one function to handle all of these updates.

(defn render-value [renderer [_ path _ new-value] input-queue]
  (let [key (last path)]
    (templates/update-t renderer [:main] {key (str new-value)})))

This function uses the templates/update-t function to update a value in a template. update-t has three arguments: the renderer, the path that the template is associated with and the map of values to update in the template. In this case the key is the last part of the path.

All value changes for the paths [:main :*] and [:pedestal :debug :*] are routed to this function.

(defn render-config []
  [...

   [:value [:main :*] render-value]
   [:value [:pedestal :debug :*] render-value]])

Rendering lists

Remember that there are two templates, one for the whole page and one for each element in the list of other counters. Writing a new function to add the :other-counter template would reveal that it is almost identical to the existing render-template function. Instead of writing this new function, the existing render-template function should be generalized.

(defn render-template [template-name initial-value-fn]
  (fn [renderer [_ path :as delta] input-queue]
    (let [parent (render/get-parent-id renderer path)
          id (render/new-id! renderer path)
          html (templates/add-template renderer path (template-name templates))]
      (dom/append! (dom/by-id parent) (html (assoc (initial-value-fn delta) :id id))))))

This is a function which returns a function. It takes the template name and a function which, when passed the delta, will return a map of initial values for the template. It returns a rendering function.

Rendering deltas for the other counters will be received in the order shown below.

[:node-create [:main :other-counters] :map]
[:node-create [:main :other-counters "abc"] :map]
[:value [:main :other-counters "abc"] nil 42]

The first delta

[:node-create [:main :other-counters] :map]

should cause the creation of the container for the other counters. The second delta

[:node-create [:main :other-counters "abc"] :map]

should add the template for a counter. The third delta

[:value [:main :other-counters "abc"] nil 42]

will update the value in the template.

The new, generalized, version of render-template can be configured to handle creating the template when the :node-create delta for [:main :other-counters :*] is received.

[:node-create [:main :other-counters :*]
  (render-template :other-counter
                   (fn [[_ path]] {:counter-id (last path)}))]
[:node-destroy [:main :other-counters :*] h/default-destroy]

The render-template function assumes that there is a parent element with an id under which these templates can be added as children. The :tutorial-client-page has a div with id other-counters where these new elements could be added but the renderer does not know that this id is the parent of the path [:main :other-counters :*].

The changes below will associate this id with the parent path of all counter nodes

(defn render-other-counters-element [renderer [_ path] _]
  (render/new-id! renderer path "other-counters"))

(defn render-config []
  [...

   [:node-create [:main :other-counters] render-other-counters-element]
   ...
   ])

Finally, a function is added to update the other counter values.

(defn render-other-counter-value [renderer [_ path _ new-value] input-queue]
  (let [key (last path)]
    (templates/update-t renderer path {:count (str new-value)})))

(defn render-config []
  [...

   [:value [:main :other-counters :*] render-other-counter-value]])

The final version of the render configuration is shown below.

(defn render-config []
  [[:node-create [:main] (render-template :tutorial-client-page
                                          (constantly {:my-counter "0"}))]
   [:node-destroy [:main] h/default-destroy]
   [:transform-enable [:main :my-counter] (h/add-send-on-click "inc-button")]
   [:transform-disable [:main :my-counter] (h/remove-send-on-click "inc-button")]
   [:value [:main :*] render-value]
   [:value [:pedestal :debug :*] render-value]

   [:node-create [:main :other-counters] render-other-counters-element]
   [:node-create [:main :other-counters :*]
    (render-template :other-counter
                     (fn [[_ path]] {:counter-id (last path)}))]
   [:node-destroy [:main :other-counters :*] h/default-destroy]
   [:value [:main :other-counters :*] render-other-counter-value]])

Notice that the renderer for :node-create and path [:main] has been updated to use the new generalized form of render-template.

With these changes in place, you should now be able to step forward and backward in the rendering aspect and see each of these functions performing its specific task.

Development and production aspects

With a working custom renderer, the Development and Production aspects of the application will now work. Click on these links in the Tools menu to try them out.

When running the application in this way, only the local counter value is displayed. As you may know, if you have been paying attention, that is because the back-end service has not yet been implemented.

Clean up

You may notice that the counter starts at 1 before the button has been clicked. This is because early in the process, a function call was added to send a message when the application starts. This is no longer required.

In the namespace tutorial-client.start, remove the following line from the create-app function.

(p/put-message (:input app) {msg/type :inc msg/topic [:my-counter]})

Next steps

In this step you have got the point where you can use the Data UI to see the application work with a simulated back-end and you can run with the real renderer in the Development and Production aspects. It would be nice if you could use the real renderer and the simulated back-end together. This would allow you to confirm that the renderer is working correctly under realistic conditions and to interact with a more realistic version of the application.

In the next section you will learn how to customize the development tools to create new aspects that allow you to see part of the application in a new and interesting way.

If you are not interested in customizing the tools and would like to get straight to implementing the service, checkout the tag v2.0.12 and go to Making the Service. If you are not interested in back-end development and would like to finish the client, checkout the tag v2.0.13 and go to Connecting to the Service.

The tag for this section is v2.0.11.

View the diff for this section.

Home | Aspects | Making the Service | Connecting to the Service