ModularDesign's FormInput
library provides an API for capturing,
accessing, and validating input from HTML form
elements. Rather than capturing
user input from each individual input
element on a page, it is often more
convenient to wrap multiple input
elements in a form
with a submit
button
that triggers input capture. Because Elm's Html
package does not include a
built-in set of functions for handling form input, a custom event handler and
custom Json decoders are needed to capture input in response to a "submit" event
on a form. The FormInput
library includes constructor functions that make it
easy to initialize an event handler that will capture form input, decoding it
into a dictionary keyed by input id
. The library also includes reader
functions that enable type-checking on input values, as well as a function that
converts a dictionary of input values to JSON.
Represents input captured from a form with multiple fields. Implemented as a
dictionary, where the key is the id
of the input element and the value is
its captured value as TypedInput
.
Given a list of HtmlTree
nodes, construct a form
element with an
observer that captures values from each input element contained in the list and
returns those values as FormInput
. The first argument (a constructor that
accepts FormInput
) specifies the message that will be passed to the program's
update function when a "submit" event is triggered on the form. The HtmlTree
that is returned will have the form
element at the root, with the listed nodes
as its children.
See FormControl.elm for a full working example.
Constructor that takes a Json
Decoder
as an argument and returns an
Html.Attribute
encoding an event handler triggered by "submit". The decoder specifies the input
field(s) to be captured and the type that will be returned when the input is
decoded.
container "form" [ inputField, submitButton ]
|> withObserver (captureOnSubmit inputDecoder)
See CaptureOnSubmit.elm for a full working example.
Constructor that takes an HtmlElement
representing a form field and
returns a Json
Decoder
.
When triggered by a "submit" event on the parent form, this decoder will return
a tuple containing the input element's id
attribute and its current value
,
encoded as TypedInput
. For the decoder work properly, the input element must
be assigned a unique id
and its inputType
must be defined.
inputDecoder =
rootElement inputField
|> fieldDecoder
See FieldDecoder.elm for a full working example.
Constructor that takes a list of HtmlTree
nodes representing internal
elements within a form
and returns a Json
Decoder
.
When triggered by a "submit" event on the form, this decoder will return a
Dict
containing values for each input
element, keyed by id
. For the decoder work
properly, each input element must be assigned a unique id
string and its
inputType
must be defined.
inputDict =
formDecoder [ input1, input2 ]
|> Json.map Submit
form =
container "form" [ input1, input2, submitButton ]
|> withObserver (captureOnSubmit inputDict)
See FormDecoder.elm for a full working example.
Given a string representing an id
, look up the value of the associated
input element in FormInput
and return the result as TypedInput
, or return
an error message.
--simulated input
input1 = ( "userName", StringInput (Json.Encode.string "Bob") )
input2 = ("userAge", IntInput (Json.Encode.string "33") )
formInput = Dict.fromList [ input1, input2 ]
formInput
|> getInputAt "userName"
--> Ok (StringInput "Bob")
Given a string representing an id
, look up the value of the associated
input element and decode the result as a String
, or return an error message.
formInput
|> readStringAt "userName"
--> Ok "Bob"
Convert FormInput
to JSON with the
Json.Encode.object
function. Before encoding, the toTypedJson
function is used to perform type
checking and conversion on input values.
formInput
|> formInputToJson
--> { userAge = 33, userName = "Bob" } : Json.Decode.Value
Convert TypedInput
to a Json Value
of the corresponding type. Because
input from a form element is always captured as a JavaScript string, numeric or
boolean input must first be decoded to a String
before it can be encoded as a
JavaScript number or boolean. This function takes care of both steps and passes
along any error messages in type conversion.
formInput
|> getInputAt "userAge"
|> Result.withDefault (IntInput Json.Encode.null)
|> toTypedJson
--> Ok 33 : Result String Json.Decode.Value
Return a JavaScript value from TypedInput
without attempting to decode. If
the TypedInput
value has been captured from an input element, the returned
value will always be a JavaScript string. Useful for debugging.
formInput
|> getInputAt "userAge"
|> Result.withDefault (IntInput Json.Encode.null)
|> extractRawJson
--> "33" : Json.Decode.Value
TypedInput
ValuesDecode TypedInput
as a string, or return an error message if the decoder
fails.
formInput
|> getInputAt "userAge"
|> Result.withDefault (IntInput Json.Encode.null)
|> readInputAsString
--> Ok "33"
Decode a StringInput
value as a string; return an error message if the
decoder fails or if the argument is a type other than StringInput
.
formInput
|> getInputAt "userName"
|> Result.withDefault (StringInput Json.Encode.null)
|> readStringInput
--> Ok "Bob"
Decode an IntInput
value as a string, then attempt to convert the string
to an Int
; return an error message if the string decoder fails, if type
conversion fails, or if the argument is a type other than IntInput
.
formInput
|> getInputAt "userAge"
|> Result.withDefault (IntInput Json.Encode.null)
|> readIntInput
--> Ok 33
Decode a FloatInput
value as a string, then attempt to convert the string
to a Float
; return an error message if the string decoder fails, if type
conversion fails, or if the argument is a type other than FloatInput
.
formInput
|> getInputAt "userWeight"
|> Result.withDefault (FloatInput Json.Encode.null)
|> readFloatInput
--> Ok 160.5
Decode a BoolInput
value as a string, then attempt to convert the string
to a Bool
; return an error message if the string decoder fails, if type
conversion fails, or if the argument is a type other than BoolInput
. Type
conversion expects a string value of "true" or "false", ignoring case.
formInput
|> getInputAt "over18Years"
|> Result.withDefault (BoolInput Json.Encode.null)
|> readBoolInput
--> Ok True
Given a Json
Decoder
,
attempt to decode a CustomInput
value; return an error message if the decoder
fails or if the argument is a type other than CustomInput
.
module ModularDesign.FormInput exposing
( captureOnSubmit, fieldDecoder, formDecoder, FormInput, formControl
, getInputAt, readStringAt, formInputToJson, toTypedJson, extractRawJson
, readInputAsString, readStringInput, readIntInput, readFloatInput
, readBoolInput, readCustomInput
)
{-|
## Built-in functions for capturing and validating form input
ModularDesign's `FormInput` library provides an API for capturing,
accessing, and validating input from HTML `form` elements. Rather than capturing
user input from each individual `input` element on a page, it is often more
convenient to wrap multiple `input` elements in a `form` with a `submit` button
that triggers input capture. Because Elm's `Html` package does not include a
built-in set of functions for handling form input, a custom event handler and
custom Json decoders are needed to capture input in response to a "submit" event
on a form. The `FormInput` library includes constructor functions that make it
easy to initialize an event handler that will capture form input, decoding it
into a dictionary keyed by input `id`. The library also includes reader
functions that enable type-checking on input values, as well as a function that
converts a dictionary of input values to JSON.
# Capturing Form Input
@docs FormInput, formControl, captureOnSubmit, fieldDecoder, formDecoder
# Accessing Form Input
@docs getInputAt, readStringAt
# Converting Form Input to JSON
@docs formInputToJson, toTypedJson, extractRawJson
# Reading `TypedInput` Values
@docs readInputAsString, readStringInput, readIntInput, readFloatInput
@docs readBoolInput, readCustomInput
-}
import ModularDesign exposing (..)
import ModularDesign.Operators exposing (..)
import ModularDesign.Helpers exposing (wrapList, thenTry, toBool)
import Html exposing (Attribute)
import Html.Events as Events
import Json.Decode as Json exposing (Decoder)
import Json.Encode
import Dict exposing (Dict)
import String
--TYPE DECLARATIONS
{-| Represents input captured from a form with multiple fields. Implemented as a
dictionary, where the *key* is the `id` of the input element and the *value* is
its captured value as `TypedInput`.
-}
type alias FormInput =
Dict String TypedInput
{-| Represents a decoder that returns a list of *key-value* pairs, where the
*key* is the `id` of the input element and the *value* is its captured value as
`TypedInput`. Used to generate a `FormInput` dictionary.
-}
type alias DecoderList =
Decoder (List (String, TypedInput))
--CAPTURING FORM INPUT
{-| Given a list of `HtmlTree` nodes, construct a `form` element with an
observer that captures values from each input element contained in the list and
returns those values as `FormInput`. The first argument (a constructor that
accepts `FormInput`) specifies the message that will be passed to the program's
update function when a "submit" event is triggered on the form. The `HtmlTree`
that is returned will have the `form` element at the root, with the listed nodes
as its children.
See FormControl.elm for a full working example.
-}
formControl : (FormInput -> msg) -> List (HtmlTree msg) -> HtmlTree msg
formControl submitAction formElements =
let
decoder =
formDecoder formElements
|> Json.map submitAction
in
container "form" formElements
|> withObserver (captureOnSubmit decoder)
{-| Constructor that takes a `Json`
[`Decoder`](http://package.elm-lang.org/packages/elm-lang/core/latest/Json-Decode#Decoder)
as an argument and returns an
[`Html.Attribute`](http://package.elm-lang.org/packages/elm-lang/html/latest/Html#Attribute)
encoding an event handler triggered by "submit". The decoder specifies the input
field(s) to be captured and the type that will be returned when the input is
decoded.
container "form" [ inputField, submitButton ]
|> withObserver (captureOnSubmit inputDecoder)
See CaptureOnSubmit.elm for a full working example.
-}
captureOnSubmit : Decoder msg -> Attribute msg
captureOnSubmit =
{ stopPropagation = False, preventDefault = True }
|> Events.onWithOptions "submit"
{-| Constructor that takes an `HtmlElement` representing a form field and
returns a `Json`
[`Decoder`](http://package.elm-lang.org/packages/elm-lang/core/latest/Json-Decode#Decoder).
When triggered by a "submit" event on the parent form, this decoder will return
a tuple containing the input element's `id` attribute and its current `value`,
encoded as `TypedInput`. For the decoder work properly, the input element must
be assigned a unique `id` and its `inputType` must be defined.
inputDecoder =
rootElement inputField
|> fieldDecoder
See FieldDecoder.elm for a full working example.
-}
fieldDecoder : HtmlElement msg -> Decoder (String, TypedInput)
fieldDecoder inputElement =
let
idString =
inputElement
|> getId ?= "noID"
typeConstructor =
inputElement.inputType ?= NullInput
in
Json.value
|> Json.at ["target", "elements", idString, "value"]
|> Json.map (\v -> (idString, typeConstructor v) )
{-| Constructor that takes a list of `HtmlTree` nodes representing internal
elements within a `form` and returns a `Json`
[`Decoder`](http://package.elm-lang.org/packages/elm-lang/core/latest/Json-Decode#Decoder).
When triggered by a "submit" event on the form, this decoder will return a
[`Dict`](http://package.elm-lang.org/packages/elm-lang/core/latest/Dict#Decoder)
containing values for each `input` element, keyed by `id`. For the decoder work
properly, each input element must be assigned a unique `id` string and its
`inputType` must be defined.
inputDict =
formDecoder [ input1, input2 ]
|> Json.map Submit
form =
container "form" [ input1, input2, submitButton ]
|> withObserver (captureOnSubmit inputDict)
See FormDecoder.elm for a full working example.
-}
formDecoder : List (HtmlTree msg) -> Decoder FormInput
formDecoder formElements =
let
inputElementList =
formElements
.|> getElementsByTag "input"
|> List.concat
|> List.filter (\n -> n |> "type" `hasValue` "submit" |> not)
initialDecoder =
inputElementList
|> List.head ?= rootElement (leaf "input")
|> fieldDecoder
|> Json.map wrapList
remainingList =
inputElementList
|> List.tail ?= []
in
initialDecoder
|> generateDecoderList remainingList
|> Json.map Dict.fromList
{-| Recursive function to build a `DecoderList`. Called by `formDecoder`.
-}
generateDecoderList : List (HtmlElement msg) -> DecoderList -> DecoderList
generateDecoderList elementList decoderList =
case List.head elementList of
Just nextElement ->
nextElement
|> fieldDecoder
|> Json.map wrapList
|> Json.object2 (++) decoderList
|> generateDecoderList (List.tail elementList ?= [])
Nothing ->
decoderList
--INPUT READERS
{-| Given a string representing an `id`, look up the value of the associated
input element in `FormInput` and return the result as `TypedInput`, or return
an error message.
--simulated input
input1 = ( "userName", StringInput (Json.Encode.string "Bob") )
input2 = ("userAge", IntInput (Json.Encode.string "33") )
formInput = Dict.fromList [ input1, input2 ]
formInput
|> getInputAt "userName"
--> Ok (StringInput "Bob")
-}
getInputAt : String -> FormInput -> Result String TypedInput
getInputAt key formInput =
case formInput |> Dict.get key of
Just inputType ->
Ok inputType
Nothing ->
Err ("Submitted form does not contain a value for " ++ key)
{-| Given a string representing an `id`, look up the value of the associated
input element and decode the result as a `String`, or return an error message.
formInput
|> readStringAt "userName"
--> Ok "Bob"
-}
readStringAt : String -> FormInput -> Result String String
readStringAt key formInput =
case formInput |> Dict.get key of
Just inputType ->
inputType
|> readInputAsString
Nothing ->
Err ("Submitted form does not contain a value for " ++ key)
{-| Convert `FormInput` to JSON with the
[`Json.Encode.object`](http://package.elm-lang.org/packages/elm-lang/core/latest/Json-Encode#object)
function. Before encoding, the `toTypedJson` function is used to perform type
checking and conversion on input values.
formInput
|> formInputToJson
--> { userAge = 33, userName = "Bob" } : Json.Decode.Value
-}
formInputToJson : FormInput -> Json.Value
formInputToJson formInput =
formInput
|> Dict.toList
.|> (\(k, v) -> (k, v |> toTypedJson != Json.Encode.null))
|> Json.Encode.object
{-| Convert `TypedInput` to a Json `Value` of the corresponding type. Because
input from a form element is always captured as a JavaScript string, numeric or
boolean input must first be decoded to a `String` before it can be encoded as a
JavaScript number or boolean. This function takes care of both steps and passes
along any error messages in type conversion.
formInput
|> getInputAt "userAge"
|> Result.withDefault (IntInput Json.Encode.null)
|> toTypedJson
--> Ok 33 : Result String Json.Decode.Value
-}
toTypedJson : TypedInput -> Result String Json.Value
toTypedJson inputType =
case inputType of
StringInput jsonValue ->
Ok jsonValue
IntInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
|> thenTry String.toInt
!|> Json.Encode.int
FloatInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
|> thenTry String.toFloat
!|> Json.Encode.float
BoolInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
|> thenTry toBool
!|> Json.Encode.bool
NullInput jsonValue ->
Ok Json.Encode.null
CustomInput jsonValue ->
Err "A custom encoder is required to convert CustomInput to JSON"
{-| Return a JavaScript value from `TypedInput` without attempting to decode. If
the `TypedInput` value has been captured from an input element, the returned
value will always be a JavaScript string. Useful for debugging.
formInput
|> getInputAt "userAge"
|> Result.withDefault (IntInput Json.Encode.null)
|> extractRawJson
--> "33" : Json.Decode.Value
-}
extractRawJson : TypedInput -> Json.Value
extractRawJson inputType =
case inputType of
StringInput jsonValue ->
jsonValue
IntInput jsonValue ->
jsonValue
FloatInput jsonValue ->
jsonValue
BoolInput jsonValue ->
jsonValue
NullInput jsonValue ->
jsonValue
CustomInput jsonValue ->
jsonValue
--READING VALUES WITH TYPE-CHECKING
{-| Decode `TypedInput` as a string, or return an error message if the decoder
fails.
formInput
|> getInputAt "userAge"
|> Result.withDefault (IntInput Json.Encode.null)
|> readInputAsString
--> Ok "33"
-}
readInputAsString : TypedInput -> Result String String
readInputAsString inputType =
case inputType of
StringInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
IntInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
FloatInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
BoolInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
NullInput jsonValue ->
jsonValue
|> Json.decodeValue (Json.null "NULL")
CustomInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
{-| Decode a `StringInput` value as a string; return an error message if the
decoder fails or if the argument is a type other than `StringInput`.
formInput
|> getInputAt "userName"
|> Result.withDefault (StringInput Json.Encode.null)
|> readStringInput
--> Ok "Bob"
-}
readStringInput : TypedInput -> Result String String
readStringInput inputType =
case inputType of
StringInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
_ ->
Err
("TypedInput is not StringInput. "
++ "To extract a string from another TypedInput use readInputAsString; "
++ "to return a default string on error use resolveStringInput.")
{-| Decode an `IntInput` value as a string, then attempt to convert the string
to an `Int`; return an error message if the string decoder fails, if type
conversion fails, or if the argument is a type other than `IntInput`.
formInput
|> getInputAt "userAge"
|> Result.withDefault (IntInput Json.Encode.null)
|> readIntInput
--> Ok 33
-}
readIntInput : TypedInput -> Result String Int
readIntInput inputType =
case inputType of
IntInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
|> thenTry String.toInt
_ ->
Err
("TypedInput is not IntInput. "
++ "To return a default value on error use resolveIntInput.")
{-| Decode a `FloatInput` value as a string, then attempt to convert the string
to a `Float`; return an error message if the string decoder fails, if type
conversion fails, or if the argument is a type other than `FloatInput`.
formInput
|> getInputAt "userWeight"
|> Result.withDefault (FloatInput Json.Encode.null)
|> readFloatInput
--> Ok 160.5
-}
readFloatInput : TypedInput -> Result String Float
readFloatInput inputType =
case inputType of
FloatInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
|> thenTry String.toFloat
_ ->
Err
("TypedInput is not FloatInput. "
++ "To return a default value on error use resolveFloatInput.")
{-| Decode a `BoolInput` value as a string, then attempt to convert the string
to a `Bool`; return an error message if the string decoder fails, if type
conversion fails, or if the argument is a type other than `BoolInput`. Type
conversion expects a string value of "true" or "false", ignoring case.
formInput
|> getInputAt "over18Years"
|> Result.withDefault (BoolInput Json.Encode.null)
|> readBoolInput
--> Ok True
-}
readBoolInput : TypedInput -> Result String Bool
readBoolInput inputType =
case inputType of
BoolInput jsonValue ->
jsonValue
|> Json.decodeValue Json.string
|> thenTry toBool
_ ->
Err
("TypedInput is not BoolInput. "
++ "To return a default value on error use resolveBoolInput.")
{-| Given a `Json`
[`Decoder`](http://package.elm-lang.org/packages/elm-lang/core/latest/Json-Decode#Decoder),
attempt to decode a `CustomInput` value; return an error message if the decoder
fails or if the argument is a type other than `CustomInput`.
-}
readCustomInput : Decoder a -> TypedInput -> Result String a
readCustomInput customDecoder inputType =
case inputType of
CustomInput jsonValue ->
jsonValue
|> Json.decodeValue customDecoder
_ ->
Err
("TypedInput is not CustomInput. "
++ "To return a default value on error use resolveBoolInput.")