├── .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. [](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 |
--------------------------------------------------------------------------------