Skip to content

Multi screen Applications

Brenton Ashworth edited this page Jun 30, 2013 · 7 revisions

Multi-screen Applications

Creating a single-page application with only one screen is hard enough. Creating one with multiple screens is much more difficult. In addition to managing application state we now also have to know which screen is active and which changes to state effect this screen.

Pedestal was designed to create applications with multiple screens without making it harder to manage state. The feature in Pedestal which supports this is called Focus.

In this section we add a login screen to the application which allows a player to enter their name before a game starts. While making this change, we will see how to use Focus to control which part of the application model is visible and will report changes. We will also see how Pedestal's workflow allows us to incrementally make this change, doing exactly one thing at a time. While making these changes we will be introduced to the following Pedestal concepts:

  • Focus
  • Static Templates

Before making these changes, start up the Data UI. We can use this to watch and interact with the changes as they are made.

Enter and store a name

This new login feature will interact with our application by sending a message which contains the player's name.

{msg/topic [:login :name] :value "Feanor"}

The name will be stored at [:login :name]. To enable this message to be sent, we create a new emitter named init-login.

(defn init-login [_]
  [[:node-create [:login] :map]
   [:node-create [:login :name] :map]
   [:transform-enable [:login :name]
    :login [{msg/type :swap msg/topic [:login :name] (msg/param :value) {}}]]])

When creating a lot of structure like this, it can be easier to write it as nested maps. The version below will have exactly the same result.

(defn init-login [_]
  [{:login
     {:name
       {:transforms
         {:login [{msg/type :swap msg/topic [:login :name] (msg/param :value) {}}]}}}}])

We use message parameters to indicate that the :value key should be supplied when the message is sent.

Configure the init-login emitter and allow changes at [:login :*] to be reported. Add the following to the top of the :emit section of the dataflow definition.

{:init init-login}
[#{[:login :*]} (app/default-emitter [])]

With these changes in place, the Data UI will now allow us to enter a player name and we should see that name appear in the data model.

Make this its own page

Ideally, a login form would appear on the page by itself and then, after submitting the form, the game would start. We would like to have two screens, one for login and another for the game.

To achieve this in Pedestal, we use Focus. Focus allows us to view only part of the application model tree and then use set-focus to view another part of the tree. We can give names to subtrees using the :focus key in the dataflow definition.

Add the configuration below to the dataflow definition.

:focus {:login [[:login]]
        :game  [[:main] [:pedestal]]
        :default :login}

The application model tree currently has three top-level nodes: :login, :game and :pedestal. This configuration associates the tree under :login with the name :login and the trees under :main and :pedestal with the name :game. These names can now be used to the set the focus. The default focus will be set to :login. When the application starts we will only see changes to the tree under the [:login] node.

To change focus, we send a special message with a topic of msg/app-model and type of :set-focus.

{msg/topic msg/app-model msg/type :set-focus :name :game}

The message above will change the focus to :game (which will now only show changes to the trees under the [:main] and [:pedestal] nodes).

Since we are not currently validating messages, this message should be sent at the same time that a player submits their name. To achieve this, add the message above to the vector of messages in init-login.

(defn init-login [_]
  [{:login
    {:name
     {:transforms
      {:login [{msg/type :swap msg/topic [:login :name] (msg/param :value) {}}
               {msg/type :set-focus msg/topic msg/app-model :name :game}]}}}}])

Try out these changes in the Data UI. You should now only see the login portion of the tree when the app begins. When you click on :swap, a dialog will open asking for a user name. Click continue to display the game screen.

One interesting thing about focus is that it allows for overlap. Two different screens can view the same subtree. Transitions between two screens that share a common subtree will not cause the common areas to be redrawn.

Using the player name

Now that we have a player name, we should use it instead of "Me". To do this we need to supply the name to the merge-counters function

(defn merge-counters [_ {:keys [me others login-name]}]
  (assoc others login-name me))

and update the config for merge-counters.

[{[:my-counter] :me [:other-counters] :others [:login :name] :login-name} [:counters]
 merge-counters :map]

Try this in the Data UI.

Rendering the login screen

To render the login screen, we need to create a new template and use it from the renderer.

Create a new template

In tools/public/design.html add a link to the new login page.

<li><a href="/design/login.html">Login</a></li>

Note: why does this need to be done?

Create file app/templates/login.html with the following content.

<_within file="application.html">

  <div id="content">

    <div class="row-fluid" template="login" field="id:id">
      <input type="text" id="login-name" value="Fingolfin" field="value:name"><br>
      <button class="btn" id="login-button">Login</button>
    </div>

  </div>

</_within>

This template has an id field and name field. The input field has an id of login-name and the button an id of login-button.

In the namespace tutorial-client.html_templates add this template.

(defmacro tutorial-client-templates
  []
  {:tutorial-client-page (dtfn (tnodes "game.html" "tutorial") #{:id})
   :login-page (tfn (tnodes "login.html" "login"))})

Instead of using a dynamic template for login, we use a static template. Static templates are filled in once when added to the DOM and cannot be updated. That is exactly what we need for the login page.

After making these changes we will need to restart the server because we have made changes to the Clojure namespace tutorial-client.html_templates.

Updating the renderer

We need to update the renderer to use the new template to render the login screen. We could just start making changes to the rendering code but Pedestal gives us a better way to make this change.

We can record the display of the login screen as well as the transition from this screen to the game screen. We can then play through this recording while updating the renderer.

To make the recording, go to the Data UI and press Alt-shift-R. After you see the indicator that the recording is in progress, enter a name in the text field and click the Login button.

In the tutorial-client.rendering namespace, configure a handler for the [:login] path.

[:node-create  [:login] add-login-template]
[:node-destroy [:login] h/default-destroy]

As before, we create a function to render a template.

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

This function is similar to the previous template-rendering function. The main difference is that we are rendering a static template. We get the template with (:login-page templates) and then add it to the DOM with (dom/append! (dom/by-id parent) (html {:id id})).

This will render the form on the page. We can try this by playing through the recording. The only other thing we need to render are the :transform-enable and :transform-disable deltas.

[:transform-enable [:login :name] add-submit-login-handler]
[:transform-disable [:login :name] remove-submit-login-event]

This is done with the following two functions.

(defn add-submit-login-handler [_ [_ path transform-name messages] input-queue]
  (events/collect-and-send :click "login-button" input-queue transform-name messages
                           {"login-name" :value}))

(defn remove-submit-login-event [_ _ _]
  (events/remove-click-event "login-button"))

The add-submit-login-handler function uses collect-and-send to capture values at specific ids and use these values to populate messages before sending them on the input queue. The call to collect-and-send above is setting up a click listener on the element with id "login-button" which, when clicked, will get the value of the element with id "login-name" and fill in any missing :value message parameters before sending the message on the input-queue

remove-click-event will simply remove the click event from the element with id "login-button".

With these changes in place, we can try out the app with its new login page.

We have introduced a bug

According the current rules of the game, we would like to remove a bubble every time a remote player scores. We have put the bubble removal logic in the renderer and it is now wrong. It will now remove a bubble any time there is a score, even if we are the player to score. Instead of trying to fix this problem in the renderer, we will move the logic for controlling the removal of bubbles to the application logic, were it should be.

In the tutorial-client.behavior namespace, add a remove-bubble function.

(defn remove-bubbles [rb other-counters]
  (assoc rb :total (apply + other-counters)))

;; add to :derive
[#{[:other-counters :*]} [:remove-bubbles] remove-bubbles :vals]

;; update config in :emit
[#{[:add-bubbles]
   [:remove-bubbles]} (app/default-emitter [:main])]

Try this in the Data UI to confirm that the correct value is being put in the data model under [:remove-bubbles].

Next, update the renderer to get rid of the logic around removing bubbles.

Remove the call to js/removeBubbles from set-score.

(defn set-score [renderer [_ path _ v] _]
  (js/setScore (game renderer) (last path) v))

Add a remove-bubbles handler function.

(defn remove-bubbles [renderer _ _]
  (js/removeBubble (game renderer)))

Finally, route value changes to [:main :remove-bubbles] to remove-bubbles in render-config

[:value [:main :remove-bubbles] remove-bubbles]

Sharing names with other players

We have collected a player name and can make use of it locally but have not yet shared this name with the other players. In the tutorial-client.behavior namespace, the publish-counter function sends the message

{msg/type :swap msg/topic [:other-counters] :value count}

and the server supplies the session id to complete the path for another player. To distribute names we will now fill in the complete path when publishing our counter and remove the code from the server which adds the session id. This will mean that we can't have two players with the same name, but for this example application, we will accept that limitation.

Update publish-counter to use the name in the path.

(defn publish-counter [{:keys [count name]}]
  [{msg/type :swap msg/topic [:other-counters name] :value count}])

Update the config for publish-counter to add the name to the input map.

:effect #{[{[:my-counter] :count [:login :name] :name} publish-counter :map]}

In the tutorial-service project, update the tutorial-service.service namespace, removing the code from the publish function which adds the session id to the message topic. The new version of publish is shown below.

(defn publish
  "Publish a message to all other connected clients."
  [{msg-data :edn-params :as request}]
  (log/info :message "received message"
            :request request
            :msg-data msg-data)
  (let [session-id (or (session-from-request request)
                       (session-id))]
    (notify-all-others session-id
                       "msg"
                       (pr-str msg-data)))
  (ring-resp/response ""))

With these changes in place, start the service and the client, get some friends together and play a game. It's so much more fun when you can see who is winning.

Next steps

It is kind of weird how the game just starts when we login. In the next section we will create another screen where players can wait before a game starts. We will also provide the ability to explicitly start a game.

The tag for this step is v0.1.4.

Home | Starting a Game

Clone this wiki locally