Gift list dev diary: routing

All development for this project is shared on github at https://github.com/codonnell/mygiftlist-blog. I will endeavor to focus on the most relevant snippets of code rather than go over all of them on this blog. For interested parties, the github repo is available to survey all of the minor details.

There are a number of ways to accomplish routing in a single page application. My personal preference on new projects is to use HTML5 routing with “normal” URLs rather than hash-prefixed ones. This allows us to serve different routes as separate HTML pages if we need to and is less confusing for users. I think it is important that a user be able to refresh the page as well as use the forward and back buttons without unexpected behavior, and so we will build our routing solution with those goals in mind.

The first routes we implement will be a login page and a welcome page. We won’t worry about routing between them; on page load, we will choose to route to one or the other based on whether the user is authenticated or not. With this in place, we will add an about page with a navbar that allows us to switch between these pages. Lastly, we’ll implement HTML5 routing.

For technology, we will use pushy, which wraps the HTML5 history API, and fulcro’s dynamic routers to implement routing.

Set up dynamic routing

First let’s implement a router and a few simple route target components. We’ll also update our Root component to render the router instead of our old CurrentUser component. Lastly, we’ll add a loading spinner and loading state to the root component. To see a full changeset for the rest of this post, see this commit.

(defsc LoginForm [this _]
  {:query []
   :ident (fn [] [:component/id :login])
   :route-segment ["login"]
   :initial-state {}}
  (dom/div {}
    (dom/div "In order to view and create gift lists, you need to...")
    (dom/div (dom/button :.ui.primary.button
               {:onClick #(auth/login)}
               "Log in or sign up"))))

(defsc Home [this _]
  {:query []
   :ident (fn [] [:component/id :home])
   :initial-state {}
   :route-segment ["home"]}
  (dom/div {}
    (dom/h3 {} "Home Screen")
    (dom/div {} "Welcome!")
    (dom/button :.ui.primary.button
      {:onClick #(auth/logout)}
      "Logout")))

(defn loading-spinner []
  (dom/div :.ui.active.inverted.dimmer
    (dom/div :.ui.loader)))

(defsc Loading [this _]
  {:query []
   :ident (fn [] [:component/id ::loading])
   :initial-state {}
   :route-segment ["loading"]}
  (loading-spinner))

(defrouter MainRouter [_ {:keys [current-state] :as props}]
  {:router-targets [Loading LoginForm Home]}
  (loading-spinner))
  
(defsc Root [this {:root/keys [router navbar loading]}]
  {:query [{:root/router (comp/get-query MainRouter)}
           {:root/navbar (comp/get-query Navbar)}
           :root/loading]
   :initial-state {:root/router {}
                   :root/navbar {}
                   :root/loading true}}
  (if loading
    (loading-spinner)
    (dom/div :.ui.container
      (ui-main-router router))))

We will also update our init function to initialize routing and route to the home page or login page depending on whether the user is authenticated.

(defn ^:export init []
  (log/info "Application starting...")
  (app/mount! SPA Root "app")
  (dr/initialize! SPA)
  (go
    (<! (auth/create-auth0-client!))
    (when (is-redirect?)
      (<! (auth/handle-redirect-callback))
      (clear-query-params!))
    (let [authenticated (<! (auth/is-authenticated?))]
      (comp/transact! SPA [(set-authenticated
                             {:authenticated authenticated})])
      (if authenticated
        (do (dr/change-route SPA (dr/path-to Home))
            (let [{:keys [sub email]} (<! (auth/get-user-info))]
              (comp/transact! SPA [(set-current-user
                                     #:user{:id sub :email email})])))
        (dr/change-route SPA (dr/path-to LoginForm))))))

Those who are reading closely will notice the addition of a set-authenticated mutation, which sets the root loading value to false and sets an authenticated value that can be used by our login/logout component. We’ve also added calls to dr/change-route using dr/path-to rather than hard-coded paths for improved code navigation.

Add an about page

Next we’ll add an about page and a navbar so that we can navigate between the home page and about page. The about page will be accessible to users who haven’t logged in. HTML5 routing will wait until the next section once we’ve gotten this bit working.

We’ll do this by writing a Navbar component and an About component, which we add as a router target.

(defsc Navbar [this {:keys [login-logout]}]
  {:query [{:login-logout (comp/get-query LoginLogoutItem)}]
   :ident (fn [] [:component/id :navbar])
   :initial-state {:login-logout {}}}
  (let [logged-in (:ui/authenticated login-logout)]
    (dom/div :.ui.secondary.menu
      (dom/a :.item
        {:onClick #(dr/change-route this (dr/path-to (if logged-in
                                                       Home
                                                       LoginForm)))}
        "Home")
      (dom/a :.item
        {:onClick #(dr/change-route this (dr/path-to About))}
        "About")
      (dom/div :.right.menu
        (ui-login-logout-item login-logout)))))

(defsc About [this _]
  {:query []
   :ident (fn [] [:component/id :home])
   :initial-state {}
   :route-segment ["about"]}
  (dom/div {}
    (dom/h3 {} "About My Gift List")
    (dom/div {} "It's a really cool app!")))

The Navbar component calls dr/change-route on onClick handlers to change what our MainRouter component is rendering; this is a vanilla use of a dynamic router. There is also a LoginLogoutItem component, which renders a login button or logout button depending on whether the user is authenticated or not.

At this point, our app is doing everything we set out to do in this post except for manipulating the address bar and managing the browser’s forward and back buttons.

HTML5 Routing

As mentioned above, we will use pushy, a wrapper around the HTML5 history API. There is a bit of a disconnect between how pushy and fulcro dynamic routers manage paths. Pushy accepts relative URLs while our router components work with vectors of route segment strings (which I will call paths). When two parts of a component of an application speak different languages, I prefer to build a translation layer and only use one of those languages in my application code. To facilitate that strategy we’ll write two translation functions.

(defn url->path
  "Given a url of the form \"/gift/123/edit?code=abcdef\", returns a
  path vector of the form [\"gift\" \"123\" \"edit\"]. Assumes the url
  starts with a forward slash. An empty url yields the path [\"home\"]
  instead of []."
  [url]
  (-> url (str/split "?") first (str/split "/") rest vec))

(defn path->url
  "Given a path vector of the form [\"gift\" \"123\" \"edit\"],
  returns a url of the form \"/gift/123/edit\"."
  [path]
  (str/join (interleave (repeat "/") path)))

With these functions in place we’re well positioned to work with path values in our application. The next step is to create our history object and navigation functions. We’ll also define a helper function routeable-path? so we can direct any unknown paths to the home page.

(defn routable-path?
  "True if there exists a router target for the given path."
  [app path]
  (let [state-map  (app/current-state app)
        root-class (app/root-class app)
        root-query (comp/get-query root-class state-map)
        ast        (eql/query->ast root-query)]
    (some? (dr/ast-node-for-route ast path))))

(def default-route ["home"])

(defonce history (pushy/pushy
                   (fn [path]
                     (dr/change-route SPA path))
                   (fn [url]
                     (let [path (url->path url)]
                       (if (routable-path? SPA path)
                         path
                         default-route)))))

(defn start! []
  (pushy/start! history))

(defn route-to! [path]
  (pushy/set-token! history (path->url path)))

(defmutation route-to
  "Mutation to go to a specific route"
  [{:keys [path]}]
  (action [_]
    (route-to! path)))

Our history object is constructed with two functions. The second receives a relative url and returns the path that we will route to. If this function returns nil, the routing is aborted. The first function receives the path returned by the second function and is responsible for making the routing happen. Both of these functions are relatively simple to construct with our helper functions.

There are just a couple more things to do before we have HTML5 routing working. First, we need to add a call to start! alongside our dr/initialize! call in init.

Next we’ll need to refactor our existing components to create HTML5 history events when they change routes. I’m conflicted on whether it’s better to use anchor tags, so users can mouse over and see the url where they’re going, or onClick attributes with our route-to mutation, which hide the implementation a little bit better and can be used at all call sites. (You cannot use an anchor tag in init for the initial routing, for example.) At the moment I lean towards consistency, so we’ll use route-to. Here are the changes to the Navbar; the other changes are largely the same and can be seen on github.

(defsc Navbar [this {:keys [login-logout]}]
  {:query [{:login-logout (comp/get-query LoginLogoutItem)}]
   :ident (fn [] [:component/id :navbar])
   :initial-state {:login-logout {}}}
  (let [logged-in (:ui/authenticated login-logout)]
    (dom/div :.ui.secondary.menu
      (dom/a :.item
        {:onClick #(comp/transact! this
                     [(route-to {:path (dr/path-to (if logged-in
                                                     Home
                                                     LoginForm))})])}
        "Home")
      (dom/a :.item
        {:onClick #(comp/transact! this
                     [(route-to {:path (dr/path-to About)})])}
        "About")
      (dom/div :.right.menu
        (ui-login-logout-item login-logout)))))

And there you have it; we’ve implemented HTML5 routing and can navigate between the home page and about page!

Prev: Gift list dev diary: authentication Next: Gift list dev diary: initial backend