9 September 2020

Building a UI Lib

Let's separate our frontend and our main code

Because my designers shouldn't have to learn all of clojure!

NOTE: I feel that this is still really rough.

I basically did this because I was building this anyway and thought that putting together a quick tutorial while I was at it would be a good use of time =)...

Why am I using leiningen? Because I haven't quite figured out how to do checkouts related things with deps.edn =)...

Setup

You can just run the commands verbatim and follow along, but if you'd rather have a github repo, it's there =)...

Create the UI library

lein new figwheel-main ui-lib -- --reagent

Create the Main Project

lein new figwheel-main main-app -- --reagent

NOTE: I do tend to change the fig:build to fig:dev and create an explict :clean-targets like so:

:clean-targets ^{:protect false} ["target" "resources/public/cljs-out"]

So I can use lein clean to clear all the compiled stuff. So just a quick note about that =)...

Now place both of these within a single parent directory so for example:

ui-lib-example
├── main-app
└── ui-lib

Now cd into each with separate terminals =)...

Now let's add [ui-lib "0.1.0-SNAPSHOT"] to the bottom of main-app's project.clj dependencies.

:dependencies [[org.clojure/clojure "1.10.1"]
               [org.clojure/clojurescript "1.10.773"]
               [reagent "0.10.0"]
               [ui-lib "0.1.0-SNAPSHOT"]]

Then in ui-lib let's create a namespace ui-lib.layout.index:

(ns ui-lib.layout.index)

(defn app-panel []
  [:div
   [:h1 "Main UI"]
   [:h3 "This is a test header!"]])

Right, now we need to run lein install within ui-lib.

Then we need to create a checkouts folder within main-app and symlink ui-lib into it.

mkdir checkouts
ln -s ../../ui-lib .

Right, now to keep things just a little tidy I'm going to create main-app.runtime in main-app

(ns main-app.runtime
  (:require [ui-lib.layout.index :as index]))

(def app-panel index/app-panel)

Then in main-app.core we do this:

(ns ^:figwheel-hooks main-app.core
  (:require
   [goog.dom :as gdom]
   [reagent.core :as reagent :refer [atom]]
   [reagent.dom :as rdom]
   [main-app.runtime :refer [app-panel]]))

(println "This text is printed from src/main_app/core.cljs. Go ahead and edit it and see reloading in action.")

(defn multiply [a b] (* a b))

(defn get-app-element []
  (gdom/getElement "app"))

(defn mount [el]
  (rdom/render [app-panel] el))

(defn mount-app-element []
  (when-let [el (get-app-element)]
    (mount el)))

;; conditionally start your application based on the presence of an "app" element
;; this is particularly helpful for testing this ns without launching the app
(mount-app-element)

;; specify reload hook with ^;after-load metadata
(defn ^:after-load on-reload []
  (mount-app-element))
  ;; optionally touch your app-state to force rerendering depending on
  ;; your application
  ;; (swap! app-state update-in [:__figwheel_counter] inc)

I'll leave doing a basic tidy up to you, removing things like multiply and the odd println's, just don't forget to also remove the relevant tests...

You can also make ui-lib.core the same as the above, just replacing the namespace import with ui-lib.layout.index instead of main-app.runtime.

Now running lein fig:dev in either terminal should show this:

Initial Setup

However, you will notice that making any edits to the app-panel function in ui-lib.layout.index won't trigger a reload if you're running lein fig:dev in the main-app.

Now that won't do at all! We want to be able to have nice reloading functionality in the application when we're tweaking UI within the ui library, otherwise putting together a nice bunch of UI is going to be a big pain! So let's fix that.

All we've got to do is tell dev.cljs.edn in main-app to watch checkouts/ui-lib. NOTE: I've managed to watch checkouts in the past, but for some reason, this isn't working at the moment, probably because I'm testing this on wsl, but 🤷, this works!

^{:open-url   false
  :watch-dirs ["test" "src" "checkouts/ui-lib"]
  :css-dirs ["resources/public/css"]
  :auto-testing true}
{:main main-app.core}

However, we should also add checkouts/ui-lib to our classpath or we'll get an error telling us that figwheel is trying to dynamically add checkouts/ui-lib to our classpath. Let's do this however in our :dev profile:

:profiles {:dev {:dependencies [[com.bhauman/figwheel-main "0.2.11"]
                                [com.bhauman/rebel-readline-cljs "0.1.4"]]
                 :source-paths ["checkouts/ui-lib"]
                 :resource-paths ["target"]
                 ;; need to add the compiled assets to the :clean-targets
                 :clean-targets ^{:protect false} ["target"]}}

Now if you go into "This is a test header!" and change "This is an alt test header!" to ui-lib.layout.index you should see this in your figwheel page 🎉:

Post getting live reload working!

Working on the UI bits!

Now time to add some basic deps to the projects!

To ui-lib's deps in project.clj we want to add:

:dependencies [[...
               [reagent "0.10.0"]
               [re-frame "1.0.0-rc2"]
               [ui-lib "0.1.0-SNAPSHOT"]]

And to main-app's deps in project.clj we want to add:

:dependencies [[...
               [reagent "0.10.0"]
               [re-frame "1.0.0-rc2"]
               [clj-commons/secretary "1.2.5-SNAPSHOT"]
               [ui-lib "0.1.0-SNAPSHOT"]]

This allows us to build complex components in either of them, while also having routing functionality in the main-app.

Here you might want to rerun lein fig:dev in the main-app project and tweak ui-lib.layout.index just to check live reload is still working fine! Assuming it's all working well we'll continue =)...

Plugging in some basic routing

Ok, let's get some routing going =)...

First let's make app-panel in the ui-lib something more appropriate, as the main-app is what's going to be handling routing.

So let's refactor it to be called home-page, don't forget to change it in ui-lib.core as well:

(defn home-page []
  [:div
   [:h1 "Home Page"]])

Let's also create a new page called about-page so we can test our routing as well!

(defn about-page []
  [:div
   [:h1 "About Page"]])

Great! Now let's update app-panel so that we can use these newly created pages!

(ns main-app.runtime
  (:require [re-frame.core :as rf]
            [ui-lib.layout.index :as index]
            [main-app.routes :as routes]))


(defn app-panel []
  (let [current-page (rf/subscribe [::routes/current-page])]
    (fn []
      [:div
       (condp = @current-page
         :about-page
         [index/about-page]
         [index/home-page])])))

If everything is working correctly, tweaking the home-page function in ui-lib should still make live reloading work!

Now let's create main-app.routes:

(ns main-app.routes
  (:require-macros [secretary.core :refer [defroute]])
  (:import [goog History]
           [goog.history Html5History EventType])
  (:require [secretary.core :as secretary]
            [goog.events :as gevents]
            [re-frame.core :as rf]))


;;; Events
(rf/reg-event-db
  ::set-page
  (fn [_db [_ page]]
    (assoc {} ::current-page page)))


;;; Subs
(rf/reg-sub
  ::current-page
  (fn [db [_]]
    (get db ::current-page)))


(defn get-token []
  js/window.location.hash)

(defn make-history []
  (doto (Html5History.)
    (.setPathPrefix (str js/window.location.protocol
                         "//"
                         js/window.location.host))
    (.setUseFragment true)))

(defn handle-url-change [e]
  (js/console.log (str "Nav To: " (get-token)))
  (when-not (.-isNavigation e)
    (js/console.log "Token set programmatically")
    (js/window.scrollTo 0 0))
  (secretary/dispatch! (get-token)))

(defonce history (make-history))

(defn hook-browser-navigation! []
  (doto history
    (gevents/listen
      EventType.NAVIGATE
      #(handle-url-change %))
    (.setEnabled true)))

(defn nav! [token]
  (.setToken history token))

(defn make-link
  ([href-str] (make-link {} href-str href-str))
  ([attrs href-str] (make-link attrs href-str href-str))
  ([attrs href href-str] (merge attrs
                           {:href href
                            :on-click #(do
                                         (.preventDefault %)
                                         (nav! href-str))})))

(defn make-home-link []
  (make-link {} js/window.location.href "/"))

(defn make-page-link [page-kw]
  (make-link {} js/window.location.href (str "/" (name page-kw))))

(defn app-routes []
  (secretary/set-config! :prefix "#")
  (defroute "/" []
    (rf/dispatch [::set-page nil]))

  (defroute "/about" []
    (rf/dispatch [::set-page :about-page]))

  (hook-browser-navigation!))

Now let's go to main-app.core and add [main-app.routes :refer [app-routes]] to the ns :require and then update mount-app-element:

(defn mount-app-element []
  (when-let [el (get-app-element)]
    (app-routes)
    (mount el)))

Great, now if you navigate to / or /#/about the home and about page should be viewable!

Let's add a navbar to the page to make navigation easier!

(defn nav-bar-panel []
  [:nav
   [:a (routes/make-home-link) "Main App"]
   [:div
    [:ul
     [:li [:a (routes/make-home-link) "Home"]]
     [:li [:a (routes/make-page-link :about) "About"]]]]])

(defn app-panel []
  (let [current-page (rf/subscribe [::routes/current-page])]
    (fn []
      [:div
       [nav-bar-panel]
       (condp = @current-page
         :about-page
         [index/about-page]
         [index/home-page])])))

Post adding a navbar

What about npm?

So the first step to get npm playing nicely with all this is adding a new entry into project.clj's :aliases:

:aliases {...
          "fig:npm"   ["run" "-m" "figwheel.main" "--" "--install-deps"]
          ...}

Not to test out the waters we want to check that any deps we add get pulled in, so create an deps.cljs file within ui-lib/src.

{:npm-deps {"react-transition-group" "4.4.1"}}

Now just run lein fig:npm in ui-lib, this should create a package.json file like:

{
  "dependencies": {
    "@cljs-oss/module-deps": "^1.1.1",
    "react": "^16.13.0",
    "react-dom": "^16.13.0",
    "react-transition-group": "^4.4.1"
  }
}

Now let's do the same in main-app, adding the alias and just running lein fig:npm, this should create the same package.json file as above!

We can also create a deps.cljs file in the src folder of main-app if we wish to give it it's own npm deps, however in this occasion I'll keep it empty.

{:npm-deps {}}

Running lein fig:npm again shows no difference to the package.json created, so that's all working =)...

Getting webpack up

We need webpack to make this all work nicely, and I personally like my javascript to be served from a fixed location js/compiled/app.js, so I'm going to change the index.html inside the resources/public folder of main-app from this line:

<script src="cljs-out/dev-main.js" type="text/javascript"></script>

to this:

<script src="js/compiled/app.js" type="text/javascript"></script>

We should adjust the dev.cljs.edn to take this into account:

^{:final-output-to "resources/public/js/compiled/app.js"
  :open-url   false
  :watch-dirs ["test" "src" "checkouts/ui-lib"]
  :css-dirs ["resources/public/css"]
  :auto-testing true}
{:main main-app.core
 :target :bundle
 :bundle-cmd {:none ["npx" "webpack" "--mode=development" :output-to "-o" :final-output-to]}
 :output-dir "resources/public/js/compiled/out"
 :source-map-timestamp true}

Ok now we need to setup webpack, specifically the @next version:

npm i -D webpack-cli
npm i -D webpack@next

If you have an issue and are ok with reinstalling then just uninstall it first:

npm rm -g webpack-cli
npm rm -g webpack

We also need to create a webpack.config.js configuration file:

module.exports = {
    target: 'web',
    entry: './resources/public/js/compiled/out/main.js',
    output: {
        path: __dirname + "/out",
        filename: 'app.js'
    },
    resolve: {
        alias: {'xmlhttprequest': false},
        modules: ['./resources/public/js/compiled/out/', './node_modules']
    }
}

Pay careful attention to the file paths for modules and entry, those should match the file generated by running the lein fig:dev so that webpack can perform the next step.

If you see things failing at the webpack step or if everything seems to build correctly but the page doesn't load properly, try to run the basic webpack command to ensure it isn't silently failing there:

npx webpack --mode=development resources/public/js/compiled/out/main.js -o resources/public/js/compiled/app.js

If everything works you'll see the same page that you had before: Post adding a navbar

However this time we're running the application through webpack! So we can pull in npm libraries!

Let's test this out =)...

We're going to add a testimonial carousel to our home page, let's first create ui-lib.layout.carousel:

(ns ui-lib.layout.carousel
  (:require [reagent.core :as r]
            [react]
            [react-transition-group :refer [Transition TransitionGroup CSSTransition]]))


(defn carousel-child
  [{:keys [direction children in]}]
  [:> CSSTransition {:in in
                     :timeout 500
                     :class-names {:enter (str "slide-enter-" (name direction))
                                   :enter-active "slide-enter-active"
                                   :exit "slide-exit"
                                   :exit-active (str "slide-exit-active-" (name direction))
                                   :exit-done "slide-exit-done"}}
   (fn [state]
     (r/as-element
       (into [:div {:class "slide-base"}] children)))])

(defn carousel
  [{:keys [direction class]}]
  (let [children (r/children (r/current-component))
        k (-> children first meta :key)]
    [:> TransitionGroup {:class ["transition-group" class]
                         :childFactory (fn [child]
                                         (react/cloneElement child #js {:direction direction}))}
     (let [child (r/reactify-component carousel-child)]
       (r/create-element child #js {:key k
                                    :direction direction
                                    :children children}))]))

(defn on-click
  [direction]
  (fn [{n :n}]
    {:n (case direction
          :left (dec n)
          :down (dec n)
          :up (inc n)
          :right (inc n))
     :dir direction}))


(defn carousel-panel [slides]
  (let [state (r/atom {:n 0 :dir :left})]
    (fn [slides]
      (let [size (count slides)]
        [:div.carousel
         [:div.container
          [:div
           (when (> size 1)
            [:div.col.col-md
             [:button {:on-click #(swap! state (on-click :left))}
              "<"]])
           [:div
            (let [slide (->> (count slides)
                          (mod (:n @state))
                          (nth slides))
                  {:keys [id colour content] :or {colour :white}} slide]
              [:div.frame {:style {:max-width "300px" :max-height "270px"}}
               [carousel {:direction (:dir @state)}
                ^{:key id}
                [:div.slide {:style {:background-color colour :min-width "200px" :min-height "250px" :border-radius "10px"}}
                 content]]])]
           (when (> size 1)
             [:div
              [:button {:on-click #(swap! state (on-click :right))}
               ">"]])]]]))))

And then add it to the home page:

(ns ui-lib.layout.index
  (:require [ui-lib.layout.carousel :refer [carousel-panel]]))


(defn home-page []
  [:div
   [:h1 "Home Page"]
   [carousel-panel
    [{:id 0
      :content [:div [:p.testimonial "A test testimonial"]]}
     {:id 1
      :content [:div [:p.testimonial "Another test testimonial"]]}]]])
...

If you have an error, try first updating the reagent version and if that doesn't work, adding the exclusions provided:

[reagent "1.0.0-alpha2" :exclusions [cljsjs/react cljsjs/react-dom]]

Reagent can at times be a little bit finicky with webpack / :npm-deps.

Great! Now when the page re-renders you should see a carousel:

Post adding a carousel

Doesn't look that great, does it?

Let's add some basic styling for it to resources/css/style.css in main-app:

@keyframes fade {
    0%,100% { opacity: 0 }
    50% { opacity: 1 }
}

.carousel-indicators li {
    background-color: lightgray;
}

.carousel-indicators .active {
    background-color: #007bff;
}

.carousel .container {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 100%;
}

.carousel .frame {
    width: 600px;
    height: 600px;
}

.carousel .row {
    align-items: center;
    height: 100%;
}

.carousel .transition-group {
    position: relative;
    height: 100%;
    width: 100%;
    overflow: hidden;
}

.carousel .slide {
    width: 100%;
    height: 100%;
}

.carousel .slide-base {
    transition: transform 500ms cubic-bezier(.645, .045, .355, 1);
    position: absolute;
    top: 0;
    width: 100%;
    height: 100%;
    left: 0;
}

.carousel .slide-enter-left {
    transform: translate(100%, 0);
}

.carousel .slide-enter-right {
    transform: translate(-100%, 0);
}

.carousel .slide-enter-up {
    transform: translate(0, 100%);
}

.carousel .slide-enter-down {
    transform: translate(0, -100%);
}

.carousel .slide-enter-active {
    transform: translate(0, 0);
}

.carousel .slide-exit {
    transform: translate(0, 0);
}

.carousel .slide-exit-active-left {
    transform: translate(-100%, 0);
}

.carousel .slide-exit-active-right {
    transform: translate(100%, 0);
}

.carousel .slide-exit-active-up {
    transform: translate(0, -100%);
}

.carousel .slide-exit-active-down {
    transform: translate(0, 100%);
}

.carousel .slide-exit-done {
    display: none;
}

Now that looks a little bit better: Post adding a carousel

Ok cool! There's still some bookkeeping to do for this bit however, let's update our project.clj:

:clean-targets ^{:protect false} ["target" "resources/public/js/compiled"]

We should also make sure it's possible to run the lein fig:dev from within ui-lib if we need it for debug purposes =)...

So you'll need to:

  1. Update the index.html inside the resources/public folder of ui-lib to:
<script src="js/compiled/app.js" type="text/javascript"></script>
  1. Adjust the dev.cljs.edn:
^{:final-output-to "resources/public/js/compiled/app.js"
  :open-url   false
  :watch-dirs ["test" "src"]
  :css-dirs ["resources/public/css"]
  :auto-testing true}
{:main ui-lib.core
 :target :bundle
 :bundle-cmd {:none ["npx" "webpack" "--mode=development" :output-to "-o" :final-output-to]}
 :output-dir "resources/public/js/compiled/out"
 :source-map-timestamp true}
  1. Setup webpack:
npm i -D webpack-cli
npm i -D webpack@next
  1. Create a webpack.config.js configuration file:
module.exports = {
    target: 'web',
    entry: './resources/public/js/compiled/out/main.js',
    output: {
        path: __dirname + "/out",
        filename: 'app.js'
    },
    resolve: {
        alias: {'xmlhttprequest': false},
        modules: ['./resources/public/js/compiled/out/', './node_modules']
    }
}

If that all goes well calling, within the ui-lib:

lein fig:npm
lein fig:dev

should let you see this!

Post getting it all of the webpack working in ui-lib

Making it easy to just start

One of the things I like to do is just make it easy to jump right into coding, so I usually create a makefile with a dev alias to just jump right in =)...

In this case however, let's use leiningen's do functionality!

So add this to :aliases:

:aliases {"dev"       ["do" "clean," "fig:npm," "fig:dev"]
          ...}

Now if everything works, just calling lein dev will get you started right away =)...

You can add that alias to both ui-lib and app-main allowing you to have a uniform way of just getting going _...

Changing deps inside UI Lib

If you change the dependencies inside ui-lib, you may need to call lein install inside the project to refresh the dependencies or you might find you dependency errors.

There's still more to do such as adding css in a way we can just import it, but for now, I think this isn't unreasonable =)...

Tags: ui re-frame beginner