This is an alternative site for discovering Elm packages. You may be looking for the official Elm package site instead.

ModularDesign.FormInput

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

type alias FormInput = Dict String TypedInput

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.

formControl : (FormInput -> msg) -> List (HtmlTree msg) -> HtmlTree msg

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.

captureOnSubmit : Decoder msg -> Attribute msg

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.

fieldDecoder : HtmlElement msg -> Decoder (String, TypedInput)

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.

formDecoder : List (HtmlTree msg) -> Decoder FormInput

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.

Accessing Form Input

getInputAt : String -> FormInput -> Result String TypedInput

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")
readStringAt : String -> FormInput -> Result String String

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"

Converting Form Input to JSON

formInputToJson : FormInput -> Json.Value

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
toTypedJson : TypedInput -> Result String Json.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
extractRawJson : TypedInput -> Json.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

Reading TypedInput Values

readInputAsString : TypedInput -> Result String String

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"
readStringInput : TypedInput -> Result String 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"
readIntInput : TypedInput -> Result String Int

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
readFloatInput : TypedInput -> Result String Float

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
readBoolInput : TypedInput -> Result String Bool

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
readCustomInput : Decoder a -> TypedInput -> Result String a

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.")