Search code examples
purescripthalogen

Purescript Halogen manually trigger input validation outside of a form


I have input fields which I have marked with a required attribute, but can't figure out a way to trigger a validation check (I am not working inside of a form, so using a default submit button action won't work for me).

A quick pursuit search shows many validity functions for core html element types, but I'm not sure how to apply these to Halogen.

Is there some way to trigger a DOM effect to check all required inputs on the page and get a result back?

Here is an example component showing what I'm trying to achieve

import Prelude

import Data.Maybe (Maybe(..))
import Halogen as H
import Halogen.HTML as HH
import Halogen.HTML.Events as HE
import Halogen.HTML.Properties as HP

data Message = Void

type State =
  { textValue :: String
  , verified :: Boolean
  }

data Query a = ContinueClicked a | InputEntered String a

inputHtml :: State -> H.ComponentHTML Query
inputHtml state =
  HH.div [ HP.class_ $ H.ClassName "input-div" ]
         [ HH.label_ [ HH.text "This is a required field" ]
         , HH.input [ HP.type_ HP.InputText
                    , HE.onValueInput $ HE.input InputEntered
                    , HP.value state.textValue
                    , HP.required true
                    ]
         , HH.button [ HE.onClick $ HE.input_ ContinueClicked ]
                     [ HH.text "Continue"]
         ]

verifiedHtml :: H.ComponentHTML Query
verifiedHtml =
  HH.div_ [ HH.h3_ [ HH.text "Verified!" ] ]

render :: State -> H.ComponentHTML Query
render state = if state.verified then verifiedHtml else inputHtml state

eval :: forall m. Query ~> H.ComponentDSL State Query Message m
eval = case _ of
  InputEntered v next -> do
    H.modify $ (_ { textValue = v })
    pure next
  ContinueClicked next -> do
    let inputValid = false -- somehow use the required prop to determine if valid
    when inputValid $ H.modify $ (_ { verified = true })
    pure next

initialState :: State
initialState =
  { textValue : ""
  , verified : false
  }

component :: forall m. H.Component HH.HTML Query Unit Message m
component =
  H.component
    { initialState: const initialState
    , render
    , eval
    , receiver: const Nothing
    }

Solution

  • I don't think relying on HTML form validation is the most effective way of checking inputs within a Halogen application. But I'll assume you have your reasons and present an answer anyway.


    First things first, if we want to deal with DOM elements we need a way to retrieve them. Here's a purescript version of document.getElementById

    getElementById
        :: forall a eff
         . (Foreign -> F a)
        -> String
        -> Eff (dom :: DOM | eff) (Maybe a)
    getElementById reader elementId =
        DOM.window
            >>= DOM.document
            <#> DOM.htmlDocumentToNonElementParentNode
            >>= DOM.getElementById (wrap elementId)
            <#> (_ >>= runReader reader)
    
    runReader :: forall a b. (Foreign -> F b) -> a -> Maybe b
    runReader r =
        hush <<< runExcept <<< r <<< toForeign
    

    (Don't worry about the new imports for now, there's a complete module at the end)

    This getElementById function takes a read* function (probably from DOM.HTML.Types) to determine the type of element you get back, and an element id as a string.

    In order to use this, we need to add an extra property to your HH.input:

    HH.input [ HP.type_ HP.InputText
             , HE.onValueInput $ HE.input InputEntered
             , HP.value state.textValue
             , HP.required true
             , HP.id_ "myInput"  <-- edit
             ]
    

    Aside: a sum type with a Show instance would be safer than hard-coding stringy ids everywhere. I'll leave that one to you.

    Cool. Now we need to call this from the ContinueClicked branch of your eval function:

    ContinueClicked next ->
        do maybeInput <- H.liftEff $
                getElementById DOM.readHTMLInputElement "myInput"
        ...
    

    This gives us a Maybe HTMLInputElement to play with. And that HTMLInputElement should have a validity property of type ValidityState, which has the information we're after.

    DOM.HTML.HTMLInputElement has a validity function that will give us access to that property. Then we'll need to do some foreign value manipulation to try and get the data out that we want. For simplicity, let's just try and pull out the valid field:

    isValid :: DOM.ValidityState -> Maybe Boolean
    isValid =
        runReader (readProp "valid" >=> readBoolean)
    

    And with that little helper, we can finish the ContinueClicked branch:

    ContinueClicked next ->
        do maybeInput <- H.liftEff $
                getElementById DOM.readHTMLInputElement "myInput"
    
           pure next <*
           case maybeInput of
                Just input ->
                    do validityState <- H.liftEff $ DOM.validity input
                       when (fromMaybe false $ isValid validityState) $
                            H.modify (_ { verified = true })
                Nothing ->
                    H.liftEff $ log "myInput not found"
    

    And then putting it all together we have...

    module Main where
    
    import Prelude
    
    import Control.Monad.Aff (Aff)
    import Control.Monad.Eff (Eff)
    import Control.Monad.Eff.Console (CONSOLE, log)
    import Control.Monad.Except (runExcept)
    
    import Data.Either (hush)
    import Data.Foreign (Foreign, F, toForeign, readBoolean)
    import Data.Foreign.Index (readProp)
    import Data.Maybe (Maybe(..), fromMaybe)
    import Data.Newtype (wrap)
    
    import DOM (DOM)
    import DOM.HTML (window) as DOM
    import DOM.HTML.HTMLInputElement (validity) as DOM
    import DOM.HTML.Types
        (ValidityState, htmlDocumentToNonElementParentNode, readHTMLInputElement) as DOM
    import DOM.HTML.Window (document) as DOM
    import DOM.Node.NonElementParentNode (getElementById) as DOM
    
    import Halogen as H
    import Halogen.Aff as HA
    import Halogen.HTML as HH
    import Halogen.HTML.Events as HE
    import Halogen.HTML.Properties as HP
    import Halogen.VDom.Driver (runUI)
    
    main :: Eff (HA.HalogenEffects (console :: CONSOLE)) Unit
    main = HA.runHalogenAff do
        body <- HA.awaitBody
        runUI component unit body
    
    type Message
        = Void
    
    type Input
        = Unit
    
    type State
        = { textValue    :: String
          , verified     :: Boolean
          }
    
    data Query a
        = ContinueClicked a
        | InputEntered String a
    
    component
        :: forall eff
         . H.Component HH.HTML Query Unit Message (Aff (console :: CONSOLE, dom :: DOM | eff))
    component =
        H.component
            { initialState: const initialState
            , render
            , eval
            , receiver: const Nothing
            }
    
    initialState :: State
    initialState =
      { textValue : ""
      , verified : false
      }
    
    render :: State -> H.ComponentHTML Query
    render state =
        if state.verified then verifiedHtml else inputHtml
      where
        verifiedHtml =
            HH.div_ [ HH.h3_ [ HH.text "Verified!" ] ]
    
        inputHtml =
            HH.div
                [ HP.class_ $ H.ClassName "input-div" ]
                [ HH.label_ [ HH.text "This is a required field" ]
                , HH.input
                    [ HP.type_ HP.InputText
                    , HE.onValueInput $ HE.input InputEntered
                    , HP.value state.textValue
                    , HP.id_ "myInput"
                    , HP.required true
                    ]
                , HH.button
                    [ HE.onClick $ HE.input_ ContinueClicked ]
                    [ HH.text "Continue" ]
                 ]
    
    eval
        :: forall eff
         . Query
        ~> H.ComponentDSL State Query Message (Aff (console :: CONSOLE, dom :: DOM | eff))
    eval = case _ of
        InputEntered v next ->
            do H.modify (_{ textValue = v })
               pure next
    
        ContinueClicked next ->
            do maybeInput <- H.liftEff $
                    getElementById DOM.readHTMLInputElement "myInput"
    
               pure next <*
               case maybeInput of
                    Just input ->
                        do validityState <- H.liftEff $ DOM.validity input
                           when (fromMaybe false $ isValid validityState) $
                                H.modify (_ { verified = true })
                    Nothing ->
                        H.liftEff $ log "myInput not found"
    
    getElementById
        :: forall a eff
         . (Foreign -> F a)
        -> String
        -> Eff (dom :: DOM | eff) (Maybe a)
    getElementById reader elementId =
        DOM.window
            >>= DOM.document
            <#> DOM.htmlDocumentToNonElementParentNode
            >>= DOM.getElementById (wrap elementId)
            <#> (_ >>= runReader reader)
    
    isValid :: DOM.ValidityState -> Maybe Boolean
    isValid =
        runReader (readProp "valid" >=> readBoolean)
    
    runReader :: forall a b. (Foreign -> F b) -> a -> Maybe b
    runReader r =
        hush <<< runExcept <<< r <<< toForeign