Workspaces is a component development environment for ClojureScript, inspired by devcards.
First add the workspaces dependency on your project.
This is recommended template for the HTML file for the workspaces:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Open+Sans" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<!-- you might need to change the js path depending on your configuration -->
<script src="/js/workspaces/main.js" type="text/javascript"></script>
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/github.min.css">
</body>
</html>
Then create the entry point for the workspaces:
(ns my-app.workspaces.main
(:require [nubank.workspaces.core :as ws]
; require your cards namespaces here
))
(defonce init (ws/mount))
;; shadow-cljs configuration
{:builds {:workspaces {:target :browser
:output-dir "resources/public/js/workspaces"
:asset-path "/js/workspaces"
:devtools {:before-load nubank.workspaces.core/before-load
:after-load nubank.workspaces.core/after-load
:http-root "resources/public"
:http-port 3689
:http-resource-root "."}
:modules {:main {:entries [my-app.workspaces.main]}}}}}
Now run with:
npx shadow-cljs watch workspaces
For some reason on my tests I couldn't make Figwheel :on-jsload
hook to work with library
code, to go around this modify the main to look like this:
(ns my-app.workspaces.main
(:require [nubank.workspaces.core :as ws]
; require your cards namespaces here
))
(defn on-js-load [] (ws/after-load))
(defonce init (ws/mount))
So you can call the hook forom there, as:
:cljsbuild {:builds
[{:id "dev"
:source-paths ["src"]
:figwheel {:on-jsload "myapp.workspaces.main/on-js-reload"}
:compiler {:main myapp.workspaces.main
:asset-path "js/workspaces/out"
:output-to "resources/public/js/workspaces/main.js"
:output-dir "resources/public/js/workspaces/out"
:source-map-timestamp true
:preloads [devtools.preload]}}]}
Now run with:
lein figwheel
To define cards you use the ws/defcard
macro, here is an example to create a React card:
(ns myapp.workspaces.cards
(:require [nubank.workspaces.core :as ws]
[nubank.workspaces.card-types.react :as ct.react]))
; simple function to create react elemnents
(defn element [name props & children]
(apply js/React.createElement name (clj->js props) children))
(ws/defcard hello-card
(ct.react/react-card
(element "div" {} "Hello World")))
You can use this to mount any React component, for a re-frame for example, you can
use (reagent/as-element [re-frame-root])
as the content. For a complete re-frame demo
check these sources.
Usually libraries like Fulcro or Re-frame will manage the state and trigger render in the proper times, but if you wanna do something with raw React, you can provide an atom to be the app state, and the card will watch that atom and triggers a root render everytime it changes.
(ws/defcard counter-example-card
(let [counter (atom 0)]
(ct.react/react-card
counter
(element "div" {}
(str "Count: " @counter)
(element "button" {:onClick #(swap! counter inc)} "+")))))
Important: The react-card
is actually a macro, the reason is that we wrap your render
call into a function that will only be called when that card is initialized. This prevents
the render calls to happen when cards are just loading.
Workspaces is built with Fulcro and has some extra support for it. Using the fulcro-card
you can easely mount a Fulcro component with the entire app, here is an example:
(ns myapp.workspaces.fulcro-demo-cards
(:require [fulcro.client.primitives :as fp]
[fulcro.client.localized-dom :as dom]
[nubank.workspaces.core :as ws]
[nubank.workspaces.card-types.fulcro :as ct.fulcro]
[nubank.workspaces.lib.fulcro-portal :as f.portal]
[fulcro.client.mutations :as fm]))
(fp/defsc FulcroDemo
[this {:keys [counter]}]
{:initial-state (fn [_] {:counter 0})
:ident (fn [] [::id "singleton"])
:query [:counter]}
(dom/div
(str "Fulcro counter demo [" counter "]")
(dom/button {:onClick #(fm/set-value! this :counter (inc counter))} "+")))
(ws/defcard fulcro-demo-card
(ct.fulcro/fulcro-card
{::f.portal/root FulcroDemo}))
By default the Fulcro card will wrap your component will a thin root, by having always
having components with idents you can leverage generic mutations, this is recommended
over making a special Root. But if you want to send your own root, you can set the
::f.porta/wrap-root? false
. Here are more options available:
::f.portal/wrap-root?
(default: true
) Wraps component into a light root::f.portal/app
(default: {}
) This is the app configuration, same options you could send to fulcro/new-fulcro-client
::f.portal/initial-state
(default {}
) Accepts a value or a function. A value will
be used to call the initial state function of your root. If you provide a function, the
value returned by it will be the initial state.When you use a Fulcro card you will notice it has an extra toolbar, in this toolbar you have two action buttons:
Inspect
: this is an integration with Fulcro Inspect, if you have the
extension active on Chrome, it will select the application of the card for inspection.Restart
: this will do a full refresh on app, unmount and mount againWorkspaces has default integration with cljs.test
, but you have to start the tests
using ws/deftest
instead of cljs.test/deftest
. The ws/deftest
will also emit a
cljs.test/deftest
call, so you can use the same for running on CI. Example test card:
(ws/deftest sample-test
(is (= 1 1)))
When you create test cards using ws/deftest
, a card will be automatically created to
run on the test on that namespace, just click on the test namespace name on the index
to load the card.
You can define settings for your card, like what initial size it should have, to do that you can add maps to the card definition:
(ns myapp.workspaces.configurated-cards
(:require [nubank.workspaces.core :as ws]
[nubank.workspaces.model :as wsm]))
(ws/defcard sized-card
{::wsm/card-width 5
::wsm/card-height 7}
(ct.react/react-card
(dom/div "Foo")))
The measuremnt is in grid tiles. A recommended way to define a card size is to add it
in default size to workspace, resize it to the appropriated size, then use the Size
button accessible from the more icon in the card header, the card current size will
be logged to the browser console.
For the built-in cards you can also determine how the element will be positioned in the card. So far we have been using the center card position but depending on the kind of component you are trying that might not be the best option.
(ws/defcard positioned-top
{::wsm/card-width 5
::wsm/card-height 7
::wsm/align {:flex 1}}
(ct.react/react-card
(dom/div "Foo on top")))
The card container is a flex element, so the previous example will put the card on top and make it occupy the full width of the container.
The default ::wsm/align
is:
{:display "flex"
:align-items "center"
:justify-content "center"}
Using the key ::wsm/node-props
you can set the style or other properties of the container node.
(ws/defcard styles-card
{::wsm/node-props {:style {:background "red" :color "white"}}}
(ct.react/react-card
(dom/div "I'm in red")))
You will probably find some combinations of card settings you keep repeating, it's totally ok to
put those in variables and re-use. You can also send as many configuration maps as you
want, in fact the return of (ct.react/react-card)
is also a map, they all just get
merged and stored as the card definition.
(def purple-card {::wsm/node-props {:style {:background "#79649a"}}})
(def align-top {::wsm/align {:flex 1}})
(ws/defcard widget-card
{::wsm/card-width 3 ::wsm/card-height 7}
purple-card
align-top
(ct.react/react-card
(dom/div "💜")))
Now that we know how to define cards, it's time to learn how to work with then.
Imagine when you are about to start working on some components of your project, you can
start by looking at the index or searching using the spotlight feature (alt+shift+a
).
By clicking on the card names you will add then to the current workspace (one will be created if you don't have any open).
The idea here is that you add just the cards there are relevant to the work you need to do, and create a workspace that can make the best use of your screen pixels.
And workspaces comes on tabs, enabling you to quickly switch between different workspace settings.
The following topics will describe what you can do to help you manage your workspaces.
You can create new workspaces by clicking at the +
tab on the interface. The workspaces
are created and stored in your browser local storage. You can rename the workspace
by clicking on its tab while it's active.
Your cards are placed in a responsive grid, this means that the number of columns you
have available will vary according to your page width size. In the right below the
workspace tabs you can see how many columns you have available right now (eg: c8
means 8 columns).
Each responsive breakpoint will have stored separated, so you can arange a workspace to fit that available width. The sizes and positions will be recorded separated by each column numbers (they vary from 2 to 20).
Each column size has 120~140px, varies depending on page width.
When you have an open workspace, there is a toolbar with some action buttons, here is a description of what each does:
Copy layout
: actually a select here, use this to copy the layout from a different responsive breakpointRefresh cards
: triggers a refresh on every card on this active workspaceDuplicate
: creates a copy of current workspaceUnify layouts
: makes every breakpoint have the same layout as the current active oneExport
: Export current workspace layouts to data (logged into browser console)Delete
: Delete current workspaceA lot of times your workspaces will be disposable, just pull a few components, work and throw away. But other times you like to create more durable ones, like a kitchen sink of all your components buildings blocks, or maybe a setup that works nice for a specific task. You a lot of effort to make it look good on many different responsive breakpoints. So would be a pain if every user of the system had to redo the task to organize those types of workspaces.
To solve that, you can use the Export
button on the workspace toolbar. It outputs
the workspace layout as a transit data on the console. You can copy that, and use
to store that workspace setup on the code, making it available to any other person
using this workspace setup.
(ws/defworkspace ui-block
"[\"^ \",\"c10\",[[\"^ \",\"i\",\"~$fulcro.incubator.workspaces.ui.reakit-ws/reakit-base\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"minH\",2]],\"c8\",[[\"^ \",\"i\",\"^0\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"^1\",2]],\"c16\",[[\"^ \",\"i\",\"^0\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"^1\",2]],\"c14\",[[\"^ \",\"i\",\"^0\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"^1\",2]],\"c2\",[[\"^ \",\"i\",\"^0\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"^1\",2]],\"c12\",[[\"^ \",\"i\",\"^0\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"^1\",2]],\"c4\",[[\"^ \",\"i\",\"^0\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"^1\",2]],\"c18\",[[\"^ \",\"i\",\"^0\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"^1\",2]],\"c20\",[[\"^ \",\"i\",\"^0\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"^1\",2]],\"c6\",[[\"^ \",\"i\",\"^0\",\"w\",2,\"h\",4,\"x\",0,\"y\",0,\"^1\",2]]]"))
When you open a shared workspace, you can't change it, it's static, but you can duplicate it and change the copy as you please.
Here is a list of available shortcuts, all of then use alt+shift
followed by a key:
alt+shift+a
: Add card to current workspace (open spotlight for card picking)alt+shift+i
: Toggle index viewalt+shift+h
: Toggle card headersalt+shift+n
: Create new local workspacealt+shift+w
: Close current workspaceTo demonstrate what a custom card takes to be created, let's take the following example:
(ws/defcard custom-card
{::wsm/init
(fn [card]
{::wsm/render
(fn [node]
(gdom/setTextContent node (str "Rendering card " (::wsm/card-id card))))})})
So card definitions are also maps. The ::wsm/init
will be called upon card initialization.
In next section we will learn about the card life cycle and how you can hook on it.
The card life cycle happens according the following events:
When cards are loaded, their settings are stored locally in an atom. Workspaces tries to make this process as light as possible, adding many cards should have the minimum overhead possible, cards are not initialized until they are placed in a visible workspace.
When the card is initialized, the map returned by it will be stored and used to manage the card while it lives.
A card is a shared unit across workspaces, so if you have a card on a active workspace and add the same card to another workspace, it will just call a new render, but not a new initialization (they potentially will share state, but that might vary depending on the card implementation).
A refresh is intended to force a new render of the component. In the beginning of these
docs we asked you setup the load hook nubank.workspaces.core/after-load
, this hook
will refresh every card in the active workspace. In pratice it will call the ::wsm/refresh
method in your card, let's see an example by extending our previous custom card to
handle refresh.
(ws/defcard custom-card
{::wsm/init
(fn [card]
(let [counter (atom 0)]
{::wsm/refresh
(fn [node]
(gdom/setTextContent node (str "Card updated, count: " (swap! counter inc) "!")))
::wsm/render
(fn [node]
(gdom/setTextContent node (str "Card rendered, count: " counter "!")))}))})
You can try clicking in the "Refresh cards" button in the workspace toolbar and see the counter updating on every refresh.
There is one exception to this flow, and that is when you change anything about the card definition itself. Workflows will detect when the card has changed (by comparing the old form with the new form) and when it changes, the whole card is disposed and remounted.
A card is disposed when all it's active references are removed from the open workspaces. When you remove a card from a workspace, it might get disposed, but only if this card is not present in any of the other open workspaces (living in tabs). This will give you a chance to free resources from that card.
(ws/defcard custom-card
{::wsm/init
(fn [card]
(let [counter (atom 0)]
{::wsm/dispose
(fn [node]
; doesn't make a real difference for resource cleaning, just a dummy example
; so you can replace the code
(gdom/setTextContent node ""))
::wsm/refresh
(fn [node]
(gdom/setTextContent node (str "Card updated, count: " (swap! counter inc) "!")))
::wsm/render
(fn [node]
(gdom/setTextContent node (str "Card rendered, count: " @counter "!")))}))})
If we try to use our alignment settings with our new card, you will see it will not work.
This is because the alignment implementation is a wrapper utility, and you have to manually call it to get it's functionality, let's see how we can extend our card to support it:
(ws/defcard custom-card
{::wsm/align {:flex 1}
::wsm/init
(fn [card]
(let [counter (atom 0)]
; wrap our definition with positioned.card, from nubank.workspaces.card-types.util
(ct.util/positioned-card card
{::wsm/dispose
(fn [node]
; doesn't make a real difference for resource cleaning, just a dummy example
; so you can replace the code
(gdom/setTextContent node ""))
::wsm/refresh
(fn [node]
(gdom/setTextContent node (str "Card updated, count: " (swap! counter inc) "!")))
::wsm/render
(fn [node]
(gdom/setTextContent node (str "Card rendered, count: " @counter "!")))})))})
Now we can use the ::wsm/align
as usual. I like to point out you can use this strategy
yourself to create wrapper functions that can add functionality to a card definition, they
are good composition blocks.
Here let's create a custom implementation for a React card, this implementation will assume the app state is an atom, and will have a timer ticking in a root property on the state atom.
; it's a good pattern to have the card init function separated from the card function
; this will make easier for others to use your card as a base for extension.
(defn react-timed-card-init [card state-atom component]
(let [{::wsm/keys [dispose refresh render] :as react-card} (ct.react/react-card-init card state-atom component)
timer (js/setInterval #(swap! state-atom update ::ticks inc) 1000)]
(assoc react-card
::wsm/dispose
(fn [node]
; clean the timer on dispose
(js/clearInterval timer)
(dispose node))
::wsm/refresh
(fn [node]
(refresh node))
::wsm/render
(fn [node]
(render node)))))
(defn react-timed-card [state-atom component]
{::wsm/init #(react-timed-card-init % state-atom component)})
(ws/defcard react-timed-card-sample
(let [state (atom {})]
(react-timed-card state
; note since we are not using the macro it's better to send a function to avoid
; premature rendering
(fn []
(dom/div (str "Tick: " (::ticks @state)))))))
To add a toolbar, you must provide the ::wsm/render-toolbar
. This time you must return
a React component that will be used as the toolbar. We suggest using components from
the namespace nubank.workspaces.ui.core
for consistency.
(defn react-timed-card-init [card state-atom component]
(let [{::wsm/keys [dispose refresh render] :as react-card} (ct.react/react-card-init card state-atom component)
timer (js/setInterval #(swap! state-atom update ::ticks inc) 1000)]
(assoc react-card
::wsm/dispose
(fn [node]
; clean the timer on dispose
(js/clearInterval timer)
(dispose node))
::wsm/refresh
(fn [node]
(refresh node))
::wsm/render
(fn [node]
(render node))
::wsm/render-toolbar
(fn []
(dom/div
(uc/button {:onClick #(js/console.log "State" @state-atom)} "Log app state"))))))
Use this provide extra functionatility for your cards.
You might noticed that the test cards are able to change the card header style to reflect the test status, and you can do this to your cards too.
Let's add a button on our toolbar to change the header color:
::wsm/render-toolbar
(fn []
(dom/div
(uc/button {:onClick #((::wsm/set-card-header-style card) {:background "#cc0"})} "Change header color")
(uc/button {:onClick #(js/console.log "State" @state-atom)} "Log app state")))
By calling the ::wsm/set-card-header-style
you can set any css you want to the header.
That's all, go make some nice cards!
Can you improve this documentation?Edit on GitHub
cljdoc is a website building & hosting documentation for Clojure/Script libraries
× close