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:
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 🎉:
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])])))
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:
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:
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:
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:
- Update the
index.html
inside theresources/public
folder ofui-lib
to:
<script src="js/compiled/app.js" type="text/javascript"></script>
- 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}
- Setup webpack:
npm i -D webpack-cli
npm i -D webpack@next
- 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!
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 =)...