Yesod form overhaul

August 3, 2011

GravatarMichael Snoyman

Motivation

We're about ready for the Yesod 0.9 release candidate, and we're trying to polish all the rough edges. Greg and I have been trying to get feedback on what's still missing and what could be done better. Luite Stegeman did a good job pointing out some shortcomings in the yesod-form package. I've made some major changes, some not directly related to his comments.

Let's start off by identifying the issues we needed to solve.

  • No direct way to do validations like "a number between 1 and 10". It was possible, just not easy.
  • No way at all to perform monadic validations (is this username taken? is this date in the past?).
  • To support i18n, we have the concept of message types. However, it hasn't been possible to mix different types for the most part.
  • Type signatures very quickly become unwieldy.

SomeMessage

A quick review of i18n in Yesod: we have a typeclass called RenderMessage defined as

class RenderMessage master message where
    renderMessage :: master
                  -> [Text] -- ^ languages
                  -> message
                  -> Text
This lets each application (distinguished by the master datatype) have its own sets of translations. Within yesod-form, we would end up with a msg type variable in a few places, such as for error messages from a field parser. And yesod-form defines a datatype FormMessage which it uses for all its messages.

But let's say we want to have an integer field for numbers between 1 and 10. There are two things that can go wrong:

  • the user could submit a non-integer. For this we want to use the built-in error message from yesod-form of type FormMessage
  • the user could enter an integer outside the range. Here we would want to use the application specific message type.

The question is how do we combine two different datatypes together. The answer: existentials.

data SomeMessage master = forall msg. RenderMessage master msg => SomeMessage msg
We now have a datatype that represents any message that can be translated for our application. By using this datatype in place of a msg type variable, we get to both achieve our primary goal and make our type signatures a bit shorter.

Less General Types

Once we are in the swing of cleaning up our type signatures, let's go for the gold. yesod-form was originally designed based on formlets, which uses very general type signatures. formlets doesn't specify how the html should be stored or in what monad the form should run. Instead, those are all given as type parameters. And up until now, yesod-form has done the same thing.

However, this doesn't really make much sense for Yesod, where we know the user will be representing their view as Widgets and running in the Handler monad. So instead of leaving these to type parameters, we've now fixed them in the types themselves. Now the types in yesod-form match up much more closely with the rest of the Yesod framework, taking parameters for the subsite, master site, and the contained value.

Hopefully this will make the library easier to use and make compiler error messages clearer.

Monadic field parser

Now with that maintenance overhead out of the way, we can start to focus on Luite's issues directly. Let's start by looking at the definition of the fieldParse record in a Field from a few days ago:

[Text] -> Either msg (Maybe a)
Fairly simple: take a list of parameters (remember, multiple values can be submitted for each field, like with multi-select fields) and either return an error message, Nothing if the input is missing, or the parsed value on success. (Missing input may or may not be an error, depending on if the field is required or optional.)

Now what we want is an easy way to add extra restriction onto that "a". Let's take our number example from before. An intField from yesod-form would have a fieldParse looking like [Text] -> Either FormMessage (Maybe Int). We now want to tack on some code that can logically be expressed as:

data MyAppMessage = BelowOne | AboveTen
withinRange :: Int -> Either MyAppMessage Int
withRange i
    | i < 1 = Left BelowOne
    | i > 10 = Left AboveTen
    | otherwise = Right i
All we need is a way to attach our withinRange to our fieldParse.

The first thing to point out is that our SomeMessage trick from earlier is absolutely necessary at this point. Without it, we would be forced to either include every possible error message in FormMessage, or redefine our built-in fields to use our custom error message type. Instead, we can now wrap up the error messages in a SomeMessage constructor and easily compose these two functions.

But let's take the slightly more complicated case: a non-pure validator. There is no provision in fieldParse for monadic side effects. The solution for this is actually very simple: wrap the return in a monad:

data Field sub master a = Field
    { fieldParse :: [Text] -> GGHandler sub master IO (Either (SomeMessage master) (Maybe a))
    , fieldView :: ...
    }

For our built-in fields the only change necessary is an added return.

The check functions

Now that the structure has been corrected, furnishing is easy. At this point, I'll let the code speak for itself:

check :: RenderMessage master msg
      => (a -> Either msg a) -> Field sub master a -> Field sub master a
check f = checkM $ return . f

-- | Return the given error message if the predicate is false.
checkBool :: RenderMessage master msg
          => (a -> Bool) -> msg -> Field sub master a -> Field sub master a
checkBool b s = check $ \x -> if b x then Right x else Left s

checkM :: RenderMessage master msg
       => (a -> GGHandler sub master IO (Either msg a))
       -> Field sub master a
       -> Field sub master a
checkM f field = field
    { fieldParse = \ts -> do
        e1 <- fieldParse field ts
        case e1 of
            Left msg -> return $ Left msg
            Right Nothing -> return $ Right Nothing
            Right (Just a) -> fmap (either (Left . SomeMessage) (Right . Just)) $ f a
    }

And just to motivate this a bit more, the use code from the hello-forms.hs sample. Note that this code simply uses Text values for its validation messages. If you're not concerned with i18n, this is always an option in your applications.

myValidForm = runFormGet $ renderTable $ pure (,,)
    <*> areq (check (\x ->
            if T.length x < 3
                then Left ("Need at least 3 letters" :: Text)
                else Right x
              ) textField)
            "Name" Nothing
    <*> areq (checkBool (>= 18) ("Must be 18 or older" :: Text) intField)
            "Age" Nothing
    <*> areq (checkM inPast dayField) "Anniversary" Nothing
  where
    inPast x = do
        now <- liftIO getCurrentTime
        return $ if utctDay now < x
                    then Left ("Need a date in the past" :: Text)
                    else Right x

Comments

comments powered by Disqus

Archives