-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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.
To render the login screen, we need to create a new template and use it from the renderer.
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.
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.
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]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.
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.