Skip to content

nubank/workspaces

Repository files navigation

workspaces

Workspaces is a component development environment for ClojureScript, inspired by devcards.

Workspaces allows you to create cards with your interface components so you don’t need to render your entire application. It also allows you to create cards for your tests so you don’t need to switch to the terminal to see the results. You can create tabs, a.k.a "workspace", and manage your cards size and position within it.

Workspaces Main

First add the workspaces dependency on your project.

Clojars 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))

NEW: Workspaces now has a custom shadow-cljs build target. If you use it, it will:

  • Auto-scan for all workspace cards and tests (based on a regex you supply)

  • Auto-configures the "main" module based on that scan.

  • Auto-detects when you add new files.

;; shadow-cljs configuration
{:builds {:cards {:target     nubank.workspaces.shadow-cljs.target
                  :ns-regexp  "-(test|cards)$"
                  :output-dir "resources/public/js/workspaces"
                  :asset-path "/js/workspaces"
                  :preloads   [] ; optional, list namespaces to be pre loaded
                  }}

You may still configure it with the old style with the :browser target, but in that case you will always have to add your workspace and test namespaces to your main.cljs file.

;; shadow-cljs configuration
{:builds {:workspaces {:target           :browser
                       :output-dir       "resources/public/js/workspaces"
                       :asset-path       "/js/workspaces"
                       :devtools         {;:preloads   [fulcro.inspect.preload ] ; include for Fulcro Inspect support
                                          :http-root          "resources/public"
                                          :http-port          3689
                                          :http-resource-root "."
                                          :preloads []}
                       :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 from 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 with 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.

  • ::f.portal/root-state This map will be merged into the app root state to be part of the initial state in the root, this is useful to set things like :ui/locale considering that a wrapped root initial state will not end up in the root (will be in :ui/root).

  • ::f.portal/computed Add this computed data to the root factory props

  • ::f.portal/root-node-props use this to send props into the root note created to mount the portal on.

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 again

Fulcro 3 is a rewrite and require new wrappers, for Fulcro 3 we got the portal and the card type in the namespace nubank.workspaces.card-types.fulcro3. The settings are the same as for Fulcro 2, all keywords should be namespaced with the fulcro3 card type namespace.

Workspaces 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.

When you add any test, you also get a card that will run the whole test suite. You can open this card by clicking at the TESTS in the index, or using spotlight to find the test-all 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 measurement 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. 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.

In the card header you will see the card title (which is the name of the card symbol) on the left, and at the right two icons. The first icon is the ``more'', mouse over it to see some card available actions:

  • Source: open a modal with the card source code

  • Solo: open a new workspace containing just this card using the whole workspace space

  • Size: prints the current card size in the browser console

  • Remount: dispose the card and start it over

After that you have an X icon to remove the card from current workspace.

Sometimes you want to focus on a single card, like when you want to see just the full test suite or want to have a card that renders your entire app.

In these cases you can open a tab with a card occupying the whole space, you can do that clicking in the Solo button from the card actions, or via spotlight, holding the alt key when clicking or hitting return to select.

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 breakpoint

  • Refresh cards: triggers a refresh on every card on this active workspace

  • Duplicate: creates a copy of current workspace

  • Unify layouts: makes every breakpoint have the same layout as the current active one

  • Export: Export current workspace layouts to data (logged into browser console)

  • Delete: Delete current workspace

A 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 view

  • alt+shift+h: Toggle card headers

  • alt+shift+n: Create new local workspace

  • alt+shift+w: Close current workspace

To 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).

The render system is based on HTML nodes, you provide a render function and workspaces will call that function with a HTML node so you can render/mount your component in it.

The definition from render (and other life cycle functions) will come from calling ::wsm/init on your card.

Here is an example of a custom card with a basic render:

(ws/defcard custom-card
  {::wsm/init
   (fn [card]
     {::wsm/render
      (fn [node]
        (gdom/setTextContent node "Hello custom card!"))})})

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!

If you have any questions, hit us at the #workspaces channel on Clojurians.