From 55fbec385f00063a392266f74f6d6e38248e3a3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hamal=20Dvo=C5=99=C3=A1k?= <mordae@anilinux.org> Date: Mon, 18 Nov 2019 00:05:52 +0100 Subject: [PATCH] Rework forms to be a lot more declarative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jan Hamal Dvořák <mordae@anilinux.org> --- examples/Simple.hs | 79 ++++++++++--- hikaru.cabal | 9 +- lib/Hikaru/Form.hs | 256 ++++++++++++++++++++++++----------------- lib/Hikaru/Localize.hs | 10 +- package.yaml | 1 + stack.yaml | 5 +- 6 files changed, 235 insertions(+), 125 deletions(-) diff --git a/examples/Simple.hs b/examples/Simple.hs index d018faf..fe4e076 100644 --- a/examples/Simple.hs +++ b/examples/Simple.hs @@ -14,19 +14,23 @@ Simple /= Easy /= Short. Happy reading. {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE ApplicativeDo #-} module Simple (main) where + import Prelude import Control.Concurrent.MVar import Control.Monad.Reader + import Data.Maybe import Data.Text (Text) + import Hikaru import Lucid import Network.HTTP.Types.Header import Network.HTTP.Types.Status import Network.Wai import Network.Wai.Handler.Warp import Network.Wai.Middleware.RequestLogger - import Hikaru -- Action ------------------------------------------------------------------ @@ -99,22 +103,23 @@ where -- Plug in a cool logging middleware. middleware $ logStdoutDev - -- Enable 300s cache for the static endpoints. - wrapAction (defaultHeader hCacheControl "public, max-age=300" >>) $ do - -- Negotiate content for the root page. - route $ getRootHtmlR <$ get <* offerHTML - route $ getRootTextR <$ get <* offerText + -- Negotiate content for the root page. + route $ getRootHtmlR <$ get <* offerHTML + route $ getRootTextR <$ get <* offerText -- Disable caching for all the following endpoints. - wrapActions (defaultHeader hCacheControl "no-store" >>) + wrapAction (defaultHeader hCacheControl "no-store" >>) $ do + -- Return search results and repeat the form. + route $ getSearchHtmlR <$ get <* seg "search" + <* offerHTML - -- Present a simple greeting page. - route $ getHelloR <$ get <* seg "hello" <*> arg - <* offerText + -- Present a simple greeting page. + route $ getHelloR <$ get <* seg "hello" <*> arg + <* offerText - -- Create an echoing JSON API. - route $ postEchoR <$ post <* seg "api" <* seg "echo" - <* offerJSON <* acceptJSON + -- Create an echoing JSON API. + route $ postEchoR <$ post <* seg "api" <* seg "echo" + <* offerJSON <* acceptJSON -- Handlers ---------------------------------------------------------------- @@ -130,6 +135,54 @@ where h1_ "Welcome!" p_ $ "You are " >> toHtml (show n) >> ". visitor!" + form_ [action_ "/search", method_ "GET"] $ do + view <- newForm "search" $ searchForm (Just "meaning of life") + formView_ view + + + getSearchHtmlR :: Action () + getSearchHtmlR = do + sendHTML $ do + (maybeQuery, view) <- getForm "search" (searchForm Nothing) + + h1_ "Search results" + form_ [method_ "GET"] $ do + formView_ view + + case maybeQuery of + Nothing -> "" + Just q -> do + hr_ [] + h2_ $ toHtml q + p_ "Sorry, no results found!" + + + searchForm :: (Monad m) => Maybe Text -> FormT Text m (Maybe Text) + searchForm q = do + q' <- inputField "q" "Query" q + _ <- button "search" "Search" + return q' + + + formView_ :: (MonadAction m, Localized l) => FormView l -> HtmlT m () + formView_ view = do + forM_ (formElements view) $ \element -> + case element of + Button{..} -> do + button_ [ id_ elemName + , name_ elemName + ] $ lc_ elemLabel + + InputField{..} -> do + label_ [ for_ elemName ] $ do + lc_ elemLabel + ":" + + input_ [ id_ elemName + , name_ elemName + , value_ (fromMaybe "" elemValue) + ] + getRootTextR :: Action () getRootTextR = do diff --git a/hikaru.cabal b/hikaru.cabal index ca5fba8..4694605 100644 --- a/hikaru.cabal +++ b/hikaru.cabal @@ -1,8 +1,10 @@ --- This file has been generated from package.yaml by hpack version 0.28.2. +cabal-version: 1.12 + +-- This file has been generated from package.yaml by hpack version 0.31.2. -- -- see: https://github.com/sol/hpack -- --- hash: ec8f75c55cd1cdb48ba2e77fa77a1586494297b85c9582601ebecf2c5e230fdb +-- hash: b5ce075070487e21e8dc52b2afc5f1995311580f4978d92703fc77c3208c217c name: hikaru version: 0.1.0.0 @@ -22,7 +24,6 @@ copyright: Jan Hamal Dvořák license: AGPL-3 license-file: LICENSE.md build-type: Simple -cabal-version: >= 1.10 extra-source-files: README.md @@ -45,7 +46,7 @@ library Paths_hikaru hs-source-dirs: lib - default-extensions: BlockArguments DeriveGeneric FlexibleInstances GeneralizedNewtypeDeriving LambdaCase MultiParamTypeClasses NamedFieldPuns NoImplicitPrelude OverloadedStrings RankNTypes RecordWildCards ScopedTypeVariables StandaloneDeriving DataKinds + default-extensions: ApplicativeDo BlockArguments DeriveGeneric FlexibleInstances GeneralizedNewtypeDeriving LambdaCase MultiParamTypeClasses NamedFieldPuns NoImplicitPrelude OverloadedStrings RankNTypes RecordWildCards ScopedTypeVariables StandaloneDeriving DataKinds ghc-options: -Wall -Wcompat build-depends: aeson >=1.4 && <1.5 diff --git a/lib/Hikaru/Form.hs b/lib/Hikaru/Form.hs index 1ffddb2..bf36924 100644 --- a/lib/Hikaru/Form.hs +++ b/lib/Hikaru/Form.hs @@ -6,149 +6,197 @@ License : AGPL-3 Maintainer : mordae@anilinux.org Stability : unstable Portability : non-portable (ghc) - -This module provides tools to simplify form building and parsing. -} module Hikaru.Form - ( Form - , FormHandler - , handleForm - - -- ** Inputs - , formInput - , formMultiple - , formOptions - , selectedOption - , selectedOptions + ( FormData + , FormView(..) + , FormElement(..) + , FormNote(..) + , FormT + , newForm + , getForm + , postForm + , button + , inputField ) where - import BasePrelude + import BasePrelude hiding (length) - import Data.ByteString.Builder (toLazyByteString) import Data.Text (Text) - import Lucid import Hikaru.Action import Hikaru.Types + import Control.Monad.State + import Control.Monad.Reader + + + type FormData = ([(Text, Text)], [(Text, FileInfo FilePath)]) + + + data FormView l + = FormView + { formElements :: [FormElement l] + , formNotes :: [FormNote l] + } + + emptyFormView :: FormView l + emptyFormView = FormView { formElements = [] + , formNotes = [] + } + + + data FormElement l + = Button + { elemName :: Text + , elemLabel :: l + } + | InputField + { elemName :: Text + , elemLabel :: l + , elemValue :: Maybe Text + , elemNotes :: [FormNote l] + } + + + data FormNote l + = NoteError + { noteLabel :: l + } + | NoteNeutral + { noteLabel :: l + } + | NoteSuccess + { noteLabel :: l + } + deriving (Eq, Ord) + + + newtype FormT l m a + = FormT + { unFormT :: ReaderT Env (StateT (FormView l) m) a + } + deriving (Functor, Applicative, Monad) + + + data Env + = Env + { envPrefix :: [Text] + , envParams :: [(Text, Text)] + , envFiles :: [(Text, FileInfo FilePath)] + , envRunChecks :: Bool + } + + emptyEnv :: Env + emptyEnv = Env { envPrefix = [] + , envParams = [] + , envFiles = [] + , envRunChecks = False + } -- | - -- HTML form with a potentially parsed object. + -- Build an unchecked form. -- - type Form m a = HtmlT m (Maybe a) + newForm :: (MonadAction m) => Text -> FormT l m a -> m (FormView l) + newForm name form = do + let env = emptyEnv { envPrefix = [name] } + (_, view) <- runForm env form + return view -- | - -- Function that takes either a form to present to the user or - -- a fully parsed object to somehow handle. + -- Build & process a checked form with parameters in the query string. -- - type FormHandler m a = Either (HtmlT m ()) a -> m () + getForm :: (MonadAction m) => Text -> FormT l m a -> m (a, FormView l) + getForm name form = do + params <- getParams + + let env = emptyEnv { envPrefix = [name] + , envParams = params + , envRunChecks = True + } + + runForm env form -- | - -- Simplifies handling form submissions. - -- - -- Example: - -- - -- @ - -- handleForm someForm \\case - -- Left incompleteForm -> do - -- sendHTML do - -- genericPage_ \"Nice Form\" $ do - -- incompleteForm - -- - -- Right entry -> do - -- processEntry entry - -- redirect \"\/entries\/\" - -- @ + -- Build & process a checked form with parameters in the form fields. -- - handleForm :: (Monad m) => Form m a -> FormHandler m a -> m () - handleForm form decide = do - (mb, mx) <- runHtmlT form + postForm :: (MonadAction m) => Text -> FormT l m a -> m (a, FormView l) + postForm name form = do + fields <- getFields + files <- getFiles - case mx of - -- A little hack to get both the HtmlT and its value and shield the - -- caller from 'Builder' and 'toHtmlRaw'. - Nothing -> decide $ Left (toHtmlRaw $ toLazyByteString $ mb mempty) - Just x -> decide $ Right x + let env = emptyEnv { envPrefix = [name] + , envParams = fields + , envFiles = files + , envRunChecks = True + } + + runForm env form -- | - -- Read a single form @\<input\>@ field as both the original text and the - -- converted value. - -- - -- Return 'Nothing' if the request was submitted using one of the HTTP - -- methods that do not support request bodies (such as @GET@ or @HEAD@). + -- Unwrap the transformer stack and run the form. -- - formInput :: (MonadAction m, FromParam a) => Text -> m (Text, Maybe a) - formInput name = do - caseMethod ("", Nothing) do - text <- fromMaybe "" <$> getFieldMaybe name - return (text, fromParam text) + runForm :: (MonadAction m) => Env -> FormT l m a -> m (a, FormView l) + runForm env form = runStateT (runReaderT (unFormT form) env) emptyFormView - -- | - -- Read multiple form @\<input\>@ fields as both the original texts and the - -- converted values. - -- - -- Return empty list if the request was submitted using one of the HTTP - -- methods that do not support request bodies (such as @GET@ or @HEAD@). - -- - formMultiple :: (MonadAction m, FromParam a) => Text -> m [(Text, a)] - formMultiple name = do - caseMethod [] do - getFieldList name - <&> mapMaybe \x -> case fromParam x of - Nothing -> Nothing - Just v -> Just (x, v) + button :: (Monad m) => Text -> l -> FormT l m Bool + button name label = do + value <- formParamMaybe name + fullName <- makeName name + appendElement $ Button { elemName = fullName + , elemLabel = label + } - -- | - -- Read selected @\<option\>@ fields and return a list of all available - -- fields along with their selection status. - -- - formOptions :: (MonadAction m, FromParam a, ToParam a, Eq a) - => Text -> [a] -> m [(Text, a, Bool)] - formOptions name options = do - found <- caseMethod [] (getFieldList name) - return $ flip map options \v -> (toParam v, v, v `elem` found) + case value of + Nothing -> return False + Just () -> return True - -- | - -- Return the first selected value in the option list returned by - -- 'formOptions'. - -- - selectedOption :: [(Text, a, Bool)] -> Maybe a - selectedOption = listToMaybe . selectedOptions + inputField :: (Monad m, ToParam a, FromParam a) + => Text -> l -> Maybe a -> FormT l m (Maybe a) + inputField name label value = do + fullName <- makeName name + value' <- formParamMaybe fullName + appendElement $ InputField { elemName = fullName + , elemLabel = label + , elemValue = value' <|> fmap toParam value + , elemNotes = [] + } - -- | - -- Return all selected values in the option list returned by - -- 'formOptions'. - -- - selectedOptions :: [(Text, a, Bool)] -> [a] - selectedOptions = mapMaybe (\(_, v, s) -> if s then Just v else Nothing) + formParamMaybe fullName - -- Helper Functions -------------------------------------------------------- + -- Form Internals --------------------------------------------------------- - -- | - -- Run action only for methods that come with bodies. - -- Otherwise return the default value. - -- - caseMethod :: (MonadAction m) => a -> m a -> m a - caseMethod dfl action = do - method <- getMethod - - case method of - "GET" -> return dfl - "HEAD" -> return dfl - "CONNECT" -> return dfl - "OPTIONS" -> return dfl - "TRACE" -> return dfl - _else -> action + appendElement :: (Monad m) => FormElement l -> FormT l m () + appendElement element = FormT do + view@FormView{formElements} <- get + put $ view { formElements = formElements <> [element] } + + + formParamMaybe :: (Monad m, FromParam a) => Text -> FormT l m (Maybe a) + formParamMaybe name = FormT do + Env{envParams} <- ask + return $ fromParam =<< lookup name envParams + + + formFileMaybe :: (Monad m) => Text -> FormT l m (Maybe (FileInfo FilePath)) + formFileMaybe name = FormT do + Env{envFiles} <- ask + return $ lookup name envFiles + + makeName :: (Monad m) => Text -> FormT l m Text + makeName name = FormT do + Env{envPrefix} <- ask + return $ mconcat $ intersperse "." $ reverse (name : envPrefix) -- vim:set ft=haskell sw=2 ts=2 et: diff --git a/lib/Hikaru/Localize.hs b/lib/Hikaru/Localize.hs index 1697f5d..d87caa4 100644 --- a/lib/Hikaru/Localize.hs +++ b/lib/Hikaru/Localize.hs @@ -90,7 +90,15 @@ where -- Return 'Nothing' if the locale is not supported. -- localize :: (Monad m) => Locale -> a -> Maybe (HtmlT m ()) - localize _lc _msg = Nothing + localize _lc = const Nothing + + + -- | + -- Instance to make 'Text' usable for interoperability and + -- gradual localization. + -- + instance Localized Text where + localize _lc = Just . toHtml -- | diff --git a/package.yaml b/package.yaml index e4e6ef6..91bfee6 100644 --- a/package.yaml +++ b/package.yaml @@ -23,6 +23,7 @@ extra-source-files: README.md ghc-options: -Wall -Wcompat default-extensions: + - ApplicativeDo - BlockArguments - DeriveGeneric - FlexibleInstances diff --git a/stack.yaml b/stack.yaml index 2f5959d..b928ab3 100644 --- a/stack.yaml +++ b/stack.yaml @@ -15,7 +15,7 @@ # resolver: # name: custom-snapshot # location: "./custom-snapshot.yaml" -resolver: lts-13.20 +resolver: lts-14.14 # User packages to be built. # Various formats can be used as shown in the example below. @@ -41,8 +41,7 @@ packages: # Dependency packages to be pulled from upstream that are not in the resolver # (e.g., acme-missiles-0.3) # extra-deps: [] -extra-deps: - - category-printf-0.1.1.0 +#extra-deps: # Override default flag values for local packages and extra-deps # flags: {} -- GitLab