Gift list dev diary: gift list form and listing
All development for this project is shared on github at https://github.com/codonnell/mygiftlist-blog. I will endeavor to focus on the most relevant snippets of code rather than go over all of them on this blog. For interested parties, the github repo is available to survey all of the minor details.
In this post we’ll give users the ability to create gift lists and add a temporary listing of a user’s gift lists. The post will focus on the form management aspect of this work rather than the backend implementation, which is similar to the user creation implementation from this post. The form we will build is relatively simple; we’re only scratching the surface of the capabilities of fulcro’s form helpers. The excellent documentation in the fulcro book explains the capabilities more fully. We will also discuss the implementation of a listing of the gift lists created by the user. We won’t cover all the changes, but you can find them in this commit. Edit (2020/07/03): The original gift list UX was incorrect, and was fixed by this commit.
Gift list form
When creating a gift list, we will generate a uuid id value in the client. This way we can structure our mutation so that it is idempotent. There is a bit more discussion of the tradeoffs here. To understand this code, it’s helpful to be familiar with fulcro’s form state.
First let’s write a small helper we can use to initialize our form state and to reset it after submission.
(ns rocks.mygiftlist.ui.gift-list
(:require
[com.fulcrologic.fulcro.algorithms.form-state :as fs]
[com.fulcrologic.fulcro.algorithms.merge :as merge]
[com.fulcrologic.fulcro.components :as comp :refer [defsc]]
[com.fulcrologic.fulcro.dom :as dom]
[com.fulcrologic.fulcro.mutations :as m]
[com.fulcrologic.semantic-ui.elements.button.ui-button :refer [ui-button]]
[com.fulcrologic.semantic-ui.collections.form.ui-form :refer [ui-form]]
[com.fulcrologic.semantic-ui.collections.form.ui-form-input :refer [ui-form-input]]
[rocks.mygiftlist.type.gift-list :as gift-list]
[rocks.mygiftlist.model.gift-list :as m.gift-list]))
(declare GiftListForm)
(defn- pristine-gift-list-form-state []
(fs/add-form-config
GiftListForm
#::gift-list {:id (random-uuid)
:name ""}))
Before we build the form component, let’s figure out how it should behave. First, there will only be one input, the gift list’s name. Gift list names should not be blank, so we need to build in some validation for that. The input shouldn’t be marked invalid until the user has typed into it. The user should not be able to submit the form unless it is valid, but the submission button should only display as disabled if validation has failed. Getting that validation logic correct can be tricky, but fulcro’s form-state makes it much easier.
This is what our form component looks like:
(defsc GiftListForm [this
{::gift-list/keys [name] :as gift-list}
{:keys [reset-form!]}]
{:ident ::gift-list/id
:query [::gift-list/id ::gift-list/name fs/form-config-join]
:initial-state (fn [_] (pristine-gift-list-form-state))
:form-fields #{::gift-list/name}}
(let [validity (fs/get-spec-validity gift-list ::gift-list/name)]
(ui-form
{:onSubmit (fn [_]
(if (= :valid validity)
(do
(comp/transact! this
[(m.gift-list/create-gift-list
(select-keys gift-list
[::gift-list/id ::gift-list/name]))])
(reset-form!))
(comp/transact! this
[(fs/mark-complete! {})])))}
(ui-form-input
{:placeholder "Birthday 2020"
:onChange (fn [evt]
(when (= :unchecked validity)
(comp/transact! this
[(fs/mark-complete!
{:field ::gift-list/name})]))
(m/set-string! this ::gift-list/name :event evt))
:error (when (= :invalid validity)
"Gift list name cannot be blank")
:autoFocus true
:fluid true
:value name})
(ui-button
{:type "submit"
:primary true
:disabled (= :invalid validity)}
"Submit"))))
The workhorses here are fs/mark-complete!
and fs/get-spec-validity
. Each of these uses state introduced by the addition of fs/form-config-join
to the component’s query. We use fs/mark-complete!
to mark that the input is ready for validation (after the user has typed in the input). Then fs/get-spec-validity
uses this readiness along with the spec for our gift list name, which rejects blank strings, to tell us if the input is not yet ready for validation, valid, or invalid.
Also worth noting is the use of a reset-form!
callback via computed props. We pass this callback via computed rather than normal props to make sure the parent component rerenders when the form is reset. That is pretty much the only purpose of the parent component:
(defsc GiftListFormPanel [this {:ui/keys [gift-list-form]}]
{:ident (fn [] [:component/id :gift-list-form-panel])
:query [{:ui/gift-list-form (comp/get-query GiftListForm)}]
:initial-state {:ui/gift-list-form {}}}
(dom/div {}
(ui-gift-list-form
(comp/computed gift-list-form
{:reset-form! #(merge/merge-component! this GiftListFormPanel
{:ui/gift-list-form
(pristine-gift-list-form-state)})}))))
List the lists
In order to prove to ourselves that we are successfully creating gift lists, we’ll create a temporary listing component. Since the component is temporary, a simple unordered list will do.
(defsc CreatedGiftList [_this {::gift-list/keys [name]}]
{:ident ::gift-list/id
:query [::gift-list/id ::gift-list/name]}
(dom/li {} name))
(defsc CreatedGiftLists [_this {:keys [created-gift-lists]}]
{:ident (fn [] [:component/id :created-gift-lists])
:query [{:created-gift-lists (comp/get-query CreatedGiftList)}]
:initial-state {:created-gift-lists []}}
(dom/ul {}
(mapv ui-created-gift-list created-gift-lists)))
I like to tie data fetching to routing, so we’ll add a call that loads the created gift list data in the :will-enter
for our Home
component. This way when the user navigates to the home page, we’ll fetch the created gift lists.
:will-enter (fn [app _]
(df/load! app [:component/id :created-gift-lists]
ui.gift-list/CreatedGiftLists)
(dr/route-immediate [:component/id :home]))
Listing resolvers
The backend needs to be able to provide the ids and names of the gift lists created by the user. Rather than do this with a single resolver, we’ll create two. The first, created-gift-lists
, identifies which gift lists are created by the user and the second, gift-list-by-id
, fetches data about these gift lists. The gift-list-by-id
resolver will serve as a general purpose resolver for fetching gift list data that we can reuse in the future. Using a general purpose resolver like this helps to simplify authorization–there’s only one resolver we need to test to verify correctness.
The created-gift-lists
resolver just returns the ids of gift lists created by the requester.
(defresolver created-gift-lists
[{::db/keys [pool] :keys [requester-id]} _]
{::pc/input #{}
::pc/output [{:created-gift-lists [::gift-list/id]}]}
{:created-gift-lists
(db/execute! pool
{:select [:gl.id]
:from [[:gift_list :gl]]
:where [:= requester-id :gl.created_by_id]
:order-by [:gl.created_at]})})
We have a new consideration to make when writing gift-list-by-id
. If we write it as we have our previous resolvers, we’ll need to make one database query for each gift list fetched by the query. This is known as the n+1 query problem, and it is very common in GraphQL servers and ORMs. Pathom’s solution to this problem is batch resolvers, and so we’ll write gift-list-by-id
as a batch resolver.
(defresolver gift-list-by-id
[{::db/keys [pool] :keys [requester-id]} inputs]
{::pc/input #{::gift-list/id}
::pc/output [::gift-list/id ::gift-list/name ::gift-list/created-at
{::gift-list/created-by [::user/id]}]
::pc/transform pc/transform-batch-resolver}
(->> {:select [:gl.id :gl.name :gl.created_at :gl.created_by_id]
:from [[:gift_list :gl]]
:where [:and
[:= requester-id :gl.created_by_id]
[:in :gl.id (mapv ::gift-list/id inputs)]]}
(db/execute! pool)
(mapv (fn [{::gift-list/keys [created-by-id] :as gift-list}]
(-> gift-list
(assoc-in [::gift-list/created-by ::user/id] created-by-id)
(dissoc ::gift-list/created-by-id))))
(pc/batch-restore-sort {::pc/inputs inputs ::pc/key ::gift-list/id})))
That’s all! We have our gift list form and listing components working, so users can create gift lists and see what they’ve created.