tape.mvc
An alternative interface to Re-Frame (and Reagent) - via an Integrant system built out of a tape.module system (ported from Duct Framework).
You must be familiar with Reagent, Re-Frame, Integrant, tape.refmap
&
tape.module
before proceeding.
Your project directory structure should be similar to:
blog
├── deps.edn
├── resources
│ └── blog
│ └── config.edn
└── src
└── blog
├── core.cljs
└── app
├── layouts
│ └── app.cljs
├── users
│ └── ...
└── posts
├── controller.cljs
├── model.cljs
└── view.cljs
Under blog.app
you should have a package for each piece of your UI (users,
posts). Each of these will have a model, view and controller namespace (not
necessarily all 3).
The model is a normal namespace, and is likely to be missing if you have little to no business logic for that UI piece.
The controller namespaces contain functions that can be registered in
Re-Frame. Each fn that is to be registered is annotated in metadata with
^{::mvc/reg <kind>}
where <kind>
is one of:
-
::mvc/event-fx
event handler with co/effect i/o -
::mvc/event-db
event handler with app-db i/o -
::mvc/sub
subscription -
::mvc/sub-raw
raw subscription -
::mvc/fx
effect handler -
::mvc/cofx
coeffect handler
To provide interceptors to event handlers, or signals to subscriptions, use the following metadata:
-
::mvc/interceptors
vector of symbols that evaluate to interceptors in the current namespace -
::mvc/signals
vector of symbols that evaluate to signals in the current namespace
Metadata at the namespace level is added to the functions that are registered, and can be used to:
-
add interceptors to all event handlers in a namespace,
-
or a signal to all subscriptions in the namespace.
Function metadata directives override namespace metadata ones.
(ns blog.app.greet.controller
(:require [tape.mvc :as mvc :include-macros true]))
(defn hello
{::mvc/reg ::mvc/event-db}
[_db [_ev-id _params]]
{::say "Hello Tape MVC!"})
(defn say
{::mvc/reg ::mvc/sub}
[db _query]
(::say db))
(mvc/defm ::module)
There is a (c/defm ::module)
call at the end that inspects the current
namespace and defines a tape.module
that will have the functions be registered
in Re-Frame. It is equivalent to:
(derive ::hello ::mvc/event-db)
(derive ::say ::mvc/sub)
(defmethod ig/init-key ::module [_ _]
(fn [config]
(module/merge-configs config {::hello hello, ::say say})))
Derived keys are collected by tape.refmap
and registered in Re-Frame.
The view namespace contains Reagent functions. Some can be registered in the views map if they are annotated with:
-
^{::mvc/reg ::mvc/view}
if the function is to be registered in the views registry map
The key in the map will be based off the controller namespace, as to match an
event (see naming conventions below) that can select a view; example:
{::greet.c/hello greet.v/hello}
.
(ns blog.app.greet.view
(:require [re-frame.core :as rf]
[tape.mvc :as mvc :include-macros true]
[blog.app.greet.controller :as greet.c]))
(defn hello
{::mvc/reg ::mvc/view}
[]
(let [say @(rf/subscribe [::greet.c/say])]
[:p say]))
(mvc/defm ::module) ;; blog.app.greet.controller must exist
There is a (mvc/defm ::module)
call at the end that inspects the current
namespace and defines a tape.module
that will have the functions be registered
in the views registry map. It is equivalent to:
(derive ::hello ::mvc/view)
(defmethod ig/init-key ::module [_ _]
(fn [config]
(module/merge-configs
config
{::hello ^{::mvc/controller-ns-str "blog.app.greet.controller"} hello})))
Derived keys are collected by tape.refmap
and registered in the view registry.
(mvc/defm ::module)
macros can also be called with a map argument that’s merged in
the module configuration output. This allows plain integrant components to be
defined. It’s more verbose than the plain function approach, but we can inject
dependencies from the system map via ig/ref
& such.
(...)
(defn hello
{::mvc/reg ::mvc/event-db}
[_db [_ev-id _params]]
{::say "Hello Tape MVC!"})
(defmethod ig/init-key ::say [_ some-db]
(fn [_ _] (ratom/reaction (::something @some-db))))
(derive ::say ::mvc/sub-raw)
(mvc/defm ::module {::say (ig/ref ::some-ns/some-db)})
To allow IDE navigation, we have two macros that proxy to Re-Frame:
(mvc/dispatch [posts.c/index])
;; => (rf/dispatch [::posts.c/index])
(mvc/subscribe [posts.c/posts])
;; => (rf/subscribe [::posts.c/posts])
In their use, the macros accept events with a symbol form (that can be navigated via IDE), but once compiled, they are in the standard Re-Frame API with no performance penalty. Added value: the handler existence is checked at compile time, and typos are avoided.
In resources/blog/config.edn
declare your modules that will
tape.module/fold-modules
into your Integrant system’s config-map:
{:tape.profile/base {}
:tape.mvc/module nil
:blog.app.greet.controller/module nil
:blog.app.greet.view/module nil}
In src/blog/core.cljs
you tape.module/read-config
and
tape.module/exec-config
your Integrant system:
(ns tape.blog
(:require
[goog.dom]
[reagent.core :as r]
[re-frame.core :as rf]
[tape.module :as module :include-macros true]
[tape.mvc :as mvc]
[tape.tools.current.controller :as current.c]
[blog.app.greet.controller :as greet.c]
[blog.app.greet.view :as greet.v]))
(module/load-hierarchy)
(def config (module/read-config "tape/blog/config.edn"))
(defonce system nil)
(defn app [] [:div @(rf/subscribe [::current.c/view-fn])])
(defn -main []
(set! system (-> config module/prep-config ig/init))
(rf/dispatch-sync [::greet.c/hello])
(r/render-component [app] (goog.dom/getElement "app")))
Note naming convention on requires:
-
controllers:
[blog.app.posts.controller :as posts.c]
-
views:
[blog.app.posts.view :as posts.v]
Note the exclusive use of namespaced keywords and the naming conventions:
-
(if
tape.router
is used) the route named::posts.c/index
dispatches -
the event with the id
::posts.c/index
which -
is handled by the
posts.c/index
handler -
and (if
tape.current
is used) renders theposts.v/index
view (if it exists, and the view was not already set from the handler) -
by setting in app-db
{::current.c/view ::posts.c/index}
(automatically via the view interceptor, or manually in the event handler) -
which results in the subscription
(rf/subscribe [::current.c/view-fn])
-
to yield the
posts.v/index
view fn