6 Forms
(ns oiiku.ui.docs.form
(:require
[oiiku.checkpoints.interface.specs :as checkpoints.specs]
[scicloj.kindly.v4.kind :as kind]))6.1 Form State representation in the Store
There is no form ‘abstraction’, only pure functions working together over a common representation of the Form State. Defining the responsibilities of the various functions is the ‘hard’ part of designing the form system.
Form State is split in two parts in the Store, according to the general principle in Oiiku-admin of splitting domain data from UI data. The ‘domain’ data of form state is stored under [:oiiku/forms <form-id>] in the Store. Actions will manipulate this data. The UI representation of the form state is stored under [:ui/forms <form-id>] in the Store. This representation is generated by prepare-fns with the domain data under [:oiiku/forms <form-id>] as input. View functions will use UI data only.
<form-id> can be a simple identifier (most likely a keyword), or a compound key like [:some/entity <entity-id] if we need to support forms for more than one entity in the Store at any one time.
6.2 Form states
| :form/initial | Clean slate, no user interaction |
| :form/dirty | User has modified form data |
| :form/invalid | Validation errors, either client or server |
| :form/submitting | Request in flight (< 1 second) |
| :form/slow-request | Request in flight (> 1 second, show spinner) |
| :form/submission-failed | Submission failed (server or network error) |
| :form/success | Successfully submitted |
6.3 Form state: :form/initial
When user interaction dictates that a form should be called into existence, the action that does this will create this data representation and put it into the Store at [:ui/forms <form-id>]:
{:form/id :checkpoints/edit-form
:form/entity {:checkpoint/id "..."
:checkpoint/name "..."}
:form/initial-entity {:checkpoint/id "..."
:checkpoint/name "..."}
:form/state :form/initial}#:form{:id :checkpoints/edit-form,
:entity #:checkpoint{:id "...", :name "..."},
:initial-entity #:checkpoint{:id "...", :name "..."},
:state :form/initial}:form/entity has the ‘current’ value of the entity - it will be updated by action handlers as the user interacts with the form. It should always have the data in the canonical domain representation (that is, using rich Clojure data rather than the strings that the DOM API supplies). :form/initial-entity has the initial value of the entity. It will never change. Its purpose is to compare :form/entity against to determine if form is ‘dirty’.
6.4 Form state: :form/dirty
As soon as the user has changed anything in form such that the :form/entity is no longer equal to the entity from which it was copied (which is stored under :form/initial-entity), then the form transitions into the ‘dirty’ state. The transitioning happens in actions that handle user input.
6.5 Prepare
- Define field attrs for Entity attrs based on Entity data
- Set disabled attrs on fields based on state
- Collect errors from :form/errors and apply to fields
6.6 Actions
- input events change the Entity data in the form
- input and submit events change the form state according to the state machine rules.
6.7 Custom widgets: oiiku-actions, oiiku-filters and schema-attr-selector
6.7.1 oiiku-filters domain data
Entities that have filters (for example, Checkpoints), will have filters as a vector of maps, like this:
checkpoints.specs/OiikuFilters[:sequential
[:map
[:schema-attr.filter/public-id :string]
[:schema-attr.filter/type [:enum "is" "is not"]]
[:schema-attr.filter/value [:or :string [:set :string]]]]]When editing filters in a form, prepare-fn will create an attr map for the oiiku-filters form widget:
Note the :on :o-filters-change attr. It updates the Form State entity directly with the domain representation of the filters.
6.8 Form element layout
Some ideas for how view code can be structured:
(comment
;; Alt. 1: Super explicit
[:div {:class #o/tw "flex flex-col gap-8 py-8"}
[widgets/input (get-in edit-form [:form/fields :checkpoint/name])]
[widgets/input-error (get-in edit-form [:form/fields :checkpoint/name])]]
;; Alt. 2: Alias magic - input-wrapper propagates attrs to children
[widgets/input-form-field (get-in edit-form [:form/fields :checkpoint/name])
[widgets/input]
[widgets/input-error]]
;; - can also wrap composite fields
[widgets/form-field-2 (get-in edit-form [:form/fields :checkpoint/name])
[widgets/oiiku-filters]]
;; Alt. 2.1: Default children
[widgets/form-field (get-in edit-form [:form/fields :checkpoint/name])]
;; Alt. 3: Wrapper, input and errors in one, input attrs passed explicitly
[widgets/input-2 {:class #o/tw "flex flex-col gap-8 py-8"
:form/input (get-in edit-form [:form/fields :checkpoint/name])}]
;; Alt. 4: Wrapper with error, explicit attrs
[widgets/error (get-in edit-form [:form/fields :checkpoint/content])
[widgets/textarea (get-in edit-form [:form/fields :checkpoint/content])]]
;; Alt. 6: attrs are passed to input element, wrapper attrs must be specified
;; explicitly.
[widgets/textarea (assoc (get-in edit-form [:form/fields :checkpoint/content])
:widget/wrapper {:class ["wrapper-class"]})]
)source: bases/admin-web/src/oiiku/ui/docs/form.clj