avatar

Pawel Zielonka

Posted on 14th August 2020

Building Speech To Text Web Application – 2. part

news-paper News | Software Development |

In the previous part, we showed you how to set up a ClojureScript project, configure shadow-cljs build and described shortly libraries which we used. In this part, We are going to show you how to set up a router and manage application state with re-frame. 

Config

In the previous part, we defined several compilation time constants in the build’s configuration. Let’s create a namespace for them with default values:

(ns aws-transcribe-clj.config)

(def debug? ^boolean goog.DEBUG)
(goog-define APP-AUDIO-LIMIT 7000)
(goog-define AWS-REGION "default")
(goog-define AWS-ROLE-ARN "default")
(goog-define AWS-RECORDINGS-BUCKET "default")
(goog-define AWS-TRANSCRIPTS-BUCKET "default")
(goog-define AWS-WEB-IDENTITY-PROVIDER-ID "default")
(goog-define FB-APP-ID "default")
(goog-define FB-SDK-VERSION "default")

Here we defined var debug? with :boolean type hint and assigned goog.DEBUG current value to it. For all release builds it will be automatically set to false. Below you can find the definitions of compile-time constants. They are going to be substituted by the compiler with values specified in the configuration of a specific build.

Root component

At first, we have to require all namespaces that we’re going to use:

(ns aws-transcribe-clj.core
  (:require [oops.core :refer [oget ocall!]]
            [reagent.core :as reagent]
            [reitit.core :as rt]
            [re-frame.core :as rf]
            [reitit.frontend.easy :as rfe]
            [taoensso.timbre :as log]
            [aws-transcribe-clj.db :as db]
            [aws-transcribe-clj.config :as c]
            [aws-transcribe-clj.routes :as r]))

Now let’s create the root component of the application and simple display mechanism. It is going to be based on the current route that will be stored in the application state, represented as a map. If none of the routes defined in the router is matched, the component will display a default view. It can be passed in the options map when creating the router.

(defn root-component
  [{:keys [router]}]
  (let [route @(rf/subscribe [::r/route])]
    [:div.container
    (if (some? route)
      (when-let [view (->> route :data :view)]
        [view])
      [(-> router rt/options :default-view)])]))

As you can see, we defined a component as a function that takes one argument which is a map. By using associative destructuring we bind the name router to a value that corresponds with :router key.

Additionally let’s create a function that will set the logging level to :info for release builds:

(defn dev-setup []
  (if-not c/debug?
    (log/set-level! :info)
    (do
      (enable-console-print!)
      (log/set-level! :debug)
      (println "dev mode"))))

and another function that will mount the component into a DOM element:

(defn ^:dev/after-load mount-root []
  (rf/clear-subscription-cache!)
  (reagent/render [root-component {:router r/router}] (ocall! js/document :getElementById "app")))

By using metadata we tell the compiler to run this function after new code is loaded. After we ensure that subscription cache is clear we can safely render the root component into the app element.

So far, we have been printing a message visible in the JavaScript console. Let’s modify the init! function so that it will:

  • configure the environment (dev or release)
  • initialize the app state by dispatching a re-frame event synchronously
  • initialize the router
  • mount the root component
(defn ^:export init! []
  (dev-setup)
  (rf/dispatch-sync [::db/initialize-db])
  (rfe/start! r/router r/on-navigate! {:use-fragment false})
  (mount-root))

At this point, we have implemented the main component along with the display mechanics. There is only one problem – our code is not working! Router, subscription calculating current route and event initializing application state are missing.

Application state

Without hesitation let’s create a new namespace!

(ns aws-transcribe-clj.db
  (:require [re-frame.core :as rf]))

Define a function returning a map of default app state:

(defn default-db [] {:route {:data {:name :loading}}})

Register a re-frame event handler that will set this map as new app state value when aws-transcribe-clj.db/initialize-db event is handled:

(rf/reg-event-db
  ::initialize-db
  (fn [db event]
    (default-db)))

Once the application database is ready, we can move on to the router implementation.

Router

Let’s create a namespace with all the required dependencies and register a subscription for retrieving the current route. As you remember, the subscription is used by the root component to determine which view it should display.

(ns aws-transcribe-clj.routes
  (:require [re-frame.core :as rf]
            [reitit.coercion :as rc]
            [reitit.coercion.spec]
            [reitit.frontend :as rfd]
            [reitit.frontend.controllers :as rfc]
            [reitit.frontend.easy :as rfe]
            [aws-transcribe-clj.views :as v]))

(rf/reg-sub ::route #(:route %))

Going forward – reitit provides several functions to manipulate the html5 history and the current window location. Let’s make use of the push-state function from reitit.frontend.easy namespace. It sets a new route and leaves the previous one in history.

In order to call it, Let’s create an effect handler responsible for navigating to a specified route. You may be tempted to do this in an event handler but this should be a pure function without any side effects. 

(rf/reg-fx
  ::navigate!
  (fn [[route params query]]
    (rfe/push-state route params query)))

(rf/reg-event-fx
  ::navigate
  [rf/trim-v]
  (fn [_ [route params query]]
    {::navigate! [route params query]}))

Now, in order to navigate to a route, you just have to dispatch an event like this:

(rf/dispatch [:aws-transcribe-clj.routes/navigate :route-id}])

In order to fully trust the aws-transcribe-clj.routes/route subscription, we have to make sure that application state is updated when a user navigates to a particular route. Let’s create an event handler for that purpose:

(rf/reg-event-db
  ::set-route
  [rf/trim-v]
  (fn [db [match]]
    (assoc db :route match)))

The best place to dispatch this event will be an on-navigate callback, which is passed to the router start! function, and will be called whenever the route changes. Additionally, we can already enable reitit controllers, by calling reitit.frontend.controllers/apply-controllers. We will use them later to perform some route-specific actions when it is changed.

(defn on-navigate!
  [new-match history]
  (let [old-match   @(rf/subscribe [::route])
        controllers (rfc/apply-controllers (:controllers old-match) new-match)]
    (rf/dispatch [::set-route (when (some? new-match)
                                (assoc new-match :controllers controllers))])))

The last step to complete routing is the router definition. We just have to pass the routes vector and options map to reitit.frontend/router function: 

(def router
  (rfd/router
    [""
    ["/" {:name :welcome
          :view #'v/welcome}]
    ["/transcribe" {:name :transcribe
                    :view #'v/transcribe}]]
    {:default-view #'v/not-found}))

Here we have two routes “/” and “/transcribe” having unique names and views – vars pointing to reagent components.

Before we move on to creating views, let’s include the Bootstrap framework via CDN links. You can find them here.

Views

Now, create a namespace with simple components used by the router:

(ns aws-transcribe-clj.views)

(defn welcome []
  [:div.container
  [:h1 "Welcome"]])

(defn transcribe []
  [:div.container
  [:h1 "Transcribe"]])

(defn not-found []
  [:div.container
  [:h1 "Not found"]])

Interactive development

Run the development build by running the following command in terminal:

$ clj -A:dev

Open a browser at http://localhost:9000, connect to the Clojure nREPL using your favorite IDE and evaluate the expressions below:

(shadow/repl :app)
(in-ns 'aws-transcribe-clj.core)
(rf/dispatch [::r/navigate :transcribe])

Notice that the location has changed to http://localhost:9000/transcribe.

If you try to open a browser at a location that isn’t defined in routes then you will see “Not found”, e.g. http://localhost:9000/missing.

This is the end of the second part. In the third part, we will discuss authentication and communication with Facebook and AWS services.