Yesod selectFieldList returning list index number instead of value

Viewed 151

I am trying to run a form that I generate using selectFieldList.

data CityContainer = CityContainer (Maybe T.Text)
                     deriving Show

ambiguityForm :: [PG.DbCity] -> Html -> MForm Handler (FormResult CityContainer, Widget)
ambiguityForm cities = renderDivs $ CityContainer
    <$> aopt (selectFieldList cityMap) "City" Nothing
    where
      cityMap :: [(T.Text, T.Text)]
      cityMap = W.mkCityStringM cities


data CityText = CityText T.Text
                deriving Show

ambigReciever :: AForm Handler CityText
ambigReciever = CityText
    <$> areq textField "City" Nothing

I run this form by calling runAmbiguityF from another route handler. runAmbiguityF then calls postAmbiguityR.

runAmbiguityF :: [PG.DbCity] -> Handler Html
runAmbiguityF cs = do
  (widget, enctype) <- generateFormPost (ambiguityForm cs)
  defaultLayout $ 
    [whamlet| 
      <form method=post action=@{AmbiguityR} enctype=#{enctype}> 
        ^{widget}
        <button type="submit">Submit
    |]  


postAmbiguityR :: Handler Html  
postAmbiguityR = do
  ((result, widget), enctype) <- runFormPost (renderDivs ambigReciever)
  case result of --hold :: CityHold
    FormSuccess cityHold -> defaultLayout $ [whamlet|#{show cityHold}|]
    FormFailure x -> 
      defaultLayout
      [whamlet|
          <p>Invalid Input, try again.
          <form method=post action=@{AmbiguityR} enctype=#{enctype}>
              ^{widget}
              <button>Submit
      |]

When I run this code I am given a drop down menu like I would expect and am able to to submit the form.

I get a FormSuccess, and am therfore shown the CityHold variable. The problem is that this variable does not hold the associated value that is created by cityMap in the ambiguityForm function. Instead I am returned the index number of the list selection that I made that is wrapped in the CityText type.

For example say the dropdown list has 10 elements. If I select the first element of the list I get back CityText "1". Say I select the last item on the dropdown I am returned CityText "10".

How can I get the value instead of the index number when I submit the form?

2 Answers

The selectField function takes an OptionList a representing a selection from a list of Haskell objects of type a. The OptionList a is a list of Option a values that combine a Text user-facing label, the a value being selected, and the Text for the HTML-level value that will be returned by the client in the form. The selectFieldList function is a specialization that uses increasing integer labels for the HTML-level values, which is why you are seeing a series of increasing integers instead of meaningful values returned by your form.

So, you want to use selectField in place of selectFieldList. That's not the end of the story though. As I understand it, you are trying to render a form with a dynamic set of choices (presumably generated monadically from a database query). When the form is posted, you are hoping to receive a meaningful HTML-level value, so that you can accept and act on it statelessly without needing to remember the original dynamic set of choices. That way, you can bypass runFormPost and act on the returned value directly.

In general, this is a bad idea! By bypassing runFormPost, you are bypassing cross-site request forgery (CSRF) protection and form validation. This might work for your specific case, if you have only a single field in your form, take care to validate the returned HTML-level value manually, and do your own CSRF mitigation (or are operating in a trusted context where this isn't an issue). But, a more general solution is possible, though it's a little hacky.

Let me use a self-contained example to illustrate. For your dynamic drop down, for each option there will be three values involved, the internal City type at the Haskell level (e.g., your PG.DbCity), and two Text values: a user-visible label that appears in the drop down menu, and a self-contained Key that will be sent in HTML-level value attributes and passed back to you to validate and convert back into a City.

So, you've got, say:

type Key = Text
data City = City { key :: Key, label :: Text } deriving (Show, Eq)

and a set of valid Citys:

validCities = [City "0101" "New York", City "0102" "New Jersey", City "0200" "Newark"]

In the real world, City can be a persist database entity, and you can use the Show instance for its entity key and some other handy text field for its label.

I'm going to assume you can monadically generate a dynamic subset of cities in a handler (e.g., through a database query):

getSomeCities :: Text -> Handler [City]
getSomeCities pfx = return $ filter (pfx `isPrefixOf . label) validCities

and monadically validate/lookup a key (e.g., "0101") against the full list of cities:

lookupCity :: Key -> Handler (Maybe City)
lookupCity k = return $ find ((== k) . key) validCities

It's worth noting here that, if you want to be stateless, you can't realistically validate the returned Key against the actual options you sent to the client. You can only check that the Key is valid in some larger context (e.g., is some valid city in the database). From a security point of view, you need to be prepared for the possibility that a client could post a key that was not among the options you provided.

Anyway, a simple dynamic dropdown using selectField might be created with the form:

dropDownForm :: [City] -> Html -> MForm Handler (FormResult City, Widget)
dropDownForm cities = renderDivs $
  areq (selectField ol) "" Nothing

  where ol :: Handler (OptionList City)
        ol = do
          mr <- getMessageRender
          return $ mkOptionList [ Option (mr lbl) city key
                                | city@(City key lbl) <- cities
                                ]

and the GET handler:

getDropdownR :: Handler Html
getDropdownR = do
  -- some dynamic subset of the valid cities
  cities <- getSomeCities "New "
  (widget, enctype) <- generateFormPost (dropDownForm cities)
  defaultLayout [whamlet|
    <form method=post action=@{DropdownR} enctype=#{enctype}>
      ^{widget}
      <button>Submit
    |]

For now, let's write a standard POST handler

postDropdownR :: Handler Html
postDropdownR = do
  ((result, _), _) <- runFormPost (dropDownForm [])
  case result of
    FormSuccess opt -> do
      setMessage . toHtml $ "You chose option " <> show opt
    FormFailure txt -> do
      setMessage (toHtml $ Text.unlines txt)
  redirect DropdownR

Because we use runFormPost, we have CSRF protection and validation for any other form fields. The only issue here is that, since we are stateless, we don't have the list of cities available, so for now I've just provided the empty list.

If you stick this into a basic Yesod server and view the HTML of the generated form, you'll see that the HTML value attributes are the self-contained keys 0101 and 0102 that we can map back to the cities.

However, if you try to POST this form, you'll get back an error:

Invalid entry: 0101

because the selectField validator is validating the returned option against an empty list of options. One simple thing to do would be to supply the full set of valid cities in postDropdownR, regardless of the subset of cities sent to the client:

postDropdownR' :: Handler Html
postDropdownR' = do
  ((result, _), _) <- runFormPost (dropDownForm' validCities) -- CHANGE HERE
  case result of
    FormSuccess opt -> do
      setMessage . toHtml $ "You chose option " <> show opt
    FormFailure txt -> do
      setMessage (toHtml $ Text.unlines txt)
  redirect DropdownR

Now, the form works fine and responds with something like:

You chose option City {key = "0102", label = "New Jersey"}

The big disadvantage is that the full set of cities must be supplied all at once, which is not going to be practical for a large database of valid cities.

The OptionList type provides some flexibility in that its type includes an olOptions :: [Option a] list of options used when rendering the form and a separate function olReadExternal :: Text -> Maybe a for validating the HTML-level value returned, but olReadExternal is still a pure function, so there's no way to run it as a database query in a monadic context.

This is where it gets hacky. We need to override the validation code for the selectField-produced Field with our own validator. This means rewriting the form as:

dropDownForm :: [City] -> Html -> MForm Handler (FormResult City, Widget)
dropDownForm cities = renderDivs $
  areq (selectField' ol) "" Nothing

  where ol :: Handler (OptionList City)
        ol = do
          mr <- getMessageRender
          return $ mkOptionList [ Option (mr lbl) city key
                                | city@(City key lbl) <- cities
                                ]

        selectField' :: Handler (OptionList City) -> Field Handler City
        selectField' ol = (selectField ol) { fieldParse = fp }

        -- adapted from `selectParser` in Yesod.Form.Fields source
        fp :: [Text] -> [FileInfo] -> Handler (Either (SomeMessage Site) (Maybe City))
        -- apparently, there are several ways of selecting nothing
        fp []         _ = return $ Right Nothing
        fp ("none":_) _ = return $ Right Nothing
        fp ("":_)     _ = return $ Right Nothing
        -- if you have a City key, you need to validate it
        fp (x:_)      _ = Right <$> lookupCity x

The change here is that we've overridden the fieldParse field in the Field so it validates using the lookupCity monadic function. In postDropDown, we switch back to runFormPosting using an empty set of cities, because the list of cities isn't used at all for validation.

With all that in place, using the code below, you get a monadically dynamic form that can be statelessly posted with the all the Yesod validation and CSRF mechanisms in place, and you can monadically validate the returned city using a handler of your own construction.

The full code:

{-# LANGUAGE OverloadedStrings     #-}
{-# LANGUAGE QuasiQuotes           #-}
{-# LANGUAGE TemplateHaskell       #-}
{-# LANGUAGE TypeFamilies          #-}
{-# LANGUAGE MultiParamTypeClasses #-}

import Yesod hiding (Key)
import Data.Text (Text)
import Data.List (find)
import qualified Data.Text as Text
import Data.Coerce

data Site = Site
mkYesod "Site" [parseRoutes|
  / DropdownR GET POST
  |]
instance Yesod Site
instance RenderMessage Site FormMessage where
  renderMessage _ _ = defaultFormMessage

type Key = Text
data City = City { key :: Key, label :: Text } deriving (Show, Eq)
validCities = [City "0101" "New York", City "0102" "New Jersey", City "0200" "Newark"]

getSomeCities :: Text -> Handler [City]
getSomeCities pfx = return $ filter (Text.isPrefixOf pfx . label) validCities

lookupCity :: Key -> Handler (Maybe City)
lookupCity k = return $ find ((== k) . key) validCities

dropDownForm :: [City] -> Html -> MForm Handler (FormResult City, Widget)
dropDownForm cities = renderDivs $
  areq (selectField' ol) "" Nothing

  where ol :: Handler (OptionList City)
        ol = do
          mr <- getMessageRender
          return $ mkOptionList [ Option (mr lbl) city key
                                | city@(City key lbl) <- cities
                                ]

        selectField' :: Handler (OptionList City) -> Field Handler City
        selectField' ol = (selectField ol) { fieldParse = fp }

        -- adapted from `selectParser` in Yesod.Form.Fields source
        fp :: [Text] -> [FileInfo] -> Handler (Either (SomeMessage Site) (Maybe City))
        -- apparently, there are several ways of selecting nothing
        fp []         _ = return $ Right Nothing
        fp ("none":_) _ = return $ Right Nothing
        fp ("":_)     _ = return $ Right Nothing
        -- if you have a City key, you need to validate it
        fp (x:_)      _ = Right <$> lookupCity x

getDropdownR :: Handler Html
getDropdownR = do
  -- some dynamic subset of the valid cities
  cities <- getSomeCities "New "
  (widget, enctype) <- generateFormPost (dropDownForm cities)
  defaultLayout [whamlet|
    <form method=post action=@{DropdownR} enctype=#{enctype}>
      ^{widget}
      <button>Submit
    |]

postDropdownR :: Handler Html
postDropdownR = do
  ((result, _), _) <- runFormPost (dropDownForm [])  -- empty city list ignored
  case result of
    FormSuccess opt -> do
      setMessage . toHtml $ "You chose option " <> show opt
    FormFailure txt -> do
      setMessage (toHtml $ Text.unlines txt)
  redirect DropdownR

main :: IO ()
main = warp 3000 Site

The solution that I ended up using was to modify the selectFieldList and optionsPairs functions directly. I still do not understand why this function would be designed to return the index + 1 of the option selected instead of the value that is mapped to said selection. Nevertheless here is what I came up with.

selectFieldList' ::
     (Eq a, Show a, RenderMessage site FormMessage, RenderMessage site msg)
  => [(msg, a)]
  -> Field (HandlerFor site) a
selectFieldList' = selectField . optionsPairs'

optionsPairs' ::
     (Show a, MonadHandler m, RenderMessage (HandlerSite m) msg)
  => [(msg, a)]
  -> m (OptionList a)
optionsPairs' opts = do
  mr <- getMessageRender
  let mkOption external (display, internal) =
          Option { optionDisplay       = mr display
                 , optionInternalValue = internal
                 , optionExternalValue = T.pack $ show external
                 }
      opts' = map snd opts
  return $ mkOptionList (zipWith mkOption opts' opts)
Related