{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE PackageImports #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} -- | Parse forms (and query strings). module Yesod.Form ( -- * Data types GForm (..) , Form , FormField , FormResult (..) -- * Unwrapping functions , runFormGet , runFormPost , runFormGet' , runFormPost' -- * Type classes , IsForm (..) , IsFormField (..) -- * Pre-built fields , requiredField , stringField , intField , dayField , boolField , htmlField , stringInput , fieldsToTable , share2 , mkIsForm , mapFormXml {- FIXME -- * Create your own formlets , incr , input , check -- * Error display , wrapperRow , sealFormlet , sealForm , sealRow -- * Formable , Formable (..) , deriveFormable , share2 -- * Pre-built form , optionalField , requiredField , notEmptyField , boolField -} ) where import Text.Hamlet import Yesod.Request import Yesod.Handler import Control.Applicative hiding (optional) import Data.Time (Day) import Data.Maybe (fromMaybe) import "transformers" Control.Monad.IO.Class import Control.Monad ((<=<), liftM, join) import Data.Monoid (Monoid (..)) import Control.Monad.Trans.State import Control.Arrow (first) import Language.Haskell.TH.Syntax import Database.Persist.Base (PersistField, EntityDef (..)) import Data.Char (isAlphaNum, toUpper, isUpper) import Data.Maybe (isJust) import Web.Routes.Quasi (SinglePiece) import Data.Int (Int64) import qualified Data.ByteString.Lazy.UTF8 as U import Yesod.Widget data FormResult a = FormMissing | FormFailure [String] | FormSuccess a deriving Show instance Functor FormResult where fmap _ FormMissing = FormMissing fmap _ (FormFailure errs) = FormFailure errs fmap f (FormSuccess a) = FormSuccess $ f a instance Applicative FormResult where pure = FormSuccess (FormSuccess f) <*> (FormSuccess g) = FormSuccess $ f g (FormFailure x) <*> (FormFailure y) = FormFailure $ x ++ y (FormFailure x) <*> _ = FormFailure x _ <*> (FormFailure y) = FormFailure y _ <*> _ = FormMissing data Enctype = UrlEncoded | Multipart instance Show Enctype where show UrlEncoded = "application/x-www-form-urlencoded" show Multipart = "multipart/form-data" instance Monoid Enctype where mempty = UrlEncoded mappend UrlEncoded UrlEncoded = UrlEncoded mappend _ _ = Multipart newtype GForm sub y xml a = GForm { deform :: Env -> FileEnv -> StateT Int (GHandler sub y) (FormResult a, xml, Enctype) } type Form sub y = GForm sub y (Widget sub y ()) type FormField sub y = GForm sub y [FieldInfo sub y] mapFormXml :: (xml1 -> xml2) -> GForm s y xml1 a -> GForm s y xml2 a mapFormXml f (GForm g) = GForm $ \e fe -> do (res, xml, enc) <- g e fe return (res, f xml, enc) data FieldInfo sub y = FieldInfo { fiLabel :: Html () , fiTooltip :: Html () , fiIdent :: String , fiInput :: Widget sub y () , fiErrors :: Html () } type Env = [(String, String)] type FileEnv = [(String, FileInfo)] instance Monoid xml => Functor (GForm sub url xml) where fmap f (GForm g) = GForm $ \env fe -> liftM (first3 $ fmap f) (g env fe) where first3 f (x, y, z) = (f x, y, z) instance Monoid xml => Applicative (GForm sub url xml) where pure a = GForm $ const $ const $ return (pure a, mempty, mempty) (GForm f) <*> (GForm g) = GForm $ \env fe -> do (f1, f2, f3) <- f env fe (g1, g2, g3) <- g env fe return (f1 <*> g1, f2 `mappend` g2, f3 `mappend` g3) fieldsToTable :: [FieldInfo sub y] -> Widget sub y () fieldsToTable = mapM_ go where go fi = do flip wrapWidget (fiInput fi) $ \w -> [$hamlet| %tr %td %label!for=$string.fiIdent.fi$ $fiLabel.fi$ .tooltip $fiTooltip.fi$ %td ^w^ %td.errors $fiErrors.fi$ |] class IsForm a where toForm :: Maybe a -> Form sub y a class IsFormField a where toFormField :: Html () -> Html () -> Maybe a -> FormField sub y a requiredField :: FieldProfile sub y a -> Html () -> Html () -> Maybe a -> FormField sub y a requiredField (FieldProfile parse render mkXml w) label tooltip orig = GForm $ \env _ -> do name <- incr let (res, val) = if null env then (FormMissing, maybe "" render orig) else case lookup name env of Nothing -> (FormMissing, "") Just "" -> (FormFailure ["Value is required"], "") Just x -> case parse x of Left e -> (FormFailure [e], x) Right y -> (FormSuccess y, x) let fi = FieldInfo { fiLabel = label , fiTooltip = tooltip , fiIdent = name , fiInput = w name >> addBody (mkXml (string name) (string val) True) , fiErrors = case res of FormFailure [x] -> string x _ -> string "" } return (res, [fi], UrlEncoded) data FieldProfile sub y a = FieldProfile { fpParse :: String -> Either String a , fpRender :: a -> String , fpHamlet :: Html () -> Html () -> Bool -> Hamlet (Routes y) , fpWidget :: String -> Widget sub y () } --------------------- Begin prebuilt forms stringField :: FieldProfile sub y String stringField = FieldProfile { fpParse = Right , fpRender = id , fpHamlet = \name val isReq -> [$hamlet| %input#$name$!name=$name$!type=text!:isReq:required!value=$val$ |] , fpWidget = \_name -> return () } instance IsFormField String where toFormField = requiredField stringField intField :: FieldProfile sub y Int intField = FieldProfile { fpParse = maybe (Left "Invalid integer") Right . readMay , fpRender = show , fpHamlet = \name val isReq -> [$hamlet| %input#$name$!name=$name$!type=number!:isReq:required!value=$val$ |] , fpWidget = \_name -> return () } instance IsFormField Int where toFormField = requiredField intField dayField :: FieldProfile sub y Day dayField = FieldProfile { fpParse = maybe (Left "Invalid day, must be in YYYY-MM-DD format") Right . readMay , fpRender = show , fpHamlet = \name val isReq -> [$hamlet| %input#$name$!name=$name$!type=date!:isReq:required!value=$val$ |] , fpWidget = \name -> do addScriptRemote "http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js" addScriptRemote "http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.1/jquery-ui.min.js" addStylesheetRemote "http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.1/themes/cupertino/jquery-ui.css" addHead [$hamlet|%script $$(function(){$$("#$string.name$").datepicker({dateFormat:'yy-mm-dd'})})|] } instance IsFormField Day where toFormField = requiredField dayField boolField :: Html () -> Html () -> Maybe Bool -> FormField sub y Bool boolField label tooltip orig = GForm $ \env _ -> do name <- incr let (res, val) = if null env then (FormMissing, fromMaybe False orig) else case lookup name env of Nothing -> (FormSuccess False, False) Just _ -> (FormSuccess True, True) let fi = FieldInfo { fiLabel = label , fiTooltip = tooltip , fiIdent = name , fiInput = addBody [$hamlet| %input#$string.name$!type=checkbox!name=$string.name$!:val:checked |] , fiErrors = case res of FormFailure [x] -> string x _ -> string "" } return (res, [fi], UrlEncoded) instance IsFormField Bool where toFormField = boolField htmlField :: FieldProfile sub y (Html ()) htmlField = FieldProfile { fpParse = Right . preEscapedString , fpRender = U.toString . renderHtml , fpHamlet = \name val isReq -> [$hamlet| %textarea#$name$!name=$name$ $val$ |] , fpWidget = \name -> do addScriptRemote "http://js.nicedit.com/nicEdit-latest.js" addHead [$hamlet|%script bkLib.onDomLoaded(function(){new nicEditor({fullPanel:true}).panelInstance("$string.name$")})|] addStyle [$hamlet|\#$string.name${min-width:400px;min-height:300px}|] } instance IsFormField (Html ()) where toFormField = requiredField htmlField readMay :: Read a => String -> Maybe a readMay s = case reads s of (x, _):_ -> Just x [] -> Nothing --------------------- End prebuilt forms --------------------- Begin prebuilt inputs stringInput :: String -> Form sub master String stringInput n = GForm $ \env _ -> return (case lookup n env of Nothing -> FormMissing Just "" -> FormFailure [n ++ ": You must provide a non-empty string"] Just x -> FormSuccess x, mempty, UrlEncoded) --------------------- End prebuilt inputs incr :: Monad m => StateT Int m String incr = do i <- get let i' = i + 1 put i' return $ "f" ++ show i' runFormGeneric :: Env -> FileEnv -> GForm sub y xml a -> GHandler sub y (FormResult a, xml, Enctype) runFormGeneric env fe f = evalStateT (deform f env fe) 1 -- | Run a form against POST parameters. runFormPost :: GForm sub y xml a -> GHandler sub y (FormResult a, xml, Enctype) runFormPost f = do rr <- getRequest (pp, files) <- liftIO $ reqRequestBody rr runFormGeneric pp files f -- | Run a form against POST parameters, disregarding the resulting HTML and -- returning an error response on invalid input. runFormPost' :: GForm sub y xml a -> GHandler sub y a runFormPost' = helper <=< runFormPost -- | Run a form against GET parameters, disregarding the resulting HTML and -- returning an error response on invalid input. runFormGet' :: Form sub y a -> GHandler sub y a runFormGet' = helper <=< runFormGet helper :: (FormResult a, b, c) -> GHandler sub y a helper (FormSuccess a, _, _) = return a helper (FormFailure e, _, _) = invalidArgs e helper (FormMissing, _, _) = invalidArgs ["No input found"] -- | Run a form against GET parameters. runFormGet :: GForm sub y xml a -> GHandler sub y (FormResult a, xml, Enctype) runFormGet f = do gs <- reqGetParams `fmap` getRequest runFormGeneric gs [] f {- -------- Prebuilt optionalField :: String -> Form sub master (Maybe String) optionalField n = Form $ \env _ -> return (FormSuccess $ lookup n env, mempty) -- FIXME requiredField :: String -> Form sub master String requiredField n = Form $ \env _ -> return (maybe FormMissing FormSuccess $ lookup n env, mempty) -- FIXME notEmptyField :: String -> Form sub master String notEmptyField n = Form $ \env _ -> return (case lookup n env of Nothing -> FormMissing Just "" -> FormFailure [n ++ ": You must provide a non-empty string"] Just x -> FormSuccess x, mempty) -- FIXME boolField :: String -> Form sub master Bool boolField n = Form $ \env _ -> return (FormSuccess $ isJust $ lookup n env, mempty) -- FIXME class Formable a where formable :: Formlet sub master a --------------- Formable instances instance Formable String where formable x = input go x `check` notEmpty where go name val = [$hamlet| %input!type=text!name=$string.name$!value=$string.val$ |] notEmpty s | null s = Left ["Value required"] | otherwise = Right s instance Formable (Maybe String) where formable x = input go (join x) `check` isEmpty where go name val = [$hamlet| %input!type=text!name=$string.name$!value=$string.val$ |] isEmpty s | null s = Right Nothing | otherwise = Right $ Just s instance Formable (Html ()) where formable = fmap preEscapedString . input go . fmap (U.toString . renderHtml) where go name val = [$hamlet|%textarea!name=$string.name$ $string.val$|] instance Formable Day where formable x = input go (fmap show x) `check` asDay where go name val = [$hamlet| %input!type=date!name=$string.name$!value=$string.val$ |] asDay s = case reads s of (y, _):_ -> Right y [] -> Left ["Invalid day"] instance Formable Int64 where formable x = input go (fmap show x) `check` asInt where go name val = [$hamlet| %input!type=number!name=$string.name$!value=$string.val$ |] asInt s = case reads s of (y, _):_ -> Right y [] -> Left ["Invalid integer"] instance Formable Double where formable x = input go (fmap numstring x) `check` asDouble where go name val = [$hamlet| %input!type=number!name=$string.name$!value=$string.val$ |] asDouble s = case reads s of (y, _):_ -> Right y [] -> Left ["Invalid double"] numstring d = let s = show d in case reverse s of '0':'.':y -> reverse y _ -> s instance Formable (Maybe Day) where formable x = input go (fmap show $ join x) `check` asDay where go name val = [$hamlet| %input!type=date!name=$string.name$!value=$string.val$ |] asDay "" = Right Nothing asDay s = case reads s of (y, _):_ -> Right $ Just y [] -> Left ["Invalid day"] instance Formable (Maybe Int) where formable x = input go (fmap show $ join x) `check` asInt where go name val = [$hamlet| %input!type=number!name=$string.name$!value=$string.val$ |] asInt "" = Right Nothing asInt s = case reads s of (y, _):_ -> Right $ Just y [] -> Left ["Invalid integer"] instance Formable (Maybe Int64) where formable x = input go (fmap show $ join x) `check` asInt where go name val = [$hamlet| %input!type=number!name=$string.name$!value=$string.val$ |] asInt "" = Right Nothing asInt s = case reads s of (y, _):_ -> Right $ Just y [] -> Left ["Invalid integer"] instance Formable Bool where formable x = Form $ \env _ -> do i <- incr let param = lookup i env let def = if null env then fromMaybe False x else isJust param return (FormSuccess $ isJust param, go i def) where go name val = addBody [$hamlet| %input!type=checkbox!name=$string.name$!:val:checked |] instance Formable Int where formable x = input go (fmap show x) `check` asInt where go name val = [$hamlet| %input!type=number!name=$string.name$!value=$string.val$ |] asInt s = case reads s of (y, _):_ -> Right y [] -> Left ["Invalid integer"] newtype Slug = Slug { unSlug :: String } deriving (Read, Eq, Show, SinglePiece, PersistField) instance Formable Slug where formable x = input go (fmap unSlug x) `check` asSlug where go name val = [$hamlet| %input!type=text!name=$string.name$!value=$string.val$ |] asSlug [] = Left ["Slug must be non-empty"] asSlug x' | all (\c -> c `elem` "-_" || isAlphaNum c) x' = Right $ Slug x' | otherwise = Left ["Slug must be alphanumeric, - and _"] -} share2 :: Monad m => (a -> m [b]) -> (a -> m [b]) -> a -> m [b] share2 f g a = do f' <- f a g' <- g a return $ f' ++ g' mkIsForm :: [EntityDef] -> Q [Dec] mkIsForm = mapM derive where derive :: EntityDef -> Q Dec derive t = do let fst3 (x, _, _) = x let cols = map (toLabel . fst3) $ entityColumns t ap <- [|(<*>)|] just <- [|pure|] nothing <- [|Nothing|] let just' = just `AppE` ConE (mkName $ entityName t) string' <- [|string|] mempty' <- [|mempty|] mfx <- [|mapFormXml|] ftt <- [|fieldsToTable|] let go_ = go ap just' string' mempty' mfx ftt let c1 = Clause [ ConP (mkName "Nothing") [] ] (NormalB $ go_ $ zip cols $ map (const nothing) cols) [] xs <- mapM (const $ newName "x") cols let xs' = map (AppE just . VarE) xs let c2 = Clause [ ConP (mkName "Just") [ConP (mkName $ entityName t) $ map VarP xs]] (NormalB $ go_ $ zip cols xs') [] return $ InstanceD [] (ConT ''IsForm `AppT` ConT (mkName $ entityName t)) [FunD (mkName "toForm") [c1, c2]] go ap just' string' mem mfx ftt a = let x = foldl (ap' ap) just' $ map (go' string' mem) a in mfx `AppE` ftt `AppE` x go' string' mempty' (label, ex) = let label' = string' `AppE` LitE (StringL label) in VarE (mkName "toFormField") `AppE` label' `AppE` mempty' `AppE` ex ap' ap x y = InfixE (Just x) ap (Just y) toLabel :: String -> String toLabel "" = "" toLabel (x:rest) = toUpper x : go rest where go "" = "" go (c:cs) | isUpper c = ' ' : c : go cs | otherwise = c : go cs