lucywang000 / clj-statecharts

State Machine and StateCharts for Clojure(Script)
https://lucywang000.github.io/clj-statecharts/
Eclipse Public License 1.0
229 stars 15 forks source link

Storing :guard context in the machine #3

Closed dpetranek closed 3 years ago

dpetranek commented 3 years ago

I'm finding this library very useful, but I'm struggling with the best way to handle validation, as for a re-frame form.

Here's my setup:

(defn validate [{:keys [username password]}]
    (-> {}
        (cond-> (empty? username) (assoc :username :required))
        (cond-> (empty? password) (assoc :password :required))
        (not-empty)))

(def simple-sign-in
    (fsm/machine
      {:id :simple-sign-in
       :initial :ready
       :states
       {:ready
        {:on
         {:submit [{:target [:> :ready :error]
                    :guard (fn [_ event] (validate event))
                    ;; TODO: figure out how to not run `validate` twice?
                    :actions (fn [_ event] (println '(rf/dispatch [:form/error (validate event)])))}
                   {:target :submitting
                    :actions (fn [_ event] (println '(rf/dispatch [:form/submit event])))}]}
         :states {:error {}}}
        :submitting {:on {:submit-success {:target :ready
                                           :actions (fn [_ _] (println '(rf/dispatch [:form/success])))}
                          :submit-failure {:target [:> :ready :error]
                                           :actions (fn [_ event] (println '(rf/dispatch [:form/error event])))}}}}}))

  (as-> (fsm/initialize simple-sign-in) $
    (fsm/transition simple-sign-in $ {:type :submit :username "" :password ""} ))

In transitioning from :ready (or [:> :ready :error]), I'm guarding the transition to the :error state by running the validation function. Unfortunately, the :guard throws away its return value, so I have to run it again in the :actions step.

Is there a way to pass the return value of the guard into the machine context? I tried messing about with assign, but doesn't really work because I think you need to return the machine state, which would mean the :guard always passes. Or maybe I'm just barking up the wrong tree, but I can't figure out how to do validation without validating twice.

lucywang000 commented 3 years ago

You're right, guards are pure functions which could not update the context.

For now you can add a "validating" state, which has an entry action that does the validation and stores the error (if any) in the context, and use an eventless transition (:always) that do the transition based on the error.

I think this could be a common requirement, would think about if there is any better way to support such use cases.

dpetranek commented 3 years ago

You know, I tried it out today and I think I like having validation be its own state. Thanks for the guidance, here's what I came up with:

(def simple-sign-in
    (fsm/machine
      {:id :simple-sign-in
       :initial :ready
       :states
       {:ready {:on {:submit :validate}
                :states {:error {:exit (fsm/assign (fn [state _] (assoc state :errors nil)))}}}

        :validate {:entry (fsm/assign (fn [state event] (assoc state :errors (validate event))))
                   :always [{:target [:> :ready :error]
                             :guard (fn [state _] (:errors state))}
                            {:target :submitting}]}

        :submitting {:on {:submit-success {:target :ready
                                           :actions (fn [_ _] (println "dispatching submit success"))}
                          :submit-failure {:target [:> :ready :error]
                                           :actions (fn [_ event] (println "dispatching submit fail" '))}}}}}))

I do think it would be helpful to discuss some "best practices" in the docs, though. Thanks again, I think this library is brilliant!