├── .gitignore ├── CHANGES.md ├── README.md ├── dev ├── public │ └── index.html └── reagent_forms │ ├── app.cljs │ └── page.cljs ├── example.html ├── forms-example ├── project.clj ├── resources │ ├── public │ │ └── css │ │ │ └── screen.css │ └── templates │ │ └── app.html ├── src-cljs │ └── forms_example │ │ └── core.cljs └── src │ └── forms_example │ └── handler.clj ├── project.clj ├── resources └── reagent-forms.css ├── src └── reagent_forms │ ├── core.cljs │ ├── datepicker.cljs │ └── macros.cljc └── test └── reagent_forms ├── core_test.cljs └── tests_runner.cljs /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | pom.xml.asc 3 | *jar 4 | /lib/ 5 | /classes/ 6 | /target/ 7 | /checkouts/ 8 | .lein-deps-sum 9 | .lein-repl-history 10 | .lein-plugins/ 11 | .lein-failures 12 | /resources/public/js/ 13 | /forms-example/resources/public/js/ 14 | /forms-example/target/ 15 | *.iml 16 | .idea 17 | /out 18 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ### 0.5.43 2 | 3 | updated to pass the id of the widget to the events, previously the event handlers had follwing signature: 4 | 5 | ```clojure 6 | (fn [path value document] ...) 7 | ``` 8 | 9 | new API accepts an additional argument containing the id specified using the `:id` key on the form element: 10 | 11 | ```clojure 12 | (fn [id path value document] ...) 13 | ``` 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # reagent-forms 2 | 3 | A ClojureScript library to provide form data bindings for [Reagent](http://holmsand.github.io/reagent/), see [here](http://yogthos.github.io/reagent-forms-example.html) for a live demo. 4 | 5 | ## Table of contents 6 | 7 | - [Install](#install) 8 | - [Usage](#usage) 9 | - [:input](#input) 10 | - [:typeahead](#typeahead) 11 | - [:checkbox](#checkbox) 12 | - [:range](#range) 13 | - [:radio](#radio) 14 | - [:file](#file) 15 | - [:files](#files) 16 | - [Lists](#lists) 17 | - [:list](#list) 18 | - [:single-select](#single-select) 19 | - [:multi-select](#multi-select) 20 | - [:label](#label) 21 | - [:alert](#alert) 22 | - [:datepicker](#datepicker) 23 | - [:container](#container) 24 | - [Validation](#validation) 25 | - [Setting component visibility](#setting-component-visibility) 26 | - [Updating attributes](#updating-attributes) 27 | - [Binding the form to a document](#binding-the-form-to-a-document) 28 | - [Adding events](#adding-events) 29 | - [Using with re-frame](#using-with-re-frame) 30 | - [Adding custom fields](#adding-custom-fields) 31 | - [Using adapters](#using-adapters) 32 | - [Mobile Gotchas](#mobile-gotchas) 33 | - [Testing](#testing) 34 | - [License](#license) 35 | 36 | ## Install 37 | 38 | [![Clojars Project](https://img.shields.io/clojars/v/reagent-forms.svg)](https://clojars.org/reagent-forms) 39 | 40 | ## Usage 41 | 42 | The library uses a Reagent atom as the document store. The components are bound to the document using the `:field` attribute. This key will be used to decide how the specific type of component should be bound. The component must also provide a unique `:id` attribute that is used to correlate it to the document. While the library is geared towards usage with Twitter Bootstrap, it is fairly agnostic about the types of components that you create. 43 | 44 | The `:id` can be a keyword, e.g: `{:id :foo}`, or a keywordized path `{:id :foo.bar}` that will map to `{:foo {:bar "value"}}`. Alternatively, you can specify a vector path explicitly `[:foo 0 :bar]`. 45 | 46 | By default the component value is that of the document field, however all components support an `:in-fn` and `:out-fn` function attributes. 47 | `:in-fn` accepts the current document value and returns what is to be displayed in the component. `:out-fn` accepts the component value 48 | and returns what is to be stored in the document. 49 | 50 | The following types of fields are supported out of the box: 51 | 52 | #### :input 53 | 54 | An input field can be of type `:text`, `:numeric`, `:range`, `:password`, `:email`, and `:textarea`. The inputs behave just like regular HTML inputs and update the document state when the `:on-change` event is triggered. 55 | 56 | ```clojure 57 | [:input.form-control {:field :text :id :first-name}] 58 | [:input.form-control {:field :numeric :id :age}] 59 | ``` 60 | 61 | The input fields can have an optional `:fmt` attribute that can provide a format string for the value: 62 | 63 | ```clojure 64 | [:input.form-control 65 | {:field :numeric :fmt "%.2f" :id :bmi :disabled true}] 66 | ``` 67 | 68 | Numeric inputs support attributes for the HTML 5 number input: 69 | 70 | ```clojure 71 | [:input 72 | {:field :numeric 73 | :id :volume 74 | :fmt "%.2f" 75 | :step "0.1" 76 | :min 0 77 | :max 10}] 78 | ``` 79 | 80 | #### :typeahead 81 | 82 | The typeahead field uses a `:data-source` key bound to a function that takes the current input and returns a list of matching results. The control uses an input element to handle user input and renders the list of choices as an unordered list element containing one or more list item elements. Users may specify the css classes used to render each of these elements using the keys :input-class, :list-class and :item-class. Users may additionally specify a css class to handle highlighting of the current selection with the :highlight-class key. Reference css classes are included in the resources/public/css/reagent-forms.css file. 83 | 84 | ```clojure 85 | (defn friend-source [text] 86 | (filter 87 | #(-> % (.toLowerCase %) (.indexOf text) (> -1)) 88 | ["Alice" "Alan" "Bob" "Beth" "Jim" "Jane" "Kim" "Rob" "Zoe"])) 89 | 90 | [:div {:field :typeahead 91 | :id :ta 92 | :input-placeholder "pick a friend" 93 | :data-source friend-source 94 | :input-class "form-control" 95 | :list-class "typeahead-list" 96 | :item-class "typeahead-item" 97 | :highlight-class "highlighted"}] 98 | ``` 99 | 100 | The typeahead field supports both mouse and keyboard selection. 101 | 102 | ##### Different display and value 103 | 104 | You can make the input's displayed value be different to the value stored in the document. You need to specify `:out-fn`, a `:result-fn` and 105 | optionally `:in-fn`. The `:data-source` needs to return a vector `[display-value stored-value]`. 106 | 107 | ```clojure 108 | (defn people-source [people] 109 | (fn [text] 110 | (->> people 111 | (filter #(-> (:name %) 112 | (.toLowerCase) 113 | (.indexOf text) 114 | (> -1))) 115 | (mapv #(vector (:name %) (:num %)))))) 116 | 117 | [:div {:field :typeahead 118 | :data-source (people-source people) 119 | :in-fn (fn [num] 120 | [(:name (first (filter #(= num (:num %)) people))) num]) 121 | :out-fn (fn [[name num]] num) 122 | :result-fn (fn [[name num]] name) 123 | :id :author.num}]]] 124 | ``` 125 | 126 | ##### Pop down the list 127 | 128 | If `:data-source` responds with the full option list when passed the keyword `:all` then the down-arrow key will show the list. 129 | 130 | ##### Selection list from Ajax 131 | 132 | The `:selections` attribute can be specified to pass an atom used to hold the selections. This gives the option to fetch the 133 | list using typeahead text - if an ajax response handler sets the atom the list will pop down. 134 | 135 | ##### Display selection on pop-down 136 | 137 | If supplied, the `:get-index` function will ensure the selected item is highlighted when the list is popped down. 138 | 139 | A full example is available in the source code for the demonstration page. 140 | 141 | #### :checkbox 142 | 143 | The checkbox field creates a checkbox element: 144 | 145 | ```clojure 146 | [:div.row 147 | [:div.col-md-2 "does data binding make you happy?"] 148 | [:div.col-md-5 149 | [:input.form-control {:field :checkbox :id :happy-bindings}]]] 150 | ``` 151 | 152 | The checkbox accepts an optional `:checked` attribute. When set the 153 | checkbox will be selected and the document path pointed to by the `:id` 154 | key will be set to `true`. 155 | 156 | ```clojure 157 | [:div.row 158 | [:div.col-md-2 "does data binding make you happy?"] 159 | [:div.col-md-5 160 | [:input.form-control {:field :checkbox :id :happy-bindings :checked true}]]] 161 | ``` 162 | 163 | #### :range 164 | 165 | Range control uses the `:min` and `:max` keys to create an HTML range input: 166 | 167 | ```clojure 168 | [:input.form-control 169 | {:field :range :min 10 :max 100 :id :some-range}] 170 | ``` 171 | 172 | #### :radio 173 | 174 | Radio buttons do not use the `:id` key since it must be unique and are instead grouped using the `:name` attribute. The `:value` attribute is used to indicate the value that is saved to the document: 175 | 176 | ```clojure 177 | [:input {:field :radio :value :a :name :radioselection}] 178 | [:input {:field :radio :value :b :name :radioselection}] 179 | [:input {:field :radio :value :c :name :radioselection}] 180 | ``` 181 | 182 | The radio button accepts an optional `:checked` attribute. When set the 183 | checkbox will be selected and the document path pointed to by the `:name` key 184 | will be set to `true`. 185 | 186 | ```clojure 187 | [:input {:field :radio :value :a :name :radioselection}] 188 | [:input {:field :radio :value :b :name :radioselection :checked true}] 189 | [:input {:field :radio :value :c :name :radioselection}] 190 | ``` 191 | 192 | #### :file 193 | 194 | The file field binds the `File` object of an ``. 195 | 196 | ```clojure 197 | [:input {:field :file :type :file}] 198 | ``` 199 | 200 | #### :files 201 | 202 | Same as file, except it works with `` and binds the entire `FileList` object. 203 | 204 | ```clojure 205 | [:input {:field :files :type :file :multiple true}] 206 | ``` 207 | 208 | ### Lists 209 | 210 | List fields contain child elements whose values are populated in the document when they are selected. The child elements must each have a `:key` attribute pointing to the value that will be saved in the document. The value of the element must be a keyword. 211 | 212 | The elements can have an optional `:visible?` keyword that points to a predicate function. The function should accept the document and return a boolean value indicatiing whether the field should be shown. 213 | 214 | #### :list 215 | 216 | The `:list` field is used for creating HTML `select` elements containing `option` child elements: 217 | 218 | ```clojure 219 | [:select.form-control {:field :list :id :many-options} 220 | [:option {:key :foo} "foo"] 221 | [:option {:key :bar} "bar"] 222 | [:option {:key :baz} "baz"]] 223 | 224 | (def months 225 | ["January" "February" "March" "April" "May" "June" 226 | "July" "August" "September" "October" "November" "December"]) 227 | 228 | [:select {:field :list :id :dob.day} 229 | (for [i (range 1 32)] 230 | [:option 231 | {:key (keyword (str i)) 232 | :visible? #(let [month (get-in % [:dob :month])] 233 | (cond 234 | (< i 29) true 235 | (< i 31) (not= month :February) 236 | (= i 31) (some #{month} [:January :March :May :July :August :October :December]) 237 | :else false))} 238 | i])] 239 | [:select {:field :list :id :dob.month} 240 | (for [month months] 241 | [:option {:key (keyword month)} month])] 242 | [:select {:field :list :id :dob.year} 243 | (for [i (range 1950 (inc (.getFullYear (js/Date.))))] 244 | [:option {:key (keyword (str i))} i])] 245 | ``` 246 | 247 | 248 | #### :single-select 249 | 250 | The single-select field behaves like the list, but supports different types of elements and allows the fields to be deselected: 251 | 252 | ```clojure 253 | [:h3 "single-select buttons"] 254 | [:div.btn-group {:field :single-select :id :unique-position} 255 | [:button.btn.btn-default {:key :left} "Left"] 256 | [:button.btn.btn-default {:key :middle} "Middle"] 257 | [:button.btn.btn-default {:key :right} "Right"]] 258 | 259 | [:h3 "single-select list"] 260 | [:ul.list-group {:field :single-select :id :pick-one} 261 | [:li.list-group-item {:key :foo} "foo"] 262 | [:li.list-group-item {:key :bar} "bar"] 263 | [:li.list-group-item {:key :baz} "baz"]] 264 | ``` 265 | 266 | #### :multi-select 267 | 268 | The multi-select field allows multiple values to be selected and set in the document: 269 | 270 | ```clojure 271 | [:h3 "multi-select list"] 272 | [:div.btn-group {:field :multi-select :id :position} 273 | [:button.btn.btn-default {:key :left} "Left"] 274 | [:button.btn.btn-default {:key :middle} "Middle"] 275 | [:button.btn.btn-default {:key :right} "Right"]] 276 | ``` 277 | 278 | #### :label 279 | 280 | Labels can be associated with a key in the document using the `:id` attribute and will display the value at that key. The lables can have an optional `:preamble` and `:postamble` keys with the text that will be rendered before and after the value respectively. The value can also be interpreted using a formatter function assigned to the `:fmt` key. The `:placeholder` key can be used to provide text that will be displayed in absence of a value: 281 | 282 | ```clojure 283 | [:label {:field :label :id :volume}] 284 | [:label {:field :label :preamble "the value is: " :id :volume}] 285 | [:label {:field :label :preamble "the value is: " :postamble "ml" :id :volume}] 286 | [:label {:field :label :preamble "the value is: " :postamble "ml" :placeholder "N/A" :id :volume}] 287 | [:label {:field :label :preamble "the value is: " :id :volume :fmt (fn [v] (if v (str v "ml") "unknown")}] 288 | ``` 289 | 290 | #### :alert 291 | 292 | Alerts are bound to an id of a field that triggers the alert and can have an optional `:event` key. The event key should point to a function that returns a boolean value. 293 | 294 | An optional `:closeable? true/false` can be provided to control if a close button should be rendered (defaults to true). 295 | 296 | When an event is supplied then the body of the alert is rendered whenever the event returns true: 297 | 298 | ```clojure 299 | [:input {:field :text :id :first-name}] 300 | [:div.alert.alert-success {:field :alert :id :last-name :event empty?} "first name is empty!"] 301 | ``` 302 | 303 | When no event is supplied, then the alert is shown whenever the value at the id is not empty and displays the value: 304 | 305 | ```clojure 306 | (def doc (atom {})) 307 | 308 | ;;define an alert that watches the `:errors.first-name` key for errors 309 | [:div.alert.alert-danger {:field :alert :id :errors.first-name}] 310 | 311 | ;;trigger the alert by setting the error key 312 | [:button.btn.btn-default 313 | {:on-click 314 | #(if (empty? (:first-name @doc)) 315 | (swap! doc assoc-in [:errors :first-name] "first name is empty!"))} 316 | "save"] 317 | ``` 318 | 319 | #### :datepicker 320 | 321 | ```clojure 322 | [:div {:field :datepicker :id :birthday :date-format "yyyy/mm/dd" :inline true}] 323 | ``` 324 | The date is stored in the document using the following format: 325 | 326 | ```clojure 327 | {:year 2014 :month 11 :day 24} 328 | ``` 329 | 330 | The datepicker can also take an optional `:auto-close?` key to indicate that it should be closed when the day is clicked. This defaults to `false`. 331 | 332 | 333 | The date format can be set using the `:date-format` key: 334 | 335 | ```Clojure 336 | {:field :datepicker :id :date :date-format "yyyy/mm/dd"} 337 | ``` 338 | 339 | The `:date-format` can also point to a function that returns the formatted date: 340 | ```Clojure 341 | {:field :datepicker 342 | :id :date 343 | :date-format (fn [{:keys [year month day]}] (str year "/" month "/" day))} 344 | ``` 345 | 346 | The above is useful in conjunction with the `:save-fn` key that allows you to supply a custom function for saving the value. 347 | For example, if you wanted to use a JavaScript date object, you could do the following: 348 | 349 | ```clojure 350 | [:div.input-group.date.datepicker.clickable 351 | {:field :datepicker 352 | :id :reminder 353 | :date-format (fn [date] 354 | (str (.getDate date) "/" 355 | (inc (.getMonth date)) "/" 356 | (.getFullYear date))) 357 | :save-fn (fn [current-date {:keys [year month day]}] 358 | (if current-date 359 | (doto (js/Date.) 360 | (.setFullYear year) 361 | (.setMonth (dec month)) 362 | (.setDate day) 363 | (.setHours (.getHours current-date)) 364 | (.setMinutes (.getMinutes current-date))) 365 | (js/Date. year (dec month) day))) 366 | :auto-close? true}] 367 | ``` 368 | 369 | Note that you need to return a new date object in updates for the component to repaint. 370 | 371 | 372 | Datepicker takes an optional `:lang` key which you can use to set the locale of the datepicker. There are currently English, Russian, German, French, Spanish, Portuguese, Finnish and Dutch built in translations. To use a built-in language pass in `:lang` with a keyword as in the following table: 373 | 374 | | Language | Keyword | 375 | |----------|---------| 376 | | English | `:en-US` (default) | 377 | | Russian | `:ru-RU` | 378 | | German | `:de-DE` | 379 | | French | `:fr-FR` | 380 | | Spanish | `:es-ES` | 381 | | Portuguese | `:pt-PT` | 382 | | Finnish | `:fi-FI` | 383 | | Dutch | `:nl-NL` | 384 | 385 | Example of using a built in language locale: 386 | 387 | ```Clojure 388 | {:field :datepicker :id :date :date-format "yyyy/mm/dd" :inline true :lang :ru-RU} 389 | ``` 390 | 391 | You can also provide a custom locale hash-map to the datepicker. `:first-day` marks the first day of the week starting from Sunday as 0. All of the keys must be specified. 392 | 393 | Example of using a custom locale hash-map: 394 | 395 | ```clojure 396 | {:field :datepicker :id :date :date-format "yyyy/mm/dd" :inline true :lang 397 | {:days ["First" "Second" "Third" "Fourth" "Fifth" "Sixth" "Seventh"] 398 | :days-short ["1st" "2nd" "3rd" "4th" "5th" "6th" "7th"] 399 | :months ["Month-one" "Month-two" "Month-three" "Month-four" "Month-five" "Month-six" 400 | "Month-seven" "Month-eight" "Month-nine" "Month-ten" "Month-eleven" "Month-twelve"] 401 | :months-short ["M1" "M2" "M3" "M4" "M5" "M6" "M7" "M8" "M9" "M10" "M11" "M12"] 402 | :first-day 0}} 403 | ``` 404 | 405 | The datepicker requires additional CSS in order to be rendered correctly. The default CSS is provided 406 | in `reagent-forms.css` in the resource path. Simply make sure that it's included on the page. 407 | The File can be read using: 408 | 409 | ```clojure 410 | (-> "reagent-forms.css" clojure.java.io/resource slurp) 411 | ``` 412 | 413 | #### :container 414 | 415 | The container element can be used to group different element. 416 | The container can be used to set the visibility of multiple elements. 417 | 418 | ```clojure 419 | [:div.form-group 420 | {:field :container 421 | :visible? #(:show-name? %)} 422 | [:input {:field :text :id :first-name}] 423 | [:input {:field :text :id :last-name}]] 424 | ``` 425 | 426 | ### Validation 427 | 428 | A validator function can be attached to a component using the `:validator` keyword. This function accepts the current state of the document, and returns a collection of classes that will be appended to the element: 429 | 430 | ```clojure 431 | [:input 432 | {:field :text 433 | :id :person.name.first 434 | :validator (fn [doc] 435 | (when (-> doc :person :name :first empty?) 436 | ["error"]))}] 437 | ``` 438 | 439 | 440 | ### Setting component visibility 441 | 442 | The components may supply an optional `:visible?` key in their attributes that points to a decision function. 443 | The function is expected to take the current value of the document and produce a truthy value that will be used to decide whether the component should be rendered, eg: 444 | 445 | ```clojure 446 | (def form 447 | [:div 448 | [:input {:field :text 449 | :id :foo}] 450 | [:input {:field :text 451 | :visible? (fn [doc] (empty? (:foo doc))) 452 | :id :bar}]]) 453 | ``` 454 | 455 | ### Updating attributes 456 | 457 | The `:set-attributes` key can be used in cases where you need to do an arbitrary update on the attributes of the component. The key must point to a function that 458 | accepts the current value of the document and the map of the attributes for the component. The function must return an updated attribute map: 459 | 460 | ```clojure 461 | [:div 462 | [:input {:field :text 463 | :id :person.name.first 464 | :validator (fn [doc] 465 | (when (= "Bob" (-> doc :person :name :first)) 466 | ["error"]))}] 467 | [:input {:field :text 468 | :id :person.name.last 469 | :set-attributes (fn [doc attrs] 470 | (assoc attrs :disabled (= "Bob" (-> doc :person :name :first))))}]] 471 | ``` 472 | 473 | Above example disables the last name input when the value of the first name input is "Bob". 474 | 475 | ## Binding the form to a document 476 | 477 | The field components behave just like any other Reagent components and can be mixed with them freely. A complete form example can be seen below. 478 | 479 | Form elements can be bound to a nested structure by using the `.` as a path separator. For example, the following component `[:input {:field :text :id :person.first-name}]` binds to the following path in the state atom `{:person {:first-name }}` 480 | 481 | 482 | ```clojure 483 | (defn row [label input] 484 | [:div.row 485 | [:div.col-md-2 [:label label]] 486 | [:div.col-md-5 input]]) 487 | 488 | (def form-template 489 | [:div 490 | (row "first name" [:input {:field :text :id :first-name}]) 491 | (row "last name" [:input {:field :text :id :last-name}]) 492 | (row "age" [:input {:field :numeric :id :age}]) 493 | (row "email" [:input {:field :email :id :email}]) 494 | (row "comments" [:textarea {:field :textarea :id :comments}])]) 495 | ``` 496 | 497 | **important note** 498 | 499 | The templates are eagerly evaluated, and you should always call the helper functions as in the example above instead of putting them in a vector. These will be replaced by Reagent components when the `bind-fields` is called to compile the template. 500 | 501 | Once a form template is created it can be bound to a document using the `bind-fields` function: 502 | 503 | ```clojure 504 | (ns myform.core 505 | (:require [reagent-forms.core :refer [bind-fields]] 506 | [reagent.core :as r])) 507 | 508 | (defn form [] 509 | (let [doc (r/atom {})] 510 | (fn [] 511 | [:div 512 | [:div.page-header [:h1 "Reagent Form"]] 513 | [bind-fields form-template doc] 514 | [:label (str @doc)]]))) 515 | 516 | (reagent/render-component [form] (.getElementById js/document "container")) 517 | ``` 518 | 519 | The form can be initialized with a populated document, and the fields will be initialize with the values found there: 520 | 521 | ```clojure 522 | (def form-template 523 | [:div 524 | (row "first name" 525 | [:input.form-control {:field :text :id :first-name}]) 526 | (row "last name" 527 | [:input.form-control {:field :text :id :last-name}]) 528 | (row "age" 529 | [:input.form-control {:field :numeric :id :age}]) 530 | (row "email" 531 | [:input.form-control {:field :email :id :email}]) 532 | (row "comments" 533 | [:textarea.form-control {:field :textarea :id :comments}])]) 534 | 535 | (defn form [] 536 | (let [doc (atom {:first-name "John" :last-name "Doe" :age 35})] 537 | (fn [] 538 | [:div 539 | [:div.page-header [:h1 "Reagent Form"]] 540 | [bind-fields form-template doc] 541 | [:label (str @doc)]]))) 542 | ``` 543 | 544 | ## Adding events 545 | 546 | The `bind-fields` function accepts optional events. Events are triggered whenever the document is updated, and will be executed in the order they are listed. Each event sees the document modified by its predecessor. 547 | 548 | The event must take 3 parameters, which are the `id`, the `path`, the `value`, and the `document`. The `id` matches the `:id` of the field, the `path` is the path of the field in the document, the `value` represent the value that was changed in the form, and the document contains the state of the form. The event can either return an updated document or `nil`, when `nil` is returned then the state of the document is unmodified. 549 | 550 | The following is an example of an event to calculate the value of the `:bmi` key when the `:weight` and `:height` keys are populated: 551 | 552 | ```clojure 553 | (defn row [label input] 554 | [:div.row 555 | [:div.col-md-2 [:label label]] 556 | [:div.col-md-5 input]]) 557 | 558 | (def form-template 559 | [:div 560 | [:h3 "BMI Calculator"] 561 | (row "Height" [:input {:field :numeric :id :height}]) 562 | (row "Weight" [:input {:field :numeric :id :weight}]) 563 | (row "BMI" [:input {:field :numeric :id :bmi :disabled true}])]) 564 | 565 | [bind-fields 566 | form-template 567 | doc 568 | (fn [id path value {:keys [weight height] :as doc}] 569 | (when (and (some #{id} [:height :weight]) weight height) 570 | (assoc-in doc [:bmi] (/ weight (* height height)))))] 571 | ``` 572 | 573 | ## Using with re-frame 574 | 575 | You can provide a custom map of event functions to `bind-fields` to use reagent-forms with a library like `re-frame`. In that case, reagent-forms will not hold any internal state and functions provided by you will be used to get, save, and update the field's value. Here's an example: 576 | 577 | ```clojure 578 | (ns foo.bar 579 | (:require [re-frame.core :as re-frame] 580 | [reagent-forms.core :refer [bind-fields]])) 581 | 582 | ; re-frame events 583 | (re-frame/reg-event-db 584 | :init 585 | (fn [_ _] 586 | {:doc {}})) 587 | 588 | (re-frame/reg-sub 589 | :doc 590 | (fn [db _] 591 | (:doc db))) 592 | 593 | (re-frame/reg-sub 594 | :value 595 | :<- [:doc] 596 | (fn [doc [_ path]] 597 | (get-in doc path))) 598 | 599 | (re-frame/reg-event-db 600 | :set-value 601 | (fn [db [_ path value]] 602 | (assoc-in db (into [:doc] path) value))) 603 | 604 | (re-frame/reg-event-db 605 | :update-value 606 | (fn [db [_ f path value]] 607 | (update-in db (into [:doc] path) f value))) 608 | 609 | ; Functions that will be called by each individual form field with an id and a value 610 | (def events 611 | {:get (fn [path] @(re-frame/subscribe [:value path])) 612 | :save! (fn [path value] (re-frame/dispatch [:set-value path value])) 613 | :update! (fn [path save-fn value] 614 | ; save-fn should accept two arguments: old-value, new-value 615 | (re-frame/dispatch [:update-value save-fn path value])) 616 | :doc (fn [] @(re-frame/subscribe [:doc]))}) 617 | 618 | ; bind-fields called with a form and a map of custom events 619 | (defn foo 620 | [] 621 | [bind-fields 622 | [:div 623 | [:input {:field :text 624 | :id :person.name.first 625 | :valid? (fn [doc] 626 | (when (= "Bob" (-> doc :person :name :first)) 627 | ["error"]))}] 628 | [:input {:field :text 629 | :id :person.name.last}]] 630 | events]) 631 | ``` 632 | 633 | ### managing visibility 634 | 635 | Element visibility can be set by either providing the id in a document that will be 636 | treated as a truthy value, or a function: 637 | 638 | ```clojure 639 | (re-frame/reg-event-db 640 | :toggle-foo 641 | (fn [db _] 642 | (update-in db [:doc :foo] not))) 643 | 644 | (re-frame/reg-sub 645 | :bar-visible? 646 | (fn [db _] 647 | (:bar db))) 648 | 649 | (re-frame/reg-event-db 650 | :toggle-bar 651 | (fn [db _] 652 | (update db :bar not))) 653 | 654 | (def form 655 | [:div 656 | [:input {:field :text 657 | :id :foo-input 658 | :visible? :foo}] 659 | [:input {:field :text 660 | :id :bar-input 661 | :visible? (fn [doc] @(re-frame/subscribe [:bar-visible?]))}] 662 | 663 | (defn page 664 | [] 665 | [:div 666 | [bind-fields 667 | [:input {:field :text 668 | :id :foo-input 669 | :visible? :foo-input-visible?}] 670 | event-fns] 671 | [:button 672 | {:on-click #(re-frame/dispatch [:toggle-foo])} 673 | "toggle foo"] 674 | [:button 675 | {:on-click #(re-frame/dispatch [:toggle-bar])} 676 | "toggle bar"]]) 677 | ``` 678 | 679 | ### adding business rules 680 | 681 | If you're using re-frame, then it's recommended that you use re-frame events to trigger recalculation of fields in the form. For example, let's take a look at a calculated BMI field: 682 | 683 | ```clojure 684 | (re-frame/reg-sub 685 | :value 686 | :<- [:doc] 687 | (fn [doc [_ path]] 688 | (get-in doc path))) 689 | 690 | (defn bmi [{:keys [weight height] :as doc}] 691 | (assoc doc :bmi (/ weight (* height height)))) 692 | 693 | (defmulti rule (fn [_ path _] path)) 694 | 695 | (defmethod rule [:height] [doc path value] 696 | (bmi doc)) 697 | 698 | (defmethod rule [:weight] [doc path value] 699 | (bmi doc)) 700 | 701 | (defmethod rule :default [doc path value] 702 | doc) 703 | 704 | (re-frame/reg-event-db 705 | :set-value 706 | (fn [{:keys [doc] :as db} [_ path value]] 707 | (-> db 708 | (assoc-in (into [:doc] path) value) 709 | (update :doc rule path value)))) 710 | 711 | (def events 712 | {:get (fn [path] @(re-frame/subscribe [:value path])) 713 | :save! (fn [path value] (re-frame/dispatch [:set-value path value])) 714 | :doc (fn [] @(re-frame/subscribe [:doc]))}) 715 | 716 | (defn row [label input] 717 | [:div 718 | [:div [:label label]] 719 | [:div input]]) 720 | 721 | (def form-template 722 | [:div 723 | [:h3 "BMI Calculator"] 724 | (row "Height" [:input {:field :numeric :id :height}]) 725 | (row "Weight" [:input {:field :numeric :id :weight}]) 726 | (row "BMI" [:label {:field :label :id :bmi}])]) 727 | 728 | (defn home-page [] 729 | [:div 730 | [:h2 "BMI example"] 731 | [bind-fields form-template events]]) 732 | ``` 733 | 734 | The `rule` multiemthod will be triggered when the `:set-value` event is called, and it will calculate BMI any time the height or weight is updated. 735 | 736 | ## Adding custom fields 737 | 738 | Custom fields can be added by implementing the `reagent-forms.core/init-field` multimethod. The method must 739 | take two parameters, where the first parameter is the field component and the second is the options. 740 | 741 | By default the options will contain the `get` and the `save!`, and `update!` keys. The `get` key points to a function that accepts an id and returns the document value associated with it. The `save!` function accepts an id and a value that will be associated with it. The `update!` function accepts an id, a function that will handle the update, and the value. The function handling the update will receive the old and the new values. 742 | 743 | ## Using adapters 744 | 745 | Adapters can be provided to fields so as to create custom storage formats for field values. These are a pair of functions passed to the field through the keys `:in-fn` and `:out-fn`. `:in-fn` modifies the stored item so that the field can make use of it while `:out-fn` modifies the output of the field before it is stored. For example, in order to use a native `js/Date` object as the storage format, the datepicker can be initialized thusly: 746 | 747 | ```clojure 748 | [:div {:field :datepicker :id :birthday :date-format "yyyy/mm/dd" :inline true 749 | :in-fn #(when % {:year (.getFullYear %) :month (.getMonth %) :day (.getDate %)}) 750 | :out-fn #(when % (js/Date (:year %) (:month %) (:day %)))}] 751 | ``` 752 | 753 | Adapters may be passed nulls so they must be able to handle those. 754 | 755 | ## Mobile Gotchas 756 | 757 | Safari on iOS will have a 300ms delay for `:on-click` events, it's possible to set a custom trigger event using the `:touch-event` key. See [here](http://facebook.github.io/react/docs/events.html) for the list of events available in React. For example, if we wanted to use `:on-touch-start` instead of `:on-click` to trigger the event then we could do the following: 758 | 759 | ```clojure 760 | [:input.form-control {:field :text :id :first-name :touch-event :on-touch-start}] 761 | ``` 762 | 763 | Note that you will also have to set the style of `cursor: pointer` for any elements other than buttons in order for events to work on iOS. 764 | 765 | The [TapEventPlugin](https://github.com/zilverline/react-tap-event-plugin) for react is another option for creating responsive events, until the functionality becomes available in React itself. 766 | 767 | ## Testing 768 | This project uses [`Doo`](https://github.com/bensu/doo) for running the tests. 769 | You must install one of the Doo-supported environments, refer to [the docs](https://github.com/bensu/doo#setting-up-environments) for details. 770 | To run the tests, for example using Phantom, do: 771 | 772 | ``` 773 | lein doo slimer test 774 | ``` 775 | 776 | ## License 777 | 778 | Copyright © 2018 Dmitri Sotnikov 779 | 780 | Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version. 781 | -------------------------------------------------------------------------------- /dev/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |

ClojureScript has not been compiled!

12 |

please run lein figwheel in order to start the compiler

13 |
14 | 15 | 19 | 20 | 56 | 60 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /dev/reagent_forms/app.cljs: -------------------------------------------------------------------------------- 1 | (ns ^:figwheel-no-load reagent-forms.app 2 | (:require [reagent-forms.page :as page])) 3 | 4 | (enable-console-print!) 5 | 6 | (page/init!) 7 | -------------------------------------------------------------------------------- /dev/reagent_forms/page.cljs: -------------------------------------------------------------------------------- 1 | (ns reagent-forms.page 2 | (:require 3 | [json-html.core :refer [edn->hiccup]] 4 | [reagent.core :as r] 5 | [reagent-forms.core :as forms])) 6 | 7 | 8 | (defn row [label input] 9 | [:div.row 10 | [:div.col-md-2 [:label label]] 11 | [:div.col-md-5 input]]) 12 | 13 | (defn radio [label name value] 14 | [:div.radio 15 | [:label 16 | [:input {:field :radio :name name :value value}] 17 | label]]) 18 | 19 | (defn input [label type id] 20 | (row label [:input.form-control {:field type :id id}])) 21 | 22 | (defn friend-source [text] 23 | (filter 24 | #(-> % (.toLowerCase %) (.indexOf text) (> -1)) 25 | ["Alice" "Alan" "Bob" "Beth" "Jim" "Jane" "Kim" "Rob" "Zoe"])) 26 | 27 | (def animals 28 | [{:Animal {:Name "Lizard" 29 | :Colour "Green" 30 | :Skin "Leathery" 31 | :Weight 100 32 | :Age 10 33 | :Hostile false}} 34 | {:Animal {:Name "Lion" 35 | :Colour "Gold" 36 | :Skin "Furry" 37 | :Weight 190000 38 | :Age 4 39 | :Hostile true}} 40 | {:Animal {:Name "Giraffe" 41 | :Colour "Green" 42 | :Skin "Hairy" 43 | :Weight 1200000 44 | :Age 8 45 | :Hostile false}} 46 | {:Animal {:Name "Cat" 47 | :Colour "Black" 48 | :Skin "Furry" 49 | :Weight 5500 50 | :Age 6 51 | :Hostile false}} 52 | {:Animal {:Name "Capybara" 53 | :Colour "Brown" 54 | :Skin "Hairy" 55 | :Weight 45000 56 | :Age 12 57 | :Hostile false}} 58 | {:Animal {:Name "Bear" 59 | :Colour "Brown" 60 | :Skin "Furry" 61 | :Weight 600000 62 | :Age 10 63 | :Hostile true}} 64 | {:Animal {:Name "Rabbit" 65 | :Colour "White" 66 | :Skin "Furry" 67 | :Weight 1000 68 | :Age 6 69 | :Hostile false}} 70 | {:Animal {:Name "Fish" 71 | :Colour "Gold" 72 | :Skin "Scaly" 73 | :Weight 50 74 | :Age 5 75 | :Hostile false}} 76 | {:Animal {:Name "Hippo" 77 | :Colour "Grey" 78 | :Skin "Leathery" 79 | :Weight 1800000 80 | :Age 10 81 | :Hostile false}} 82 | {:Animal {:Name "Zebra" 83 | :Colour "Black/White" 84 | :Skin "Hairy" 85 | :Weight 200000 86 | :Age 9 87 | :Hostile false}} 88 | {:Animal {:Name "Squirrel" 89 | :Colour "Grey" 90 | :Skin "Furry" 91 | :Weight 300 92 | :Age 1 93 | :Hostile false}} 94 | {:Animal {:Name "Crocodile" 95 | :Colour "Green" 96 | :Skin "Leathery" 97 | :Weight 500000 98 | :Age 10 99 | :Hostile true}}]) 100 | 101 | (defn- animal-text 102 | "Return the display text for an animal" 103 | [animal] 104 | (str (:Name animal) " [" (:Colour animal) " " (:Skin animal) "]")) 105 | 106 | (defn- animal-match 107 | "Return true if the given text is found in one of 108 | the Name, Skin or Colour fields. False otherwise" 109 | [animal text] 110 | (let [fields [:Name :Colour :Skin] 111 | text (.toLowerCase text)] 112 | (reduce (fn [_ field] 113 | (if (-> animal 114 | field 115 | .toLowerCase 116 | (.indexOf text) 117 | (> -1)) 118 | (reduced true) 119 | false)) 120 | false 121 | fields))) 122 | 123 | (defn- animal-list 124 | "Generate the list of matching instruments for the given input list 125 | and match text. 126 | Returns a vector of vectors for a reagent-forms data-source." 127 | [animals text] 128 | (->> animals 129 | (filter #(-> % 130 | :Animal 131 | (animal-match text))) 132 | (mapv #(vector (animal-text (:Animal %)) (:Animal %))))) 133 | 134 | (defn- get-item-index 135 | "Return the index of the specified item within the current selections. 136 | The selections is the vector returned by animal-source. Item is whatever 137 | the the document id is, or the in-fn returns, if there is one." 138 | [item selections] 139 | (first (keep-indexed (fn [idx animal] 140 | (when (animal-match 141 | (second animal) 142 | item) 143 | idx)) 144 | selections))) 145 | 146 | (defn- animal-source 147 | [doc text] 148 | (cond 149 | (= text :all) 150 | (animal-list animals "") 151 | 152 | :else 153 | (animal-list animals text))) 154 | 155 | (defn- animal-out-fn 156 | "The reagent-forms :out-fn for the animal chooser. We use the out-fn to 157 | store the animal object in the document and return just the name for display 158 | in the component." 159 | [doc val] 160 | (let [[animal-display animal] val] ; may be 161 | (if (:Name animal) 162 | (do 163 | (swap! doc #(assoc % :animal animal)) 164 | (:Name animal)) 165 | (do 166 | (swap! doc #(assoc % :animal nil)) 167 | val)))) 168 | 169 | (defn form-template [doc] 170 | [:div 171 | [:div.row 172 | [:div.col-md-2 [:label.col-form-label {:field :label :id :volume}]] 173 | [:div.col-md-5 174 | [:input.form-control 175 | {:field :numeric 176 | :class "classy" 177 | :id :volume 178 | :validator (fn [doc] (if (= 5.00 (:volume doc)) ["error"])) 179 | :fmt "%.2f" 180 | :step "0.1" 181 | :min 0 182 | :max 1.3}]]] 183 | (input "first name" :text :person.first-name) 184 | [:div.row 185 | [:div.col-md-2] 186 | [:div.col-md-5 187 | [:div.alert.alert-danger 188 | {:field :alert :id :errors.first-name}]]] 189 | 190 | [:div 191 | {:field :container 192 | :visible? #(not-empty (get-in % [:person :first-name]))} 193 | (row "last name" [:input.form-control 194 | {:field :text 195 | :id :person.last-name 196 | :validator (fn [doc] (if-not (= "Bobberton" (-> doc :person :last-name)) 197 | ["error"]))}])] 198 | [:div.row 199 | [:div.col-md-2] 200 | [:div.col-md-5 201 | [:div.alert.alert-success 202 | {:field :alert :id :person.last-name :event empty?} 203 | "last name is empty!"]]] 204 | 205 | [:div.row 206 | [:div.col-md-2 [:label "Age"]] 207 | [:div.col-md-5 208 | [:div 209 | [:label 210 | [:input 211 | {:field :datepicker :id :age :date-format "yyyy/mm/dd" :inline true}]]]]] 212 | 213 | (input "email" :email :person.email) 214 | (row 215 | "comments" 216 | [:textarea.form-control 217 | {:field :textarea :id :comments}]) 218 | 219 | [:hr] 220 | (input "kg" :numeric :weight-kg) 221 | (input "lb" :numeric :weight-lb) 222 | 223 | [:hr] 224 | [:h3 "BMI Calculator"] 225 | (input "height" :numeric :height) 226 | (input "weight" :numeric :weight) 227 | (row "BMI" 228 | [:input.form-control 229 | {:field :numeric :fmt "%.2f" :id :bmi :disabled true}]) 230 | [:hr] 231 | 232 | (row "Best friend" 233 | [:div {:field :typeahead 234 | :id :ta 235 | :data-source friend-source 236 | :input-placeholder "Who's your best friend? You can pick only one" 237 | :input-class "form-control" 238 | :list-class "typeahead-list" 239 | :item-class "typeahead-item" 240 | :highlight-class "highlighted"}]) 241 | [:br] 242 | 243 | (row "isn't data binding lovely?" 244 | [:input {:field :checkbox :id :databinding.lovely}]) 245 | [:label 246 | {:field :label 247 | :preamble "The level of awesome: " 248 | :placeholder "N/A" 249 | :id :awesomeness}] 250 | 251 | [:input {:field :range :min 1 :max 10 :id :awesomeness}] 252 | 253 | [:h3 "option list"] 254 | [:div.form-group 255 | [:label "pick an option"] 256 | [:select.form-control {:field :list :id :many.options} 257 | [:option {:key :foo} "foo"] 258 | [:option {:key :bar} "bar"] 259 | [:option {:key :baz} "baz"]]] 260 | 261 | (radio 262 | "Option one is this and that—be sure to include why it's great" 263 | :foo :a) 264 | (radio 265 | "Option two can be something else and selecting it will deselect option one" 266 | :foo :b) 267 | 268 | [:hr] 269 | 270 | (row "Big typeahead example (down arrow shows list)" 271 | [:div 272 | {:field :typeahead 273 | :id [:animal-text] 274 | :input-placeholder "Animals" 275 | :data-source (fn [text] (animal-source doc text)) 276 | :result-fn (fn [[animal-display animal]] animal-display) 277 | :out-fn (fn [val] (animal-out-fn doc val)) 278 | :get-index (fn [item selections] (get-item-index item selections)) 279 | :clear-on-focus? false 280 | :input-class "form-control" 281 | :list-class "typeahead-list" 282 | :item-class "typeahead-item" 283 | :highlight-class "highlighted"} 284 | ]) 285 | 286 | [:h3 "multi-select buttons"] 287 | [:div.btn-group {:field :multi-select :id :every.position} 288 | [:button.btn.btn-default {:key :left} "Left"] 289 | [:button.btn.btn-default {:key :middle} "Middle"] 290 | [:button.btn.btn-default {:key :right} "Right"]] 291 | 292 | [:h3 "single-select buttons"] 293 | [:div.btn-group {:field :single-select :id :unique.position} 294 | [:button.btn.btn-default {:key :left} "Left"] 295 | [:button.btn.btn-default {:key :middle} "Middle"] 296 | [:button.btn.btn-default {:key :right} "Right"]] 297 | 298 | [:h3 "single-select list"] 299 | [:div.list-group {:field :single-select :id :pick-one} 300 | [:div.list-group-item {:key :foo} "foo"] 301 | [:div.list-group-item {:key :bar} "bar"] 302 | [:div.list-group-item {:key :baz} "baz"]] 303 | 304 | [:h3 "multi-select list"] 305 | [:ul.list-group {:field :multi-select :id :pick-a-few} 306 | [:li.list-group-item {:key :foo} "foo"] 307 | [:li.list-group-item {:key :bar} "bar"] 308 | [:li.list-group-item {:key :baz} "baz"]]]) 309 | 310 | (defn home-page [] 311 | (let [doc (r/atom {:person {:first-name "John" 312 | :age 35 313 | :email "foo@bar.baz"} 314 | :volume 5 315 | :weight 100 316 | :height 200 317 | :bmi 0.5 318 | :comments "some interesting comments\non this subject" 319 | :radioselection :b 320 | :position [:left :right] 321 | :pick-one :bar 322 | :unique {:position :middle} 323 | :pick-a-few [:bar :baz] 324 | :many {:options :bar} 325 | :animal-text "" 326 | :animal nil}) 327 | opts {:get (fn [path] (get-in @doc path)) 328 | :save! (fn [path value] (swap! doc assoc-in path value)) 329 | :update! (fn [f path new-value] (swap! doc #(f path new-value))) 330 | :doc (fn [] @doc)}] 331 | (fn [] 332 | [:div 333 | [:div.page-header [:h1 "Sample Form"]] 334 | 335 | [forms/bind-fields 336 | (form-template opts #_doc) 337 | doc 338 | (fn [[id] value {:keys [weight-lb weight-kg] :as document}] 339 | (cond 340 | (= id :weight-lb) 341 | (assoc document :weight-kg (/ value 2.2046)) 342 | (= id :weight-kg) 343 | (assoc document :weight-lb (* value 2.2046)) 344 | :else nil)) 345 | (fn [[id] value {:keys [height weight] :as document}] 346 | (when (and (some #{id} [:height :weight]) weight height) 347 | (assoc document :bmi (/ weight (* height height)))))] 348 | 349 | [:button.btn.btn-default 350 | {:on-click 351 | #(if (empty? (get-in @doc [:person :first-name])) 352 | (swap! doc assoc-in [:errors :first-name] "first name is empty"))} 353 | "save"] 354 | 355 | [:hr] 356 | [:h1 "Document State"] 357 | [edn->hiccup @doc]]))) 358 | 359 | (defn mount-root [] 360 | (r/render [home-page] (.getElementById js/document "app"))) 361 | 362 | 363 | (defn init! [] 364 | (mount-root)) 365 | -------------------------------------------------------------------------------- /forms-example/project.clj: -------------------------------------------------------------------------------- 1 | (defproject forms-example "0.1.0-SNAPSHOT" 2 | :description "A sample application using reagent-forms" 3 | :url "https://github.com/yogthos/reagent-forms" 4 | 5 | :dependencies 6 | [[org.clojure/clojure "1.7.0"] 7 | [reagent-forms "0.5.23"] 8 | [json-html "0.3.5"] 9 | [org.clojure/clojurescript "1.7.122"] 10 | [selmer "0.9.1"] 11 | [ring-server "0.4.0"] 12 | [lib-noir "0.9.7"]] 13 | 14 | :ring {:handler forms-example.handler/app} 15 | 16 | :cljsbuild 17 | {:builds 18 | [{:source-paths ["src-cljs"], 19 | :compiler 20 | {:output-dir "resources/public/js/", 21 | :optimizations :none, 22 | :output-to "resources/public/js/app.js", 23 | :source-map true, 24 | :pretty-print true}}]} 25 | 26 | :plugins 27 | [[lein-ring "0.8.10"] 28 | [lein-cljsbuild "1.0.6"]] 29 | 30 | :jvm-opts ["-server"] 31 | 32 | :profiles 33 | {:uberjar {:aot :all} 34 | :release {:ring {:open-browser? false 35 | :stacktraces? false 36 | :auto-reload? false}} 37 | :dev {:dependencies [[ring-mock "0.1.5"] 38 | [ring/ring-devel "1.4.0"]]}}) 39 | -------------------------------------------------------------------------------- /forms-example/resources/public/css/screen.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 4 | height: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /forms-example/resources/templates/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Welcome to forms-example 5 | 6 | 7 |
8 |
9 |
10 |

If you're seeing this message, that means you haven't yet compiled your ClojureScript!

11 |

Please run lein cljsbuild auto dev to start the ClojureScript compiler and reload the page.

12 |

See ClojureScript documentation for further details.

13 |
14 |
15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | {% style "/css/screen.css" %} 23 | 24 | {% script "/js/goog/base.js" %} 25 | {% script "/js/app.js" %} 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /forms-example/src-cljs/forms_example/core.cljs: -------------------------------------------------------------------------------- 1 | (ns forms-example.core 2 | (:require [json-html.core :refer [edn->hiccup]] 3 | [reagent.core :as reagent :refer [atom]] 4 | [reagent-forms.core :refer [bind-fields init-field value-of]])) 5 | 6 | (defn row [label input] 7 | [:div.row 8 | [:div.col-md-2 [:label label]] 9 | [:div.col-md-5 input]]) 10 | 11 | (defn radio [label name value] 12 | [:div.radio 13 | [:label 14 | [:input {:field :radio :name name :value value}] 15 | label]]) 16 | 17 | (defn input [label type id] 18 | (row label [:input.form-control {:field type :id id}])) 19 | 20 | (defn friend-source [text] 21 | (filter 22 | #(-> % (.toLowerCase %) (.indexOf text) (> -1)) 23 | ["Alice" "Alan" "Bob" "Beth" "Jim" "Jane" "Kim" "Rob" "Zoe"])) 24 | 25 | (def form-template 26 | [:div 27 | (input "first name" :text :person.first-name) 28 | [:div.row 29 | [:div.col-md-2] 30 | [:div.col-md-5 31 | [:div.alert.alert-danger 32 | {:field :alert :id :errors.first-name}]]] 33 | 34 | (input "last name" :text :person.last-name) 35 | [:div.row 36 | [:div.col-md-2] 37 | [:div.col-md-5 38 | [:div.alert.alert-success 39 | {:field :alert :id :person.last-name :event empty?} 40 | "last name is empty!"]]] 41 | 42 | [:div.row 43 | [:div.col-md-2 [:label "Age"]] 44 | [:div.col-md-5 45 | [:div 46 | [:label 47 | [:input 48 | {:field :datepicker :id :age :date-format "yyyy/mm/dd" :inline true}]]]]] 49 | 50 | (input "email" :email :person.email) 51 | (row 52 | "comments" 53 | [:textarea.form-control 54 | {:field :textarea :id :comments}]) 55 | 56 | [:hr] 57 | (input "kg" :numeric :weight-kg) 58 | (input "lb" :numeric :weight-lb) 59 | 60 | [:hr] 61 | [:h3 "BMI Calculator"] 62 | (input "height" :numeric :height) 63 | (input "weight" :numeric :weight) 64 | (row "BMI" 65 | [:input.form-control 66 | {:field :numeric :fmt "%.2f" :id :bmi :disabled true}]) 67 | [:hr] 68 | 69 | (row "Best friend" 70 | [:div {:field :typeahead 71 | :id :ta 72 | :data-source friend-source 73 | :input-placeholder "Who's your best friend? You can pick only one" 74 | :input-class "form-control" 75 | :list-class "typeahead-list" 76 | :item-class "typeahead-item" 77 | :highlight-class "highlighted"}]) 78 | [:br] 79 | 80 | (row "isn't data binding lovely?" 81 | [:input {:field :checkbox :id :databinding.lovely}]) 82 | [:label 83 | {:field :label 84 | :preamble "The level of awesome: " 85 | :placeholder "N/A" 86 | :id :awesomeness}] 87 | 88 | [:input {:field :range :min 1 :max 10 :id :awesomeness}] 89 | 90 | [:h3 "option list"] 91 | [:div.form-group 92 | [:label "pick an option"] 93 | [:select.form-control {:field :list :id :many.options} 94 | [:option {:key :foo} "foo"] 95 | [:option {:key :bar} "bar"] 96 | [:option {:key :baz} "baz"]]] 97 | 98 | (radio 99 | "Option one is this and that—be sure to include why it's great" 100 | :foo :a) 101 | (radio 102 | "Option two can be something else and selecting it will deselect option one" 103 | :foo :b) 104 | 105 | [:h3 "multi-select buttons"] 106 | [:div.btn-group {:field :multi-select :id :every.position} 107 | [:button.btn.btn-default {:key :left} "Left"] 108 | [:button.btn.btn-default {:key :middle} "Middle"] 109 | [:button.btn.btn-default {:key :right} "Right"]] 110 | 111 | [:h3 "single-select buttons"] 112 | [:div.btn-group {:field :single-select :id :unique.position} 113 | [:button.btn.btn-default {:key :left} "Left"] 114 | [:button.btn.btn-default {:key :middle} "Middle"] 115 | [:button.btn.btn-default {:key :right} "Right"]] 116 | 117 | [:h3 "single-select list"] 118 | [:div.list-group {:field :single-select :id :pick-one} 119 | [:div.list-group-item {:key :foo} "foo"] 120 | [:div.list-group-item {:key :bar} "bar"] 121 | [:div.list-group-item {:key :baz} "baz"]] 122 | 123 | [:h3 "multi-select list"] 124 | [:ul.list-group {:field :multi-select :id :pick-a-few} 125 | [:li.list-group-item {:key :foo} "foo"] 126 | [:li.list-group-item {:key :bar} "bar"] 127 | [:li.list-group-item {:key :baz} "baz"]]]) 128 | 129 | (defn page [] 130 | (let [doc (atom {:person {:first-name "John" 131 | :age 35 132 | :email "foo@bar.baz"} 133 | :weight 100 134 | :height 200 135 | :bmi 0.5 136 | :comments "some interesting comments\non this subject" 137 | :radioselection :b 138 | :position [:left :right] 139 | :pick-one :bar 140 | :unique {:position :middle} 141 | :pick-a-few [:bar :baz] 142 | :many {:options :bar}})] 143 | (fn [] 144 | [:div 145 | [:div.page-header [:h1 "Sample Form"]] 146 | 147 | [bind-fields 148 | form-template 149 | doc 150 | (fn [[id] value {:keys [weight-lb weight-kg] :as document}] 151 | (cond 152 | (= id :weight-lb) 153 | (assoc document :weight-kg (/ value 2.2046)) 154 | (= id :weight-kg) 155 | (assoc document :weight-lb (* value 2.2046)) 156 | :else nil)) 157 | (fn [[id] value {:keys [height weight] :as document}] 158 | (when (and (some #{id} [:height :weight]) weight height) 159 | (assoc document :bmi (/ weight (* height height)))))] 160 | 161 | [:button.btn.btn-default 162 | {:on-click 163 | #(if (empty? (get-in @doc [:person :first-name])) 164 | (swap! doc assoc-in [:errors :first-name]"first name is empty"))} 165 | "save"] 166 | 167 | [:hr] 168 | [:h1 "Document State"] 169 | [edn->hiccup @doc]]))) 170 | 171 | (reagent/render-component [page] (.getElementById js/document "app")) 172 | -------------------------------------------------------------------------------- /forms-example/src/forms_example/handler.clj: -------------------------------------------------------------------------------- 1 | (ns forms-example.handler 2 | (:require [compojure.core :refer [GET defroutes]] 3 | [noir.util.middleware :refer [app-handler]] 4 | [compojure.route :as route] 5 | [selmer.parser :as parser])) 6 | 7 | (defn resource [r] 8 | (-> (Thread/currentThread) 9 | (.getContextClassLoader) 10 | (.getResource r) 11 | slurp)) 12 | 13 | (defroutes base-routes 14 | (GET "/" [] 15 | (parser/render-file "templates/app.html" 16 | {:forms-css (resource "reagent-forms.css") 17 | :json-css (resource "json.human.css")})) 18 | (route/resources "/") 19 | (route/not-found "Not Found")) 20 | 21 | (def app (app-handler [base-routes])) 22 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject reagent-forms "0.5.44" 2 | :description "data binding library for Reagent" 3 | :url "https://github.com/yogthos/reagent-forms" 4 | :license {:name "Eclipse Public License" 5 | :url "http://www.eclipse.org/legal/epl-v10.html"} 6 | :clojurescript? true 7 | :dependencies [[reagent "0.10.0"]] 8 | :plugins [[codox "0.10.4"]] 9 | :profiles {:dev 10 | {:dependencies [[org.clojure/clojure "1.10.1"] 11 | [org.clojure/clojurescript "1.10.748"] 12 | [json-html "0.4.7"] 13 | [cider/piggieback "0.4.2"] 14 | [figwheel-sidecar "0.5.19"] 15 | [doo "0.1.10" ]] 16 | :plugins [[lein-cljsbuild "1.1.7"] 17 | [lein-figwheel "0.5.19"] 18 | [lein-doo "0.1.11"]] 19 | :source-paths ["src" "dev"] 20 | :resource-paths ["dev"] 21 | :figwheel 22 | {:http-server-root "public" 23 | :nrepl-port 7002 24 | :css-dirs ["resources"] 25 | :nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]} 26 | :clean-targets ^{:protect false} 27 | [:target-path 28 | [:cljsbuild :builds :app :compiler :output-dir] 29 | [:cljsbuild :builds :app :compiler :output-to]] 30 | :cljsbuild {:builds 31 | {:app 32 | {:source-paths ["src" "dev"] 33 | :figwheel {:on-jsload "reagent-forms.page/mount-root"} 34 | :compiler {:main "reagent-forms.app" 35 | :asset-path "js/out" 36 | :output-to "dev/public/js/app.js" 37 | :output-dir "dev/public/js/out" 38 | :source-map true 39 | :optimizations :none 40 | :pretty-print true}} 41 | :test 42 | {:source-paths ["src" "test"] 43 | :compiler {:output-to "out/test.js" 44 | :output-dir "out" 45 | :main "reagent-forms.tests-runner" 46 | :optimizations :none 47 | :pretty-print true}}}}}}) 48 | -------------------------------------------------------------------------------- /resources/reagent-forms.css: -------------------------------------------------------------------------------- 1 | 2 | .typeahead-list { 3 | list-style-type: none; 4 | padding: 5px; 5 | margin-top: 3px; 6 | border: solid 1px grey; 7 | border-radius: 3px; 8 | position: absolute; 9 | z-index: 2; 10 | background-color: white; 11 | overflow-y: scroll; 12 | max-height: 300px; 13 | width: 445px} 14 | 15 | 16 | .typeahead-item { 17 | padding: 3px; 18 | } 19 | 20 | li.highlighted 21 | { 22 | padding: 3px; 23 | border: 1px solid grey; 24 | background-color: grey; 25 | color: white; 26 | } 27 | 28 | /*! 29 | * Datepicker for Bootstrap 30 | * 31 | * Copyright 2012 Stefan Petre 32 | * Licensed under the Apache License v2.0 33 | * http://www.apache.org/licenses/LICENSE-2.0 34 | * 35 | */ 36 | .datepicker-wrapper { 37 | position: relative; 38 | } 39 | .datepicker.dp-inline { 40 | top: 0; 41 | left: 0; 42 | padding: 4px; 43 | margin-top: 1px; 44 | -webkit-border-radius: 4px; 45 | -moz-border-radius: 4px; 46 | border-radius: 4px; 47 | } 48 | .datepicker.dp-dropdown { 49 | position: absolute; 50 | z-index: 1000; 51 | top: 100%; 52 | left: 0; 53 | padding: 4px; 54 | margin-top: 1px; 55 | min-width: 160px; 56 | float: left; 57 | -webkit-border-radius: 4px; 58 | -moz-border-radius: 4px; 59 | border-radius: 4px; 60 | background-color: white; 61 | border: 1px solid #ccc; // IE8 fallback 62 | border: 1px solid rgba(0,0,0,.15); 63 | background-clip: padding-box; 64 | 65 | } 66 | .datepicker:before { 67 | content: ''; 68 | display: inline-block; 69 | border-left: 7px solid transparent; 70 | border-right: 7px solid transparent; 71 | border-bottom: 7px solid #ccc; 72 | border-bottom-color: rgba(0, 0, 0, 0.2); 73 | position: absolute; 74 | top: -7px; 75 | left: 6px; 76 | } 77 | .datepicker:after { 78 | content: ''; 79 | display: inline-block; 80 | border-left: 6px solid transparent; 81 | border-right: 6px solid transparent; 82 | border-bottom: 6px solid #ffffff; 83 | position: absolute; 84 | top: -6px; 85 | left: 7px; 86 | } 87 | .datepicker > div { 88 | display: none; 89 | } 90 | .datepicker table { 91 | width: 100%; 92 | margin: 0; 93 | } 94 | .datepicker td { 95 | cursor: pointer; 96 | } 97 | .datepicker td.month, td.year, { 98 | text-align: center; 99 | line-height: 54px; 100 | margin: 2px; 101 | cursor: pointer; 102 | -webkit-border-radius: 4px; 103 | -moz-border-radius: 4px; 104 | border-radius: 4px; 105 | } 106 | .datepicker td span { 107 | line-height: 54px; 108 | margin: 2px; 109 | cursor: pointer; 110 | -webkit-border-radius: 4px; 111 | -moz-border-radius: 4px; 112 | border-radius: 4px; 113 | } 114 | 115 | .datepicker td, 116 | .datepicker th { 117 | text-align: center; 118 | width: 20px; 119 | height: 20px; 120 | -webkit-border-radius: 4px; 121 | -moz-border-radius: 4px; 122 | border-radius: 4px; 123 | } 124 | .datepicker td.day:hover { 125 | background: #eeeeee; 126 | cursor: pointer; 127 | } 128 | .datepicker td.day.disabled { 129 | color: #eeeeee; 130 | } 131 | .datepicker td.old, 132 | .datepicker td.new { 133 | color: #999999; 134 | } 135 | .datepicker td.active, 136 | .datepicker td.active:hover { 137 | color: #ffffff; 138 | background-color: #006dcc; 139 | background-image: -moz-linear-gradient(top, #0088cc, #0044cc); 140 | background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc)); 141 | background-image: -webkit-linear-gradient(top, #0088cc, #0044cc); 142 | background-image: -o-linear-gradient(top, #0088cc, #0044cc); 143 | background-image: linear-gradient(to bottom, #0088cc, #0044cc); 144 | background-repeat: repeat-x; 145 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0044cc', GradientType=0); 146 | border-color: #0044cc #0044cc #002a80; 147 | border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); 148 | *background-color: #0044cc; 149 | /* Darken IE7 buttons by default so they stand out more given they won't have borders */ 150 | 151 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 152 | color: #fff; 153 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 154 | } 155 | .datepicker td.active:hover, 156 | .datepicker td.active:hover:hover, 157 | .datepicker td.active:focus, 158 | .datepicker td.active:hover:focus, 159 | .datepicker td.active:active, 160 | .datepicker td.active:hover:active, 161 | .datepicker td.active.active, 162 | .datepicker td.active:hover.active, 163 | .datepicker td.active.disabled, 164 | .datepicker td.active:hover.disabled, 165 | .datepicker td.active[disabled], 166 | .datepicker td.active:hover[disabled] { 167 | color: #ffffff; 168 | background-color: #0044cc; 169 | *background-color: #003bb3; 170 | } 171 | .datepicker td.active:active, 172 | .datepicker td.active:hover:active, 173 | .datepicker td.active.active, 174 | .datepicker td.active:hover.active { 175 | background-color: #003399 \9; 176 | } 177 | 178 | .datepicker td:hover, td span:hover { 179 | background: #eeeeee; 180 | } 181 | .datepicker td span.active { 182 | color: #ffffff; 183 | background-color: #006dcc; 184 | background-image: -moz-linear-gradient(top, #0088cc, #0044cc); 185 | background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc)); 186 | background-image: -webkit-linear-gradient(top, #0088cc, #0044cc); 187 | background-image: -o-linear-gradient(top, #0088cc, #0044cc); 188 | background-image: linear-gradient(to bottom, #0088cc, #0044cc); 189 | background-repeat: repeat-x; 190 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0044cc', GradientType=0); 191 | border-color: #0044cc #0044cc #002a80; 192 | border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); 193 | *background-color: #0044cc; 194 | /* Darken IE7 buttons by default so they stand out more given they won't have borders */ 195 | 196 | filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); 197 | color: #fff; 198 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 199 | } 200 | .datepicker td span.active:hover, 201 | .datepicker td span.active:focus, 202 | .datepicker td span.active:active, 203 | .datepicker td span.active.active, 204 | .datepicker td span.active.disabled, 205 | .datepicker td span.active[disabled] { 206 | color: #ffffff; 207 | background-color: #0044cc; 208 | *background-color: #003bb3; 209 | } 210 | .datepicker td span.active:active, 211 | .datepicker td span.active.active { 212 | background-color: #003399 \9; 213 | } 214 | .datepicker td span.old { 215 | color: #999999; 216 | } 217 | .datepicker th.switch { 218 | width: 145px; 219 | } 220 | .datepicker th.next, 221 | .datepicker th.prev { 222 | font-size: 21px; 223 | } 224 | .datepicker thead tr:first-child th { 225 | cursor: pointer; 226 | } 227 | .datepicker thead tr:first-child th:hover { 228 | background: #eeeeee; 229 | } 230 | .input-append.date .add-on i, 231 | .input-prepend.date .add-on i { 232 | display: block; 233 | cursor: pointer; 234 | width: 16px; 235 | height: 16px; 236 | } 237 | -------------------------------------------------------------------------------- /src/reagent_forms/core.cljs: -------------------------------------------------------------------------------- 1 | (ns reagent-forms.core 2 | (:require-macros 3 | [reagent-forms.macros 4 | :refer [render-element]]) 5 | (:require 6 | [clojure.walk 7 | :refer [postwalk]] 8 | [clojure.string 9 | :as string 10 | :refer [split trim join blank?]] 11 | [goog.string 12 | :as gstring] 13 | [goog.string.format] 14 | [reagent.core 15 | :as r 16 | :refer [atom cursor]] 17 | [reagent.dom 18 | :as rdom] 19 | [reagent-forms.datepicker 20 | :refer [parse-format format-date datepicker]])) 21 | 22 | (defn value-of [element] 23 | (-> element .-target .-value)) 24 | 25 | (defn- scroll-to [element idx] 26 | (let [list-elem (-> element 27 | .-target 28 | .-parentNode 29 | (.getElementsByTagName "ul") 30 | (.item 0)) 31 | idx (if (< idx 0) 0 idx) 32 | item-elem (-> list-elem 33 | .-children 34 | (.item idx)) 35 | [item-height offset-top] (if item-elem 36 | [(.-scrollHeight item-elem) 37 | (.-offsetTop item-elem)] 38 | [0 0])] 39 | (set! (.-scrollTop list-elem) 40 | (- offset-top 41 | (* 2 item-height))))) 42 | 43 | (def ^:private id->path 44 | (memoize 45 | (fn [id] 46 | (if (sequential? id) 47 | id 48 | (let [segments (split (subs (str id) 1) #"\.")] 49 | (mapv keyword segments)))))) 50 | 51 | (def ^:private cursor-for-id 52 | (memoize 53 | (fn [doc id] 54 | (cursor doc (id->path id))))) 55 | 56 | (defn run-events [doc id value events] 57 | (let [path (id->path id)] 58 | (reduce #(or (%2 id path value %1) %1) doc events))) 59 | 60 | (defn- mk-update-fn [doc events] 61 | (fn [id update-fn value] 62 | (let [result (swap! (cursor-for-id doc id) 63 | (fn [current-value] 64 | (update-fn current-value value)))] 65 | (when-not (empty? events) 66 | (swap! doc run-events id result events))))) 67 | 68 | (defn- mk-save-fn [doc events] 69 | (fn [id value] 70 | (reset! (cursor-for-id doc id) value) 71 | (when-not (empty? events) 72 | (swap! doc run-events id value events)))) 73 | 74 | (defn wrap-get-fn [get wrapper] 75 | (fn [id] 76 | (wrapper (get id)))) 77 | 78 | (defn wrap-save-fn [save! wrapper] 79 | (fn [id value] 80 | (save! id (wrapper value)))) 81 | 82 | (defn wrap-update-fn [update! wrapper] 83 | (fn [id update-fn value] 84 | (update! id update-fn (wrapper value)))) 85 | 86 | (defn wrap-fns [{:keys [doc get save! update!]} node] 87 | {:doc doc 88 | :get (if-let [in-fn (:in-fn (second node))] 89 | (wrap-get-fn get in-fn) 90 | get) 91 | :save! (if-let [out-fn (:out-fn (second node))] 92 | (wrap-save-fn save! out-fn) 93 | save!) 94 | :update! (if-let [out-fn (:out-fn (second node))] 95 | (wrap-update-fn update! out-fn) 96 | update!)}) 97 | 98 | (defn set-disabled [attrs update-disabled?] 99 | (if (and update-disabled? 100 | (not (nil? (:disabled attrs)))) 101 | (update attrs :disabled #(if (fn? %) (%) %)) 102 | attrs)) 103 | 104 | (defn call-attr 105 | [doc attr] 106 | (let [doc (if (fn? doc) (doc) @doc)] 107 | (if (fn? attr) (attr doc) (get-in doc (id->path attr))))) 108 | 109 | (defn update-class [attrs classes] 110 | (if (not-empty classes) 111 | (update attrs :class #(string/join " " (remove empty? (into (if (string? %) [%] %) classes)))) 112 | attrs)) 113 | 114 | (defn update-attrs [{:keys [set-attributes] :as attrs} doc] 115 | (or (when set-attributes (set-attributes (if (fn? doc) (doc) @doc) attrs)) attrs)) 116 | 117 | (defn set-validation-class [attrs doc] 118 | (if-let [valid (:validator attrs)] 119 | (update-class attrs (call-attr doc valid)) 120 | attrs)) 121 | 122 | (defn clean-attrs [attrs] 123 | (dissoc attrs 124 | :fmt 125 | :event 126 | :field 127 | :in-fn 128 | :out-fn 129 | :inline 130 | :save-fn 131 | :preamble 132 | :visible? 133 | :postamble 134 | :validator 135 | :date-format 136 | :auto-close? 137 | :set-attributes)) 138 | 139 | ;;coerce the input to the appropriate type 140 | (defmulti format-type 141 | (fn [field-type _] 142 | (if (#{:range :numeric} field-type) 143 | :numeric 144 | field-type))) 145 | 146 | (defn format-value [fmt value] 147 | (if (and (not (js/isNaN (js/parseFloat value))) fmt) 148 | (gstring/format fmt value) 149 | value)) 150 | 151 | (defmethod format-type :numeric 152 | [_ n] 153 | (when (not-empty n) 154 | (let [parsed (js/parseFloat n)] 155 | (when-not (js/isNaN parsed) 156 | parsed)))) 157 | 158 | (defmethod format-type :default 159 | [_ value] value) 160 | 161 | ;;bind the field to the document based on its type 162 | (defmulti bind 163 | (fn [{:keys [field]} _] 164 | (if (#{:text :numeric :password :email :tel :range :textarea} field) 165 | :input-field field))) 166 | 167 | (defmethod bind :input-field 168 | [{:keys [field id fmt]} {:keys [get save!]}] 169 | {:value (let [value (or (get id) "")] 170 | (format-value fmt value)) 171 | :on-change #(save! id (->> % (value-of) (format-type field)))}) 172 | 173 | (defmethod bind :checkbox 174 | [{:keys [id]} {:keys [get save!]}] 175 | {:checked (boolean (get id)) 176 | :on-change #(->> id get not (save! id))}) 177 | 178 | (defmethod bind :default [_ _]) 179 | 180 | (defn- set-attrs 181 | [[type attrs & body] opts & [default-attrs]] 182 | (into 183 | [type (merge 184 | default-attrs 185 | (bind attrs opts) 186 | (dissoc attrs :checked :default-checked))] 187 | body)) 188 | 189 | ;;initialize the field by binding it to the document and setting default options 190 | (defmulti init-field 191 | (fn [[_ {:keys [field]}] _] 192 | (let [field (keyword field)] 193 | (if (#{:range :text :password :email :tel :textarea} field) 194 | :input-field field)))) 195 | 196 | (defmethod init-field :container 197 | [[type attrs & body] {:keys [doc]}] 198 | (render-element attrs doc (into [type attrs] body))) 199 | 200 | (defmethod init-field :input-field 201 | [[_ {:keys [field] :as attrs} :as component] {:keys [doc] :as opts}] 202 | (render-element attrs doc 203 | (set-attrs component opts {:type field}))) 204 | 205 | (defmethod init-field :numeric 206 | [[type {:keys [id fmt] :as attrs}] {:keys [get save! doc]}] 207 | (let [input-value (atom nil)] 208 | (render-element 209 | attrs doc 210 | [type (merge 211 | {:type :number 212 | :value (or @input-value (get id "")) 213 | :on-change #(->> (value-of %) (reset! input-value)) 214 | :on-blur #(do 215 | (reset! input-value nil) 216 | (->> (value-of %) 217 | (format-value fmt) 218 | (format-type :numeric) 219 | (save! id)))} 220 | attrs)]))) 221 | 222 | (defmethod init-field :datepicker 223 | [[_ {:keys [id date-format inline auto-close? disabled lang save-fn] :or {lang :en-US} :as attrs}] {:keys [doc get save! update!]}] 224 | (let [fmt (if (fn? date-format) 225 | date-format 226 | #(format-date % (parse-format date-format))) 227 | selected-date (get id) 228 | selected-month (if (pos? (:month selected-date)) 229 | (dec (:month selected-date)) 230 | (:month selected-date)) 231 | today (js/Date.) 232 | year (or (:year selected-date) (.getFullYear today)) 233 | month (or selected-month (.getMonth today)) 234 | day (or (:day selected-date) (.getDate today)) 235 | expanded? (atom false) 236 | mouse-on-list? (atom false) 237 | dom-node (atom nil) 238 | save-value (if save-fn #(update! id save-fn %) #(save! id %))] 239 | (r/create-class 240 | {:component-did-mount 241 | (fn [this] 242 | (->> this rdom/dom-node .-firstChild .-firstChild (reset! dom-node))) 243 | :component-did-update 244 | (fn [this] 245 | (->> this rdom/dom-node .-firstChild .-firstChild (reset! dom-node))) 246 | :render 247 | (render-element attrs doc 248 | [:div.datepicker-wrapper 249 | [:div.input-group.date 250 | [:input.form-control 251 | (merge 252 | {:read-only true 253 | :on-blur #(when-not @mouse-on-list? 254 | (reset! expanded? false)) 255 | :type :text 256 | :on-click (fn [e] 257 | (.preventDefault e) 258 | (when-not (if (fn? disabled) (disabled) disabled) 259 | (swap! expanded? not))) 260 | :value (if-let [date (get id)] 261 | (fmt date) 262 | "")} 263 | attrs)] 264 | [:span.input-group-addon 265 | {:on-click (fn [e] 266 | (.preventDefault e) 267 | (when-not (if (fn? disabled) (disabled) disabled) 268 | (swap! expanded? not) 269 | (.focus @dom-node)))} 270 | [:i.glyphicon.glyphicon-calendar]]] 271 | [datepicker year month day dom-node mouse-on-list? expanded? auto-close? #(get id) save-value inline lang]])}))) 272 | 273 | 274 | (defmethod init-field :checkbox 275 | [[_ {:keys [id field checked default-checked] :as attrs} :as component] {:keys [doc save!] :as opts}] 276 | (when (or checked default-checked) 277 | (save! id true)) 278 | (render-element (dissoc attrs :checked :default-checked) doc 279 | (set-attrs component opts {:type field}))) 280 | 281 | (defmethod init-field :label 282 | [[type {:keys [id preamble postamble placeholder fmt] :as attrs}] {:keys [doc get]}] 283 | (render-element attrs doc 284 | [type attrs preamble 285 | (let [value (get id)] 286 | (if fmt 287 | (fmt value) 288 | (if value 289 | (str value postamble) 290 | placeholder)))])) 291 | 292 | (defmethod init-field :alert 293 | [[type {:keys [id event touch-event closeable?] :or {closeable? true} :as attrs} & body] {:keys [doc get save!]}] 294 | (render-element attrs doc 295 | (if event 296 | (when (event (get id)) 297 | (into [type attrs] body)) 298 | (if-let [message (not-empty (get id))] 299 | [type attrs 300 | (when closeable? 301 | [:button.close 302 | {:type "button" 303 | :aria-hidden true 304 | (or touch-event :on-click) #(save! id nil)} 305 | "X"]) 306 | message])))) 307 | 308 | (defmethod init-field :radio 309 | [[type {:keys [name value checked default-checked] :as attrs} & body] {:keys [doc get save!]}] 310 | (when (or checked default-checked) 311 | (save! name value)) 312 | (render-element attrs doc 313 | (into 314 | [type 315 | (merge 316 | (dissoc attrs :value :default-checked) 317 | {:type :radio 318 | :checked (= value (get name)) 319 | :on-change #(save! name value)})] 320 | body))) 321 | 322 | (defmethod init-field :typeahead 323 | [[type {:keys [id data-source input-class list-class item-class highlight-class input-placeholder result-fn choice-fn clear-on-focus? selections get-index] 324 | :as attrs 325 | :or {result-fn identity 326 | choice-fn identity 327 | clear-on-focus? true}}] {:keys [doc get save!]}] 328 | (let [typeahead-hidden? (atom true) 329 | mouse-on-list? (atom false) 330 | selected-index (atom -1) 331 | selections (or selections (atom [])) 332 | get-index (or get-index (constantly -1)) 333 | choose-selected #(when (and (not-empty @selections) (> @selected-index -1)) 334 | (let [choice (nth @selections @selected-index)] 335 | (save! id choice) 336 | (choice-fn choice) 337 | (reset! typeahead-hidden? true)))] 338 | (render-element attrs doc 339 | [type 340 | [:input {:type :text 341 | :disabled (:disabled attrs) 342 | :placeholder input-placeholder 343 | :class input-class 344 | :value (let [v (get id)] 345 | (if-not (iterable? v) 346 | v (first v))) 347 | :on-focus #(when clear-on-focus? (save! id nil)) 348 | :on-blur #(when-not @mouse-on-list? 349 | (reset! typeahead-hidden? true) 350 | (reset! selected-index -1)) 351 | :on-change #(when-let [value (trim (value-of %))] 352 | (reset! selections (data-source (.toLowerCase value))) 353 | (save! id (value-of %)) 354 | (reset! typeahead-hidden? false) 355 | (reset! selected-index (if (= 1 (count @selections)) 0 -1))) 356 | :on-key-down #(do 357 | (case (.-which %) 358 | 38 (do 359 | (.preventDefault %) 360 | (when-not (or @typeahead-hidden? (<= @selected-index 0)) 361 | (swap! selected-index dec) 362 | (scroll-to % @selected-index))) 363 | 40 (do 364 | (.preventDefault %) 365 | (if @typeahead-hidden? 366 | (do 367 | 368 | (reset! selections (data-source :all)) 369 | (reset! selected-index (get-index (-> % 370 | value-of 371 | trim) 372 | @selections)) 373 | (reset! typeahead-hidden? false) 374 | (scroll-to % @selected-index)) 375 | (when-not (= @selected-index (dec (count @selections))) 376 | (save! id (value-of %)) 377 | (swap! selected-index inc) 378 | (scroll-to % @selected-index)))) 379 | 9 (choose-selected) 380 | 13 (do 381 | (.preventDefault %) 382 | (choose-selected)) 383 | 27 (do (reset! typeahead-hidden? true) 384 | (reset! selected-index -1)) 385 | "default"))}] 386 | 387 | [:ul {:style {:display (if (or (empty? @selections) @typeahead-hidden?) :none :block)} 388 | :class list-class 389 | :on-mouse-enter #(reset! mouse-on-list? true) 390 | :on-mouse-leave #(reset! mouse-on-list? false)} 391 | (doall 392 | (map-indexed 393 | (fn [index result] 394 | [:li {:tab-index index 395 | :key index 396 | :class (if (= @selected-index index) highlight-class item-class) 397 | :on-mouse-over #(do 398 | (reset! selected-index (js/parseInt (.getAttribute (.-target %) "tabIndex")))) 399 | :on-click #(do 400 | (.preventDefault %) 401 | (reset! typeahead-hidden? true) 402 | (save! id result) 403 | (choice-fn result))} 404 | (result-fn result)]) 405 | @selections))]]))) 406 | 407 | (defmethod init-field :file 408 | [[type {:keys [id] :as attrs}] {:keys [doc save!]}] 409 | (render-element attrs doc 410 | [type (merge {:type :file 411 | :on-change #(save! id (-> % .-target .-files array-seq first))} 412 | attrs)])) 413 | 414 | (defmethod init-field :files 415 | [[type {:keys [id] :as attrs}] {:keys [doc save!]}] 416 | (render-element attrs doc 417 | [type (merge {:type :file 418 | :multiple true 419 | :on-change #(save! id (-> % .-target .-files))} 420 | attrs)])) 421 | 422 | (defn- group-item 423 | [[type {:keys [key touch-event disabled] :as attrs} & body] 424 | {:keys [save! multi-select]} selections field id] 425 | (letfn [(handle-click! [] 426 | (if multi-select 427 | (do 428 | (swap! selections update-in [key] not) 429 | (save! id (->> @selections (filter second) (map first)))) 430 | (let [value (get @selections key)] 431 | (reset! selections {key (not value)}) 432 | (save! id (when (get @selections key) key)))))] 433 | (fn [] 434 | (let [disabled? (if (fn? disabled) (disabled) disabled) 435 | active? (get @selections key) 436 | button-or-input? (let [t (subs (name type) 0 5)] 437 | (or (= t "butto") (= t "input"))) 438 | class (->> [(when active? "active") 439 | (when (and disabled? (not button-or-input?)) "disabled")] 440 | (remove blank?) 441 | (join " "))] 442 | [type 443 | (dissoc 444 | (merge {:class class 445 | (or touch-event :on-click) 446 | (when-not disabled? handle-click!)} 447 | (clean-attrs attrs) 448 | {:disabled disabled?}) 449 | (when-not button-or-input? :disabled)) 450 | body])))) 451 | 452 | (defn- mk-selections [id selectors {:keys [get multi-select] :as ks}] 453 | (let [value (get id)] 454 | (reduce 455 | (fn [m [_ {:keys [key]}]] 456 | (assoc m key (boolean (some #{key} (if multi-select value [value]))))) 457 | {} selectors))) 458 | 459 | (defn extract-selectors 460 | "selectors might be passed in inline or as a collection" 461 | [selectors] 462 | (if (keyword? (ffirst selectors)) 463 | selectors (first selectors))) 464 | 465 | (defn- selection-group 466 | [[type {:keys [field id] :as attrs} & selection-items] {:keys [get doc] :as opts}] 467 | (let [selection-items (extract-selectors selection-items) 468 | selections (atom (mk-selections id selection-items opts)) 469 | selectors (map (fn [item] 470 | {:visible? (:visible? (second item)) 471 | :selector [(group-item item opts selections field id)]}) 472 | selection-items)] 473 | (fn [] 474 | (when-not (get id) 475 | (swap! selections #(into {} (map (fn [[k]] [k false]) %)))) 476 | (into [type (clean-attrs attrs)] 477 | (->> selectors 478 | (filter 479 | #(if-let [visible? (:visible? %)] 480 | (call-attr doc visible?) 481 | true)) 482 | (map :selector)))))) 483 | 484 | (defmethod init-field :single-select 485 | [[_ attrs :as field] {:keys [doc] :as opts}] 486 | (render-element attrs doc 487 | [selection-group field opts])) 488 | 489 | (defmethod init-field :multi-select 490 | [[_ attrs :as field] {:keys [doc] :as opts}] 491 | (render-element attrs doc 492 | [selection-group field (assoc opts :multi-select true)])) 493 | 494 | (defn map-options [options] 495 | (into 496 | {} 497 | (for [[_ {:keys [key]} label] options] 498 | [(str label) key]))) 499 | 500 | (defn default-selection [options v] 501 | (->> options 502 | (filter #(= v (get-in % [1 :key]))) 503 | (first) 504 | (last))) 505 | 506 | (defmethod init-field :list 507 | [[type {:keys [id] :as attrs} & options] {:keys [doc get save!]}] 508 | (let [options (extract-selectors options) 509 | options-lookup (map-options options) 510 | selection (atom (or 511 | (get id) 512 | (get-in (first options) [1 :key])))] 513 | (save! id @selection) 514 | (render-element attrs doc 515 | [type 516 | (merge 517 | attrs 518 | {:default-value (default-selection options @selection) 519 | :on-change #(save! id (clojure.core/get options-lookup (value-of %)))}) 520 | (doall 521 | (filter 522 | #(if-let [visible (:visible? (second %))] 523 | (call-attr doc visible) true) 524 | options))]))) 525 | 526 | (defn- field? [node] 527 | (and (coll? node) 528 | (map? (second node)) 529 | (contains? (second node) :field))) 530 | 531 | (defn make-form 532 | [form opts] 533 | (postwalk 534 | (fn [node] 535 | (if (field? node) 536 | (let [opts (wrap-fns opts node) 537 | field (init-field node opts)] 538 | (if (fn? field) [field] field)) 539 | node)) 540 | form)) 541 | 542 | (defmulti bind-fields 543 | "Creates data bindings between the form fields and the supplied atom or calls 544 | the supplied functions (when `doc` is a map) on events triggered by fields. 545 | form - the form template with the fields 546 | doc - the document that the fields will be bound to 547 | events - any events that should be triggered when the document state changes" 548 | (fn [_ doc & _] 549 | (type doc))) 550 | 551 | (defmethod bind-fields PersistentArrayMap 552 | [form opts] 553 | (let [form (make-form 554 | form 555 | (-> opts 556 | (update :get 557 | (fn [get] 558 | (fn [id] 559 | (get (id->path id))))) 560 | (update :save! 561 | (fn [save!] 562 | (fn [id value] 563 | (save! (id->path id) value)))) 564 | (update :update! 565 | (fn [update!] 566 | (fn [id save-fn value] 567 | (update! (id->path id) save-fn value))))))] 568 | (fn [] form))) 569 | 570 | (defmethod bind-fields :default 571 | [form doc & events] 572 | (let [opts {:doc doc 573 | :get #(deref (cursor-for-id doc %)) 574 | :save! (mk-save-fn doc events) 575 | :update! (mk-update-fn doc events)} 576 | form (make-form form opts)] 577 | (fn [] form))) 578 | -------------------------------------------------------------------------------- /src/reagent_forms/datepicker.cljs: -------------------------------------------------------------------------------- 1 | (ns reagent-forms.datepicker 2 | (:require 3 | [clojure.string :as s] 4 | [reagent.core :refer [atom]])) 5 | 6 | (def dates 7 | {:en-US {:days ["Sunday" "Monday" "Tuesday" "Wednesday" "Thursday" "Friday" "Saturday"] 8 | :days-short ["Su" "Mo" "Tu" "We" "Th" "Fr" "Sa"] 9 | :months ["January" "February" "March" "April" "May" "June" "July" "August" "September" "October" "November" "December"] 10 | :months-short ["Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec"] 11 | :first-day 0} 12 | :ru-RU {:days ["воскресенье" "понедельник" "вторник" "среда" "четверг" "пятница" "суббота"] 13 | :days-short ["Вс" "Пн" "Вт" "Ср" "Чт" "Пт" "Сб"] 14 | :months ["Январь" "Февраль" "Март" "Апрель" "Май" "Июнь" "Июль" "Август" "Сентябрь" "Октябрь" "Ноябрь" "Декабрь"] 15 | :months-short ["Янв" "Фев" "Мар" "Апр" "Май" "Июн" "Июл" "Авг" "Сен" "Окт" "Ноя" "Дек"] 16 | :first-day 1} 17 | :fr-FR {:days ["dimanche" "lundi" "mardi" "mercredi" "jeudi" "vendredi" "samedi"] 18 | :days-short ["D" "L" "M" "M" "J" "V" "S"] 19 | :months ["janvier" "février" "mars" "avril" "mai" "juin" "juillet" "août" "septembre" "octobre" "novembre" "décembre"] 20 | :months-short ["janv." "févr." "mars" "avril" "mai" "juin" "juil." "aût" "sept." "oct." "nov." "déc."] 21 | :first-day 1} 22 | :de-DE {:days ["Sonntag" "Montag" "Dienstag" "Mittwoch" "Donnerstag" "Freitag" "Samstag"] 23 | :days-short ["So" "Mo" "Di" "Mi" "Do" "Fr" "Sa"] 24 | :months ["Januar" "Februar" "März" "April" "Mai" "Juni" "Juli" "August" "September" "Oktober" "November" "Dezember"] 25 | :months-short ["Jan" "Feb" "Mär" "Apr" "Mai" "Jun" "Jul" "Aug" "Sep" "Okt" "Nov" "Dez"] 26 | :first-day 1} 27 | :es-ES {:days ["domingo" "lunes" "martes" "miércoles" "jueves" "viernes" "sábado"] 28 | :days-short ["D" "L" "M" "X" "J" "V" "S"] 29 | :months ["enero" "febrero" "marzo" "abril" "mayo" "junio" "julio" "agosto" "septiembre" "octubre" "noviembre" "diciembre"] 30 | :months-short ["ene" "feb" "mar" "abr" "may" "jun" "jul" "ago" "sep" "oct" "nov" "dic"] 31 | :first-day 1} 32 | :pt-PT {:days ["Domingo" "Segunda-feira" "Terça-feira" "Quarta-feira" "Quinta-feira" "Sexta-feira" "Sábado"] 33 | :days-short ["Dom" "Seg" "Ter" "Qua" "Qui" "Sex" "Sáb"] 34 | :months ["Janeiro" "Fevereiro" "Março" "Abril" "Maio" "Junho" "Julho" "Agosto" "Setembro" "Outubro" "Novembro" "Dezembro"] 35 | :months-short ["Jan" "Fev" "Mar" "Abr" "Mai" "Jun" "Jul" "Ago" "Set" "Out" "Nov" "Dez"] 36 | :first-day 1} 37 | :fi-FI {:days ["Sunnuntai" "Maanantai" "Tiistai" "Keskiviikko" "Torstai" "Perjantai" "Lauantai"] 38 | :days-short ["Su" "Ma" "Ti" "Ke" "To" "Pe" "La"] 39 | :months ["Tammikuu" "Helmikuu" "Maaliskuu" "Huhtikuu" "Toukokuu" "Kesäkuu" "Heinäkuu" "Elokuu" "Syyskuu" "Lokakuu" "Marraskuu" "Joulukuu"] 40 | :months-short ["Tammi" "Helmi" "Maalis" "Huhti" "Touko" "Kesä" "Heinä" "Elo" "Syys" "Loka" "Marras" "Joulu"] 41 | :first-day 1} 42 | :nl-NL {:days ["zondag" "maandag" "dinsdag" "woensdag" "donderdag" "vrijdag" "zaterdag"] 43 | :days-short ["zo" "ma" "di" "wo" "do" "vr" "za"] 44 | :months ["januari" "februari" "maart" "april" "mei" "juni" "juli" "augustus" "september" "oktober" "november" "december"] 45 | :months-short ["jan" "feb" "maa" "apr" "mei" "jun" "jul" "aug" "sep" "okt" "nov" "dec"] 46 | :first-day 1}}) 47 | 48 | (defn separator-matcher [fmt] 49 | (if-let [separator (or (re-find #"[.\/\-\s].*?" fmt) " ")] 50 | [separator 51 | (condp = separator 52 | "." #"\." 53 | " " #"W+" 54 | (re-pattern separator))])) 55 | 56 | (defn split-parts [fmt matcher] 57 | (->> (s/split fmt matcher) (map keyword) vec)) 58 | 59 | (defn parse-format [fmt] 60 | (let [fmt (or fmt "mm/dd/yyyy") 61 | [separator matcher] (separator-matcher fmt) 62 | parts (split-parts fmt matcher)] 63 | (when (empty? parts) 64 | (throw (js/Error. "Invalid date format."))) 65 | {:separator separator :matcher matcher :parts parts})) 66 | 67 | (defn blank-date [] 68 | (doto (js/Date.) 69 | (.setHours 0) 70 | (.setMinutes 0) 71 | (.setSeconds 0) 72 | (.setMilliseconds 0))) 73 | 74 | (defn parse-date [date fmt] 75 | (let [parts (s/split date (:matcher fmt)) 76 | date (blank-date) 77 | fmt-parts (count (:parts fmt))] 78 | (if (= (count (:parts fmt)) (count parts)) 79 | (loop [year (.getFullYear date) 80 | month (.getMonth date) 81 | day (.getDate date) 82 | i 0] 83 | (if (not= i fmt-parts) 84 | (let [val (js/parseInt (parts i) 10) 85 | val (if (js/isNaN val) 1 val) 86 | part ((:parts fmt) i)] 87 | (cond 88 | (some #{part} [:d :dd]) (recur year month val (inc i)) 89 | (some #{part} [:m :mm]) (recur year (dec val) day (inc i)) 90 | (= part :yy) (recur (+ 2000 val) month day (inc i)) 91 | (= part :yyyy) (recur val month day (inc i)))) 92 | (js/Date. year month day 0 0 0))) 93 | date))) 94 | 95 | (defn formatted-value [v] 96 | (str (if (< v 10) "0" "") v)) 97 | 98 | (defn format-date [{:keys [year month day]} {:keys [separator parts]}] 99 | (s/join separator 100 | (map 101 | #(cond 102 | (some #{%} [:d :dd]) (formatted-value day) 103 | (some #{%} [:m :mm]) (formatted-value month) 104 | (= % :yy) (.substring (str year) 2) 105 | (= % :yyyy) year) 106 | parts))) 107 | 108 | (defn leap-year? [year] 109 | (or 110 | (and 111 | (= 0 (mod year 4)) 112 | (not= 0 (mod year 100))) 113 | (= 0 (mod year 400)))) 114 | 115 | (defn days-in-month [year month] 116 | ([31 (if (leap-year? year) 29 28) 31 30 31 30 31 31 30 31 30 31] month)) 117 | 118 | (defn first-day-of-week [year month local-first-day] 119 | (let [day-num (.getDay (js/Date. year month 1))] 120 | (mod (- day-num local-first-day) 7))) 121 | 122 | (defn gen-days [current-date get save! expanded? auto-close? local-first-day] 123 | (let [[year month day] @current-date 124 | num-days (days-in-month year month) 125 | last-month-days (if (pos? month) (days-in-month year (dec month))) 126 | first-day (first-day-of-week year month local-first-day)] 127 | (->> 128 | (for [i (range 42)] 129 | (cond 130 | (< i first-day) 131 | [:td.day.old 132 | (when last-month-days 133 | (- last-month-days (dec (- first-day i))))] 134 | (< i (+ first-day num-days)) 135 | (let [day (inc (- i first-day)) 136 | date {:year year :month (inc month) :day day}] 137 | [:td.day 138 | {:class (when-let [doc-date (get)] 139 | (when (= doc-date date) "active")) 140 | :on-click #(do 141 | (swap! current-date assoc-in [2] day) 142 | (if (= (get) date) 143 | (save! nil) 144 | (save! date)) 145 | (when auto-close? (reset! expanded? false)))} 146 | day]) 147 | :else 148 | [:td.day.new 149 | (when (< month 11) 150 | (inc (- i (+ first-day num-days))))])) 151 | (partition 7) 152 | (map (fn [week] (into [:tr] week)))))) 153 | 154 | (defn last-date [[year month day]] 155 | (if (pos? month) 156 | [year (dec month) day] 157 | [(dec year) 11 day])) 158 | 159 | (defn next-date [[year month day]] 160 | (if (= month 11) 161 | [(inc year) 0 day] 162 | [year (inc month) day])) 163 | 164 | (defn year-picker [date view-selector] 165 | (let [start-year (atom (- (first @date) 10))] 166 | (fn [] 167 | [:table.table-condensed 168 | [:thead 169 | [:tr 170 | [:th.prev {:on-click #(swap! start-year - 10)} "‹"] 171 | [:th.switch 172 | {:col-span 2} 173 | (str @start-year " - " (+ @start-year 10))] 174 | [:th.next {:on-click #(swap! start-year + 10)} "›"]]] 175 | (into [:tbody] 176 | (for [row (->> (range @start-year (+ @start-year 12)) (partition 4))] 177 | (into [:tr] 178 | (for [year row] 179 | [:td.year 180 | {:on-click #(do 181 | (swap! date assoc-in [0] year) 182 | (reset! view-selector :month)) 183 | :class (when (= year (first @date)) "active")} 184 | year]))))]))) 185 | 186 | (defn month-picker [date view-selector {:keys [months-short]}] 187 | (let [year (atom (first @date))] 188 | (fn [] 189 | [:table.table-condensed 190 | [:thead 191 | [:tr 192 | [:th.prev {:on-click #(swap! year dec)} "‹"] 193 | [:th.switch 194 | {:col-span 2 :on-click #(reset! view-selector :year)} @year] 195 | [:th.next {:on-click #(swap! year inc)} "›"]]] 196 | (into 197 | [:tbody] 198 | (for [row (->> months-short 199 | (map-indexed vector) 200 | (partition 4))] 201 | (into [:tr] 202 | (for [[idx month-name] row] 203 | [:td.month 204 | {:class 205 | (let [[cur-year cur-month] @date] 206 | (when (and (= @year cur-year) (= idx cur-month)) "active")) 207 | :on-click 208 | #(do 209 | (swap! date (fn [[_ _ day]] [@year idx day])) 210 | (reset! view-selector :day))} 211 | month-name]))))]))) 212 | 213 | (defn day-picker [date get save! view-selector expanded? auto-close? {:keys [months days-short first-day]}] 214 | (let [local-first-day first-day 215 | local-days-short (->> (cycle days-short) 216 | (drop local-first-day) ; first day as offset 217 | (take 7))] 218 | [:table.table-condensed 219 | [:thead 220 | [:tr 221 | [:th.prev {:on-click #(swap! date last-date)} "‹"] 222 | [:th.switch 223 | {:col-span 5 224 | :on-click #(reset! view-selector :month)} 225 | (str (nth months (second @date)) " " (first @date))] 226 | [:th.next {:on-click #(swap! date next-date)} "›"]] 227 | (into 228 | [:tr] 229 | (map-indexed (fn [i dow] 230 | ^{:key i} [:th.dow dow]) 231 | local-days-short))] 232 | (into [:tbody] 233 | (gen-days date get save! expanded? auto-close? local-first-day))])) 234 | 235 | (defn datepicker [year month day dom-node mouse-on-list? expanded? auto-close? get save! inline lang] 236 | (let [date (atom [year month day]) 237 | view-selector (atom :day) 238 | names (if (and (keyword? lang) (contains? dates lang)) 239 | (lang dates) 240 | (if (every? #(contains? lang %) [:months :months-short :days :days-short :first-day]) 241 | lang 242 | (:en-US dates)))] 243 | (fn [] 244 | [:div {:class (str "datepicker" (when-not @expanded? " dropdown-menu") (if inline " dp-inline" " dp-dropdown")) 245 | :on-mouse-enter #(reset! mouse-on-list? true) 246 | :on-mouse-leave #(reset! mouse-on-list? false) 247 | :on-click (fn [e] 248 | (.preventDefault e) 249 | (reset! mouse-on-list? true) 250 | (.focus @dom-node))} 251 | (condp = @view-selector 252 | :day [day-picker date get save! view-selector expanded? auto-close? names] 253 | :month [month-picker date view-selector names] 254 | :year [year-picker date view-selector])]))) 255 | -------------------------------------------------------------------------------- /src/reagent_forms/macros.cljc: -------------------------------------------------------------------------------- 1 | (ns reagent-forms.macros 2 | (:require [clojure.walk :refer [postwalk]])) 3 | 4 | (defmacro render-element [attrs doc & body] 5 | `(fn [] 6 | (let [update-disabled?# (not (some #{(:field ~attrs)} 7 | [:multi-select :single-select])) 8 | body# (postwalk 9 | (fn [c#] 10 | (if (map? c#) 11 | (-> c# 12 | (reagent-forms.core/set-validation-class ~doc) 13 | (reagent-forms.core/update-attrs ~doc) 14 | (reagent-forms.core/set-disabled update-disabled?#) 15 | (reagent-forms.core/clean-attrs)) 16 | c#)) 17 | ~@body)] 18 | (if-let [visible# (:visible? ~attrs)] 19 | (when (reagent-forms.core/call-attr ~doc visible#) 20 | body#) 21 | body#)))) 22 | -------------------------------------------------------------------------------- /test/reagent_forms/core_test.cljs: -------------------------------------------------------------------------------- 1 | (ns reagent-forms.core-test 2 | (:require [clojure.test :refer [deftest is are testing]] 3 | [reagent.core :as r] 4 | [reagent-forms.core :as core])) 5 | 6 | (defn- update&double 7 | [path value doc] 8 | (update-in doc path * 2)) 9 | 10 | (deftest scroll-to-test 11 | (let [item-elem {:scrollHeight 100 12 | :offsetTop 300} 13 | list-elem (fn [i] 14 | (is (= i 0)) 15 | (clj->js {:scrollTop 999 16 | :children 17 | {:item (fn [idx] 18 | (is (= idx 3)) 19 | (clj->js item-elem))}})) 20 | ul (fn [tag-name] 21 | (is (= tag-name "ul")) 22 | (clj->js {:item list-elem})) 23 | element (clj->js 24 | {:target 25 | {:parentNode 26 | {:getElementsByTagName ul}}})] 27 | (is (= 100 (core/scroll-to element 3))))) 28 | 29 | (deftest id->path-test 30 | (are [input expected] 31 | (= (core/id->path input) expected) 32 | :a [:a] 33 | :a.b.c [:a :b :c])) 34 | 35 | (deftest cursor-for-id-test 36 | (with-redefs [reagent.core/cursor (fn [doc id] [doc id])] 37 | (is (= (core/cursor-for-id :doc :a.b.c) 38 | [:doc [:a :b :c]])))) 39 | 40 | (deftest run-events-test 41 | (let [state {} 42 | f1 (fn [path value doc] 43 | (assoc-in doc path value))] 44 | (is (= (core/run-events state :kw 2 [f1 update&double update&double]) 45 | {:kw 8})))) 46 | 47 | (deftest mk-update-fn-test 48 | (testing "Value is associated in the doc." 49 | (let [state (r/atom {:kw 5}) 50 | f (core/mk-update-fn state []) 51 | update-fn (fn [_ val] val)] 52 | (f :kw update-fn :val) 53 | (is (= @state {:kw :val})))) 54 | (testing "Returned function runs all the events." 55 | (let [state (r/atom {:kw 5}) 56 | f (core/mk-update-fn state [update&double update&double]) 57 | update-fn (fn [_ val] val)] 58 | (f :kw update-fn 10) 59 | (is (= @state {:kw 40}))))) 60 | 61 | (deftest mk-save-fn-test 62 | (testing "Value is associated in the doc." 63 | (let [state (r/atom {}) 64 | f (core/mk-save-fn state [])] 65 | (f :kw :val) 66 | (is (= @state 67 | {:kw :val})))) 68 | (testing "Returned function runs all the events." 69 | (let [state (r/atom {}) 70 | f (core/mk-save-fn state [update&double update&double])] 71 | (f :kw 1) 72 | (is (= @state {:kw 4}))))) 73 | 74 | (deftest wrap-fns-test 75 | (testing "Functions map is properly formed." 76 | (let [fns {:doc :doc-fn 77 | :get :get-fn 78 | :save! :save-fn 79 | :update! :update-fn}] 80 | (is (= (core/wrap-fns fns nil) 81 | {:doc :doc-fn 82 | :get :get-fn 83 | :save! :save-fn 84 | :update! :update-fn})))) 85 | (testing "Functions are being wrapped." 86 | (let [fns {:doc :doc-fn 87 | :get :get-fn 88 | :save! :save-fn 89 | :update! :update-fn} 90 | node [:div {:in-fn :in-fn 91 | :out-fn :out-fn}] 92 | mock-wrap-fn (partial conj [])] 93 | (with-redefs [core/wrap-get-fn mock-wrap-fn 94 | core/wrap-save-fn mock-wrap-fn 95 | core/wrap-update-fn mock-wrap-fn] 96 | (is (= (core/wrap-fns fns node) 97 | {:doc :doc-fn 98 | :get [:get-fn :in-fn] 99 | :save! [:save-fn :out-fn] 100 | :update! [:update-fn :out-fn]})))))) 101 | 102 | (deftest format-value-test 103 | (are [format input expected] 104 | (= (core/format-value format input) expected) 105 | "%.2f" "0.123123" "0.12" 106 | "%d" "3.123123" "3")) 107 | 108 | (deftest format-type-test 109 | (are [field-type input expected] 110 | (= (core/format-type field-type input) expected) 111 | :numeric nil nil 112 | :numeric "xyz" nil 113 | :numeric "12" 12 114 | :numeric "12xyz" 12 115 | :numeric "0.123" 0.123 116 | 117 | :range nil nil 118 | :range "xyz" nil 119 | :range "12" 12 120 | :range "12xyz" 12 121 | :range "0.123" 0.123 122 | 123 | :other-type nil nil 124 | :other-type "xyz" "xyz" 125 | :other-type "12" "12")) 126 | 127 | (deftest bind-test 128 | (are [field expected] 129 | (= (dissoc 130 | (core/bind field 131 | {:get identity}) 132 | :on-change) 133 | expected) 134 | 135 | {:field :input-field 136 | :id "12.12312312" 137 | :fmt "%.2f"} 138 | {:value "12.12"} 139 | 140 | {:field :checkbox 141 | :id :id} 142 | {:checked true} 143 | 144 | {:field :checkbox 145 | :id false} 146 | {:checked false} 147 | 148 | {:field :some-field} nil)) 149 | 150 | (deftest set-attrs-test 151 | (let [div [:div {:checked true 152 | :default-checked true 153 | :fmt :fmt 154 | :event :event 155 | :field :field 156 | :inline :inline 157 | :save-fn :save-fn 158 | :preamble :preamble 159 | :postamble :postamble 160 | :visible? :visible? 161 | :date-format :date-format 162 | :auto-close? :auto-close? 163 | :random-attr :random-attr} 164 | "body"]] 165 | (testing "Attrs are cleaned." 166 | (is (= (core/set-attrs div {}) 167 | [:div {:random-attr :random-attr} "body"]))) 168 | (testing "Opts are binded." 169 | (with-redefs [core/bind (fn [attrs opts] 170 | (is (= attrs (second div))) 171 | opts)] 172 | (is (= (core/set-attrs div {:get :get :save! :save}) 173 | [:div {:random-attr :random-attr 174 | :get :get 175 | :save! :save} 176 | "body"])))) 177 | (testing "Default attributes are applied." 178 | (is (= (core/set-attrs div {} {:default-attr :default-attr}) 179 | [:div {:random-attr :random-attr 180 | :default-attr :default-attr} 181 | "body"]))))) 182 | 183 | (deftest init-field-test 184 | (let [dissoc-fns (fn [[type attrs & body]] 185 | (into [type (dissoc attrs :on-change 186 | :on-blur 187 | :on-focus 188 | :on-blur 189 | :on-change 190 | :on-key-down 191 | :on-mouse-enter 192 | :on-mouse-leave)] 193 | body))] 194 | 195 | ; typeahead 196 | (let [state {:ta "a"} 197 | [_ input ul] 198 | ((core/init-field [:div {:field :typeahead 199 | :id :ta 200 | :input-placeholder "pick a friend" 201 | :data-source (fn []) 202 | :input-class "form-control" 203 | :list-class "typeahead-list" 204 | :item-class "typeahead-item" 205 | :highlight-class "highlighted"}] 206 | {:doc (atom state) 207 | :get (fn [kw] (when kw (kw state))) 208 | :save! (fn [& _]) 209 | :update! (fn [& _])}))] 210 | (is (= (dissoc-fns input) 211 | [:input {:placeholder "pick a friend" 212 | :disabled nil 213 | :value "a" 214 | :type :text 215 | :class "form-control"}])) 216 | (is (= (dissoc-fns ul) 217 | [:ul {:style {:display :none} 218 | :class "typeahead-list"} 219 | []]))) 220 | 221 | ; single-select 222 | (let [state {} 223 | [_ component] 224 | ((core/init-field [:div.btn-group {:field :single-select :id :selected} 225 | [:button.btn.btn-default {:key :left} "Left"] 226 | [:button.btn.btn-default {:key :middle} "Middle"] 227 | [:button.btn.btn-default {:key :right} "Right"]] 228 | {:doc (atom state) 229 | :get (fn [kw] (when kw (kw state))) 230 | :save! (fn [& _]) 231 | :update! (fn [& _])}))] 232 | (is (= component 233 | [:div.btn-group {:field :single-select 234 | :id :selected} 235 | [:button.btn.btn-default {:key :left} "Left"] 236 | [:button.btn.btn-default {:key :middle} "Middle"] 237 | [:button.btn.btn-default {:key :right} "Right"]]))) 238 | 239 | (are [state input expected] 240 | (let [comp ((core/init-field input {:doc (atom state) 241 | :get (fn [kw] (when kw (kw state))) 242 | :save! (fn [& _]) 243 | :update! (fn [[& _]])}))] 244 | (is (= (dissoc-fns comp) expected))) 245 | ; container 246 | {} 247 | [:div {:field :container 248 | :valid? :invalid} 249 | "body"] 250 | [:div {:valid? :invalid} 251 | "body"] 252 | 253 | {:id "some-text"} 254 | [:div {:field :container 255 | :valid? :id} 256 | "body"] 257 | [:div {:valid? :id 258 | :class "some-text"} 259 | "body"] 260 | 261 | ; text 262 | {} 263 | [:input {:field :text}] 264 | [:input {:type :text :value ""}] 265 | 266 | {} 267 | [:input {:field :text :disabled (fn [] false)}] 268 | [:input {:type :text :value "" :disabled false}] 269 | 270 | {:id "some-text"} 271 | [:input {:field :text 272 | :id :id}] 273 | [:input {:type :text 274 | :value "some-text" 275 | :id :id}] 276 | 277 | ; numeric 278 | {} 279 | [:input {:field :numeric}] 280 | [:input {:type :text :value nil}] 281 | 282 | ; checkbox 283 | {} 284 | [:input {:field :checkbox :id :non-existent}] 285 | [:input {:type :checkbox :id :non-existent :checked false}] 286 | 287 | {:id "yep"} 288 | [:input {:field :checkbox :id :id}] 289 | [:input {:type :checkbox :id :id :checked true}] 290 | 291 | ; range 292 | {:id 12} 293 | [:input {:field :range :min 10 :max 100 :id :id}] 294 | [:input {:type :range 295 | :value 12 296 | :min 10 297 | :max 100 298 | :id :id}] 299 | 300 | ; radio 301 | {} 302 | [:input {:field :radio :value :b :name :radio}] 303 | [:input {:name :radio :type :radio :checked false}] 304 | 305 | {:id :a} 306 | [:input {:field :radio :value :a :name :id}] 307 | [:input {:name :id :type :radio :checked true}] 308 | 309 | ; file 310 | {} 311 | [:input {:field :file :type :file}] 312 | [:input {:type :file}] 313 | 314 | {} 315 | [:input {:field :file :multiple true}] 316 | [:input {:type :file :multiple true}] 317 | 318 | ; list 319 | {} 320 | [:select {:field :list :id :many-options} 321 | [:option {:key :foo} "foo"] 322 | [:option {:key :bar} "bar"] 323 | [:option {:key :baz} "baz"]] 324 | [:select {:id :many-options :default-value "foo"} 325 | [[:option {:key :foo} "foo"] 326 | [:option {:key :bar} "bar"] 327 | [:option {:key :baz} "baz"]]] 328 | 329 | {:many-options :bar} 330 | [:select {:field :list :id :many-options} 331 | [:option {:key :foo} "foo"] 332 | [:option {:key :bar} "bar"] 333 | [:option {:key :baz} "baz"]] 334 | [:select {:id :many-options :default-value "bar"} 335 | [[:option {:key :foo} "foo"] 336 | [:option {:key :bar} "bar"] 337 | [:option {:key :baz} "baz"]]]))) 338 | 339 | (deftest bind-fields-test 340 | (with-redefs [core/wrap-fns (fn [_ node] node) 341 | core/init-field (fn [node _] node)] 342 | (let [component [:div 343 | [:input {:field :text :id :a}] 344 | [:input {:field :numeric}] 345 | [:input {:field :range}]] 346 | result ((core/bind-fields component nil))] 347 | (is (= result component)))) 348 | 349 | (testing ":doc is associated with :get when map is passed." 350 | (with-redefs [core/init-field 351 | (fn [node opts] 352 | (is (= opts 353 | {:doc :get 354 | :get :get 355 | :save! :save! 356 | :update! :update!})) 357 | node)] 358 | (let [component [:div 359 | [:input {:field :text :id :a}] 360 | [:input {:field :numeric}] 361 | [:input {:field :range}]] 362 | result ((core/bind-fields component {:get :get 363 | :save! :save! 364 | :update! :update!}))] 365 | (is (= result component)))))) 366 | -------------------------------------------------------------------------------- /test/reagent_forms/tests_runner.cljs: -------------------------------------------------------------------------------- 1 | (ns reagent-forms.tests-runner 2 | (:require [doo.runner :refer-macros [doo-tests]] 3 | [reagent-forms.core-test])) 4 | 5 | (doo-tests 'reagent-forms.core-test) 6 | --------------------------------------------------------------------------------