RAD macros generate Fulcro components. RAD will always include code in these components that helps automate the
management of state. Forms will manage the client-side load, save, dirty checking, validation, etc. You can simply use
the helper functions like form/save!
to ask the form system to do such operations for you, and write the
actual rendering of the form by hand.
BUT, eliminating the need to write all of this boilerplate UI code
can be a huge win early in your project. So, if you do not include a render body, then RAD will attempt to generate
one for you, but only if you install a render plugin.
RAD depends on React
, but does not directly use any DOM or native code. Thus, UI plugins can target both a
look and platform for UI generation.
At the time of this writing only a web plugin exists, and it uses Semantic UI CSS to provide the general look-and-feel
(though semantic UI is easy to theme, so that is easy to style without having to resort to code). Perhaps by the
time you read this there will also be plugins for React native.
Once you’ve selected the UI plugin for generating UI, you still have a lot of control over the site-specific style
of a given control or format via "style". This is nothing more than the ability to give a hint
as to the kind of information an attribute represents so that the UI plugin (or your own control) can
change to suit a particular need.
For example, an :instant
in the database might be a epoch-based timestamp, but perhaps you just care to use it
with a constant time (say midnight in the user’s time zone). You might then hint that the attribute should
have the style of a "date at midnight", which you could just invent a keyword name for: :date-at-midnight
.
RAD supports the ability to set and override a control style at many levels. The attribute itself can
be given a style:
(defattr :account/created-on :instant
{ao/style :long-timestamp
...})
and forms and reports will allow you to override that style via things like formatters
and field style overrides.
See the form-options
and report-options
namespaces for particular details.
RAD places the definition of controls inside of the Fulcro application itself (which has a location for
just such extensible data). The map for UI element lookup looks something like this (subject to change and
customization in UI plugins):
(def all-controls
{;; Form-related UI
;; completely configurable map...element types are malleable as are the styles. Plugins will need to doc where
;; they vary from the "standard" set.
:com.fulcrologic.rad.form/element->style->layout
{:form-container {:default sui-form/standard-form-container
:file-as-icon sui-form/file-icon-renderer}
:form-body-container {:default sui-form/standard-form-layout-renderer}
:ref-container {:default sui-form/standard-ref-container
:file sui-form/file-ref-container}}
:com.fulcrologic.rad.form/type->style->control
{:text {:default text-field/render-field}
:enum {:default enumerated-field/render-field
:autocomplete autocomplete/render-autocomplete-field}
:string {:default text-field/render-field
:autocomplete autocomplete/render-autocomplete-field
:viewable-password text-field/render-viewable-password
:password text-field/render-password
:sorted-set text-field/render-dropdown
:com.fulcrologic.rad.blob/file-upload blob-field/render-file-upload}
:int {:default int-field/render-field}
:long {:default int-field/render-field}
:decimal {:default decimal-field/render-field}
:boolean {:default boolean-field/render-field}
:instant {:default instant/render-field
:date-at-noon instant/render-date-at-noon-field}
:ref {:pick-one entity-picker/to-one-picker
:pick-many entity-picker/to-many-picker}}
;; Report-related controls
:com.fulcrologic.rad.report/style->layout
{:default sui-report/render-table-report-layout
:list sui-report/render-list-report-layout}
:com.fulcrologic.rad.report/control-style->control
{:default sui-report/render-standard-controls}
:com.fulcrologic.rad.report/row-style->row-layout
{:default sui-report/render-table-row
:list sui-report/render-list-row}
:com.fulcrologic.rad.control/type->style->control
{:boolean {:toggle boolean-input/render-control
:default boolean-input/render-control}
:string {:default text-input/render-control
:search text-input/render-control}
:picker {:default picker-controls/render-control}
:button {:default action-button/render-control}}})
The idea is that layouts and controls should be pluggable and extensible simply by inventing new ones and adding them
to the map installed in your application.
The map also allows you to minimize your CLJS build size by only configuring the controls you use. Thus a library of
controls might include a very large number of styles and type support, but because you can centralize the inclusion
and requires for those items into one minimized map you can much more easily control the UI generation and overhead
from one location. These are the primary reasons we do not use some other mechanism for this like multi-methods, which
cannot be dead-code eliminated and are hard to navigate in source.
UI Plugin libraries should come with a function that can install all of their controls at once.
The report namespace allows you to define (or override) field formatters via report/install-formatter!
.
A form is really just a Fulcro component. RAD includes the macro defsc-form
that can auto-generate the various component options
(query, ident, route target parameters, etc.) from your already-declared attributes. The fo
namespace is an alias
for the com.fulcrologic.rad.form-options
namespace.
A form should have a minimum of 2 attributes:
fo/id
-
An attribute (not keyword) that represents the primary key of the entity/document/table being edited.
fo/attributes
-
A vector of attributes (not keywords) that represent the attributes to be edited in the form. These
can be scalar or reference attributes, but must have a resolver that can resolve them from the ::form/id
attribute,
and must also be capable of being saved using that ID.
Most forms that are used directly (and not just as sub-forms) must also include a route prefix to make them
capable of direct use:
fo/route-prefix
-
A single string. Every form ends up with two routes: [prefix "create" :id]
and
[prefix "edit" :id]
. The form
namespace includes helpers edit!
and create!
to trigger these routes, but
simply routing to them will invoke the action (edit/create).
If you have configured UI generation then that is all you need. Thus a minimal form that is using
the maximal amount of RAD plugins and automation is quite small:
(form/defsc-form AccountForm [this props]
{fo/id account/id
fo/attributes [account/name account/email account/enabled?]
fo/route-prefix "account"})
There are pre-written functions in the form
ns for the common actions:
(form/create! app-ish FormClass)
-
Create a new instance of an entity using the given form class.
(form/edit! app-ish FormClass id)
-
Edit the given entity with id
using FormClass
(form/delete! app-ish qualified-id-keyword id)
-
Delete an entity. Should not be done while in the form unless
combined with some other routing instruction.
We are now to the point of seeing what a complete Fulcro RAD client looks like. The bar minimal client will have:
-
A Root UI component
-
(optional) Some kind of "landing" page (default route)
-
One or more forms/reports.
-
The client initialization (shown earlier).
(ns com.example.ui
(:require
[com.example.model.account :as acct]
[com.fulcrologic.fulcro.components :as comp :refer [defsc]]
#?(:clj [com.fulcrologic.fulcro.dom-server :as dom :refer [div]]
:cljs [com.fulcrologic.fulcro.dom :as dom :refer [div]])
[com.fulcrologic.fulcro.routing.dynamic-routing :refer [defrouter]]
[com.fulcrologic.rad.authorization :as auth]
[com.fulcrologic.rad.form-options :as fo]
[com.fulcrologic.rad.form :as form]))
(form/defsc-form AccountForm [this props]
{fo/id acct/id
fo/attributes [acct/name]
fo/route-prefix "account"})
(defsc LandingPage [this props]
{:query ['*]
:ident (fn [] [:component/id ::LandingPage])
:initial-state {}
:route-segment ["landing-page"]}
(div
(dom/button {:onClick (fn [] (form/create! this AccountForm))}
"Create a New Account"))
(defrouter MainRouter [this props]
{:router-targets [LandingPage AccountForm]})
(def ui-main-router (comp/factory MainRouter))
(defsc Root [this {::auth/keys [authorization]
:keys [authenticator router]}]
{:query [{:router (comp/get-query MainRouter)}]
:initial-state {:router {}}}
(div :.ui.container.segment
(ui-main-router router)))
The landing page in this example includes a sample button to create a new account, but
of course you’ll also need to add some seed data to your database, wrap things with some authorization, etc.
The data type and rendering style of an attribute (along with extended parameters possibly defined by input styles in
their respective documentation) are the first line of data enforcement: Saying that something is a decimal number with
a US currency style will already ensure that the user cannot input "abc" into the field.
Further constraining the value might be something you can say at the general attribute level (age
must be between 0
and 130), or may be contextual within a specific form (from-date
must be before to-date
).
Validators are functions as described in Fulcro’s Form State support:
They are functions that return :valid
, :invalid
, or :unknown
(the field isn’t ready to be checked yet).
They are easily constructed using the form-state/make-validator
helper, which takes into account the current completion
marker on the field itself (which prevents validation messages from showing too early).
Attribute-level validation checks can be specified with a predicate:
(defattr name :account/name :string
{ao/valid? (fn [nm] (boolean (seq nm)))})
Custom validations are defined at the form level with the ::form/validator
key. If there are validators at both
layers then the form one completely overrides all attribute validators. If you want to compose validators from
the attributes then use attr/make-attribute-validator
on your complete model, and use the result in the form validator:
(ns model ...)
(def all-attributes (concat account/attributes ...)
(def all-attribute-validator (attr/make-attribute-validator all-attributes))
...
(ns account)
(def account-validator (fs/make-validator (fn [form field]
(case field
:account/email (str/ends-with? (get form field) "example.com")
(= :valid (model/all-attribute-validator form field))))))
The message shown to the user for an invalid field is also configurable at the form or attribute level.
The existence of a message on the form overrides the message declared on the attribute.
(attr/defattr age :thing/age :int
::attr/validation-message (fn [age]
(str "Age must be between 0 and 130.")))
...
(form/defsc-form ThingForm [this props]
{::form/validation-messages
{:thing/age (fn [form-props k]
(str (get form-props k) " is an invalid age."))}
...})
The form-based overrides are useful when you have dependencies between fields, since they can consider all of the
data in the form at once and incorporate it into the check and validation message. For example you might want to
require a new email user use their lower-case first name as a prefix for an email address you’re going to generate
in your system. You might use something like this:
(def account-validator (fs/make-validator (fn [form field]
(case field
:account/email (let [prefix (or
(some-> form
(get :account/name)
(str/split #"\s")
(first)
(str/lower-case))
"")]
(str/starts-with? (get form :account/email) prefix))
(= :valid (model/all-attribute-validator form field))))))
It is quite common for a form to cover more than one entity (row or document) in a database. An account might have
one or more addresses. An invoice has a customer, line items, and references to inventory. In RAD, combining related
data requires a form definition for each uniquely identifiable entity/row/document. These can have to-one or to-many
relationships.
A given entity and its related data can be joined together into a single form interaction by making one of the forms
the master. This must be a form that resolves to a single entity, and whose subforms are reachable by resolvers through
the attributes of that master (or descendants).
Any form can automatically serve as a master. The master is simply selected by routing to it, since that will start
that form’s state machine which in turn will end up controlling the entire interaction. The subforms themselves can
act as standalone forms, but will not be running their own state machine unless you route directly to them. Interestingly
this means that forms can have both a sibling and parent-child relationship in your application’s UI graph.
All forms are typically added to a top-level router so that each kind of entity can be worked with in isolation. However,
some forms may also make sense to use a subforms within the context of others. An example might be an AddressForm
. While
it might make sense to allow someone to edit an address in isolation, the address itself probably belongs to some other
entity that may wish to allow editing of that sub-entity in its context.
A simple example of this would look as follows:
(form/defsc-form AddressForm [this props]
{::form/id address/id
::form/attributes [address/street address/city address/state address/zip]
::form/cancel-route ["landing-page"]
::form/route-prefix "address"
::form/title "Edit Address"})
(form/defsc-form AccountForm [this props]
{::form/id acct/id
::form/attributes [acct/name acct/email acct/active? acct/addresses]
::form/cancel-route ["landing-page"]
::form/route-prefix "account"
::form/title "Edit Account"
::form/subforms {:account/addresses {::form/ui AddressForm}}})
(defrouter MainRouter [this props]
{:router-targets [AccountForm AddressForm]})
In the above example the AddressForm
is completely usable to edit an address (if you have an ID) or create one
(if it makes sense to your application to create one in isolation). But it is also used as a subform through the
:account/addresses
attribute where the ::form/subforms
map is used to configure which form should be used for
the items of the to-many relationship. Additional keys in the subforms
map entries allow for specific behavioral
support.
This section assumes you know a bit about Fulcro’s Form State support. The validation system used in RAD
is just that, with some automation stacked on top. It is important to understand that validation does not
start taking effect on a field until it is "marked complete", and a form is never considered "valid"
until everything it is considered "complete". RAD will automatically mark things complete as users
interact with form fields (often on blur), but creation needs you to indicate what (pre-filled) fields
should be considered "already complete".
The rules the built-in RAD form state machine uses:
The attributes options for setting defaults when things are created are:
::attr/default-value
-
Can be placed on an attribute to indicate a default value for this attribute.
::form/default-values
-
A map from attribute name (as a keyword) to a default value. Subform data can be placed in this
tree.
One of the core questions in any relation is: does the referring entity/table/document "own" the target? In other words
does it create and destroy it? When there is a graph of such relations this question is also recursive (and is handled
by things like CASCADE
in SQL and isComponent
markers in Datomic).
When there is not an ownership relation one still needs to know if the referring entity is allowed to create new ones
(destroying them is usually ruled out, since others could be using it).
In the cases where there is not an ownership relation we usually model it as some kind of "picker" in a form, allowing
the user to simply select (or search for) "which" of the existing targets are desired. When there is an ownership
relation the form will usually model the items as editable sub-forms, with optional controls that allow the
addition and removal of the elements in the relation.
The form management system uses the concept of "subforms" to models all of the possible relationships, relies on
database adapters to manage things like cascading deletes, and needs some additional configuration (on a per-form basis)
from you as to how relations should be rendered and interacted with in the UI.
The following sections cover various relational use-cases that RAD forms support.
In this case the referenced item springs into existence when the parent creates it, and drops from existence when
it is no longer referenced. Database adapters model this in various ways, but the concept at the form layer is
simple: If you’re creating it then you’ll be creating a new thing, an edit will edit the current thing, and if you
drop the reference you’ll depend on the database adapter’s save logic to delete it (which may or may not be implemented,
you may need to add save middleware).
The form rendering system can derive that it is a to-one relation from the cardinality declared on the reference
attribute. The ownership nature is more of a rendering concern than anything: If the new thing is exclusively owned
then we know we have to generate a subform that can fill out the details.
|
This kind of relation can also be modelled by folding the referred items attributes into the owner. For example
if you have an edge called :account/primary-address that is a to-one relation to an address, but you don’t plan
to do real normalization of addresses (which is difficult), then you could also just make :account/primary-street and
such on the account itself and skip the relational nature altogether.
|
|
This use-case is partially implemented. It will work well when selecting from a relatively small set of
targets, but will not currently perform well if the list of potential targets is many thousands or greater.
|
In this case setting up the relation is nothing more that picking some pre-existing thing in the database. There
are several sub-aspects to this problem:
-
Should you be able to create a new one?
-
When selecting an existing one, how do you manage large lists of potential candidates (search, caching, etc.)?
-
How do you label the items so the user can select them?
At the time of this writing the answers are:
-
Not yet generically implemented. Setting a to-one relation is a selection process
unless you hand-write the UI yourself; However, it is relatively easy to implement a UI control that can do both.
-
This is an option of the UI control used to do the selection. At present all of the potential matches are pre-loaded.
This is also something you can easily customize by simply writing your own control.
-
This is something you can configure.
A demonstration of this case is as follows: Assume we want to generate a form for an invoice. The invoice will
have line items (to many, owned by the invoice), and each line item will point to an item from our inventory (owned
by inventory, not the line item).
We can start from the bottom. The inventory item itself might have this model in a Datomic database:
(ns com.example.model.item
(:require
[com.fulcrologic.rad.attributes-options :as ao]
[com.fulcrologic.rad.attributes :refer [defattr]]))
(defattr id :item/id :uuid
{ao/identity? true
ao/schema :production})
(defattr item-name :item/name :string
{ao/identities #{:item/id}
ao/schema :production})
...
followed by the line item model:
(ns com.example.model.line-item
(:require
[com.fulcrologic.rad.form-options :as fo]
[com.fulcrologic.rad.attributes :refer [defattr]]
[com.fulcrologic.rad.attributes-options :as ao]))
(defattr id :line-item/id :uuid
{ao/identity? true
ao/schema :production})
(defattr item :line-item/item :ref
{ao/target :item/id
ao/required? true
ao/cardinality :one
ao/identities #{:line-item/id}
ao/schema :production})
(defattr quantity :line-item/quantity :int
{ao/required? true
ao/identities #{:line-item/id}
ao/schema :production})
...
note the :line-item/item
reference. It is a to-one that targets entities that have an :item/id
. There is no
Datomic marker indicating that it is a component, so we’ve already inferred that the line item doesn’t own it. But
it might also be possible that the line item could be allowed to create new ones. We just don’t know for sure
unless we provide more context.
In RAD we do that at the form layer:
(form/defsc-form LineItemForm [this props]
{fo/id line-item/id
fo/attributes [line-item/item line-item/quantity]
;; Picker-related rendering
fo/field-styles {:line-item/item :pick-one}
fo/field-options {:line-item/item {::picker-options/query-key :item/all-items
::picker-options/query-component item-forms/ItemForm
::picker-options/options-xform (fn [normalized-result raw-response]
(mapv
(fn [{:item/keys [id name price]}]
{:text (str name " - " (math/numeric->currency-str price)) :value [:item/id id]})
(sort-by :item/name raw-response)))
::picker-options/cache-time-ms 60000}}})
Here we’ve generated a normal form. We’ve included the line-item/item
attribute, and since that is a ref we must
normally include subform configuration; however, we do not intend to render a subform. We can use fo/field-styles
to indicate to RAD that a reference attribute will be rendered as a field. In this case the :pick-one
field type
will look in field-options
for additional information. This field type, of course, could also just be set as
::form/field-style
on the attribute itself.
The fo/field-options
map should contain an entry for each :pick-one
field style. The options are:
::picker-options/query-key
-
A top-level EDN query key that can return the entities you want to choose from.
::picker-options/cache-key
-
(optional) A key under which to cache the options. If not supplied this assumes query key.
::picker-options/query-component
-
(optional) A UI component that can be used for the subquery. This allows the picker options
to be normalized into your normal database. If not supplied then the options will stored purely in the options cache.
::picker-options/options-xform
-
a (fn [normalized-result raw-result] picker-options)
. This function, if supplied,
is given both the raw and normalized result. It must return a vector of {:text "" :value v}
that will be used
as the picker’s options.
::picker-options/cache-time-ms
-
How long, in ms, should the options be cached at the cache key? Defaults to 100ms.
At this point you can use the LineItemForm
and it will allow you to pick from the existing items in your
database as long as you have a resolver. Something like this on the server (assuming you installed the
attribute to resolver generator in your parser) would fit the bill:
(defattr all-items :item/all-items :ref
{::attr/target :item/id
::pc/output [{:item/all-items [:item/id]}]
::pc/resolve (fn [{:keys [query-params] :as env} _]
#?(:clj
{:item/all-items (queries/get-all-items env query-params)}))})
The next case we’ll consider is the case where a form has a to-many relationship, and the items referred to are
created (and owned) by that parent form. This case uses a normal form for the to-many items, and is
pretty simple to configure. Say you have accounts, and each account can have multiple addresses (the addresses
are not globally normalized but instead just owned by the account, since they are hard to globally normalize).
The addresses attribute looks like you’d expect:
(ns com.example.model.account ...)
(defattr addresses :account/addresses :ref
{::attr/target :address/id
::attr/cardinality :many
:com.fulcrologic.rad.database-adapters.datomic/schema :production
:com.fulcrologic.rad.database-adapters.datomic/entity-ids #{:account/id}})
and the UI for an AddressForm
might look like this:
(form/defsc-form AddressForm [this props]
{::form/id address/id
::form/attributes [address/street address/city address/state address/zip]
::form/cancel-route ["landing-page"]
::form/route-prefix "address"})
The AccountForm
would then simply use that AddressForm
in a subform definition like so:
(form/defsc-form AccountForm [this props]
{::form/id acct/id
::form/attributes [acct/name acct/addresses]
::form/cancel-route ["landing-page"]
::form/route-prefix "account"
::form/subforms {:account/addresses {::form/ui AddressForm
::form/can-delete-row? (fn [parent item] (< 1 (count (:account/addresses parent))))
::form/can-add-row? (fn [parent] (< (count (:account/addresses parent)) 2))}}})
Here the subform information for the :account/addresses
field indicates:
-
::form/ui
- The UI component to use for editing the target(s).
-
::form/can-delete-row?
- A lambda that receives the current parent (account) props and the a referred item. If
it returns true then that item should show a delete button.
-
::form/can-add-row?
- A lambda that receives the current parent (account). If
it returns true then the UI should include some kind of add control for adding a new row (address). You can also
return :append
(default) or :prepend
if you’d like the newly added item to appear first or last.
So our form shown above does not allow the user to delete the address if it is the only one, and prevents them from
adding more than 2.
|
This use-case is not yet implemented.
|
There are currently 3 kinds of dynamism supported by RAD:
-
The ability for a field to be a completely computed bit of UI based on the current form, with no stored state.
-
The ability to derive one or more stored fields, spreadsheet-style, where the values are computed from user-input
fields, the where the results of the computation are stored in the model.
-
The ability to hook into the UI state machine of the form in order to drive dependent field changes and also
drive I/O for things like cascading dropdowns and dynamically loading information
of interest to the user about the form in progress (username already in use, current list price of an item, etc.).
A purely computational (display-only) attribute is simple enough to declare:
(defattr subtotal :line-item/subtotal :decimal
{::attr/computed-value (fn [{::form/keys [props] :as form-env} attr]
(let [{:line-item/keys [quantity quoted-price]} props]
(math/round (math/* quantity quoted-price) 2)))})
Such a field will show as a read-only field (formatted according to the field style you select). The function is
supplied with the form rendering env (which includes the current form props) and the attribute definition of the
field that is changing. The return value will be the displayed value, and must match the declared type of the field.
These attributes will never appear in Fulcro state. They are pure UI artifacts, and recompute their value when the
form renders.
You actually have access to the entire set of props in the form, but you should note that other computed fields are
not in the data model. So if you have data dependencies across computed fields you’ll end up re-computing intermediate
results.
Derived fields are attributes that are meant to actually appear in Fulcro state, and can also (optionally) participate in Form I/O
(i.e. be saved to your server database). Derived fields are meant to be very easy to reason over in a full-form sense,
and are meant to be an easy way to manage interdependencies of calculated data.
Each form can set up a derived field calculation by adding a :derive-fields
trigger to the form:
(defn add-subtotal* [{:line-item/keys [quantity quoted-price] :as item}]
(assoc item :line-item/subtotal (math/* quantity quoted-price)))
(form/defsc-form LineItemForm [this props]
{::form/id line-item/id
::form/attributes [line-item/item line-item/quantity line-item/quoted-price line-item/subtotal]
::form/triggers {:derive-fields (fn [new-form-tree] (add-subtotal* new-form-tree))}
A derive-fields
trigger is a referentially-transparent function that will receive the tree of denormalized
form props for the form, and must return an optionally-updated version of that same tree. Since it is a tree it
is very easy to reason over, even when there is nested data that is to be changed.
If a master form and child form both have derive-fields
triggers, then the behavior is well-defined:
-
An attribute change will always trigger the :derive-fields
on the form where the attribute lives, if defined.
-
The master form’s :derive-fields
will be triggered on each attribute change, and is guaranteed to run after
the nested one.
-
A row add/delete will always trigger the master form’s :derive-fields
, if defined.
Note: Deeply nested forms do not run :derive-fields
for forms between the master and the form on which the
attribute changed.
Assume you have an invoice that contains line item’s that use the above form. The :invoice/total
is clearly a
sum of the line item’s subtotals. Therefore the invoice (which in this example is the master form) would look like
this:
(defn sum-subtotals* [{:invoice/keys [line-items] :as invoice}]
(assoc invoice :invoice/total
(reduce
(fn [t {:line-item/keys [subtotal]}]
(math/+ t subtotal))
(math/zero)
line-items)))
(form/defsc-form InvoiceForm [this props]
{::form/id invoice/id
::form/attributes [invoice/customer invoice/date invoice/line-items invoice/total]
...
::form/subforms {:invoice/line-items {::form/ui LineItemForm}}
::form/triggers {:derive-fields (fn [new-form-tree] (sum-subtotals* new-form-tree))}
...})
Now an attribute change of the item on a line item will first trigger the derived field update of
subtotal on the LineItemForm
, and then the master form’s derived field update will fix the total.
|
It may be tempting to use this mechanism to invent values that are unrelated to the form and put them
into the state. This is legal, but placing data in Fulcro’s state database does not guarantee they will show up in
rendered props. Fulcro pulls props from the database according to the component’s query, and forms only place the
listed attributes in that query. This means if you put an arbitrary key into the state of your form it will not show
up unless you also add it to the ::form/query-inclusion of that form. Of course, auto-rendering will also know nothing about it unless it is listed
as some kind of attribute. You can define a no-op attribute (at attribute with nothing more than a type) as a way to
render such on-the-fly values, but you should also be careful about how such props might interact with form loads and
saves.
|
The next dynamic support feature is the :on-change
trigger. This trigger happens due to a user-driven change
of an attribute on the form. Such triggers do not cascade.
This trigger is ultimately driven by the form/input-changed!
function (which is used by all pre-built form fields
to indicate changes).
The :on-change
trigger is implemented as a hook into the Fulcro UI State Machine that is controlling the form, and must be
coded using that API. The Fulcro Developer’s Guide covers the full API in detail. The most important aspect of this
API is that it is side-effect free. You are passed an immutable UISM environment, and thread any number of uism
functions
together against that env
to evolve it into a new desired env, which you return. This is then processed by the state machine
system to cause the desired effects.
Code for UISM handlers generally looks something like this:
(fn [env]
(-> env
(uism/apply-action ...)
(some-helper-you-wrote)
(cond->
condition? (optional-thing))))
|
Handlers must either return an updated env or nil (which means "do nothing"). Returning anything else
is an error. There are checks in the internals that try to detect if you make a mistake and will show an error in the
console.
|
In RAD Forms, the on-change
handler is passed the UI State machine environment, along with some other convenient
values: the ident of the form being modified, the keyword name of the attribute that changed, along with that attribute’s
old and new value.
In our Line Item example we allow a user to pick an item from inventory, which has a pre-defined price. Users
of the invoice form might need to override this price to give a discount or correct an error in pricing. Therefore, each
line item will have a :line-item/quoted-price
. Every time the user selects an item to sell on a line item we want
push the inventory price of the item into the item’s quoted-price. We cannot do this with the derived-fields
trigger because that
trigger does not know what changed, and we only want to push the item price into quoted price on item change (not
every time the form changes). This is a prime use-case for an :on-change
, and can be coded like this:
(form/defsc-form LineItemForm [this props]
{fo/id line-item/id
fo/attributes [line-item/item line-item/quantity line-item/quoted-price line-item/subtotal]
fo/triggers {:on-change (fn [{::uism/keys [state-map] :as uism-env} form-ident k old-value new-value]
(case k
;; In this example items are normalized, so `new-value` will be the ident
;; of an item in the database, which in turn has an :item/price field.
:line-item/item
(let [item-price (get-in state-map (conj new-value :item/price))
target-path (conj form-ident :line-item/quoted-price)]
;; apply-action allows you to update the Fulcro state database. It works
;; as-if you were doing an `update` on `state-map`.
(uism/apply-action uism-env assoc-in target-path item-price))
The :on-change
triggers always precede :derive-fields
triggers, so that the global derivation can depend upon
values pushed from one field to another.
|
The goals of RAD are stated in this section, but only some of the type support is fully-implemented and
stable.
|
Fulcro uses EDN for its data representation, and supports all of the data types that transit supports
out of the box, at least at the storage/transmission layer. Some of these type, however, have further complications. The
two most pressing are time and precise representation of numbers, but others certainly exist.
RAD includes support for helping deal with these problems.
The standard way to represent time is as an offset from the epoch in milliseconds. This is the de-facto representation
in the JVM, JS VM, transit, and many storage systems. As such, it is the standard for the instant
type in RAD. User
interfaces also need to localize the date and time to either the user or context of the form/report in question.
There are standard implementations of localization for js and the JVM, but since we’re using CLJC already it makes the
most since to us to just use cljc.java-time
, which is a library that unifies the API of the standard JVM Time API.
This makes it much simpler to write localized support for dates and times in CLJC files. To date we are avoiding the
tick
library because it is not yet as mature, and is overkill for RAD itself (though you can certainly use it
in your applications).
At the time of this writing RAD supports only the storage of instants (Java/js Date objects), and requires that you
select a time-zone for the context of your processing. The concept of LocalDate
and LocalTime
can easily be added,
but for now the style of the UI control determines what the user interaction looks like. This means that when you
ask the user for a date, it will be stored as a specific time on a specific date in a specific time zone.
For example, an Invoice might require a date (which could be in the context of the receiver or the shipper). The
"ideal" solution is to do time zone offset calculations, but a reasonable approximation might be to just
store the date relative to noon (or midnight, etc.) in the time zone of the user. This can be supported with a
simple UI control style:
(defattr date :invoice/date :instant
{fo/field-style :date-at-noon
...})
Of course you can provide your own style definitions for controls, and you can also choose to store
things like "Local Dates" as simple strings (or a LocalDate type if your storage engine has one)
in your database if you wish to completely avoid the time zone complication. At that point you could
also add Transit support for local dates to your network layer, and keep those items in the correct type
in a full-stack manner.
|
At the time of this writing the date-time namespace requires the 10-year time zone range from Joda Timezone. This
will most likely be removed from RAD and changed to a requirement for your application, since you can then select
the time zone file that best meets your application’s size and functionality requirements.
|
In order to use date/time support in RAD you must set the time zone so that RAD knows how to adjust local date and
times into proper UTC offsets. Setting the time zone can be done in a couple of ways, depending on the
desired usage context.
It is important to note that the server (CLJ) side will typically only deal with already-adjusted UTC offsets. Thus,
the code on the server mostly just read/saves the values without having to do anything else. A UTC offset is unambiguous,
just not human friendly. The user interface is where RAD does this human interfacing.
In CLJS you are commonly dealing with a lot of (potentially behind-the-scenes) asynchronous logic. Fulcro makes most
of the model appear synchronous, but the reality is quite different in implementation. Fortunately, most UI contexts
are aimed at the user, and that user usually has a particular time zone that is of interest to them. Thus, the
time zone on the client side can usually be set to some reasonable default on client startup (perhaps based on the
browser’s known locale) and further refined when a user logs in (via a preference that you allow them to set).
Thus, CLJS code will typically call (datetime/set-timezone! "America/Los_Angeles")
, where the string argument
is one of the standard time zone names. The are available from (cljc.java-time.zone-id/get-available-zone-ids)
.
;; Typical client initialization
(defn init []
(log/info "Starting App")
;; set some kind of default tz until they log in
(datetime/set-timezone! "America/Los_Angeles")
(form/install-ui-controls! app sui/all-controls)
(attr/register-attributes! model/all-attributes)
(app/mount! app Root "app"))
|
The above action is all that is needed to get most of RAD working. The remainder of the date/time support is
used internally, and can also be convenient for your own logic as your requirements grow.
|
It is also possible that you may wish to temporarily override the currently-selected time zone for some context. This
is true for CLJS (though you will have to be careful to manage async behavior there), and is central to CLJ operation.
In CLJ your normal reads and mutations will be dealing with UTC offsets that have already been properly adjusted in the
client. There are times when you’ll want to deal with timezone-centric data (in reports and calculations, for example,
you might need to choose a range from the user’s perspective).
Most of the functions in the date-time
namespace allow you to pass the zone name (string version of zone id) as
an optional parameter, but the default value comes from the dynamic var datetime/current-timezone
as a ZoneID
instance, not a string.
So, you can get a thread-local binding for this with the standard Clojure:
(binding [datetime/*current-timezone* (zone-id/of "America/New_York")]
...)
The macro with-timezone
makes this a less noisy:
(with-timezone "America/New_York"
...)
See the doc strings on the functions in com.fulcrologic.rad.type-support.date-time
namespace for more details on
what support currently exists. This namespace will grow as needs arise, but many of the things you might need
are easily doable using cljc.java-time
(already included)
and tick (an easy add-on dependency) as long as you center your logic around
the *current-timezone
when appropriate.
EDN and Transit already support the concept of representing and transmitting arbitrary precision numbers. CLJ uses the
built-in BigDecimal
and BigInteger
JVM support for runtime implementation and seamless math operation. Unfortunately,
CLJS accepts the notation for these, but uses only JS numbers as the actual runtime representation. This means that
logic written in CLJC cannot be trusted to do math.
In RAD we desire the representation on the client to be closer to what you’d have on the server. Most applications
have large amounts of their logic on the client these days, so it makes no sense, in our opinion, to simply pass numbers
around as unmarked strings and expect things to work well.
Therefore RAD has full-stack support for BigDecimal (BigInteger may be added, as needed). Not just in type, but in
operation. The com.fulcrologic.rad.type-support.decimal
namespace includes constructors that work the same
in CLJ and CLJS (you would avoid using suffixes like M
, since the CLJS code would map that to Number), and many
of the common mathematical operations you’d need to implement your calculations in CLJS (PRs encouraged for adding
ones you find missing).
Working with these looks like the following:
(ns example
(:require
[com.fulcrologic.rad.type-support.decimal :as math]))
;; Works the same in CLJ and CLJS.
(-> (math/numeric 41)
(math/div 3) ; division defaults to 20 digits of precision, can be set
(math/+ 35))
TODO: Need math/with-precision
instead of just an arg to div
.
Of course you can use clojure exclusions and refer to get rid of the math
prefix,
but since it is common to need normal math for other UI operations we do not
recommend it.
Fields that are declared to be arbitrary precision numerics will automatically
live in your Fulcro database as this math/numeric
type (which is CLJ is BigDecimal,
and in CLJS is a transit-tagged BigDecimal (a wrapped string)).
The JS implementation is currently provided by big.js
(which you must add to your package.json). Most of the functions
will auto-coerce values, and you can also ask for a particular calculation to be done with
primitive math (which will run much faster but incur inaccuracies).
You can ask for imprecise (but fast) math operation (only really affects CLJS)
with:
(time (reduce math/+ 0 (range 0 10000)))
"Elapsed time: 251.240947 msecs"
=> 49995000M
(time (math/with-primitive-ops (reduce math/+ 0 (range 0 10000))))
"Elapsed time: 1.9688 msecs"
=> 49995000
which will run much faster, but you are responsible for knowing when that is safe. This allows
you to compose functions that were written for accuracy into new routines where the accuracy isn’t necessary.
|
with-primitive-ops coerces the value down to a js/Number (or JVM double ), and then
calls Clojure’s pre-defined + , etc. This primarily exists for cases where you’re doing something in a UI that
must render quickly, but that uses data in this numeric format. For example a dynamically-adjusting report where
you know the standard math to be accurate enough for transient purposes.
|
|
with-primitive-ops returns the value of the last statement in the body. If that is a numeric value then
it will be a primitive numeric value (since you’re using primitives). You must coerce it back using math/numeric
if you need the arbitrary precision data type for storage.
|
RAD Forms can support file uploads, along with download/preview of previously-uploaded files.
-
Attribute(s) that represent the details you want to store in a database to track the file.
-
An attribute that represents the file itself and can be used to generate a URL of the file. EQL resolvers
send transit, so it is not possible to query for the file content via a Pathom resolver. Instead you must
supply a resolver that can, given the current parsing context, resolve the URL of the file’s content for download
by the UI.
File transfer support leverages Fulcro’s normal file upload mechanisms for upload and the normal HTTP GET mechanisms for
download. The file is sent as a separate upload mutation during form interaction, and upload progress blocks exiting
the form until the upload is complete (the form field itself for the upload relies on correctly-installed
validation for this to function).
The file itself is stored on the server as a temporary file until such time as you save the form itself (though
you can also configure the form to auto-save when upload is complete). When you save the form you must use
the save middleware to move the temporary file to a permanent store of your choice and then augment the
incoming form data to include the details about the file that will allow your file detail resolver to
emit a proper URL for getting the file.
RAD’s built-in support for BLOBs requires that you define a place in one of your database stores to keep a fingerprint
for the file. RAD uses SHA256 to generate such a fingerprint for files (much like git
). The fingerprint is treated
as the key to the binary data in the store where you place the bytes of the file. This allows you to do things like
duplicate detection, and can help in situations where many users might upload the same content (your regular database
would track who has access to what files, but they’d be deduped).
Forms need to know where to upload the file content. Fulcro requires an HTTP remote for file upload, since it sends the
file through a normal HTTP POST. If your primary remote is HTTP, then your client needs nothing more than the standard
file upload middleware added to the request middleware on the client, and file upload middleware on the server that
can receive the files.
The general operation of file support in RAD is shown in the diagram below. As the user edits a form with a file
upload control they can choose local files. RAD generates a SHA for each file, and begins uploading it immediately
(tracking progress and disabling save/navigation until the upload is complete). The SHA is stored in the form field
(and is what you’ll have in your database as a key to find the binary data later).
The file is saved in a temporary store (usually a temporary disk file).
Once the file(s) is/are uploaded then the form can be saved. When the user does this the SHA comes across in the save
delta and middleware on the server detects it. This triggers the content (named as the SHA) to be moved from the
temporary store to a permanent store. Of course the SHA is saved in the entity/document/row of your database (along
with other facets of the file you’ve set up, such as user-specified filename).
The permanent store is configured to understand how to provide a URL (properly protected) to serve the file content,
allowing the form, reports, and other features of your application to provide the file content on demand.
Temporary
RAD Form Store (usu. temp file)
+----------+ +------------+
| {d} +----->| SHA bytes |
| SHA | | |
| filename | | |
+--+-------+ +----+-------+
| |
| save! - - - - -> | bytes moved to real store
| triggers |
v v
+----------+ +------------+
| {s} | | SHA bytes | Permanent Store
| SHA | | | (S3, disk, etc.)
| filename | | |
+---------++ +----+-------+
| |
RAD DB |
| |
+- - - - - ->| SHA based URL
|
v
Browser
Since RAD controls the rendering of the file in forms it needs to know how to group together attributes of a file
so that it knows which is the filename, which is the URL, etc. RAD does this by keyword "narrowing", our term
for the process of using the current attribute’s full name as a namespace (by replacing /
with .
) and adding
a new name.
Thus, if you define a blob attribute :file/sha
then the filename attribute will be assumed to be :file.sha/filename
by the auto-generated UI in RAD. You can use rewrite middleware and custom resolvers if you want to save it under a
different name in your real database, but it is easiest in greenfield projects just to adopt the convention.
There is a special macro in the blob
namespace defblobattr
that should be used to declare a BLOB-tracking attribute
in your database. It ensures that you supply sufficient information about the attribute for uploads to work correctly.
A sample file
entity (backed by Datomic) might be defined like this:
(ns com.example.model-rad.file
(:require
[com.fulcrologic.rad.attributes :refer [defattr]]
[com.fulcrologic.rad.attributes-options :as ao]
[com.fulcrologic.rad.blob :as blob]))
(defattr id :file/id :uuid
{ao/identity? true
ao/schema :production})
;; :files is the name of the BLOB store, and :remote is the Fulcro remote that uploads go to.
(blob/defblobattr sha :file/sha :files :remote
{ao/identities #{:file/id}
ao/schema :production})
(defattr filename :file.sha/filename :string
{ao/schema :production
ao/identities #{:file/id}})
(defattr uploaded-on :file/uploaded-on :instant
{ao/schema :production
ao/identities #{:file/id}})
(def attributes [id sha filename uploaded-on])
The defblobattr
requires you supply a keyword for the attribute, the name of the permanent store for the content
(:files
in this example), and the name of the Fulcro client remote (:remote
in this example) that can transmit the
file bytes.
You must configure an HTTP remote on the client that includes the Fulcro file upload middleware. This is
covered in the Fulcro Developer’s guide, but looks like this:
(def request-middleware
(->
(net/wrap-fulcro-request)
(file-upload/wrap-file-upload)))
(defonce app (app/fulcro-app {:remotes {:remote (http/fulcro-http-remote {:url "/api"
:request-middleware request-middleware})}
The server setup needs several things.
First, you need to define a temporary and permanent store. RAD requires a store to implement the
com.fulcrologic.rad.blob-storage/Storage
protocol.
The temporary store can just use the pre-supplied transient store, which uses (and tries to garbage collect) temporary
disk files on your server’s disk. RAD’s transient store requires connection stickiness so that the eventual form save will go to the
save server as the temporary store. If that is not possible in your deployment then you may wish to use your permanent store
as the temporary store and just plan on cleaning up stray files at some future time.
Once you’ve defined you two stores you can add the blob support to your Ring middleware and as a plugin to your
Pathom parser.
There are two parts to the Ring middleware, and one is optional and is only necessary if you plan to serve the BLOB URLs from your server.
(ns com.example.components.blob-store
(:require
[com.fulcrologic.rad.blob-storage :as storage]
[mount.core :refer [defstate]]))
(defstate temporary-blob-store
:start
(storage/transient-blob-store "" 1))
(defstate file-blob-store
:start
(storage/transient-blob-store "/files" 10000))
;; -------------------------------------------
(ns com.example.components.ring-middleware
(:require
[com.example.components.blob-store :as bs]
[com.example.components.config :as config]
[com.fulcrologic.fulcro.networking.file-upload :as file-upload]
[com.fulcrologic.fulcro.server.api-middleware :as server]
[com.fulcrologic.rad.blob :as blob]
[mount.core :refer [defstate]]
[ring.middleware.defaults :refer [wrap-defaults]]
[ring.util.response :as resp]
[taoensso.timbre :as log]))
(defstate middleware
:start
(let [defaults-config (:ring.middleware/defaults-config config/config)]
(-> not-found-handler
(wrap-api "/api")
;; Fulcro support for integrated file uploads
(file-upload/wrap-mutation-file-uploads {})
;; RAD integration for *serving* files FROM RAD blob store (at /files URI)
(blob/wrap-blob-service "/files" bs/file-blob-store)
(server/wrap-transit-params {})
(server/wrap-transit-response {})
(wrap-html-routes)
(wrap-defaults defaults-config))))
You must also install plugins and resolvers to your parser:
(ns com.example.components.parser
(:require
[com.example.components.blob-store :as bs]
[com.example.components.database :refer [datomic-connections]]
[com.example.model-rad.attributes :refer [all-attributes]]
[com.fulcrologic.rad.blob :as blob]
[com.fulcrologic.rad.pathom :as pathom]
[mount.core :refer [defstate]]))
...
(defstate parser
:start
(pathom/new-parser config
[...
;; Enables binary object upload integration with RAD
(blob/pathom-plugin bs/temporary-blob-store {:files bs/file-blob-store})
...]
[resolvers
...
(blob/resolvers all-attributes)]))
The blob plugin mainly puts the temporary store and permanent store(s) into the parsing env so that they are available
when built-in blob-related reads/mutations are called. The BLOB resolvers use the keyword narrowing of your SHA attribute
and the env
to provide values that can be derived from the SHA and the store (i.e. :file.sha/url
).
A file is tracked by a SHA. Therefore you can support a fixed number of files simply be defining more than one
SHA-based attribute on an entity/document/row of your database. You can also support general to-many support for files
simply by creating a ref
attribute that refers to a entity/row/document that has a file SHA on it.
Each set of UI rendering controls will have one or more ways of rendering and dealing with file uploads. See the
documentation of the rendering system you’ve chosen to see what comes with it. Of course, you can always render
exactly what you want simply by following Fulcro and RAD documentation.
You can use the blob/upload-file!
function to submit a file for upload processing. The system will automatically
add a status and progress attribute to the in-memory entity in your Fulcro client db.
Assuming this
represents the UI instance that has the file upload field, the call to start an upload is:
(blob/upload-file! this blob-attribute js-file {:file-ident (comp/get-ident this)})
If your blob-attribute
had the keyword :file/sha
then you’d see a :file.sha/progress
and :file.sha/status
appear
on that entity and update as the file upload progresses. Saving the form should then automatically move the file
content (named by SHA) from temporary to permanent storage.
The Storage
protocol defines a blob-url
method. This method is under the control of the implementation, of course,
and may do nothing more than return the SHA you hand it. You are really responsible for hooking RAD up to a binary store
that works for your deployment. The built-in support assumes that you’ll serve the file content through your server
for access control. The provided middleware simply asks the Storage protocol for a stream of the file’s bytes, and
serves them at a URI on your server.
Thus, you might configure your permanent blob store to return the URL /files/<SHA>
, and then configure your Ring middleware
to provide the correct file when asked for /files/<SHA>
. This is what the middleware configuration shown earlier will
do.