(ns re-com.input-time (:require-macros [re-com.core :refer [handler-fn]]) (:require [reagent.core :as reagent] [re-com.validate :refer [css-style? html-attr? number-or-string?] :refer-macros [validate-args-macro]] [re-com.text :refer [label]] [re-com.box :refer [h-box gap]] [re-com.util :refer [pad-zero-number deref-or-value]])) (defn- time->mins [time] (rem time 100)) (defn- time->hrs [time] (quot time 100)) (defn- to-int "Parse the string 's' to a valid int. On parse failure, return 0" [s] (let [val (js/parseInt s)] (if (js/isNaN val) 0 val))) (defn- triple->time "Return a time integer from a triple int vector of form [H _ M]" [[hr _ mi]] (+ (* hr 100) mi)) ;; a four digit integer: HHMM ;; This regular expression matchs all valid forms of time entry, including partial ;; forms which happen during user entry. ;; It is composed of 3 'or' options, separated by '|', and within each, is a sub-re which ;; attempts to match the HH ':' MM parts. ;; So any attempt to match against this re, using "re-matches" will return ;; a vector of 10 items: ;; - the 1st item will be the entire string matched ;; - followed by 3 groups of 3. (def ^{:private true} triple-seeking-re #"^(\d{0,2})()()$|^(\d{0,1})(:{0,1})(\d{0,2})$|^(\d{0,2})(:{0,1})(\d{0,2})$") (defn- extract-triple-from-text [text] (->> text (re-matches triple-seeking-re) ;; looks for different ways of matching triples H : M (rest) ;; get rid of the first value. It is the entire matched string. (filter (comp not nil?)))) ;; of the 9 items, there should be only 3 non-nil matches coresponding to H : M (defn- text->time "return as a time int, the contents of 'text'" [text] (->> text extract-triple-from-text (map to-int) ;; make them ints (or 0) triple->time)) ;; turn the triple of values into a single int (defn- time->text "return a string of format HH:MM for 'time'" [time] (let [hrs (time->hrs time) mins (time->mins time)] (str (pad-zero-number hrs 2) ":" (pad-zero-number mins 2)))) (defn- valid-text? "Return true if text passes basic time validation. Can't do to much validation because user input might not be finished. Why? On the way to entering 6:30, you must pass through the invalid state of '63'. So we only really check against the triple-extracting regular expression" [text] (= 3 (count (extract-triple-from-text text)))) (defn- valid-time? [time] (cond (nil? time) false ;; can't be nil (> 0 time) false ;; must be a poistive number (< 60 (time->mins time)) false ;; too many mins :else true)) (defn- validate-arg-times [model minimum maximum] (assert (and (number? model) (valid-time? model)) (str "[input-time] given an invalid :model - " model)) (assert (and (number? minimum) (valid-time? minimum)) (str "[input-time] given an invalid :minimum - " minimum)) (assert (and (number? maximum) (valid-time? maximum)) (str "[input-time] given an invalid :maximum - " maximum)) (assert (<= minimum maximum) (str "[input-time] :minimum " minimum " > :maximum " maximum)) true) (defn- force-valid-time "Validate the time supplied. Return either the time or, if it is invalid, return something valid" [time min max previous] (cond (nil? time) previous (not (valid-time? time)) previous (< time min) min (< max time) max :else time)) (defn- on-new-keypress "Called each time the field gets a keypress, or paste operation. Rests the text-model only if the new text is valid" [event text-model] (let [current-text (-> event .-target .-value)] ;; gets the current input field text (when (valid-text? current-text) (reset! text-model current-text)))) (defn- lose-focus-if-enter "When Enter is pressed, force the component to lose focus" [ev] (when (= (.-keyCode ev) 13) (-> ev .-target .blur) true)) (defn- on-defocus "Called when the field looses focus. Re-validate what has been entered, comparing to mins and maxs. Invoke the callback as necessary" [text-model min max callback previous-val] (let [time (text->time @text-model) time (force-valid-time time min max previous-val)] (reset! text-model (time->text time)) (when (and callback (not= time previous-val)) (callback time)))) (def input-time-args-desc [{:name :model :required true :type "integer | string | atom" :validate-fn number-or-string? :description "a time in integer form. e.g. '09:30am' is 930"} {:name :on-change :required true :type "integer -> nil" :validate-fn fn? :description "called when user entry completes and value is new. Passed new value as integer"} {:name :minimum :required false :default 0 :type "integer | string" :validate-fn number-or-string? :description "user can't enter a time less than this value"} {:name :maximum :required false :default 2359 :type "integer | string" :validate-fn number-or-string? :description "user can't enter a time more than this value"} {:name :disabled? :required false :default false :type "boolean | atom" :description "when true, user input is disabled"} {:name :show-icon? :required false :default false :type "boolean" :description "when true, a clock icon will be displayed to the right of input field"} {:name :hide-border? :required false :default false :type "boolean" :description "when true, input filed is displayed without a border"} {:name :width :required false :type "string" :validate-fn string? :description "standard CSS width setting for width of the input box (excluding the icon if present)"} {:name :height :required false :type "string" :validate-fn string? :description "standard CSS height setting"} {:name :class :required false :type "string" :validate-fn string? :description "CSS class names, space separated"} {:name :style :required false :type "CSS style map" :validate-fn css-style? :description "CSS style. e.g. {:color \"red\" :width \"50px\"}" } {:name :attr :required false :type "HTML attr map" :validate-fn html-attr? :description [:span "HTML attributes, like " [:code ":on-mouse-move"] [:br] "No " [:code ":class"] " or " [:code ":style"] "allowed"]}]) (defn input-time "I return the markup for an input box which will accept and validate times. Parameters - refer input-time-args above" [& {:keys [model minimum maximum on-change class style attr] :as args :or {minimum 0 maximum 2359}}] {:pre [(validate-args-macro input-time-args-desc args "input-time") (validate-arg-times (deref-or-value model) minimum maximum)]} (let [deref-model (deref-or-value model) text-model (reagent/atom (time->text deref-model)) previous-model (reagent/atom deref-model)] (fn [& {:keys [model minimum maximum width height disabled? hide-border? show-icon?] :as args :or {minimum 0 maximum 2359}}] {:pre [(validate-args-macro input-time-args-desc args "input-time") (validate-arg-times (deref-or-value model) minimum maximum)]} (let [style (merge (when hide-border? {:border "none"}) style) new-val (deref-or-value model) new-val (if (< new-val minimum) minimum new-val) new-val (if (> new-val maximum) maximum new-val)] ;; if the model is different to that currently shown in text, then reset the text to match ;; other than that we want to keep the current text, because the user is probably typing (when (not= @previous-model new-val) (reset! text-model (time->text new-val)) (reset! previous-model new-val)) [h-box :class "rc-input-time" :style (merge {:height height} style) :children [[:input (merge {:type "text" :class (str "time-entry " class) :style (merge {:width width} style) :value @text-model :disabled (deref-or-value disabled?) :on-change (handler-fn (on-new-keypress event text-model)) :on-blur (handler-fn (on-defocus text-model minimum maximum on-change @previous-model)) :on-key-up (handler-fn (lose-focus-if-enter event))} attr)] (when show-icon? #_[:div.time-icon ;; TODO: Remove [:span.glyphicon.glyphicon-time {:style {:position "static" :margin "auto"}}]] [:div.time-icon [:i.zmdi.zmdi-hc-fw-rc.zmdi-time {:style {:position "static" :margin "auto"}}]])]]))))