Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/nubank/workspaces

Live development environment for Clojurescript
https://github.com/nubank/workspaces

Last synced: 7 days ago
JSON representation

Live development environment for Clojurescript

Awesome Lists containing this project

README

        

:lang: en
:encoding: UTF-8
:doctype: book
:source-highlighter: coderay
:source-language: clojure
:toc: left
:toclevels: 3
:sectlinks:
:sectanchors:
:imagesdir: public/img
:leveloffset: 1
:sectnums:

ifdef::env-github[]
:tip-caption: :bulb:
:note-caption: :information_source:
:important-caption: :heavy_exclamation_mark:
:caution-caption: :fire:
:warning-caption: :warning:
endif::[]

ifdef::env-github[]
toc::[]
endif::[]

= Workspaces

image:https://img.shields.io/clojars/v/nubank/workspaces.svg[link=https://clojars.org/nubank/workspaces]

Workspaces is a component development environment for ClojureScript,
inspired by https://github.com/bhauman/devcards[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.

image:workspaces-main.gif[Workspaces Main]

== Setup

First add the workspaces dependency on your project.

https://clojars.org/nubank/workspaces[image:https://clojars.org/nubank/workspaces/latest-version.svg[Clojars
Project]]

This is recommended template for the HTML file for the workspaces:

[source,html]
----











----

Then create the entry point for the workspaces:

[source,clojure]
----
(ns my-app.workspaces.main
(:require [nubank.workspaces.core :as ws]
; require your cards namespaces here
))

(defonce init (ws/mount))
----

=== With Shadow-CLJS

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.

[source,clojure]
----
;; 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.

[source,clojure]
----
;; 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
....

=== With Figwheel

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:

[source,clojure]
----
(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:

[source,clojure]
----
: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
....

== Creating cards

To define cards you use the `ws/defcard` macro, here is an example to
create a React card:

[source,clojure]
----
(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
https://github.com/Day8/re-frame/[re-frame] for example, you can use
`(reagent/as-element [re-frame-root])` as the content. For a complete
re-frame demo check
https://github.com/nubank/workspaces/blob/master/examples/workspaces-shadow-example/src/myapp/workspaces/reframe_demo_cards.cljs[these
sources].

=== Stateful React cards

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.

[source,clojure]
----
(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.

=== Fulcro cards

Workspaces is built with http://fulcro.fulcrologic.com/[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:

[source,clojure]
----
(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
https://github.com/fulcrologic/fulcro-inspect[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

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.

=== Test cards

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:

[source,clojure]
----
(ws/deftest sample-test
(is (= 1 1)))
----

=== Namespace test cards

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.

=== All tests 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.

== Card settings

=== Card size

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:

[source,clojure]
----
(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.

=== Card content alignment

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.

[source,clojure]
----
(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:

[source,clojure]
----
{:display "flex"
:align-items "center"
:justify-content "center"}
----

=== Container node props

Using the key `::wsm/node-props` you can set the style or other
properties of the container node.

[source,clojure]
----
(ws/defcard styles-card
{::wsm/node-props {:style {:background "red" :color "white"}}}
(ct.react/react-card
(dom/div "I'm in red")))
----

=== Setting templates

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.

[source,clojure]
----
(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 "💜")))
----

== Using Workspaces

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.

=== Creating 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.

=== Responsive grid

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.

=== Card actions

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.

=== Solo cards

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.

=== Workspace actions

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

=== Sharing workspaces

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.

[source,clojure]
----
(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.

== Keyboard shortcuts

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

== Developing custom card types

To demonstrate what a custom card takes to be created, let’s take the
following example:

[source,clojure]
----
(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.

=== Card life cycle

The card life cycle happens according the following events:

==== Initialization

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

==== Rendering

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:

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

==== Refresh

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.

[source,clojure]
----
(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.

==== Dispose

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.

[source,clojure]
----
(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 "!")))}))})
----

=== Positioned cards

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:

[source,clojure]
----
(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.

=== Extending a card type

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.

[source,clojure]
----
; 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)))))))
----

=== Adding a toolbar

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.

[source,clojure]
----
(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.

=== Controlling the card header style

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:

[source,clojure]
----
::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!

== Support

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