(ns auto-ap.ssr.components.multi-modal (:require [auto-ap.cursor :as cursor] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.components :as com] [auto-ap.ssr.components.timeline :as timeline] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils :refer [assert-schema html-response main-transformer modal-response wrap-form-4xx-2 wrap-schema-enforce]] [bidi.bidi :as bidi] [hiccup.util :as hu] [malli.core :as mc] [malli.core :as m])) (def default-form-props {:hx-ext "response-targets" :hx-swap "outerHTML" :hx-target-400 "#form-errors .error-content" :hx-trigger "submit" :hx-target "this" "x-trap" "true"}) (defprotocol ModalWizardStep (step-key [this]) (edit-path [this request]) (render-step [this request]) (step-schema [this]) (step-name [this])) (defprotocol Initializable (init-step-params [this multi-form-state request])) (defprotocol Discardable (can-discard? [this step-params]) (discard-changes [this request])) (defprotocol LinearModalWizard (hydrate-from-request [this request]) (get-current-step [this]) (navigate [this step-key]) (form-schema [this]) (steps [this]) (get-step [this step-key]) (render-wizard [this request]) (submit [this request])) (defrecord MultiStepFormState [snapshot edit-path step-params]) (defn select-state [multi-form-state edit-path default] (->MultiStepFormState (:snapshot multi-form-state) edit-path (or (get-in (:snapshot multi-form-state) edit-path) default))) (defn merge-multi-form-state [{:keys [snapshot edit-path step-params] :as multi-form-state}] (let [cursor (cursor/cursor (or snapshot {})) ;; this hack makes sure that, in the event of a missing vector entry, will make sure to add it first edit-cursor (cond-> cursor (seq edit-path) (cursor/ensure-path! edit-path {}) (seq edit-path) (get-in edit-path {})) _ (cursor/transact! edit-cursor (fn [spot] (merge spot step-params)))] (assoc multi-form-state :snapshot @cursor :edit-path [] :step-params @cursor))) (def step-key-schema (mc/schema [:orn {:decode/arbitrary clojure.edn/read-string :encode/arbitrary pr-str} [:sub-step [:cat :keyword [:or :int :string]]] [:step :keyword]])) (def encode-step-key (m/-instrument {:schema [:=> [:cat step-key-schema] :any]} (fn encode-step-key [sk] (mc/encode step-key-schema sk main-transformer)))) (defn render-timeline [linear-wizard current-step validation-route] (let [step-names (map #(step-name (get-step linear-wizard %)) (steps linear-wizard)) active-index (.indexOf step-names (step-name current-step))] (timeline/vertical-timeline {} (for [[n i] (map vector (steps linear-wizard) (range))] (timeline/vertical-timeline-step (cond-> {} (= i active-index) (assoc :active? true) (< i active-index) (assoc :visited? true) (= i (dec (count step-names))) (assoc :last? true)) [:a.cursor-pointer.whitespace-nowrap {:x-data (hx/json {:timelineIndex i}) :hx-put (hu/url (bidi/path-for ssr-routes/only-routes validation-route) {:from (encode-step-key (step-key current-step)) :to (encode-step-key (step-key (get-step linear-wizard n)))})} (step-name (get-step linear-wizard n))]))))) (defn back-button [linear-wizard step validation-route] [:a.cursor-pointer.whitespace-nowrap.font-medium.text-blue-600 {:hx-put (hu/url (bidi/path-for ssr-routes/only-routes validation-route) {:from (encode-step-key (step-key step)) :to (encode-step-key (->> (partition-all 2 1 (steps linear-wizard)) (filter (fn [[from to]] (= to (step-key step)))) ffirst))}) :class "dark:text-blue-500"} "Back"]) (defn default-next-button [linear-wizard step validation-route] (let [steps (steps linear-wizard) last? (= (step-key step) (last steps)) next-step (when-not last? (->> steps (drop-while #(not= (step-key step) %)) (drop 1) first (get-step linear-wizard)))] (com/validated-save-button (cond-> {:errors (seq fc/*form-errors*) ;;:x-data (hx/json {}) :x-ref "next" :class "w-48"} (not last?) (assoc :hx-put (hu/url (bidi/path-for ssr-routes/only-routes validation-route) {:from (encode-step-key (step-key step)) :to (encode-step-key (step-key next-step))}))) (if next-step (step-name next-step) "Save") (when-not last? [:div.w-5.h-5 svg/arrow-right])))) (defn default-step-body [params & children] [:div.space-y-1 {} children]) (defn default-step-footer [linear-wizard step & {:keys [validation-route discard-button next-button]}] [:div.flex.justify-end [:div.flex.items-baseline.gap-x-4 (com/form-errors {:errors (:errors (:step-params fc/*form-errors*))}) (when (not= (first (steps linear-wizard)) (step-key step)) (when validation-route (back-button linear-wizard step validation-route))) (when (and (satisfies? Discardable step) (can-discard? step @fc/*current*)) discard-button) (cond next-button next-button validation-route (default-next-button linear-wizard step validation-route) :else [:div "No action possible."])]]) (defn default-render-step [linear-wizard step & {:keys [head body footer validation-route discard-route]}] (let [is-last? (= (step-key step) (last (steps linear-wizard)))] (com/modal-card-advanced {"@keydown.enter.prevent.stop" "$refs.next.click()" :class (str "w-full h-full md:w-[750px] md:h-[600px] group-[.forward]/transition:htmx-swapping:opacity-0 group-[.forward]/transition:htmx-swapping:-translate-x-1/4 group-[.forward]/transition:htmx-swapping:scale-75 group-[.forward]/transition:htmx-swapping:ease-in group-[.forward]/transition:htmx-added:opacity-0 group-[.forward]/transition:htmx-added:scale-75 group-[.forward]/transition:htmx-added:translate-x-1/4 group-[.forward]/transition:htmx-added:ease-out group-[.backward]/transition:htmx-swapping:opacity-0 group-[.backward]/transition:htmx-swapping:translate-x-1/4 group-[.backward]/transition:htmx-swapping:scale-75 group-[.backward]/transition:htmx-swapping:ease-in group-[.backward]/transition:htmx-added:opacity-0 group-[.backward]/transition:htmx-added:scale-75 group-[.backward]/transition:htmx-added:-translate-x-1/4 group-[.backward]/transition:htmx-added:ease-out opacity-100 translate-x-0 scale-100" (when is-last? "last-modal-step") " transition duration-150 ") #_#_":class" (hiccup/raw "{ \"htmx-added:opacity-0 opacity-100\": $data.transitionType=='forward', \"htmx-swapping:translate-x-2/3 htmx-swapping:opacity-0 htmx-swapping:scale-0 htmx-added:-translate-x-2/3 htmx-added:opacity-0 htmx-added:scale-0 scale-100 translate-x-0 opacity-100\": $data.transitionType=='backward' } ") "x-data" ""} (com/modal-header {} head) #_(com/modal-header-attachment {}) [:div.flex.shrink.overflow-auto.grow [:div.grow-0.pr-6.pt-2.bg-gray-100.self-stretch.hidden.md:block #_{:style "margin-left:-20px"} (render-timeline linear-wizard step validation-route)] (com/modal-body {} body)] (com/modal-footer {} footer)))) (defn wrap-ensure-step [handler] (-> (fn [{:keys [wizard multi-form-state] :as request}] (assert-schema (step-schema (get-current-step wizard)) (:step-params multi-form-state)) (handler request)) (wrap-form-4xx-2 (fn [{:keys [wizard] :as request}] ;; THIS MAY BE BETTER TO JUST MAKE THE LINEAR WIZARD POPULATE FROM THE REQUEST (html-response (render-wizard wizard request) :headers {"x-transition-type" "none" "HX-reswap" "outerHTML"}))))) (defn get-transition-type [wizard from-step-key to-step-key] (let [to-step-index (.indexOf (steps wizard) to-step-key) from-step-index (.indexOf (steps wizard) from-step-key)] (cond (= -1 to-step-index) nil (= -1 from-step-index) nil (= from-step-index to-step-index) nil (> from-step-index to-step-index) "backward" :else "forward"))) (defn render-navigate [{ {:keys [wizard] :as request} :request to-step :to-step}] (let [current-step (get-current-step wizard) wizard (navigate wizard to-step) new-step (get-current-step wizard) transition-type (get-transition-type wizard (step-key current-step) to-step)] (html-response (render-wizard wizard (-> request (assoc :multi-form-state (-> (:multi-form-state request) (merge-multi-form-state) (select-state (edit-path new-step request) {}) (#(cond-> % (satisfies? Initializable new-step) (assoc :step-params (init-step-params new-step % request)))))))) :headers {"HX-reswap" (when transition-type "outerHTML swap:0.16s") "x-transition-type" (or transition-type "none")}))) (def next-handler (-> (fn [{:keys [wizard] :as request}] (render-navigate {:request request :to-step (:to (:query-params request))})) (wrap-ensure-step) (wrap-schema-enforce :query-schema [:map [:to step-key-schema]]))) (def discard-handler (-> (fn [{:keys [wizard multi-form-state] :as request}] (let [current-step (get-current-step wizard) to-step (:to (:query-params request)) wizard (navigate wizard to-step) transition-type (get-transition-type wizard (step-key current-step) to-step)] (html-response (render-wizard wizard (-> request (assoc :multi-form-state (discard-changes current-step multi-form-state)))) :headers {"HX-reswap" (when transition-type "outerHTML swap:0.16s") "x-transition-type" (or transition-type "none")}))) (wrap-schema-enforce :query-schema [:map [:to step-key-schema]]))) (def submit-handler (-> (fn [{:keys [wizard multi-form-state] :as request}] (submit wizard (-> request (assoc :multi-form-state (merge-multi-form-state multi-form-state))))) (wrap-ensure-step))) (defn default-render-wizard [linear-wizard {:keys [multi-form-state form-errors snapshot current-step] :as request} & {:keys [form-params]}] (let [current-step (get-current-step linear-wizard) edit-path (edit-path current-step request)] [:form#wizard-form form-params (fc/start-form multi-form-state (when form-errors {:step-params form-errors}) (list (fc/with-field :snapshot (com/hidden {:name (fc/field-name) :value (pr-str (fc/field-value))})) (fc/with-field :edit-path (com/hidden {:name (fc/field-name) :value (pr-str (or edit-path []))})) (com/hidden {:name "current-step" :value (pr-str (step-key current-step))}) (fc/with-field :step-params (com/modal {:id "wizardmodal"} (render-step current-step request)))))])) (defn wrap-wizard [handler linear-wizard] (fn [request] (let [current-step-key (if-let [current-step (get (:form-params request) "current-step")] (mc/decode step-key-schema current-step main-transformer) (first (steps linear-wizard))) current-step (get-step linear-wizard current-step-key) multi-form-state (-> (:multi-form-state request) (update :snapshot (fn [snapshot] (mc/decode (form-schema linear-wizard) snapshot main-transformer))) (update :step-params (fn [step-params] (or (mc/decode (step-schema current-step) step-params main-transformer) {} ;; Todo add a defaultable )))) request (-> request (assoc :multi-form-state multi-form-state)) linear-wizard (navigate linear-wizard current-step-key)] (handler (assoc request :wizard (hydrate-from-request linear-wizard request)))))) (defn open-wizard-handler [{:keys [wizard current-step] :as request}] (modal-response [:div#transitioner.flex-1 {:x-data (hx/json {"transitionType" "none"}) :x-ref "transitioner" :class "" "@htmx:after-request" "if(event.detail.xhr.getResponseHeader('x-transition-type')) { $refs.transitioner.classList.remove('forward') $refs.transitioner.classList.remove('backward'); $refs.transitioner.classList.add('group/transition') $refs.transitioner.classList.add(event.detail.xhr.getResponseHeader('x-transition-type')); } else { $refs.transitioner.classList.remove('group/transition') } "} (render-wizard wizard request)])) (defn wrap-init-multi-form-state [handler get-multi-form-state] (-> (fn init-multi-form [request] (handler (assoc request :multi-form-state (get-multi-form-state request)))) (wrap-nested-form-params))) (defn wrap-decode-multi-form-state [handler] (wrap-init-multi-form-state handler (fn parse-multi-form-state [request] (map->MultiStepFormState (mc/decode [:map [:snapshot {:optional true :decode/arbitrary #(clojure.edn/read-string {:readers clj-time.coerce/data-readers :eof nil} %)} [:maybe :any]] [:edit-path {:optional true :decode/arbitrary (fn [z] (clojure.edn/read-string z))} [:maybe [:sequential {:min 0} any?]]] [:step-params {:optional true} [:maybe :any]]] (:form-params request) main-transformer))))) #_(comment (def f {"snapshot" "{:invoices [{:invoice_id 17592297837035, :amount 23.0, :invoice {:db/id 17592297837035, :invoice/vendor {:db/id 17592186045722, :vendor/name \"Sysco\"}, :invoice/client {:db/id 17592232555238}, :invoice/outstanding-balance 23.0, :invoice/invoice-number \"702,34\"}} {:invoice_id 17592297837049, :amount 23.0, :invoice {:db/id 17592297837049, :invoice/vendor {:db/id 17592186045722, :vendor/name \"Sysco\"}, :invoice/client {:db/id 17592232555238}, :invoice/outstanding-balance 23.0, :invoice/invoice-number \"80[234234\"}}], :client 17592232555238}", "edit-path" "[]", "current-step" ":payment-details", "mode" "advanced", "step-params" {"invoices" {"0" {"invoice_id" "17592297837035", "amount" "1"}, "1" {"invoice_id" "17592297837049", "amount" "23.00"}}}}) (mc/decode [:map [:step-params {:optional true} [:maybe :any]]] f main-transformer))