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 bare 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]
{fo/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 as 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]
{fo/id address/id
fo/attributes [address/street address/city address/state address/zip]
fo/cancel-route ["landing-page"]
fo/route-prefix "address"
fo/title "Edit Address"})
(form/defsc-form AccountForm [this props]
{fo/id acct/id
fo/attributes [acct/name acct/email acct/active? acct/addresses]
fo/cancel-route ["landing-page"]
fo/route-prefix "account"
fo/title "Edit Account"
fo/subforms {:account/addresses {fo/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 fo/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:
fo/default-value
-
Can be placed on an attribute to indicate a default value for this attribute.
fo/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 model 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
fo/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]
{fo/id address/id
fo/attributes [address/street address/city address/state address/zip]
fo/cancel-route ["landing-page"]
fo/route-prefix "address"})
The AccountForm
would then simply use that AddressForm
in a subform definition like so:
(form/defsc-form AccountForm [this props]
{fo/id acct/id
fo/attributes [acct/name acct/addresses]
fo/cancel-route ["landing-page"]
fo/route-prefix "account"
fo/subforms {:account/addresses {fo/ui AddressForm
fo/can-delete-row? (fn [parent item] (< 1 (count (:account/addresses parent))))
fo/can-add-row? (fn [parent] (< (count (:account/addresses parent)) 2))}}})
Here the subform information for the :account/addresses
field indicates:
-
fo/ui
- The UI component to use for editing the target(s).
-
fo/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.
-
fo/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, 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]
{fo/id line-item/id
fo/attributes [line-item/item line-item/quantity line-item/quoted-price line-item/subtotal]
fo/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]
{fo/id invoice/id
fo/attributes [invoice/customer invoice/date invoice/line-items invoice/total]
...
fo/subforms {:invoice/line-items {fo/ui LineItemForm}}
fo/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 fo/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 sense 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.