├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── elm.json ├── example ├── .gitignore ├── README.md ├── elm.json ├── index.html └── src │ ├── Main.elm │ ├── Model.elm │ ├── Simple.elm │ ├── Update.elm │ ├── View.elm │ └── View │ └── Bootstrap.elm ├── package.json ├── src ├── Form.elm └── Form │ ├── Error.elm │ ├── Field.elm │ ├── Init.elm │ ├── Input.elm │ ├── Test.elm │ ├── Test │ ├── Helpers.elm │ └── ValidationExpectation.elm │ ├── Tree.elm │ └── Validate.elm └── tests ├── ChangedFields.elm ├── Example.elm ├── MetaTests.elm ├── Model.elm ├── Validate.elm └── ValidationTests.elm /.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff/ 2 | /index.html 3 | docs.json 4 | example/main.js 5 | node_modules/ 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | os: 4 | - linux 5 | - osx 6 | 7 | env: 8 | matrix: 9 | - ELM_VERSION=0.19.0 TARGET_NODE_VERSION=node 10 | - ELM_VERSION=0.19.0 TARGET_NODE_VERSION=4.0 11 | 12 | before_install: 13 | - if [ ${TRAVIS_OS_NAME} == "osx" ]; 14 | then brew update; brew install nvm; mkdir ~/.nvm; export NVM_DIR=~/.nvm; source $(brew --prefix nvm)/nvm.sh; 15 | fi 16 | - echo -e "Host github.com\n\tStrictHostKeyChecking no\n" >> ~/.ssh/config 17 | 18 | install: 19 | - nvm install $TARGET_NODE_VERSION 20 | - nvm use $TARGET_NODE_VERSION 21 | - node --version 22 | - npm --version 23 | - npm install -g elm@${ELM_VERSION} elm-test@0.19.0-beta4 24 | - npm install 25 | 26 | script: 27 | - node_modules/elm-format/bin/elm-format --validate src tests example 28 | - cd example 29 | - elm make src/Main.elm --output=/dev/null 30 | - cd .. 31 | - npm test 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Emilien Taque 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of elm-form nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elm Form 2 | 3 | HTML live form builders and validation for Elm. [![Build Status](https://travis-ci.org/etaque/elm-form.svg?branch=master)](https://travis-ci.org/etaque/elm-form) 4 | 5 | elm package install etaque/elm-form 6 | 7 | For when the classical "a message per field" doesn't work well for you, at the price of losing some type safety (field names are made of strings, see [#97](https://github.com/etaque/elm-form/issues/97)). 8 | 9 | ## Support 10 | 11 | We have a dedicated channel in [Elm slack](https://elmlang.herokuapp.com/), join us in `#elm-form` for any question, support or issues coordination. 12 | 13 | ## Features 14 | 15 | * Validation API similar to `Json.Decode` with the standard `map`, `andThen`, etc: you either get the desired output value or all field errors 16 | * HTML inputs helpers with pre-wired handlers for live validation 17 | * Suite of basic validations, with a way to add your own 18 | * Unlimited fields, see `andMap` function (as in `Json.Extra`) 19 | * Nested fields (`foo.bar.baz`) and lists (`todos.1.checked`) enabling rich form build 20 | 21 | See [complete example here](http://etaque.github.io/elm-form/example/) ([source code](https://github.com/etaque/elm-form/tree/master/example)). 22 | 23 | 24 | ## Basic usage 25 | 26 | See the [example validation test suite](https://github.com/etaque/elm-form/blob/master/tests/ValidationTests.elm) 27 | and [test helper function docs](http://package.elm-lang.org/packages/etaque/elm-form/latest/Form-Test) 28 | for how to test-drive validations. 29 | 30 | ```elm 31 | module Main exposing (Foo, Model, Msg(..), app, formView, init, update, validate, view) 32 | 33 | import Browser 34 | import Form exposing (Form) 35 | import Form.Input as Input 36 | import Form.Validate as Validate exposing (..) 37 | import Html exposing (..) 38 | import Html.Attributes exposing (..) 39 | import Html.Events exposing (..) 40 | 41 | 42 | 43 | -- your expected form output 44 | 45 | 46 | type alias Foo = 47 | { bar : String 48 | , baz : Bool 49 | } 50 | 51 | 52 | 53 | -- Add form to your model and msgs 54 | 55 | 56 | type alias Model = 57 | { form : Form () Foo } 58 | 59 | 60 | type Msg 61 | = NoOp 62 | | FormMsg Form.Msg 63 | 64 | 65 | 66 | -- Setup form validation 67 | 68 | 69 | init : Model 70 | init = 71 | { form = Form.initial [] validate } 72 | 73 | 74 | validate : Validation () Foo 75 | validate = 76 | succeed Foo 77 | |> andMap (field "bar" email) 78 | |> andMap (field "baz" bool) 79 | 80 | 81 | 82 | -- Forward form msgs to Form.update 83 | 84 | 85 | update : Msg -> Model -> Model 86 | update msg ({ form } as model) = 87 | case msg of 88 | NoOp -> 89 | model 90 | 91 | FormMsg formMsg -> 92 | { model | form = Form.update validate formMsg form } 93 | 94 | 95 | 96 | -- Render form with Input helpers 97 | 98 | 99 | view : Model -> Html Msg 100 | view { form } = 101 | Html.map FormMsg (formView form) 102 | 103 | 104 | formView : Form () Foo -> Html Form.Msg 105 | formView form = 106 | let 107 | -- error presenter 108 | errorFor field = 109 | case field.liveError of 110 | Just error -> 111 | -- replace toString with your own translations 112 | div [ class "error" ] [ text (Debug.toString error) ] 113 | 114 | Nothing -> 115 | text "" 116 | 117 | -- fields states 118 | bar = 119 | Form.getFieldAsString "bar" form 120 | 121 | baz = 122 | Form.getFieldAsBool "baz" form 123 | in 124 | div [] 125 | [ label [] [ text "Bar" ] 126 | , Input.textInput bar [] 127 | , errorFor bar 128 | , label [] 129 | [ Input.checkboxInput baz [] 130 | , text "Baz" 131 | ] 132 | , errorFor baz 133 | , button 134 | [ onClick Form.Submit ] 135 | [ text "Submit" ] 136 | ] 137 | 138 | 139 | app = 140 | Browser.sandbox 141 | { init = init 142 | , update = update 143 | , view = view 144 | } 145 | 146 | ``` 147 | 148 | 149 | ## Advanced usage 150 | 151 | ### Custom inputs 152 | 153 | * For rendering, `Form.getFieldAsString`/`Bool` provides a `FieldState` record with all required fields (see package doc). 154 | 155 | * For event handling, see all field related messages in `Form.Msg` type. 156 | 157 | Overall, having a look at current [helpers source code](https://github.com/etaque/elm-form/blob/master/src/Form/Input.elm) should give you a good idea of the thing. 158 | 159 | ### Incremental validation 160 | 161 | Similar to what Json.Extra provides you can also use `Form.andMap` 162 | 163 | ```elm 164 | Form.succeed Player 165 | |> andMap (field "email" (string |> andThen email)) 166 | |> andMap (field "power" int) 167 | ``` 168 | 169 | ### Nested records 170 | 171 | * Validation: 172 | 173 | ```elm 174 | validation = 175 | map2 Player 176 | (field "email" (string |> andThen email)) 177 | (field "power" (int |> andThen (minInt 0))) 178 | (field "options" 179 | (map2 Options 180 | (field "foo" string) 181 | (field "bar" string) 182 | ) 183 | ) 184 | ``` 185 | 186 | * View: 187 | 188 | ```elm 189 | Input.textInput (Form.getFieldAsString "options.foo" form) [] 190 | ``` 191 | 192 | ### Dynamic lists 193 | 194 | ```elm 195 | -- model 196 | type alias TodoList = 197 | { title : String 198 | , items : List String 199 | } 200 | 201 | -- validation 202 | validation : Validation () Issue 203 | validation = 204 | map2 TodoList 205 | (field "title" string) 206 | (field "items" (list string)) 207 | 208 | -- view 209 | formView : Form () Issue -> Html Form.Msg 210 | formView form = 211 | div 212 | [ class "todo-list" ] 213 | [ Input.textInput 214 | (Form.getFieldAsString "title" form) 215 | [ placeholder "Title" ] 216 | , div [ class "items" ] <| 217 | List.map 218 | (itemView form) 219 | (Form.getListIndexes "items" form) 220 | , button 221 | [ class "add" 222 | , onClick (Form.Append "items") 223 | ] 224 | [ text "Add" ] 225 | ] 226 | 227 | itemView : Form () Issue -> Int -> Html Form.Msg 228 | itemView form i = 229 | div 230 | [ class "item" ] 231 | [ Input.textInput 232 | (Form.getFieldAsString ("items." ++ (String.fromInt i)) form) 233 | [] 234 | , a 235 | [ class "remove" 236 | , onClick (Form.RemoveItem "items" i) 237 | ] 238 | [ text "Remove" ] 239 | ] 240 | ``` 241 | 242 | 243 | ### Initial values and reset 244 | 245 | * At form initialization: 246 | 247 | ```elm 248 | import Form.Field as Field 249 | 250 | 251 | initialFields : List ( String, Field ) 252 | initialFields = 253 | [ ( "power", Field.string "10" ) 254 | , ( "options" 255 | , Field.group 256 | [ ( "foo", Field.string "blah" ) 257 | , ( "bar", Field.string "meh" ) 258 | ] 259 | ) 260 | ] 261 | 262 | 263 | initialForm : Form 264 | initialForm = 265 | Form.initial initialFields validation 266 | ``` 267 | 268 | See `Form.Field` type for more options. 269 | 270 | * On demand: 271 | 272 | ```elm 273 | button [ onClick (Form.Reset initialFields) ] [ text "Reset" ] 274 | ``` 275 | 276 | *Note:* To have programmatic control over any `input[type=text]`/`textarea` value, like reseting or changing the value, you must set the `value` attribute with `Maybe.withDefault "" state.value`, as seen [here](https://github.com/etaque/elm-form/pull/57/files#diff-bfb877e82b2c89b329fcda943a258611R50). There's a downside of doing this: if the user types too fast, the caret can go crazy. 277 | 278 | More info: https://github.com/evancz/elm-html/pull/81#issuecomment-145676200 279 | 280 | 281 | ### Custom errors 282 | 283 | ```elm 284 | type LocalError = Fatal | NotSoBad 285 | 286 | validation : Validation LocalError Foo 287 | validation = 288 | (field "foo" (string |> customError Fatal)) 289 | 290 | -- creates `Form.Error.CustomError Fatal` 291 | ``` 292 | 293 | 294 | ### Async validation 295 | 296 | This package doesn't provide anything special for async validation, but doesn't prevent you to do that either. As field values are accessible from `update` with `Form.getStringAt/getBoolAt`, you can process them as you need, trigger effects like an HTTP request, and then add any errors to the view by yourself. 297 | 298 | Another way would be to enable dynamic validation reload, to make it dependant of an effect, as it's part of the form state. Please ping me if this feature would be useful to you. 299 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "etaque/elm-form", 4 | "summary": "Live validation of form inputs in Elm", 5 | "license": "BSD-3-Clause", 6 | "version": "3.0.0", 7 | "exposed-modules": [ 8 | "Form", 9 | "Form.Error", 10 | "Form.Validate", 11 | "Form.Field", 12 | "Form.Input", 13 | "Form.Init", 14 | "Form.Test", 15 | "Form.Test.ValidationExpectation" 16 | ], 17 | "elm-version": "0.19.0 <= v < 0.20.0", 18 | "dependencies": { 19 | "elm/core": "1.0.0 <= v < 2.0.0", 20 | "elm/html": "1.0.0 <= v < 2.0.0", 21 | "elm/json": "1.0.0 <= v < 2.0.0", 22 | "elm/regex": "1.0.0 <= v < 2.0.0", 23 | "elm/time": "1.0.0 <= v < 2.0.0", 24 | "elm-explorations/test": "1.0.0 <= v < 2.0.0" 25 | }, 26 | "test-dependencies": { 27 | "elm/parser": "1.0.0 <= v < 2.0.0" 28 | } 29 | } -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff/ 2 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Elm SimpleForm example 2 | 3 | Compile: 4 | 5 | $ elm make src/Main.elm --output main.js 6 | 7 | Then open `index.html` in your browser. 8 | -------------------------------------------------------------------------------- /example/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "../src/" 6 | ], 7 | "elm-version": "0.19.0", 8 | "dependencies": { 9 | "direct": { 10 | "elm/browser": "1.0.0", 11 | "elm/core": "1.0.0", 12 | "elm/html": "1.0.0", 13 | "elm/json": "1.0.0", 14 | "elm/parser": "1.0.0", 15 | "elm/regex": "1.0.0" 16 | }, 17 | "indirect": { 18 | "elm/time": "1.0.0", 19 | "elm/url": "1.0.0", 20 | "elm/virtual-dom": "1.0.0" 21 | } 22 | }, 23 | "test-dependencies": { 24 | "direct": {}, 25 | "indirect": {} 26 | } 27 | } -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Elm Form example 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /example/src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Browser 4 | import Update exposing (init, update) 5 | import View exposing (view) 6 | 7 | 8 | 9 | -- App 10 | 11 | 12 | main = 13 | Browser.sandbox 14 | { init = init 15 | , view = view 16 | , update = update 17 | } 18 | -------------------------------------------------------------------------------- /example/src/Model.elm: -------------------------------------------------------------------------------- 1 | module Model exposing (CustomError(..), Date, Model, Msg(..), Profile, Superpower(..), Todo, User, asyncCheck, dateParser, initialFields, naturalInt, roles, superpowers, validate, validateDate, validateProfile, validateSuperpower, validateTodo, validateUrl) 2 | 3 | import Form exposing (Form) 4 | import Form.Error as Error 5 | import Form.Field as Field exposing (Field) 6 | import Form.Validate as Validate exposing (..) 7 | import Parser exposing ((|.), (|=), Parser) 8 | import Regex 9 | 10 | 11 | type Msg 12 | = NoOp 13 | | FormMsg Form.Msg 14 | 15 | 16 | type alias Model = 17 | { form : Form CustomError User 18 | , userMaybe : Maybe User 19 | } 20 | 21 | 22 | type CustomError 23 | = Ooops 24 | | Nope 25 | | AlreadyTaken 26 | | InvalidSuperpower 27 | | InvalidDate 28 | 29 | 30 | type alias Date = 31 | { year : Int 32 | , month : Int 33 | , day : Int 34 | } 35 | 36 | 37 | type alias User = 38 | { name : String 39 | , email : String 40 | , admin : Bool 41 | , birthday : Date 42 | , profile : Profile 43 | , todos : List Todo 44 | } 45 | 46 | 47 | type alias Profile = 48 | { website : Maybe String 49 | , role : String 50 | , superpower : Superpower 51 | , age : Int 52 | , bio : String 53 | } 54 | 55 | 56 | type Superpower 57 | = Flying 58 | | Invisible 59 | 60 | 61 | type alias Todo = 62 | { done : Bool 63 | , label : String 64 | } 65 | 66 | 67 | initialFields : List ( String, Field ) 68 | initialFields = 69 | [ ( "name", Field.string "hey" ) 70 | , ( "profile" 71 | , Field.group 72 | [ ( "age", Field.string "33" ) ] 73 | ) 74 | , ( "todos" 75 | , Field.list 76 | [ Field.group 77 | [ ( "done", Field.bool True ) 78 | , ( "label", Field.string "Remember the milk" ) 79 | ] 80 | ] 81 | ) 82 | ] 83 | 84 | 85 | roles : List String 86 | roles = 87 | [ "role1", "role2" ] 88 | 89 | 90 | superpowers : List String 91 | superpowers = 92 | [ "flying", "invisible" ] 93 | 94 | 95 | validate : Validation CustomError User 96 | validate = 97 | map6 98 | User 99 | (field "name" (string |> andThen nonEmpty)) 100 | (field "email" (email |> andThen (asyncCheck True))) 101 | (field "admin" (bool |> defaultValue False)) 102 | (field "date" validateDate) 103 | (field "profile" validateProfile) 104 | (field "todos" (list validateTodo)) 105 | 106 | 107 | validateDate : Validation CustomError Date 108 | validateDate = 109 | let 110 | parseDate text = 111 | text 112 | |> Parser.run dateParser 113 | |> Result.mapError (always (Error.value (Error.CustomError InvalidDate))) 114 | 115 | -- This should use much more complicated logic to ensure it's actually valid 116 | validateDayIsValid date validationField = 117 | if date.month > 12 || date.month < 1 then 118 | Err (Error.value (Error.CustomError InvalidDate)) 119 | 120 | else if date.day > 31 || date.day < 1 then 121 | Err (Error.value (Error.CustomError InvalidDate)) 122 | 123 | else 124 | Ok date 125 | in 126 | customValidation string parseDate 127 | |> andThen validateDayIsValid 128 | |> mapError (always (Error.value (Error.CustomError InvalidDate))) 129 | 130 | 131 | dateParser : Parser Date 132 | dateParser = 133 | Parser.succeed Date 134 | |= Parser.int 135 | |. Parser.symbol "-" 136 | |. Parser.chompIf ((==) '0') 137 | |= Parser.int 138 | |. Parser.symbol "-" 139 | |. Parser.chompIf ((==) '0') 140 | |= Parser.int 141 | |. Parser.end 142 | 143 | 144 | validateProfile : Validation CustomError Profile 145 | validateProfile = 146 | succeed Profile 147 | |> andMap 148 | (field "website" 149 | (oneOf 150 | [ emptyString |> map (\_ -> Nothing) 151 | , validateUrl |> map Just 152 | ] 153 | ) 154 | ) 155 | |> andMap (field "role" (string |> andThen (includedIn roles))) 156 | |> andMap (field "superpower" validateSuperpower) 157 | |> andMap (field "age" naturalInt) 158 | |> andMap (field "bio" (string |> defaultValue "")) 159 | 160 | 161 | validateSuperpower : Validation CustomError Superpower 162 | validateSuperpower = 163 | customValidation 164 | string 165 | (\s -> 166 | case s of 167 | "flying" -> 168 | Ok Flying 169 | 170 | "invisible" -> 171 | Ok Invisible 172 | 173 | _ -> 174 | Err (customError InvalidSuperpower) 175 | ) 176 | 177 | 178 | validateTodo : Validation CustomError Todo 179 | validateTodo = 180 | map2 Todo 181 | (field "done" bool) 182 | (field "label" string) 183 | 184 | 185 | {-| Check if the string is a valid URL. 186 | -} 187 | validateUrl : Validation e String 188 | validateUrl = 189 | let 190 | urlRegex = 191 | Regex.fromString "^(https?://)" 192 | |> Maybe.withDefault Regex.never 193 | in 194 | string 195 | |> andThen (format urlRegex) 196 | 197 | 198 | 199 | -- eq. to: int `andThen` (minInt 0) 200 | 201 | 202 | naturalInt : Validation CustomError Int 203 | naturalInt = 204 | customValidation 205 | int 206 | (\i -> 207 | if i > 0 then 208 | Ok i 209 | 210 | else 211 | Err (customError Nope) 212 | ) 213 | 214 | 215 | asyncCheck : Bool -> String -> Validation CustomError String 216 | asyncCheck serverIsOk s = 217 | if serverIsOk then 218 | succeed s 219 | 220 | else 221 | fail (customError AlreadyTaken) 222 | -------------------------------------------------------------------------------- /example/src/Simple.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (Foo, Model, Msg(..), app, formView, init, update, validate, view) 2 | 3 | import Browser 4 | import Form exposing (Form) 5 | import Form.Input as Input 6 | import Form.Validate as Validate exposing (..) 7 | import Html exposing (..) 8 | import Html.Attributes exposing (..) 9 | import Html.Events exposing (..) 10 | 11 | 12 | 13 | -- your expected form output 14 | 15 | 16 | type alias Foo = 17 | { bar : String 18 | , baz : Bool 19 | } 20 | 21 | 22 | 23 | -- Add form to your model and msgs 24 | 25 | 26 | type alias Model = 27 | { form : Form () Foo } 28 | 29 | 30 | type Msg 31 | = NoOp 32 | | FormMsg Form.Msg 33 | 34 | 35 | 36 | -- Setup form validation 37 | 38 | 39 | init : Model 40 | init = 41 | { form = Form.initial [] validate } 42 | 43 | 44 | validate : Validation () Foo 45 | validate = 46 | succeed Foo 47 | |> andMap (field "bar" email) 48 | |> andMap (field "baz" bool) 49 | 50 | 51 | 52 | -- Forward form msgs to Form.update 53 | 54 | 55 | update : Msg -> Model -> Model 56 | update msg ({ form } as model) = 57 | case msg of 58 | NoOp -> 59 | model 60 | 61 | FormMsg formMsg -> 62 | { model | form = Form.update validate formMsg form } 63 | 64 | 65 | 66 | -- Render form with Input helpers 67 | 68 | 69 | view : Model -> Html Msg 70 | view { form } = 71 | Html.map FormMsg (formView form) 72 | 73 | 74 | formView : Form () Foo -> Html Form.Msg 75 | formView form = 76 | let 77 | -- error presenter 78 | errorFor field = 79 | case field.liveError of 80 | Just error -> 81 | -- replace toString with your own translations 82 | div [ class "error" ] [ text (Debug.toString error) ] 83 | 84 | Nothing -> 85 | text "" 86 | 87 | -- fields states 88 | bar = 89 | Form.getFieldAsString "bar" form 90 | 91 | baz = 92 | Form.getFieldAsBool "baz" form 93 | in 94 | div [] 95 | [ label [] [ text "Bar" ] 96 | , Input.textInput bar [] 97 | , errorFor bar 98 | , label [] 99 | [ Input.checkboxInput baz [] 100 | , text "Baz" 101 | ] 102 | , errorFor baz 103 | , button 104 | [ onClick Form.Submit ] 105 | [ text "Submit" ] 106 | ] 107 | 108 | 109 | app = 110 | Browser.sandbox 111 | { init = init 112 | , update = update 113 | , view = view 114 | } 115 | -------------------------------------------------------------------------------- /example/src/Update.elm: -------------------------------------------------------------------------------- 1 | module Update exposing (init, update) 2 | 3 | import Form exposing (Form) 4 | import Model exposing (..) 5 | 6 | 7 | init : Model 8 | init = 9 | { form = Form.initial initialFields validate, userMaybe = Nothing } 10 | 11 | 12 | update : Msg -> Model -> Model 13 | update msg ({ form } as model) = 14 | case msg of 15 | NoOp -> 16 | model 17 | 18 | FormMsg formMsg -> 19 | case ( formMsg, Form.getOutput form ) of 20 | ( Form.Submit, Just user ) -> 21 | { model | userMaybe = Just user } 22 | 23 | _ -> 24 | { model | form = Form.update validate formMsg form } 25 | -------------------------------------------------------------------------------- /example/src/View.elm: -------------------------------------------------------------------------------- 1 | module View exposing (formView, todoItemView, todosView, view) 2 | 3 | import Form exposing (Form) 4 | import Form.Input as Input 5 | import Html exposing (..) 6 | import Html.Attributes exposing (..) 7 | import Html.Events exposing (..) 8 | import Model exposing (..) 9 | import Set exposing (Set) 10 | import String 11 | import View.Bootstrap exposing (..) 12 | 13 | 14 | view : Model -> Html Msg 15 | view { form, userMaybe } = 16 | div 17 | [] 18 | [ Html.map FormMsg (formView form) 19 | , case userMaybe of 20 | Just user -> 21 | p [ class "alert alert-success" ] [ text (Debug.toString user) ] 22 | 23 | Nothing -> 24 | text "" 25 | ] 26 | 27 | 28 | formView : Form CustomError User -> Html Form.Msg 29 | formView form = 30 | let 31 | roleOptions = 32 | ( "", "--" ) :: List.map (\i -> ( i, String.toUpper i )) roles 33 | 34 | superpowerOptions = 35 | List.map (\i -> ( i, String.toUpper i )) superpowers 36 | 37 | disableSubmit = 38 | Set.isEmpty <| Form.getChangedFields form 39 | 40 | submitBtnAttributes = 41 | [ onClick Form.Submit 42 | , classList 43 | [ ( "btn btn-primary", True ) 44 | , ( "disabled", disableSubmit ) 45 | ] 46 | ] 47 | ++ (if disableSubmit then 48 | [ attribute "disabled" "true" ] 49 | 50 | else 51 | [] 52 | ) 53 | in 54 | div 55 | [ class "form-horizontal" 56 | , style "margin" "50px auto" 57 | , style "width" "600px" 58 | ] 59 | [ legend [] [ text "Elm Simple Form example" ] 60 | , textGroup (text "Name") 61 | (Form.getFieldAsString "name" form) 62 | , textGroup (text "Email address") 63 | (Form.getFieldAsString "email" form) 64 | , checkboxGroup (text "Administrator") 65 | (Form.getFieldAsBool "admin" form) 66 | , dateGroup (text "Date") 67 | (Form.getFieldAsString "date" form) 68 | , textGroup (text "Website") 69 | (Form.getFieldAsString "profile.website" form) 70 | , selectGroup roleOptions 71 | (text "Role") 72 | (Form.getFieldAsString "profile.role" form) 73 | , radioGroup superpowerOptions 74 | (text "Superpower") 75 | (Form.getFieldAsString "profile.superpower" form) 76 | , textGroup (text "Age") 77 | (Form.getFieldAsString "profile.age" form) 78 | , textAreaGroup (text "Bio") 79 | (Form.getFieldAsString "profile.bio" form) 80 | , todosView form 81 | , formActions 82 | [ button submitBtnAttributes 83 | [ text "Submit" ] 84 | , text " " 85 | , button 86 | [ onClick (Form.Reset initialFields) 87 | , class "btn btn-default" 88 | ] 89 | [ text "Reset" ] 90 | ] 91 | ] 92 | 93 | 94 | todosView : Form CustomError User -> Html Form.Msg 95 | todosView form = 96 | let 97 | allTodos = 98 | List.concatMap (todoItemView form) (Form.getListIndexes "todos" form) 99 | in 100 | div 101 | [ class "row" ] 102 | [ colN 3 103 | [ label [ class "control-label" ] [ text "Todolist" ] 104 | , br [] [] 105 | , button [ onClick (Form.Append "todos"), class "btn btn-xs btn-default" ] [ text "Add" ] 106 | ] 107 | , colN 9 108 | [ div [ class "todos" ] allTodos 109 | ] 110 | ] 111 | 112 | 113 | todoItemView : Form CustomError User -> Int -> List (Html Form.Msg) 114 | todoItemView form i = 115 | let 116 | labelField = 117 | Form.getFieldAsString ("todos." ++ String.fromInt i ++ ".label") form 118 | in 119 | [ div 120 | [ class ("input-group" ++ errorClass labelField.liveError) ] 121 | [ span 122 | [ class "input-group-addon" ] 123 | [ Input.checkboxInput 124 | (Form.getFieldAsBool ("todos." ++ String.fromInt i ++ ".done") form) 125 | [] 126 | ] 127 | , Input.textInput 128 | labelField 129 | [ class "form-control" ] 130 | , span 131 | [ class "input-group-btn" ] 132 | [ button 133 | [ onClick (Form.RemoveItem "todos" i), class "btn btn-danger" ] 134 | [ text "Remove" ] 135 | ] 136 | ] 137 | , br [] [] 138 | ] 139 | -------------------------------------------------------------------------------- /example/src/View/Bootstrap.elm: -------------------------------------------------------------------------------- 1 | module View.Bootstrap exposing (GroupBuilder, checkboxGroup, colN, dateGroup, errorClass, errorMessage, formActions, formGroup, radioGroup, row, selectGroup, textAreaGroup, textGroup) 2 | 3 | import Form exposing (FieldState, Form) 4 | import Form.Error exposing (Error, ErrorValue) 5 | import Form.Field as Field 6 | import Form.Input as Input 7 | import Html exposing (..) 8 | import Html.Attributes exposing (..) 9 | import Model exposing (..) 10 | 11 | 12 | row : List (Html Form.Msg) -> Html Form.Msg 13 | row content = 14 | div [ class "row" ] content 15 | 16 | 17 | colN : Int -> List (Html Form.Msg) -> Html Form.Msg 18 | colN i content = 19 | div [ class ("col-xs-" ++ String.fromInt i) ] content 20 | 21 | 22 | type alias GroupBuilder a = 23 | Html Form.Msg -> FieldState CustomError a -> Html Form.Msg 24 | 25 | 26 | formGroup : Html Form.Msg -> Maybe (ErrorValue CustomError) -> List (Html Form.Msg) -> Html Form.Msg 27 | formGroup label_ maybeError inputs = 28 | div 29 | [ class ("row form-group " ++ errorClass maybeError) ] 30 | [ colN 3 31 | [ label [ class "control-label" ] [ label_ ] ] 32 | , colN 5 33 | inputs 34 | , colN 4 35 | [ errorMessage maybeError ] 36 | ] 37 | 38 | 39 | formActions : List (Html Form.Msg) -> Html Form.Msg 40 | formActions content = 41 | row 42 | [ div [ class "col-xs-offset-3 col-xs-9" ] content ] 43 | 44 | 45 | textGroup : GroupBuilder String 46 | textGroup label_ state = 47 | formGroup label_ 48 | state.liveError 49 | [ Input.textInput state 50 | [ class "form-control" 51 | , value (Maybe.withDefault "" state.value) 52 | ] 53 | ] 54 | 55 | 56 | dateGroup : GroupBuilder String 57 | dateGroup label_ state = 58 | formGroup label_ 59 | state.liveError 60 | [ Input.baseInput "date" 61 | Field.String 62 | Form.Text 63 | state 64 | [ class "form-control" 65 | , value (Maybe.withDefault "" state.value) 66 | , placeholder "yyyy-mm-dd" 67 | ] 68 | ] 69 | 70 | 71 | textAreaGroup : GroupBuilder String 72 | textAreaGroup label_ state = 73 | formGroup label_ 74 | state.liveError 75 | [ Input.textArea state 76 | [ class "form-control" 77 | , value (Maybe.withDefault "" state.value) 78 | ] 79 | ] 80 | 81 | 82 | checkboxGroup : GroupBuilder Bool 83 | checkboxGroup label_ state = 84 | formGroup (text "") 85 | state.liveError 86 | [ div 87 | [ class "checkbox" ] 88 | [ label [] 89 | [ Input.checkboxInput state [] 90 | , label_ 91 | ] 92 | ] 93 | ] 94 | 95 | 96 | selectGroup : List ( String, String ) -> GroupBuilder String 97 | selectGroup options label_ state = 98 | formGroup label_ 99 | state.liveError 100 | [ Input.selectInput options state [ class "form-control" ] ] 101 | 102 | 103 | radioGroup : List ( String, String ) -> GroupBuilder String 104 | radioGroup options label_ state = 105 | let 106 | item ( v, l ) = 107 | label 108 | [ class "radio-inline" ] 109 | [ Input.radioInput v state [] 110 | , text l 111 | ] 112 | in 113 | formGroup label_ 114 | state.liveError 115 | (List.map item options) 116 | 117 | 118 | errorClass : Maybe error -> String 119 | errorClass maybeError = 120 | Maybe.map (\_ -> "has-error") maybeError |> Maybe.withDefault "" 121 | 122 | 123 | errorMessage : Maybe (ErrorValue CustomError) -> Html Form.Msg 124 | errorMessage maybeError = 125 | case maybeError of 126 | Just error -> 127 | p 128 | [ class "help-block" ] 129 | [ text (Debug.toString error) ] 130 | 131 | Nothing -> 132 | span 133 | [ class "help-block" ] 134 | [ text "\u{2007}" ] 135 | 136 | 137 | 138 | --   139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elm-form", 3 | "version": "1.0.0", 4 | "description": "HTML live forms builders and validation for Elm.", 5 | "main": "index.js", 6 | "directories": { 7 | "example": "example", 8 | "test": "tests" 9 | }, 10 | "scripts": { 11 | "test": "node_modules/elm-test/bin/elm-test" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/etaque/elm-form.git" 16 | }, 17 | "author": "Emilien Taque", 18 | "license": "BSD-3-Clause", 19 | "bugs": { 20 | "url": "https://github.com/etaque/elm-form/issues" 21 | }, 22 | "homepage": "https://github.com/etaque/elm-form#readme", 23 | "devDependencies": { 24 | "elm-format": "0.8.0", 25 | "elm-test": "v0.19.0-beta4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Form.elm: -------------------------------------------------------------------------------- 1 | module Form exposing 2 | ( Msg(..), InputType(..), Form, FieldState 3 | , initial, update 4 | , getFieldAsString, getFieldAsBool, getListIndexes 5 | , getFocus, isSubmitted, getErrors, getOutput, getChangedFields 6 | ) 7 | 8 | {-| Simple forms made easy: A Dict implementation of the core `Json.Decode` API, 9 | with state lifecycle and input helpers for the views. 10 | 11 | 12 | # Types 13 | 14 | @docs Msg, InputType, Form, FieldState 15 | 16 | 17 | # Init/update lifecyle 18 | 19 | @docs initial, update 20 | 21 | 22 | # Field state accessors 23 | 24 | @docs getFieldAsString, getFieldAsBool, getListIndexes 25 | 26 | 27 | # Global state accessors 28 | 29 | @docs getFocus, isSubmitted, getErrors, getOutput, getChangedFields 30 | 31 | -} 32 | 33 | import Dict exposing (Dict) 34 | import Form.Error as Error exposing (Error, ErrorValue) 35 | import Form.Field as Field exposing (Field, FieldValue) 36 | import Form.Tree as Tree 37 | import Form.Validate as Validate exposing (Validation) 38 | import Result 39 | import Set exposing (Set) 40 | 41 | 42 | {-| Form to embed in your model. Type parameters are: 43 | 44 | - `customError` - a custom error type to extend built-in errors (set to `()` if you don't need it) 45 | - `output` - the type of the validation output. 46 | 47 | -} 48 | type Form customError output 49 | = F (Model customError output) 50 | 51 | 52 | {-| Private 53 | -} 54 | type alias Model customError output = 55 | { fields : Field 56 | , focus : Maybe String 57 | , dirtyFields : Set String 58 | , changedFields : Set String 59 | , originalValues : Dict String (Maybe FieldValue) 60 | , isSubmitted : Bool 61 | , output : Maybe output 62 | , errors : Error customError 63 | } 64 | 65 | 66 | {-| Initial form state. See `Form.Field` for initial fields, and `Form.Validate` for validation. 67 | -} 68 | initial : List ( String, Field ) -> Validation e output -> Form e output 69 | initial initialFields validation = 70 | let 71 | model = 72 | { fields = Tree.group initialFields 73 | , focus = Nothing 74 | , dirtyFields = Set.empty 75 | , changedFields = Set.empty 76 | , originalValues = Dict.empty 77 | , isSubmitted = False 78 | , output = Nothing 79 | , errors = Tree.group [] 80 | } 81 | in 82 | F (updateValidate validation model) 83 | 84 | 85 | {-| Field state containing all necessary data for view and update, 86 | can be retrived with `Form.getFieldAsString` or `Form.getFieldAsBool`. 87 | 88 | - `path` - qualified path of the field in the form, with dots for nested fields (`field.subfield`) 89 | - `value` - a `Maybe` of the requested type 90 | - `error` - a `Maybe` of the field error 91 | - `liveError` - same but with added logic for live validation 92 | (see [`getLiveErrorAt`](https://github.com/etaque/elm-form/blob/master/src/Form.elm) impl) 93 | - `isDirty` - if the field content has been changed since last validation 94 | - `isChanged` - if the field value has changed since last init/reset 95 | - `hasFocus` - if the field is currently focused 96 | 97 | -} 98 | type alias FieldState e a = 99 | { path : String 100 | , value : Maybe a 101 | , error : Maybe (ErrorValue e) 102 | , liveError : Maybe (ErrorValue e) 103 | , isDirty : Bool 104 | , isChanged : Bool 105 | , hasFocus : Bool 106 | } 107 | 108 | 109 | {-| Get field state at path, with value as a `String`. 110 | -} 111 | getFieldAsString : String -> Form e o -> FieldState e String 112 | getFieldAsString = 113 | getField getStringAt 114 | 115 | 116 | {-| Get field state at path, with value as a `Bool`. 117 | -} 118 | getFieldAsBool : String -> Form e o -> FieldState e Bool 119 | getFieldAsBool = 120 | getField getBoolAt 121 | 122 | 123 | getField : (String -> Form e o -> Maybe a) -> String -> Form e o -> FieldState e a 124 | getField getValue path form = 125 | { path = path 126 | , value = getValue path form 127 | , error = getErrorAt path form 128 | , liveError = getLiveErrorAt path form 129 | , isDirty = isDirtyAt path form 130 | , isChanged = isChangedAt path form 131 | , hasFocus = getFocus form == Just path 132 | } 133 | 134 | 135 | {-| return a list of indexes so one can build qualified names of fields in list. 136 | -} 137 | getListIndexes : String -> Form e o -> List Int 138 | getListIndexes path (F model) = 139 | let 140 | length = 141 | getFieldAt path model 142 | |> Maybe.map (Tree.asList >> List.length) 143 | |> Maybe.withDefault 0 144 | in 145 | List.range 0 (length - 1) 146 | 147 | 148 | {-| Form messages for `update`. 149 | -} 150 | type Msg 151 | = NoOp 152 | | Focus String 153 | | Blur String 154 | | Input String InputType FieldValue 155 | | Append String 156 | | RemoveItem String Int 157 | | Submit 158 | | Validate 159 | | Reset (List ( String, Field )) 160 | 161 | 162 | {-| Input types to determine live validation behaviour. 163 | -} 164 | type InputType 165 | = Text 166 | | Textarea 167 | | Select 168 | | Radio 169 | | Checkbox 170 | 171 | 172 | {-| Update form state with the given message 173 | -} 174 | update : Validation e output -> Msg -> Form e output -> Form e output 175 | update validation msg (F model) = 176 | case msg of 177 | NoOp -> 178 | F model 179 | 180 | Focus name -> 181 | let 182 | newModel = 183 | { model | focus = Just name } 184 | in 185 | F newModel 186 | 187 | Blur name -> 188 | let 189 | newDirtyFields = 190 | Set.remove name model.dirtyFields 191 | 192 | newModel = 193 | { model | focus = Nothing, dirtyFields = newDirtyFields } 194 | in 195 | F (updateValidate validation newModel) 196 | 197 | Input name inputType fieldValue -> 198 | let 199 | newFields = 200 | setFieldAt name (Tree.Value fieldValue) model 201 | 202 | isDirty = 203 | case inputType of 204 | Text -> 205 | True 206 | 207 | Textarea -> 208 | True 209 | 210 | _ -> 211 | False 212 | 213 | newDirtyFields = 214 | if isDirty then 215 | Set.insert name model.dirtyFields 216 | 217 | else 218 | model.dirtyFields 219 | 220 | ( newChangedFields, newOriginalValues ) = 221 | if Set.member name model.changedFields then 222 | let 223 | storedValue = 224 | Dict.get name model.originalValues 225 | |> Maybe.withDefault Nothing 226 | 227 | shouldBeNothing v = 228 | case v of 229 | Field.String "" -> 230 | True 231 | 232 | Field.Bool False -> 233 | True 234 | 235 | _ -> 236 | False 237 | 238 | sameAsOriginal = 239 | case storedValue of 240 | Just v -> 241 | v == fieldValue 242 | 243 | Nothing -> 244 | shouldBeNothing fieldValue 245 | 246 | changedFields = 247 | if sameAsOriginal then 248 | Set.remove name model.changedFields 249 | 250 | else 251 | model.changedFields 252 | in 253 | ( changedFields, model.originalValues ) 254 | 255 | else 256 | let 257 | originalValue = 258 | getFieldAt name model |> Maybe.andThen Tree.asValue 259 | in 260 | ( Set.insert name model.changedFields, Dict.insert name originalValue model.originalValues ) 261 | 262 | newModel = 263 | { model 264 | | fields = newFields 265 | , dirtyFields = newDirtyFields 266 | , changedFields = newChangedFields 267 | , originalValues = newOriginalValues 268 | } 269 | in 270 | F (updateValidate validation newModel) 271 | 272 | Append listName -> 273 | let 274 | listFields = 275 | getFieldAt listName model 276 | |> Maybe.map Tree.asList 277 | |> Maybe.withDefault [] 278 | 279 | newListFields = 280 | listFields ++ [ Tree.Value Field.EmptyField ] 281 | 282 | newModel = 283 | { model 284 | | fields = setFieldAt listName (Tree.List newListFields) model 285 | } 286 | in 287 | F newModel 288 | 289 | RemoveItem listName index -> 290 | let 291 | listFields = 292 | getFieldAt listName model 293 | |> Maybe.map Tree.asList 294 | |> Maybe.withDefault [] 295 | 296 | fieldNamePattern = 297 | listName ++ String.fromInt index 298 | 299 | filterChangedFields = 300 | Set.filter (not << String.startsWith fieldNamePattern) 301 | 302 | filterOriginalValue = 303 | Dict.filter (\c _ -> not <| String.startsWith fieldNamePattern c) 304 | 305 | newListFields = 306 | List.take index listFields ++ List.drop (index + 1) listFields 307 | 308 | newModel = 309 | { model 310 | | fields = setFieldAt listName (Tree.List newListFields) model 311 | , changedFields = filterChangedFields model.changedFields 312 | , originalValues = filterOriginalValue model.originalValues 313 | } 314 | in 315 | F (updateValidate validation newModel) 316 | 317 | Submit -> 318 | let 319 | validatedModel = 320 | updateValidate validation model 321 | in 322 | F { validatedModel | isSubmitted = True } 323 | 324 | Validate -> 325 | F (updateValidate validation model) 326 | 327 | Reset fields -> 328 | let 329 | newModel = 330 | { model 331 | | fields = Tree.group fields 332 | , dirtyFields = Set.empty 333 | , changedFields = Set.empty 334 | , originalValues = Dict.empty 335 | , isSubmitted = False 336 | } 337 | in 338 | F (updateValidate validation newModel) 339 | 340 | 341 | updateValidate : Validation e o -> Model e o -> Model e o 342 | updateValidate validation model = 343 | case validation model.fields of 344 | Ok output -> 345 | { model 346 | | errors = 347 | Tree.group [] 348 | , output = Just output 349 | } 350 | 351 | Err error -> 352 | { model 353 | | errors = 354 | error 355 | , output = Nothing 356 | } 357 | 358 | 359 | getFieldAt : String -> Model e o -> Maybe Field 360 | getFieldAt qualifiedName model = 361 | Tree.getAtPath qualifiedName model.fields 362 | 363 | 364 | getStringAt : String -> Form e o -> Maybe String 365 | getStringAt name (F model) = 366 | getFieldAt name model |> Maybe.andThen Field.asString 367 | 368 | 369 | getBoolAt : String -> Form e o -> Maybe Bool 370 | getBoolAt name (F model) = 371 | getFieldAt name model |> Maybe.andThen Field.asBool 372 | 373 | 374 | setFieldAt : String -> Field -> Model e o -> Field 375 | setFieldAt path field model = 376 | Tree.setAtPath path field model.fields 377 | 378 | 379 | {-| Get form output, in case of validation success. 380 | -} 381 | getOutput : Form e o -> Maybe o 382 | getOutput (F model) = 383 | model.output 384 | 385 | 386 | {-| Get form submission state. Useful to show errors on unchanged fields. 387 | -} 388 | isSubmitted : Form e o -> Bool 389 | isSubmitted (F model) = 390 | model.isSubmitted 391 | 392 | 393 | {-| Get list of errors on qualified paths. 394 | -} 395 | getErrors : Form e o -> List ( String, Error.ErrorValue e ) 396 | getErrors (F model) = 397 | Tree.valuesWithPath model.errors 398 | 399 | 400 | getErrorAt : String -> Form e o -> Maybe (ErrorValue e) 401 | getErrorAt path (F model) = 402 | Tree.getAtPath path model.errors |> Maybe.andThen Tree.asValue 403 | 404 | 405 | getLiveErrorAt : String -> Form e o -> Maybe (ErrorValue e) 406 | getLiveErrorAt name form = 407 | if isSubmitted form || (isChangedAt name form && not (isDirtyAt name form)) then 408 | getErrorAt name form 409 | 410 | else 411 | Nothing 412 | 413 | 414 | isChangedAt : String -> Form e o -> Bool 415 | isChangedAt qualifiedName (F model) = 416 | Set.member qualifiedName model.changedFields 417 | 418 | 419 | isDirtyAt : String -> Form e o -> Bool 420 | isDirtyAt qualifiedName (F model) = 421 | Set.member qualifiedName model.dirtyFields 422 | 423 | 424 | {-| Return currently focused field, if any. 425 | -} 426 | getFocus : Form e o -> Maybe String 427 | getFocus (F model) = 428 | model.focus 429 | 430 | 431 | {-| Get set of changed fields. 432 | -} 433 | getChangedFields : Form e o -> Set String 434 | getChangedFields (F model) = 435 | model.changedFields 436 | -------------------------------------------------------------------------------- /src/Form/Error.elm: -------------------------------------------------------------------------------- 1 | module Form.Error exposing (Error, ErrorValue(..), value) 2 | 3 | {-| Validation errors. 4 | 5 | @docs Error, ErrorValue, value 6 | 7 | -} 8 | 9 | import Form.Tree as Tree exposing (Tree) 10 | 11 | 12 | {-| Tree of errors. 13 | -} 14 | type alias Error e = 15 | Tree (ErrorValue e) 16 | 17 | 18 | {-| A validation error. See `Form.Validate.customError` for `CustomError` building. 19 | -} 20 | type ErrorValue e 21 | = Empty 22 | | InvalidString 23 | | InvalidEmail 24 | | InvalidFormat 25 | | InvalidInt 26 | | InvalidFloat 27 | | InvalidBool 28 | | SmallerIntThan Int 29 | | GreaterIntThan Int 30 | | SmallerFloatThan Float 31 | | GreaterFloatThan Float 32 | | ShorterStringThan Int 33 | | LongerStringThan Int 34 | | NotIncludedIn 35 | | CustomError e 36 | 37 | 38 | {-| Build a tree node (a leaf) for this error 39 | -} 40 | value : ErrorValue a -> Error a 41 | value = 42 | Tree.Value 43 | -------------------------------------------------------------------------------- /src/Form/Field.elm: -------------------------------------------------------------------------------- 1 | module Form.Field exposing 2 | ( Field, FieldValue(..), value, string, bool, group, list 3 | , asString, asBool 4 | ) 5 | 6 | {-| Read and write field values. 7 | 8 | 9 | # Constructors 10 | 11 | @docs Field, FieldValue, value, string, bool, group, list 12 | 13 | 14 | # Value readers 15 | 16 | @docs asString, asBool 17 | 18 | -} 19 | 20 | import Form.Tree as Tree exposing (Tree) 21 | 22 | 23 | {-| A field is a tree node. 24 | -} 25 | type alias Field = 26 | Tree FieldValue 27 | 28 | 29 | {-| Form field. Can either be a group of named fields, or a final field. 30 | -} 31 | type FieldValue 32 | = String String 33 | | Bool Bool 34 | | EmptyField 35 | 36 | 37 | {-| Build a field from its value. 38 | -} 39 | value : FieldValue -> Field 40 | value = 41 | Tree.Value 42 | 43 | 44 | {-| Build a string field, for text inputs, selects, etc. 45 | -} 46 | string : String -> Field 47 | string = 48 | String >> Tree.Value 49 | 50 | 51 | {-| Build a boolean field, for checkboxes. 52 | -} 53 | bool : Bool -> Field 54 | bool = 55 | Bool >> Tree.Value 56 | 57 | 58 | {-| Gather named fields as a group field. 59 | -} 60 | group : List ( String, Field ) -> Field 61 | group = 62 | Tree.group 63 | 64 | 65 | {-| Gather fields as a list field. 66 | -} 67 | list : List Field -> Field 68 | list = 69 | Tree.List 70 | 71 | 72 | {-| Get field value as boolean. 73 | -} 74 | asBool : Field -> Maybe Bool 75 | asBool field = 76 | case field of 77 | Tree.Value (Bool b) -> 78 | Just b 79 | 80 | _ -> 81 | Nothing 82 | 83 | 84 | {-| Get field value as string. 85 | -} 86 | asString : Field -> Maybe String 87 | asString field = 88 | case field of 89 | Tree.Value (String s) -> 90 | Just s 91 | 92 | _ -> 93 | Nothing 94 | -------------------------------------------------------------------------------- /src/Form/Init.elm: -------------------------------------------------------------------------------- 1 | module Form.Init exposing (setBool, setString, setGroup, setList) 2 | 3 | {-| Helpers for initial fields values 4 | 5 | @docs setBool, setString, setGroup, setList 6 | 7 | -} 8 | 9 | import Form.Field as Field exposing (Field, FieldValue) 10 | 11 | 12 | {-| Builds a tuple of field name and boolean value 13 | -} 14 | setBool : String -> Bool -> ( String, Field ) 15 | setBool name b = 16 | ( name, Field.bool b ) 17 | 18 | 19 | {-| Builds a tuple of field name and string value 20 | -} 21 | setString : String -> String -> ( String, Field ) 22 | setString name str = 23 | ( name, Field.string str ) 24 | 25 | 26 | {-| Build a group of values, for nested forms. 27 | -} 28 | setGroup : String -> List ( String, Field ) -> ( String, Field ) 29 | setGroup name pairs = 30 | ( name, Field.group pairs ) 31 | 32 | 33 | {-| Build a list of values, for dynamic fields setList 34 | -} 35 | setList : String -> List Field -> ( String, Field ) 36 | setList name items = 37 | ( name, Field.list items ) 38 | -------------------------------------------------------------------------------- /src/Form/Input.elm: -------------------------------------------------------------------------------- 1 | module Form.Input exposing 2 | ( Input 3 | , baseInput, textInput, passwordInput, textArea, checkboxInput, selectInput, radioInput 4 | ) 5 | 6 | {-| Html input view helpers, wired for elm-form validation. 7 | 8 | @docs Input 9 | 10 | @docs baseInput, textInput, passwordInput, textArea, checkboxInput, selectInput, radioInput 11 | 12 | -} 13 | 14 | import Form exposing (FieldState, Form, InputType(..), Msg(..)) 15 | import Form.Error exposing (ErrorValue(..)) 16 | import Form.Field as Field exposing (Field, FieldValue(..)) 17 | import Html exposing (..) 18 | import Html.Attributes as HtmlAttr exposing (..) 19 | import Html.Events exposing (..) 20 | import Json.Decode as Json 21 | import Maybe exposing (andThen) 22 | import String 23 | 24 | 25 | {-| An input renders Html from a field state and list of additional attributes. 26 | All input functions using this type alias are pre-wired with event handlers. 27 | -} 28 | type alias Input e a = 29 | FieldState e a -> List (Attribute Msg) -> Html Msg 30 | 31 | 32 | {-| Untyped input, first param is `type` attribute. 33 | -} 34 | baseInput : String -> (String -> FieldValue) -> InputType -> Input e String 35 | baseInput t toFieldValue inputType state attrs = 36 | let 37 | formAttrs = 38 | [ type_ t 39 | , value (state.value |> Maybe.withDefault "") 40 | , onInput (toFieldValue >> Input state.path inputType) 41 | , onFocus (Focus state.path) 42 | , onBlur (Blur state.path) 43 | ] 44 | in 45 | input (formAttrs ++ attrs) [] 46 | 47 | 48 | {-| Text input. 49 | -} 50 | textInput : Input e String 51 | textInput = 52 | baseInput "text" String Text 53 | 54 | 55 | {-| Password input. 56 | -} 57 | passwordInput : Input e String 58 | passwordInput = 59 | baseInput "password" String Text 60 | 61 | 62 | {-| Textarea. 63 | -} 64 | textArea : Input e String 65 | textArea state attrs = 66 | let 67 | formAttrs = 68 | [ value (state.value |> Maybe.withDefault "") 69 | , onInput (String >> Input state.path Textarea) 70 | , onFocus (Focus state.path) 71 | , onBlur (Blur state.path) 72 | ] 73 | in 74 | Html.textarea (formAttrs ++ attrs) [] 75 | 76 | 77 | {-| Select input. 78 | -} 79 | selectInput : List ( String, String ) -> Input e String 80 | selectInput options state attrs = 81 | let 82 | formAttrs = 83 | [ on 84 | "change" 85 | (targetValue |> Json.map (String >> Input state.path Select)) 86 | , onFocus (Focus state.path) 87 | , onBlur (Blur state.path) 88 | ] 89 | 90 | buildOption ( k, v ) = 91 | option [ value k, selected (state.value == Just k) ] [ text v ] 92 | in 93 | select (formAttrs ++ attrs) (List.map buildOption options) 94 | 95 | 96 | {-| Checkbox input. 97 | -} 98 | checkboxInput : Input e Bool 99 | checkboxInput state attrs = 100 | let 101 | formAttrs = 102 | [ type_ "checkbox" 103 | , checked (state.value |> Maybe.withDefault False) 104 | , onCheck (Bool >> Input state.path Checkbox) 105 | , onFocus (Focus state.path) 106 | , onBlur (Blur state.path) 107 | ] 108 | in 109 | input (formAttrs ++ attrs) [] 110 | 111 | 112 | {-| Radio input. 113 | -} 114 | radioInput : String -> Input e String 115 | radioInput value state attrs = 116 | let 117 | formAttrs = 118 | [ type_ "radio" 119 | , name state.path 120 | , HtmlAttr.value value 121 | , checked (state.value == Just value) 122 | , onFocus (Focus state.path) 123 | , onBlur (Blur state.path) 124 | , on 125 | "change" 126 | (targetValue |> Json.map (String >> Input state.path Radio)) 127 | ] 128 | in 129 | input (formAttrs ++ attrs) [] 130 | -------------------------------------------------------------------------------- /src/Form/Test.elm: -------------------------------------------------------------------------------- 1 | module Form.Test exposing (describeValidation, testValidation) 2 | 3 | {-| Helpers to test your validations. See 4 | [an example validation test suite here](https://github.com/etaque/elm-form/tree/master/tests). 5 | 6 | @docs describeValidation, testValidation 7 | 8 | -} 9 | 10 | import Form.Test.Helpers as TestHelpers 11 | import Form.Test.ValidationExpectation exposing (ValidationExpectation(..)) 12 | import Form.Validate as Validate exposing (Validation) 13 | import Test exposing (..) 14 | 15 | 16 | {-| Test your `Validation`s with a List of test input String, ValidationExpectation pairs. 17 | 18 | import Form.Error 19 | import Form.Test exposing (..) 20 | import Form.Test.ValidationExpectation exposing (ValidationExpectation(..)) 21 | import Form.Validate 22 | 23 | describeValidation "email" 24 | Form.Validate.email 25 | [ ( "valid@email.com", Valid ) 26 | , ( "This is definitely not an email address" 27 | , Invalid Form.Error.InvalidEmail 28 | ) 29 | ] 30 | 31 | -} 32 | describeValidation : String -> Validate.Validation e a -> List ( String, ValidationExpectation e a ) -> Test 33 | describeValidation description validation cases = 34 | let 35 | testCases = 36 | List.map (testValidation validation) cases 37 | in 38 | describe (description ++ " validations") testCases 39 | 40 | 41 | {-| Create a single test case for a `Validation`. 42 | 43 | import Form.Error 44 | import Form.Test exposing (..) 45 | import Form.Test.ValidationExpectation exposing (ValidationExpectation(..)) 46 | import Form.Validate 47 | 48 | testValidation Form.Validate.email ( "valid@email.com", Valid ) 49 | 50 | -} 51 | testValidation : Validate.Validation e a -> ( String, ValidationExpectation e a ) -> Test 52 | testValidation validation (( stringToValidate, validationExpectation ) as validationCase) = 53 | let 54 | shallowExpectationString = 55 | case validationExpectation of 56 | Valid -> 57 | "Valid" 58 | 59 | ValidDecodesTo a -> 60 | "ValidDecodesTo" 61 | 62 | Invalid _ -> 63 | "Invalid " 64 | 65 | InvalidCustomError _ -> 66 | "InvalidCustomError" 67 | in 68 | Test.test ("expect " ++ shallowExpectationString ++ "with input '" ++ stringToValidate ++ "'") <| 69 | \() -> 70 | TestHelpers.getValidationExpectation validation validationCase 71 | -------------------------------------------------------------------------------- /src/Form/Test/Helpers.elm: -------------------------------------------------------------------------------- 1 | module Form.Test.Helpers exposing (expectError, getValidationExpectation, run) 2 | 3 | import Expect exposing (Expectation) 4 | import Form.Error exposing (ErrorValue) 5 | import Form.Field as Field 6 | import Form.Test.ValidationExpectation exposing (..) 7 | import Form.Tree 8 | import Form.Validate as Validate exposing (Validation) 9 | 10 | 11 | getValidationExpectation : Validation e a -> ( String, ValidationExpectation e a ) -> Expect.Expectation 12 | getValidationExpectation validation ( stringToValidate, validationExpectation ) = 13 | case validationExpectation of 14 | Valid -> 15 | case run validation stringToValidate of 16 | Ok resultingString -> 17 | Expect.pass 18 | 19 | Err formWithErrors -> 20 | case Form.Tree.valuesWithPath formWithErrors of 21 | [ ( "fieldKey", actualErrorValue ) ] -> 22 | Invalid actualErrorValue 23 | |> Expect.equal Valid 24 | 25 | _ -> 26 | Expect.fail "Unexpected form structure (this is probably a bug in this testing library)." 27 | 28 | Invalid expectedError -> 29 | expectError validation stringToValidate expectedError validationExpectation 30 | 31 | Form.Test.ValidationExpectation.ValidDecodesTo decodesToValue -> 32 | case run validation stringToValidate of 33 | Ok resultingString -> 34 | Expect.equal decodesToValue resultingString 35 | 36 | Err formWithErrors -> 37 | case Form.Tree.valuesWithPath formWithErrors of 38 | [ ( "fieldKey", actualErrorValue ) ] -> 39 | Invalid actualErrorValue 40 | |> Expect.equal Valid 41 | 42 | _ -> 43 | Expect.fail "Unexpected form structure (this is probably a bug in this testing library)." 44 | 45 | Form.Test.ValidationExpectation.InvalidCustomError expectedCustomError -> 46 | expectError validation stringToValidate (Form.Error.CustomError expectedCustomError) validationExpectation 47 | 48 | 49 | expectError : Validation e a -> String -> ErrorValue e -> ValidationExpectation e a -> Expectation 50 | expectError validation stringToValidate expectedError validationExpectation = 51 | case run validation stringToValidate of 52 | Ok value -> 53 | Expect.equal validationExpectation (ValidDecodesTo value) 54 | 55 | Err formWithErrors -> 56 | case Form.Tree.valuesWithPath formWithErrors of 57 | [ ( "fieldKey", actualError ) ] -> 58 | Expect.equal (Invalid expectedError) (Invalid actualError) 59 | 60 | _ -> 61 | Expect.fail "Unexpected form structure (this is probably a bug in this testing library)." 62 | 63 | 64 | run : Validation e a -> String -> Result (Form.Error.Error e) a 65 | run validation stringToValidate = 66 | Field.group [ ( "fieldKey", Field.string stringToValidate ) ] 67 | |> Validate.field "fieldKey" validation 68 | -------------------------------------------------------------------------------- /src/Form/Test/ValidationExpectation.elm: -------------------------------------------------------------------------------- 1 | module Form.Test.ValidationExpectation exposing (ValidationExpectation(..)) 2 | 3 | {-| Test expectations for use with functions from `Form.Test`. 4 | 5 | @docs ValidationExpectation 6 | 7 | -} 8 | 9 | import Form.Error 10 | 11 | 12 | {-| Use with `Form.Test.describeValidation` or `Form.Test.testValidation` to express the result you expect. 13 | -} 14 | type ValidationExpectation e a 15 | = Valid 16 | | ValidDecodesTo a 17 | | Invalid (Form.Error.ErrorValue e) 18 | | InvalidCustomError e 19 | -------------------------------------------------------------------------------- /src/Form/Tree.elm: -------------------------------------------------------------------------------- 1 | module Form.Tree exposing 2 | ( Tree(..), group 3 | , getAtPath, getAtName, getAtIndex, asList, asValue, valuesWithPath 4 | , setAtPath 5 | ) 6 | 7 | {-| Data structures 8 | 9 | 10 | # Tree structure and builders 11 | 12 | @docs Tree, group 13 | 14 | 15 | # Readers 16 | 17 | @docs getAtPath, getAtName, getAtIndex, asList, asValue, valuesWithPath 18 | 19 | 20 | # Writers 21 | 22 | @docs setAtPath 23 | 24 | -} 25 | 26 | import Dict exposing (Dict) 27 | import String 28 | 29 | 30 | {-| Field values and errors are stored as trees. 31 | -} 32 | type Tree value 33 | = Group (Dict String (Tree value)) 34 | | List (List (Tree value)) 35 | | Value value 36 | 37 | 38 | type Fragment 39 | = StringFragment String 40 | | IntFragment Int 41 | 42 | 43 | {-| Get node at given path 44 | -} 45 | getAtPath : String -> Tree value -> Maybe (Tree value) 46 | getAtPath path tree = 47 | let 48 | walkPath fragment maybeField = 49 | case fragment of 50 | IntFragment index -> 51 | maybeField |> Maybe.andThen (getAtIndex index) 52 | 53 | StringFragment name -> 54 | maybeField |> Maybe.andThen (getAtName name) 55 | in 56 | List.foldl walkPath (Just tree) (extractFragments path) 57 | 58 | 59 | {-| Get node at name, if group 60 | -} 61 | getAtName : String -> Tree value -> Maybe (Tree value) 62 | getAtName name value = 63 | case value of 64 | Group items -> 65 | Dict.get name items 66 | 67 | _ -> 68 | Nothing 69 | 70 | 71 | {-| Get node at index, if list of nodes. 72 | -} 73 | getAtIndex : Int -> Tree value -> Maybe (Tree value) 74 | getAtIndex index value = 75 | case value of 76 | List items -> 77 | items 78 | |> List.drop index 79 | |> List.head 80 | 81 | Group items -> 82 | Dict.get (String.fromInt index) items 83 | 84 | Value _ -> 85 | Nothing 86 | 87 | 88 | {-| Get list of errors on qualified paths. 89 | -} 90 | valuesWithPath : Tree value -> List ( String, value ) 91 | valuesWithPath tree = 92 | let 93 | mapGroupItem path ( name, error ) = 94 | walkTree (path ++ [ name ]) error 95 | 96 | walkTree path value = 97 | case value of 98 | Group items -> 99 | List.concatMap 100 | (mapGroupItem path) 101 | (Dict.toList items) 102 | 103 | List items -> 104 | List.concatMap 105 | (mapGroupItem path) 106 | (List.indexedMap (\index item -> ( String.fromInt index, item )) items) 107 | 108 | Value item -> 109 | [ ( String.join "." path, item ) ] 110 | in 111 | walkTree [] tree 112 | 113 | 114 | {-| Extract value, if possible. 115 | -} 116 | asValue : Tree value -> Maybe value 117 | asValue node = 118 | case node of 119 | Value value -> 120 | Just value 121 | 122 | _ -> 123 | Nothing 124 | 125 | 126 | {-| Get field as a list of fields 127 | -} 128 | asList : Tree value -> List (Tree value) 129 | asList value = 130 | case value of 131 | List items -> 132 | items 133 | 134 | _ -> 135 | [] 136 | 137 | 138 | {-| Helper to create a group value. 139 | -} 140 | group : List ( String, Tree value ) -> Tree value 141 | group items = 142 | items 143 | |> Dict.fromList 144 | |> Group 145 | 146 | 147 | extractFragments : String -> List Fragment 148 | extractFragments name = 149 | String.split "." name 150 | |> List.map toFragment 151 | 152 | 153 | toFragment : String -> Fragment 154 | toFragment s = 155 | String.toInt s 156 | |> Maybe.map IntFragment 157 | |> Maybe.withDefault (StringFragment s) 158 | 159 | 160 | {-| Set node in tree at given path. 161 | -} 162 | setAtPath : String -> Tree value -> Tree value -> Tree value 163 | setAtPath path node tree = 164 | recursiveSet (extractFragments path) node tree 165 | 166 | 167 | recursiveSet : List Fragment -> Tree value -> Tree value -> Tree value 168 | recursiveSet fragments node tree = 169 | case fragments of 170 | head :: rest -> 171 | case head of 172 | IntFragment index -> 173 | asList tree 174 | |> updateListAtIndex index (recursiveSet rest node) 175 | |> List 176 | 177 | StringFragment name -> 178 | let 179 | target = 180 | getAtName name tree |> Maybe.withDefault (Group Dict.empty) 181 | 182 | childNode = 183 | recursiveSet rest node target 184 | in 185 | merge (Group (Dict.fromList [ ( name, childNode ) ])) tree 186 | 187 | [] -> 188 | node 189 | 190 | 191 | updateListAtIndex : Int -> (a -> a) -> List a -> List a 192 | updateListAtIndex index updater = 193 | List.indexedMap 194 | (\i f -> 195 | if i == index then 196 | updater f 197 | 198 | else 199 | f 200 | ) 201 | 202 | 203 | merge : Tree value -> Tree value -> Tree value 204 | merge t1 t2 = 205 | case ( t1, t2 ) of 206 | ( Group g1, Group g2 ) -> 207 | Group (Dict.union g1 g2) 208 | 209 | _ -> 210 | t1 211 | -------------------------------------------------------------------------------- /src/Form/Validate.elm: -------------------------------------------------------------------------------- 1 | module Form.Validate exposing 2 | ( Validation, field, map, succeed, andThen, andMap, customError, defaultValue, mapError, withCustomError, sequence 3 | , map2, map3, map4, map5, map6, map7, map8 4 | , list, string, int, float, bool, maybe, email, emptyString 5 | , minInt, maxInt, minFloat, maxFloat, minLength, maxLength, nonEmpty, format, includedIn 6 | , fail, customValidation, oneOf 7 | ) 8 | 9 | {-| Form validation. 10 | 11 | 12 | # Combinators 13 | 14 | @docs Validation, field, map, succeed, andThen, andMap, customError, defaultValue, mapError, withCustomError, sequence 15 | 16 | 17 | # Fixed-size forms 18 | 19 | @docs map2, map3, map4, map5, map6, map7, map8 20 | 21 | 22 | # Type extractors 23 | 24 | @docs list, string, int, float, bool, date, maybe, email, emptyString 25 | 26 | 27 | # Common filters 28 | 29 | @docs minInt, maxInt, minFloat, maxFloat, minLength, maxLength, nonEmpty, format, includedIn 30 | 31 | 32 | # Custom validations 33 | 34 | @docs fail, customValidation, oneOf 35 | 36 | -} 37 | 38 | import Dict exposing (Dict) 39 | import Form.Error as Error exposing (Error, ErrorValue(..)) 40 | import Form.Field as Field exposing (Field, FieldValue(..)) 41 | import Form.Tree as Tree 42 | import Regex exposing (Regex) 43 | import Result 44 | import String 45 | 46 | 47 | {-| A validation is a function that takes a form field and returns a result 48 | being either a validation error or the expected object. 49 | -} 50 | type alias Validation customError output = 51 | Field -> Result (Error customError) output 52 | 53 | 54 | {-| Map over the result of the validation. 55 | 56 | field "myfield" (string |> map String.trim) 57 | 58 | -} 59 | map : (a -> b) -> Validation e a -> Validation e b 60 | map f validation validationField = 61 | Result.map f (validation validationField) 62 | 63 | 64 | {-| Apply a new validation to the result of the validation. 65 | 66 | field "myfield" (int |> andThen (minInt 10)) 67 | 68 | -} 69 | andThen : (a -> Validation e b) -> Validation e a -> Validation e b 70 | andThen callback validation validationField = 71 | validation validationField |> Result.andThen (\next -> callback next validationField) 72 | 73 | 74 | {-| Incremental form validation for records with more that 8 fields. 75 | 76 | Form.Validate.succeed SomeRecord 77 | |> andMap (field "foo" string) 78 | |> andMap (field "bar" string) 79 | 80 | -} 81 | andMap : Validation e a -> Validation e (a -> b) -> Validation e b 82 | andMap aValidation partialValidation validationField = 83 | case ( partialValidation validationField, aValidation validationField ) of 84 | ( Ok partial, Ok a ) -> 85 | Ok (partial a) 86 | 87 | ( partialResult, aResult ) -> 88 | Err (mergeMany [ errMaybe partialResult, errMaybe aResult ]) 89 | 90 | 91 | {-| Rescue a failed validation with the supplied value. 92 | -} 93 | defaultValue : a -> Validation e a -> Validation e a 94 | defaultValue a validation validationField = 95 | Ok (Result.withDefault a (validation validationField)) 96 | 97 | 98 | {-| Call Result.mapError on validation result. 99 | -} 100 | mapError : (Error e1 -> Error e2) -> Validation e1 a -> Validation e2 a 101 | mapError f validation = 102 | \validationField -> Result.mapError f (validation validationField) 103 | 104 | 105 | {-| Arrange that if a validation fails, it has the given custom error. 106 | 107 | field "customerId" 108 | (V.int 109 | |> andThen (minInt 1) 110 | |> andThen (maxInt 9999) 111 | |> withCustomError InvalidIdentity 112 | ) 113 | 114 | -} 115 | withCustomError : customErr -> Validation e a -> Validation customErr a 116 | withCustomError = 117 | mapError << always << customError 118 | 119 | 120 | {-| Helper to create a CustomError. 121 | -} 122 | customError : e -> Error e 123 | customError = 124 | CustomError >> Error.value 125 | 126 | 127 | {-| Access the given field in the group. 128 | 129 | field "name" string 130 | 131 | -} 132 | field : String -> Validation e a -> Validation e a 133 | field key validation validationField = 134 | Tree.getAtName key validationField 135 | |> Maybe.withDefault (Tree.Value EmptyField) 136 | |> validation 137 | |> Result.mapError 138 | (\e -> Tree.group [ ( key, e ) ]) 139 | 140 | 141 | {-| Validation a form with two fields. 142 | -} 143 | map2 : (a -> b -> m) -> Validation e a -> Validation e b -> Validation e m 144 | map2 func v1 v2 = 145 | map func v1 146 | |> andMap v2 147 | 148 | 149 | 150 | -- apply (form1 func v1) v2 151 | 152 | 153 | {-| Validation a form with three fields. 154 | -} 155 | map3 : (a -> b -> c -> m) -> Validation e a -> Validation e b -> Validation e c -> Validation e m 156 | map3 func v1 v2 v3 = 157 | map2 func v1 v2 158 | |> andMap v3 159 | 160 | 161 | {-| Validation a form with four fields. 162 | -} 163 | map4 : (a -> b -> c -> d -> m) -> Validation e a -> Validation e b -> Validation e c -> Validation e d -> Validation e m 164 | map4 func v1 v2 v3 v4 = 165 | map3 func v1 v2 v3 166 | |> andMap v4 167 | 168 | 169 | {-| Validation a form with five fields. 170 | -} 171 | map5 : (a -> b -> c -> d -> e -> m) -> Validation err a -> Validation err b -> Validation err c -> Validation err d -> Validation err e -> Validation err m 172 | map5 func v1 v2 v3 v4 v5 = 173 | map4 func v1 v2 v3 v4 174 | |> andMap v5 175 | 176 | 177 | {-| Validation a form with six fields. 178 | -} 179 | map6 : (a -> b -> c -> d -> e -> f -> m) -> Validation err a -> Validation err b -> Validation err c -> Validation err d -> Validation err e -> Validation err f -> Validation err m 180 | map6 func v1 v2 v3 v4 v5 v6 = 181 | map5 func v1 v2 v3 v4 v5 182 | |> andMap v6 183 | 184 | 185 | {-| Validation a form with seven fields. 186 | -} 187 | map7 : (a -> b -> c -> d -> e -> f -> g -> m) -> Validation err a -> Validation err b -> Validation err c -> Validation err d -> Validation err e -> Validation err f -> Validation err g -> Validation err m 188 | map7 func v1 v2 v3 v4 v5 v6 v7 = 189 | map6 func v1 v2 v3 v4 v5 v6 190 | |> andMap v7 191 | 192 | 193 | {-| Validation a form with eight fields. 194 | -} 195 | map8 : (a -> b -> c -> d -> e -> f -> g -> h -> m) -> Validation err a -> Validation err b -> Validation err c -> Validation err d -> Validation err e -> Validation err f -> Validation err g -> Validation err h -> Validation err m 196 | map8 func v1 v2 v3 v4 v5 v6 v7 v8 = 197 | map7 func v1 v2 v3 v4 v5 v6 v7 198 | |> andMap v8 199 | 200 | 201 | {-| Private 202 | -} 203 | mergeMany : List (Maybe (Error e)) -> Error e 204 | mergeMany errors = 205 | errors 206 | |> List.filterMap identity 207 | |> List.foldl groupErrorsUnion (Tree.group []) 208 | 209 | 210 | {-| Private 211 | -} 212 | groupErrorsUnion : Error e -> Error e -> Error e 213 | groupErrorsUnion e1 e2 = 214 | case ( e1, e2 ) of 215 | ( Tree.Group g1, Tree.Group g2 ) -> 216 | Tree.Group (Dict.union g1 g2) 217 | 218 | _ -> 219 | e2 220 | 221 | 222 | {-| Private 223 | -} 224 | errMaybe : Result e a -> Maybe e 225 | errMaybe res = 226 | case res of 227 | Ok _ -> 228 | Nothing 229 | 230 | Err e -> 231 | Just e 232 | 233 | 234 | {-| Validation an integer using `String.toInt`. 235 | -} 236 | int : Validation e Int 237 | int v = 238 | Field.asString v 239 | |> Maybe.andThen String.toInt 240 | |> Result.fromMaybe (Error.value InvalidInt) 241 | 242 | 243 | {-| Validation a float using `String.toFloat`. 244 | -} 245 | float : Validation e Float 246 | float v = 247 | Field.asString v 248 | |> Maybe.andThen String.toFloat 249 | |> Result.fromMaybe (Error.value InvalidFloat) 250 | 251 | 252 | {-| Validation a String. 253 | -} 254 | string : Validation e String 255 | string v = 256 | case Field.asString v of 257 | Just s -> 258 | if String.isEmpty s then 259 | Err (Error.value Empty) 260 | 261 | else 262 | Ok s 263 | 264 | Nothing -> 265 | Err (Error.value InvalidString) 266 | 267 | 268 | {-| Validate an empty string, otherwise failing with InvalidString. 269 | Useful with `oneOf` for optional fields with format validation. 270 | -} 271 | emptyString : Validation e String 272 | emptyString v = 273 | case Field.asString v of 274 | Just s -> 275 | if String.isEmpty s then 276 | Ok s 277 | 278 | else 279 | Err (Error.value InvalidString) 280 | 281 | Nothing -> 282 | Ok "" 283 | 284 | 285 | {-| Validation a Bool. 286 | -} 287 | bool : Validation e Bool 288 | bool v = 289 | case Field.asBool v of 290 | Just b -> 291 | Ok b 292 | 293 | Nothing -> 294 | Ok False 295 | 296 | 297 | {-| Transform validation result to `Maybe`, using `Result.toMaybe`. 298 | -} 299 | maybe : Validation e a -> Validation e (Maybe a) 300 | maybe validation validationField = 301 | Ok (Result.toMaybe (validation validationField)) 302 | 303 | 304 | {-| Fails if `String.isEmpty`. 305 | -} 306 | nonEmpty : String -> Validation e String 307 | nonEmpty s validationField = 308 | if String.isEmpty s then 309 | Err (Error.value Empty) 310 | 311 | else 312 | Ok s 313 | 314 | 315 | {-| Min length for String. 316 | -} 317 | minLength : Int -> String -> Validation e String 318 | minLength min s validationField = 319 | if String.length s >= min then 320 | Ok s 321 | 322 | else 323 | Err (Error.value (ShorterStringThan min)) 324 | 325 | 326 | {-| Max length for String. 327 | -} 328 | maxLength : Int -> String -> Validation e String 329 | maxLength max s validationField = 330 | if String.length s <= max then 331 | Ok s 332 | 333 | else 334 | Err (Error.value (LongerStringThan max)) 335 | 336 | 337 | {-| Min value for Int. 338 | -} 339 | minInt : Int -> Int -> Validation e Int 340 | minInt min i validationField = 341 | if i >= min then 342 | Ok i 343 | 344 | else 345 | Err (Error.value (SmallerIntThan min)) 346 | 347 | 348 | {-| Max value for Int. 349 | -} 350 | maxInt : Int -> Int -> Validation e Int 351 | maxInt max i validationField = 352 | if i <= max then 353 | Ok i 354 | 355 | else 356 | Err (Error.value (GreaterIntThan max)) 357 | 358 | 359 | {-| Min value for Float. 360 | -} 361 | minFloat : Float -> Float -> Validation e Float 362 | minFloat min i validationField = 363 | if i >= min then 364 | Ok i 365 | 366 | else 367 | Err (Error.value (SmallerFloatThan min)) 368 | 369 | 370 | {-| Max value for Float. 371 | -} 372 | maxFloat : Float -> Float -> Validation e Float 373 | maxFloat max i validationField = 374 | if i <= max then 375 | Ok i 376 | 377 | else 378 | Err (Error.value (GreaterFloatThan max)) 379 | 380 | 381 | {-| Validates format of the string. 382 | -} 383 | format : Regex -> String -> Validation e String 384 | format regex s validationField = 385 | if Regex.contains regex s then 386 | Ok s 387 | 388 | else 389 | Err (Error.value InvalidFormat) 390 | 391 | 392 | {-| Stolen to elm-validate. 393 | -} 394 | validEmailPattern : Regex 395 | validEmailPattern = 396 | "^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" 397 | |> Regex.fromStringWith { caseInsensitive = True, multiline = False } 398 | |> Maybe.withDefault Regex.never 399 | 400 | 401 | {-| Check if the string is a valid email address. 402 | -} 403 | email : Validation e String 404 | email = 405 | string 406 | |> andThen 407 | (\s -> 408 | format validEmailPattern s 409 | |> mapError (\_ -> Error.value InvalidEmail) 410 | ) 411 | 412 | 413 | {-| Check if the string is included in the given list. 414 | -} 415 | includedIn : List String -> String -> Validation e String 416 | includedIn items s validationField = 417 | if List.member s items then 418 | Ok s 419 | 420 | else 421 | Err (Error.value NotIncludedIn) 422 | 423 | 424 | {-| A validation that always fails. Useful for contextual validation. 425 | -} 426 | fail : Error e -> Validation e a 427 | fail error validationField = 428 | Err error 429 | 430 | 431 | {-| A validation that always succeeds. Useful for contextual validation. 432 | -} 433 | succeed : a -> Validation e a 434 | succeed a validationField = 435 | Ok a 436 | 437 | 438 | {-| Custom validation for your special cases. 439 | -} 440 | customValidation : Validation e a -> (a -> Result (Error e) b) -> Validation e b 441 | customValidation validation callback validationField = 442 | validation validationField |> Result.andThen callback 443 | 444 | 445 | {-| First successful validation wins, from left to right. 446 | -} 447 | oneOf : List (Validation e a) -> Validation e a 448 | oneOf validations validationField = 449 | let 450 | results = 451 | List.map (\v -> v validationField) validations 452 | 453 | walkResults result combined = 454 | case ( combined, result ) of 455 | ( Ok _, _ ) -> 456 | combined 457 | 458 | _ -> 459 | result 460 | in 461 | List.foldl walkResults (Err (Error.value Empty)) results 462 | 463 | 464 | {-| Combine a list of validations into a validation producing a list of all 465 | results. 466 | -} 467 | sequence : List (Validation e a) -> Validation e (List a) 468 | sequence validations = 469 | List.foldr (map2 (::)) (succeed []) validations 470 | 471 | 472 | {-| Validate a list of fields. 473 | -} 474 | list : Validation e a -> Validation e (List a) 475 | list validation validationField = 476 | case validationField of 477 | Tree.List items -> 478 | let 479 | results = 480 | List.map validation items 481 | 482 | indexedErrMaybe index res = 483 | case res of 484 | Ok _ -> 485 | Nothing 486 | 487 | Err e -> 488 | Just ( String.fromInt index, e ) 489 | 490 | errors = 491 | results 492 | |> List.indexedMap indexedErrMaybe 493 | |> List.filterMap identity 494 | in 495 | if List.isEmpty errors then 496 | Ok (List.filterMap Result.toMaybe results) 497 | 498 | else 499 | Err (Tree.group errors) 500 | 501 | _ -> 502 | Ok [] 503 | -------------------------------------------------------------------------------- /tests/ChangedFields.elm: -------------------------------------------------------------------------------- 1 | module ChangedFields exposing (all) 2 | 3 | import Expect exposing (..) 4 | import Form exposing (Form) 5 | import Form.Field as Field 6 | import Model 7 | import Set exposing (Set) 8 | import Test exposing (..) 9 | 10 | 11 | all : Test 12 | all = 13 | describe "Test Changed fields function" 14 | [ test "one field changed" <| 15 | \_ -> 16 | let 17 | ( name, value ) = 18 | ( "name", "John Doe" ) 19 | 20 | formAfterInput = 21 | formStringInput name value initialForm 22 | 23 | changedFields = 24 | Form.getChangedFields formAfterInput 25 | 26 | expected = 27 | Set.insert name Set.empty 28 | in 29 | equal expected changedFields 30 | , test "field changed and change reverted back" <| 31 | \_ -> 32 | let 33 | ( name, value ) = 34 | ( "name", "John Doe" ) 35 | 36 | formAfterInput = 37 | formStringInput name value initialForm 38 | |> formStringInput name "hey" 39 | 40 | changedFields = 41 | Form.getChangedFields formAfterInput 42 | in 43 | equal Set.empty changedFields 44 | , test "checkbox field changed" <| 45 | \_ -> 46 | let 47 | name = 48 | "admin" 49 | 50 | formAfterInput = 51 | formBoolInput name True initialForm 52 | 53 | changedFields = 54 | Form.getChangedFields formAfterInput 55 | 56 | expected = 57 | Set.insert name Set.empty 58 | in 59 | equal expected changedFields 60 | , test "checkbox field checked and unchecked" <| 61 | \_ -> 62 | let 63 | name = 64 | "admin" 65 | 66 | formAfterInput = 67 | formBoolInput name True initialForm 68 | |> formBoolInput name False 69 | 70 | changedFields = 71 | Form.getChangedFields formAfterInput 72 | in 73 | equal Set.empty changedFields 74 | ] 75 | 76 | 77 | formBoolInput : String -> Bool -> Form Model.CustomError Model.User -> Form Model.CustomError Model.User 78 | formBoolInput name value form = 79 | Form.update Model.validate (Form.Input name Form.Checkbox (Field.Bool value)) form 80 | 81 | 82 | formStringInput : String -> String -> Form Model.CustomError Model.User -> Form Model.CustomError Model.User 83 | formStringInput name value form = 84 | Form.update Model.validate (Form.Input name Form.Text (Field.String value)) form 85 | 86 | 87 | initialForm : Form Model.CustomError Model.User 88 | initialForm = 89 | Form.initial Model.initialFields Model.validate 90 | 91 | 92 | validatedForm : Form Model.CustomError Model.User 93 | validatedForm = 94 | Form.update Model.validate Form.Validate initialForm 95 | -------------------------------------------------------------------------------- /tests/Example.elm: -------------------------------------------------------------------------------- 1 | module Example exposing (all) 2 | 3 | import Expect exposing (..) 4 | import Form exposing (Form) 5 | import Form.Error exposing (..) 6 | import Form.Field as Field 7 | import Model 8 | import Test exposing (..) 9 | 10 | 11 | all : Test 12 | all = 13 | describe "Initial example validation" 14 | [ test "has no output" <| 15 | \_ -> equal Nothing (Form.getOutput validatedForm) 16 | , test "has errors on expected fields" <| 17 | \_ -> 18 | equal 19 | (Form.getErrors validatedForm) 20 | [ ( "date", CustomError Model.InvalidDate ) 21 | , ( "email", InvalidString ) 22 | , ( "profile.role", InvalidString ) 23 | , ( "profile.superpower", InvalidString ) 24 | ] 25 | , test "append, set then get field in list" <| 26 | \_ -> 27 | let 28 | ( name, value ) = 29 | ( "links.0.name", "Twitter" ) 30 | 31 | formAfterAppend = 32 | Form.update Model.validate (Form.Append "links") initialForm 33 | 34 | formAfterInput = 35 | Form.update Model.validate (Form.Input name Form.Text (Field.String value)) formAfterAppend 36 | 37 | maybeState = 38 | Form.getFieldAsString name formAfterInput 39 | in 40 | equal (Just value) maybeState.value 41 | ] 42 | 43 | 44 | initialForm : Form Model.CustomError Model.User 45 | initialForm = 46 | Form.initial Model.initialFields Model.validate 47 | 48 | 49 | validatedForm : Form Model.CustomError Model.User 50 | validatedForm = 51 | Form.update Model.validate Form.Validate initialForm 52 | -------------------------------------------------------------------------------- /tests/MetaTests.elm: -------------------------------------------------------------------------------- 1 | module MetaTests exposing (all) 2 | 3 | import Expect exposing (Expectation) 4 | import Form.Error exposing (ErrorValue) 5 | import Form.Test.Helpers 6 | import Form.Test.ValidationExpectation as ValidationExpectation 7 | import Form.Validate as Validate exposing (Validation, string) 8 | import Test exposing (..) 9 | 10 | 11 | all : Test 12 | all = 13 | describe "meta tests" 14 | [ test "expect success but get error" <| 15 | \() -> 16 | let 17 | actual = 18 | Form.Test.Helpers.getValidationExpectation Validate.email 19 | ( "This is definitely not an email address" 20 | , ValidationExpectation.Valid 21 | ) 22 | 23 | expected = 24 | ValidationExpectation.Invalid Form.Error.InvalidEmail 25 | |> Expect.equal ValidationExpectation.Valid 26 | in 27 | actual 28 | |> Expect.equal expected 29 | , test "expect success get success" <| 30 | \() -> 31 | let 32 | actual = 33 | Form.Test.Helpers.getValidationExpectation Validate.email 34 | ( "validemail@example.com" 35 | , ValidationExpectation.Valid 36 | ) 37 | in 38 | actual 39 | |> Expect.equal Expect.pass 40 | , test "expected error" <| 41 | \() -> 42 | let 43 | actual = 44 | Form.Test.Helpers.getValidationExpectation Validate.email 45 | ( "This is definitely not an email address" 46 | , ValidationExpectation.Invalid Form.Error.Empty 47 | ) 48 | 49 | expected = 50 | ValidationExpectation.Invalid Form.Error.InvalidEmail 51 | |> Expect.equal (ValidationExpectation.Invalid Form.Error.Empty) 52 | in 53 | actual 54 | |> Expect.equal expected 55 | , test "different error than expected" <| 56 | \() -> 57 | let 58 | actual = 59 | Form.Test.Helpers.getValidationExpectation Validate.email 60 | ( "This is definitely not an email address" 61 | , ValidationExpectation.Invalid Form.Error.InvalidEmail 62 | ) 63 | in 64 | actual 65 | |> Expect.equal Expect.pass 66 | , test "custom error" <| 67 | \() -> 68 | let 69 | actual = 70 | Form.Test.Helpers.getValidationExpectation validateSuperpower 71 | ( "This is not a superpower" 72 | , ValidationExpectation.InvalidCustomError InvalidSuperpower 73 | ) 74 | in 75 | actual 76 | |> Expect.equal Expect.pass 77 | , test "expect custom error but get no error" <| 78 | \() -> 79 | let 80 | actual = 81 | Form.Test.Helpers.getValidationExpectation validateSuperpower 82 | ( "flying" 83 | , ValidationExpectation.InvalidCustomError InvalidSuperpower 84 | ) 85 | 86 | expected = 87 | Expect.equal 88 | (ValidationExpectation.InvalidCustomError InvalidSuperpower) 89 | (ValidationExpectation.ValidDecodesTo Flying) 90 | in 91 | actual 92 | |> Expect.equal expected 93 | ] 94 | 95 | 96 | type Superpower 97 | = Flying 98 | | Invisible 99 | 100 | 101 | type CustomError 102 | = InvalidSuperpower 103 | 104 | 105 | validateSuperpower : Validation CustomError Superpower 106 | validateSuperpower = 107 | Validate.customValidation 108 | string 109 | (\s -> 110 | case s of 111 | "flying" -> 112 | Ok Flying 113 | 114 | "invisible" -> 115 | Ok Invisible 116 | 117 | _ -> 118 | Err (Validate.customError InvalidSuperpower) 119 | ) 120 | -------------------------------------------------------------------------------- /tests/Model.elm: -------------------------------------------------------------------------------- 1 | module Model exposing (CustomError(..), Date, Model, Msg(..), Profile, Superpower(..), Todo, User, asyncCheck, dateParser, initialFields, naturalInt, roles, superpowers, validate, validateDate, validateProfile, validateSuperpower, validateTodo, validateUrl) 2 | 3 | import Form exposing (Form) 4 | import Form.Error as Error 5 | import Form.Field as Field exposing (Field) 6 | import Form.Validate as Validate exposing (..) 7 | import Parser exposing ((|.), (|=), Parser) 8 | import Regex 9 | 10 | 11 | type Msg 12 | = NoOp 13 | | FormMsg Form.Msg 14 | 15 | 16 | type alias Model = 17 | { form : Form CustomError User 18 | , userMaybe : Maybe User 19 | } 20 | 21 | 22 | type CustomError 23 | = Ooops 24 | | Nope 25 | | AlreadyTaken 26 | | InvalidSuperpower 27 | | InvalidDate 28 | 29 | 30 | type alias Date = 31 | { year : Int 32 | , month : Int 33 | , day : Int 34 | } 35 | 36 | 37 | type alias User = 38 | { name : String 39 | , email : String 40 | , admin : Bool 41 | , birthday : Date 42 | , profile : Profile 43 | , todos : List Todo 44 | } 45 | 46 | 47 | type alias Profile = 48 | { website : Maybe String 49 | , role : String 50 | , superpower : Superpower 51 | , age : Int 52 | , bio : String 53 | } 54 | 55 | 56 | type Superpower 57 | = Flying 58 | | Invisible 59 | 60 | 61 | type alias Todo = 62 | { done : Bool 63 | , label : String 64 | } 65 | 66 | 67 | initialFields : List ( String, Field ) 68 | initialFields = 69 | [ ( "name", Field.string "hey" ) 70 | , ( "profile" 71 | , Field.group 72 | [ ( "age", Field.string "33" ) ] 73 | ) 74 | , ( "todos" 75 | , Field.list 76 | [ Field.group 77 | [ ( "done", Field.bool True ) 78 | , ( "label", Field.string "Remember the milk" ) 79 | ] 80 | ] 81 | ) 82 | ] 83 | 84 | 85 | roles : List String 86 | roles = 87 | [ "role1", "role2" ] 88 | 89 | 90 | superpowers : List String 91 | superpowers = 92 | [ "flying", "invisible" ] 93 | 94 | 95 | validate : Validation CustomError User 96 | validate = 97 | map6 98 | User 99 | (field "name" (string |> andThen nonEmpty)) 100 | (field "email" (email |> andThen (asyncCheck True))) 101 | (field "admin" (bool |> defaultValue False)) 102 | (field "date" validateDate) 103 | (field "profile" validateProfile) 104 | (field "todos" (list validateTodo)) 105 | 106 | 107 | validateDate : Validation CustomError Date 108 | validateDate = 109 | let 110 | parseDate text = 111 | text 112 | |> Parser.run dateParser 113 | |> Result.mapError (always (Error.value (Error.CustomError InvalidDate))) 114 | 115 | -- This should use much more complicated logic to ensure it's actually valid 116 | validateDayIsValid date validationField = 117 | if date.month > 12 || date.month < 1 then 118 | Err (Error.value (Error.CustomError InvalidDate)) 119 | 120 | else if date.day > 31 || date.day < 1 then 121 | Err (Error.value (Error.CustomError InvalidDate)) 122 | 123 | else 124 | Ok date 125 | in 126 | customValidation string parseDate 127 | |> andThen validateDayIsValid 128 | |> mapError (always (Error.value (Error.CustomError InvalidDate))) 129 | 130 | 131 | dateParser : Parser Date 132 | dateParser = 133 | Parser.succeed Date 134 | |= Parser.int 135 | |. Parser.symbol "-" 136 | |. Parser.chompIf ((==) '0') 137 | |= Parser.int 138 | |. Parser.symbol "-" 139 | |. Parser.chompIf ((==) '0') 140 | |= Parser.int 141 | |. Parser.end 142 | 143 | 144 | validateProfile : Validation CustomError Profile 145 | validateProfile = 146 | succeed Profile 147 | |> andMap 148 | (field "website" 149 | (oneOf 150 | [ emptyString |> map (\_ -> Nothing) 151 | , validateUrl |> map Just 152 | ] 153 | ) 154 | ) 155 | |> andMap (field "role" (string |> andThen (includedIn roles))) 156 | |> andMap (field "superpower" validateSuperpower) 157 | |> andMap (field "age" naturalInt) 158 | |> andMap (field "bio" (string |> defaultValue "")) 159 | 160 | 161 | validateSuperpower : Validation CustomError Superpower 162 | validateSuperpower = 163 | customValidation 164 | string 165 | (\s -> 166 | case s of 167 | "flying" -> 168 | Ok Flying 169 | 170 | "invisible" -> 171 | Ok Invisible 172 | 173 | _ -> 174 | Err (customError InvalidSuperpower) 175 | ) 176 | 177 | 178 | validateTodo : Validation CustomError Todo 179 | validateTodo = 180 | map2 Todo 181 | (field "done" bool) 182 | (field "label" string) 183 | 184 | 185 | {-| Check if the string is a valid URL. 186 | -} 187 | validateUrl : Validation e String 188 | validateUrl = 189 | let 190 | urlRegex = 191 | Regex.fromString "^(https?://)" 192 | |> Maybe.withDefault Regex.never 193 | in 194 | string 195 | |> andThen (format urlRegex) 196 | 197 | 198 | 199 | -- eq. to: int `andThen` (minInt 0) 200 | 201 | 202 | naturalInt : Validation CustomError Int 203 | naturalInt = 204 | customValidation 205 | int 206 | (\i -> 207 | if i > 0 then 208 | Ok i 209 | 210 | else 211 | Err (customError Nope) 212 | ) 213 | 214 | 215 | asyncCheck : Bool -> String -> Validation CustomError String 216 | asyncCheck serverIsOk s = 217 | if serverIsOk then 218 | succeed s 219 | 220 | else 221 | fail (customError AlreadyTaken) 222 | -------------------------------------------------------------------------------- /tests/Validate.elm: -------------------------------------------------------------------------------- 1 | module Validate exposing (all) 2 | 3 | import Expect exposing (..) 4 | import Form 5 | import Form.Error as Error 6 | import Form.Field as Field 7 | import Form.Input as Input 8 | import Form.Tree as Tree 9 | import Form.Validate as Validate exposing (Validation) 10 | import Fuzz exposing (..) 11 | import Test exposing (..) 12 | 13 | 14 | all : Test 15 | all = 16 | describe "Form.Validate" 17 | [ fuzz (list int) "Transforms a list of successes to a success of lists" <| 18 | \nums -> 19 | nums 20 | |> List.map Validate.succeed 21 | |> Validate.sequence 22 | |> run 23 | |> Expect.equal (Ok nums) 24 | , fuzz3 (list string) string string "Transforms a list with successes and failures into a failure list" <| 25 | \strings firstErr secondErr -> 26 | let 27 | successes = 28 | List.map 29 | (\str -> Validate.succeed str |> Validate.field str) 30 | strings 31 | 32 | failure str = 33 | Validate.fail (Validate.customError str) 34 | |> Validate.field str 35 | 36 | validations = 37 | successes ++ ((failure firstErr :: successes) ++ (failure secondErr :: successes)) 38 | in 39 | validations 40 | |> Validate.sequence 41 | |> run 42 | |> Expect.equal 43 | (Tree.group 44 | [ ( firstErr, Validate.customError firstErr ) 45 | , ( secondErr, Validate.customError secondErr ) 46 | ] 47 | |> Err 48 | ) 49 | , test "Puts the errors at the correct indexes" <| 50 | \_ -> 51 | let 52 | validate = 53 | Validate.field "field_name" 54 | (Validate.list 55 | (Validate.string |> Validate.andThen (Validate.minLength 4)) 56 | ) 57 | 58 | initialForm = 59 | Form.initial [ ( "field_name", Field.list [ Field.value (Field.String "longer"), Field.value (Field.String "not"), Field.value (Field.String "longer") ] ) ] validate 60 | in 61 | Expect.equal 62 | [ ( "field_name.1", Error.ShorterStringThan 4 ) ] 63 | (Form.getErrors initialForm) 64 | , test "Gets index errors from error groups" <| 65 | \_ -> 66 | let 67 | validate = 68 | Validate.field "a" 69 | (Validate.list 70 | (Validate.field "b" 71 | (Validate.list 72 | (Validate.string |> Validate.andThen (Validate.minLength 4)) 73 | ) 74 | ) 75 | ) 76 | 77 | initialForm = 78 | Form.initial 79 | [ ( "a" 80 | , Field.list 81 | [ Field.group [ ( "b", Field.list [ Field.value (Field.String "init") ] ) ] 82 | , Field.group [ ( "b", Field.list [ Field.value (Field.String "init") ] ) ] 83 | , Field.group [ ( "b", Field.list [ Field.value (Field.String "init") ] ) ] 84 | ] 85 | ) 86 | ] 87 | validate 88 | 89 | updatedForm = 90 | initialForm 91 | |> Form.update validate (Form.Input "a.0.b.0" Form.Text (Field.String "longer")) 92 | |> Form.update validate (Form.Input "a.1.b.0" Form.Text (Field.String "not")) 93 | |> Form.update validate (Form.Input "a.2.b.0" Form.Text (Field.String "longer")) 94 | 95 | expectedField = 96 | { path = "a.1.b.0" 97 | , value = Just "not" 98 | , error = Just (Error.ShorterStringThan 4) 99 | , liveError = Nothing 100 | , isDirty = True 101 | , isChanged = True 102 | , hasFocus = False 103 | } 104 | in 105 | Expect.equal 106 | expectedField 107 | (Form.getFieldAsString "a.1.b.0" updatedForm) 108 | , test "Errors stay matched-up when an item is removed" <| 109 | \_ -> 110 | let 111 | validate = 112 | Validate.field "a" 113 | (Validate.list 114 | (Validate.field "b" 115 | (Validate.list 116 | (Validate.string |> Validate.andThen (Validate.minLength 4)) 117 | ) 118 | ) 119 | ) 120 | 121 | initialForm = 122 | Form.initial 123 | [ ( "a" 124 | , Field.list 125 | [ Field.group [ ( "b", Field.list [ Field.value (Field.String "init") ] ) ] 126 | , Field.group [ ( "b", Field.list [ Field.value (Field.String "init") ] ) ] 127 | , Field.group [ ( "b", Field.list [ Field.value (Field.String "init") ] ) ] 128 | ] 129 | ) 130 | ] 131 | validate 132 | 133 | updatedForm = 134 | initialForm 135 | |> Form.update validate (Form.Input "a.0.b.0" Form.Text (Field.String "longer")) 136 | |> Form.update validate (Form.Input "a.1.b.0" Form.Text (Field.String "not")) 137 | |> Form.update validate (Form.Input "a.2.b.0" Form.Text (Field.String "longer")) 138 | |> Form.update validate (Form.RemoveItem "a" 1) 139 | 140 | expectedField = 141 | { path = "a.1.b.0" 142 | , value = Just "longer" 143 | , error = Nothing 144 | , liveError = Nothing 145 | , isDirty = True 146 | , isChanged = True 147 | , hasFocus = False 148 | } 149 | in 150 | Expect.equal 151 | expectedField 152 | (Form.getFieldAsString "a.1.b.0" updatedForm) 153 | ] 154 | 155 | 156 | run : Validation e a -> Result (Error.Error e) a 157 | run validation = 158 | Field.group [] |> validation 159 | -------------------------------------------------------------------------------- /tests/ValidationTests.elm: -------------------------------------------------------------------------------- 1 | module ValidationTests exposing (suite) 2 | 3 | import Form.Error 4 | import Form.Test 5 | import Form.Test.ValidationExpectation exposing (ValidationExpectation(..)) 6 | import Form.Validate 7 | import Model 8 | import Test exposing (..) 9 | 10 | 11 | suite : Test 12 | suite = 13 | describe "validations" 14 | [ Form.Test.describeValidation "superpower" 15 | Model.validateSuperpower 16 | [ ( "invisible", Valid ) 17 | , ( "unvisible", Invalid (Form.Error.CustomError Model.InvalidSuperpower) ) 18 | , ( "invisible", ValidDecodesTo Model.Invisible ) 19 | , ( "flying", ValidDecodesTo Model.Flying ) 20 | ] 21 | , Form.Test.describeValidation "email" 22 | Form.Validate.email 23 | [ ( "valid@email.com", Valid ) 24 | , ( "This is definitely not an email address", Invalid Form.Error.InvalidEmail ) 25 | , ( "stillvalid@withoutTLD", Valid ) 26 | ] 27 | , Form.Test.describeValidation "naturalInt" 28 | Model.naturalInt 29 | [ ( "123", ValidDecodesTo 123 ) 30 | , ( "-123", Invalid (Form.Error.CustomError Model.Nope) ) 31 | ] 32 | ] 33 | --------------------------------------------------------------------------------