PureScript possesses some already existing libraries regarding validation ("purescript-formless-independent", "purescript-home-run-ball", etc.), but they seemed far too clunky to be used in a simple way for simple things. The project contains an interface with the native "purescript-validation"
Motivations
During the years we have seen a profusion of frontend libraries and frameworks. Each one having their little tweaks or characteristics, making them different from one another. Same can be said for one of the most important aspect of the frontend world: validation process. While assimilating this problematic to our project, this article from Damian Dulisz is a good starting point to begin our thought process:
“[...] if you work on a more data oriented application you’ll notice that you’re validating much more than just your inputs. Then you have to combine the above approach with custom validation methods or computed values. This introduces additional noise both in the templates and in your code. I also believe that templates are not the best place for declaring application logic.”
Yet we are also ready to approve this assertion by Daniel Steigerwald:
“Almost all validation libraries I have seen (and I wrote) suck. Everything is nice and all with isRequired and isEmail, but then suddenly things become complicated. Custom validations, async validation, cross form fields or even cross forms validation rules, we all have been there. The reason for all of that is simple. There is no such thing as an ideal validation library. [...] Duplication is far cheaper than the wrong abstraction.”
These conclusions lead us to a hybrid proposition, taking benefits from both. So we made a simple interface which:
- Relies on a set of robust validators functions. This slideshare of Joseph Kachmar invited us to use the PureScript native "purescript-validation". These simple use cases can easily be redone in every component/hook/store possible.
- Proposes the most simple abstraction. We have abstracted the process throughout two main actions: a method to check a specific entity, another one to check the entire set.
Behaviors
- methods checking the whole set of entities: we click on the main call-to-action of the form, a global validation is attended.
- methods dynamically checking one or a few entities: controlling one particular async fields, validating a specific field while writing on it, etc.
- each global validation increment a step: as if it was lap ( dynamic validation does not)
API
- ~/plugins/Hooks/formValidation component to be reused for composition pattern
type Methods =
-- | Check if previous "try/asyncTry" contains error
-- |
-- | variant: * constraining to a given field,
-- | * idem + provided error
( hasError :: Boolean
, hasError' :: Field -> Boolean
, hasError_ :: Field -> String -> Boolean
-- | Remove all current error without running parallel side effects
-- |
-- | variant: * constrained to a given field
, removeError :: Effect Unit
, removeError' :: Field -> Effect Unit
-- | Store number of "try/asyncTry" made (ie. "global" validation made)
-- |
-- | variant: * boxed wrapped value
, tryCount :: Int
, tryCountBox :: T.Box Int
-- | Exec a synchronous validation, run parallel side effects, return result
-- |
-- | variant: * Aff monad dependent validation rules
, try :: (Unit -> Effect VForm) -> Effect EForm
, asyncTry :: (Unit -> Aff VForm) -> Aff EForm
-- | Exec a dynamic synchronous validation, no parallel side effects runned,
-- | returned only provided validation part (ie. not the whole result)
-- | nor returned result)
-- |
-- | variant: * Aff monad dependent validation rules
, try' :: (Unit -> Effect VForm) -> Effect EForm
, asyncTry' :: (Unit -> Aff VForm) -> Aff EForm
)
-
"purescript-validation" (SemiGroup) Provide basic API were we can append validation rules for multiple fields
- some already existing rules can be retrieved within the
Hello.Plugins.Hooks.FormValidation.Unboxed
(vanilla) andHello.Plugins.Hooks.FormValidation.Boxed
(Toestand) - depending on context, every rules will returned either a
SemiGroup
Effect VForm
orAff VForm
- some already existing rules can be retrieved within the
Unboxed helpers
While using vanilla values (not using Toestand)
import Hello.Plugins.Hooks.FormValidation.Unboxed as FV
validateName :: String -> Effect VForm
validateName = FV.nonEmpty "name_field"
Boxed helpers
While using Toestand boxes
import Toestand as T
import Hello.Plugins.Hooks.FormValidation.Boxed as FV
validateName :: T.Box String -> Effect VForm
validateName = FV.nonEmpty "password"
Use cases
Basic validation
- perform a global validation when user clicks on a submit button
- display a global error message if any entity is invalid
type FormData =
( email :: String
, password :: String
)
defaultData :: Record FormData
defaultData =
{ email: ""
, password: ""
}
validateEmail :: Record FormData -> Effect VForm
validateEmail r = FV.nonEmpty "email" r.email
<> FV.email "email" r.email
validatePassword :: Record FormData -> Effect VForm
validatePassword r = FV.nonEmpty "password" r.password
globalValidation :: Record FormData -> Effect VForm
globalValidation r = validateEmail r <> validatePassword r
component :: R.Component ()
component = R.hooksComponent cname cpt where
cpt _props_ _ = do
fv <- FV.useFormValidation
{ state, setStateKey, bindStateKey } <- useStateRecord defaultData
-- @onSubmit: exec whole form validation and exec callback
onSubmit <- pure \_ -> do
result <- fv.try (\_ -> globalValidation state)
case result of
Left err -> console.warn3 "form error" state err
Right _ -> props.callback state
-- @render
...
if (fv.hasError)
H.text "Form error!"
...
H.button { on { click: onSubmit } }
...
Dynamic validation
- validate a specific input when user enters a new value
- display an error message below the element
type FormData =
( email :: String
, password :: String
)
defaultData :: Record FormData
defaultData =
{ email: ""
, password: ""
}
validateEmail :: String -> Effect VForm
validateEmail s = FV.nonEmpty "email" s
<> FV.email "email" s
validatePassword :: String -> Effect VForm
validatePassword = FV.nonEmpty "password"
globalValidation :: Record FormData -> Effect VForm
globalValidation r = validateEmail r.email
<> validatePassword r.password
component :: R.Component ()
component = R.hooksComponent cname cpt where
cpt _props_ _ = do
fv <- FV.useFormValidation
{ state, setStateKey, bindStateKey } <- useStateRecord defaultData
-- @onEmailChange: dynamically check the format as the user write on input
onEmailChange <- pure \value -> do
setStateKey "email" value
fv.removeError' "email"
fv.try' (\_ -> validateEmail value)
-- @render
...
B.formInput
{ callback: onEmailChange
, value: state.email
}
if (fv.hasError' "email")
H.text "Please enter a valid email address"
...
Debouncing validation & Async validation
- defer a validation operation (see David Corbacho's article for details explanation about debouncing)
- check for specific error for a field to display
- call an asynchronous method executing a validation business for an element
type FormData =
( email :: String
, password :: String
)
defaultData :: Record FormData
defaultData =
{ email: ""
, password: ""
}
-- simulate call to API
dynamicValidation :: String -> Aff VForm
dynamicValidation = simulateOnlineEmailUnicity "email"
simulateOnlineEmailUnicity :: String -> String -> Aff VForm
simulateOnlineEmailUnicity field value
| value == "already@used.com" = pure $ invalid [ field /\ "emailUnicity" ]
| otherwise = pure $ pure unit
component :: R.Component ()
component = R.hooksComponent cname cpt where
cpt _props_ _ = do
fv <- FV.useFormValidation
{ state, setStateKey, bindStateKey } <- useStateRecord defaultData
-- @onEmailChange: exec async validation for email unicity
onEmailChange <- useDebounce 1000 \value -> launchAff_ do
liftEffect do
setStateKey "email" value
console.info "attempting dynamic validation on email"
result <- fv.asyncTry' (\_ -> dynamicValidation value)
liftEffect $ console.log result
-- @render
...
B.formInput
{ callback: onEmailChange
, value: state.email
}
if (fv.hasError_ "email" "emailUnicity")
H.text "This email is already being used"
...
Shared configuration validation
- execute a validation outside of a component
- perform identical validation operations from multiple scopes
-- @TODO: make an example where the validation rules and execution are being
-- made from within a Store (need Toestand library re-writing)
Evolutive validation type
- start with a basic validation
- if first submission fails, switch to a dynamic validation
Why? It is a common case in UX design, mixing simplicity in message feedback and reward system. The aim is to display any errors after a first form submissionion. Then, if the user rightfully correct its false input, the error feedback disappears.
...
-- @onPasswordChange: showing UI best practices regarding validation
onPasswordChange <- pure \value -> do
setStateKey "password" value
if (fv.tryCount > 0) -- (!) only if one submit has already been made
then pure unit
<* fv.removeError' "password"
<* fv.try' (\_ -> validatePassword password)
else pure unit
-- @onSubmit: exec whole form validation and exec callback
onSubmit <- pure \_ -> do
result <- fv.try (\_ -> globalValidation state)
case result of
Left err -> console.warn3 "form error" state err
Right _ -> props.callback state
...