Skip to content
Snippets Groups Projects
Verified Commit 55fbec38 authored by jan.hamal.dvorak's avatar jan.hamal.dvorak
Browse files

Rework forms to be a lot more declarative

parent f34d2ce3
No related branches found
No related tags found
No related merge requests found
...@@ -14,19 +14,23 @@ Simple /= Easy /= Short. Happy reading. ...@@ -14,19 +14,23 @@ Simple /= Easy /= Short. Happy reading.
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ApplicativeDo #-}
module Simple (main) module Simple (main)
where where
import Prelude
import Control.Concurrent.MVar import Control.Concurrent.MVar
import Control.Monad.Reader import Control.Monad.Reader
import Data.Maybe
import Data.Text (Text) import Data.Text (Text)
import Hikaru
import Lucid import Lucid
import Network.HTTP.Types.Header import Network.HTTP.Types.Header
import Network.HTTP.Types.Status import Network.HTTP.Types.Status
import Network.Wai import Network.Wai
import Network.Wai.Handler.Warp import Network.Wai.Handler.Warp
import Network.Wai.Middleware.RequestLogger import Network.Wai.Middleware.RequestLogger
import Hikaru
-- Action ------------------------------------------------------------------ -- Action ------------------------------------------------------------------
...@@ -99,14 +103,15 @@ where ...@@ -99,14 +103,15 @@ where
-- Plug in a cool logging middleware. -- Plug in a cool logging middleware.
middleware $ logStdoutDev middleware $ logStdoutDev
-- Enable 300s cache for the static endpoints.
wrapAction (defaultHeader hCacheControl "public, max-age=300" >>) $ do
-- Negotiate content for the root page. -- Negotiate content for the root page.
route $ getRootHtmlR <$ get <* offerHTML route $ getRootHtmlR <$ get <* offerHTML
route $ getRootTextR <$ get <* offerText route $ getRootTextR <$ get <* offerText
-- Disable caching for all the following endpoints. -- 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. -- Present a simple greeting page.
route $ getHelloR <$ get <* seg "hello" <*> arg route $ getHelloR <$ get <* seg "hello" <*> arg
...@@ -130,6 +135,54 @@ where ...@@ -130,6 +135,54 @@ where
h1_ "Welcome!" h1_ "Welcome!"
p_ $ "You are " >> toHtml (show n) >> ". visitor!" 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 :: Action ()
getRootTextR = do getRootTextR = do
......
-- 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 -- see: https://github.com/sol/hpack
-- --
-- hash: ec8f75c55cd1cdb48ba2e77fa77a1586494297b85c9582601ebecf2c5e230fdb -- hash: b5ce075070487e21e8dc52b2afc5f1995311580f4978d92703fc77c3208c217c
name: hikaru name: hikaru
version: 0.1.0.0 version: 0.1.0.0
...@@ -22,7 +24,6 @@ copyright: Jan Hamal Dvořák ...@@ -22,7 +24,6 @@ copyright: Jan Hamal Dvořák
license: AGPL-3 license: AGPL-3
license-file: LICENSE.md license-file: LICENSE.md
build-type: Simple build-type: Simple
cabal-version: >= 1.10
extra-source-files: extra-source-files:
README.md README.md
...@@ -45,7 +46,7 @@ library ...@@ -45,7 +46,7 @@ library
Paths_hikaru Paths_hikaru
hs-source-dirs: hs-source-dirs:
lib 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 ghc-options: -Wall -Wcompat
build-depends: build-depends:
aeson >=1.4 && <1.5 aeson >=1.4 && <1.5
......
...@@ -6,149 +6,197 @@ License : AGPL-3 ...@@ -6,149 +6,197 @@ License : AGPL-3
Maintainer : mordae@anilinux.org Maintainer : mordae@anilinux.org
Stability : unstable Stability : unstable
Portability : non-portable (ghc) Portability : non-portable (ghc)
This module provides tools to simplify form building and parsing.
-} -}
module Hikaru.Form module Hikaru.Form
( Form ( FormData
, FormHandler , FormView(..)
, handleForm , FormElement(..)
, FormNote(..)
-- ** Inputs , FormT
, formInput , newForm
, formMultiple , getForm
, formOptions , postForm
, selectedOption , button
, selectedOptions , inputField
) )
where where
import BasePrelude import BasePrelude hiding (length)
import Data.ByteString.Builder (toLazyByteString)
import Data.Text (Text) import Data.Text (Text)
import Lucid
import Hikaru.Action import Hikaru.Action
import Hikaru.Types 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 -- Build & process a checked form with parameters in the query string.
-- a fully parsed object to somehow handle.
-- --
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. -- Build & process a checked form with parameters in the form fields.
--
-- Example:
--
-- @
-- handleForm someForm \\case
-- Left incompleteForm -> do
-- sendHTML do
-- genericPage_ \"Nice Form\" $ do
-- incompleteForm
--
-- Right entry -> do
-- processEntry entry
-- redirect \"\/entries\/\"
-- @
-- --
handleForm :: (Monad m) => Form m a -> FormHandler m a -> m () postForm :: (MonadAction m) => Text -> FormT l m a -> m (a, FormView l)
handleForm form decide = do postForm name form = do
(mb, mx) <- runHtmlT form fields <- getFields
files <- getFiles
case mx of let env = emptyEnv { envPrefix = [name]
-- A little hack to get both the HtmlT and its value and shield the , envParams = fields
-- caller from 'Builder' and 'toHtmlRaw'. , envFiles = files
Nothing -> decide $ Left (toHtmlRaw $ toLazyByteString $ mb mempty) , envRunChecks = True
Just x -> decide $ Right x }
runForm env form
-- | -- |
-- Read a single form @\<input\>@ field as both the original text and the -- Unwrap the transformer stack and run the form.
-- 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@).
-- --
formInput :: (MonadAction m, FromParam a) => Text -> m (Text, Maybe a) runForm :: (MonadAction m) => Env -> FormT l m a -> m (a, FormView l)
formInput name = do runForm env form = runStateT (runReaderT (unFormT form) env) emptyFormView
caseMethod ("", Nothing) do
text <- fromMaybe "" <$> getFieldMaybe name
return (text, fromParam text)
-- | button :: (Monad m) => Text -> l -> FormT l m Bool
-- Read multiple form @\<input\>@ fields as both the original texts and the button name label = do
-- converted values. value <- formParamMaybe name
-- fullName <- makeName name
-- 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)
appendElement $ Button { elemName = fullName
, elemLabel = label
}
-- | case value of
-- Read selected @\<option\>@ fields and return a list of all available Nothing -> return False
-- fields along with their selection status. Just () -> return True
--
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)
-- | inputField :: (Monad m, ToParam a, FromParam a)
-- Return the first selected value in the option list returned by => Text -> l -> Maybe a -> FormT l m (Maybe a)
-- 'formOptions'. inputField name label value = do
-- fullName <- makeName name
selectedOption :: [(Text, a, Bool)] -> Maybe a value' <- formParamMaybe fullName
selectedOption = listToMaybe . selectedOptions
appendElement $ InputField { elemName = fullName
, elemLabel = label
, elemValue = value' <|> fmap toParam value
, elemNotes = []
}
-- | formParamMaybe fullName
-- 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)
-- Helper Functions -------------------------------------------------------- -- Form Internals ---------------------------------------------------------
-- | appendElement :: (Monad m) => FormElement l -> FormT l m ()
-- Run action only for methods that come with bodies. appendElement element = FormT do
-- Otherwise return the default value. view@FormView{formElements} <- get
-- put $ view { formElements = formElements <> [element] }
caseMethod :: (MonadAction m) => a -> m a -> m a
caseMethod dfl action = do
method <- getMethod formParamMaybe :: (Monad m, FromParam a) => Text -> FormT l m (Maybe a)
formParamMaybe name = FormT do
case method of Env{envParams} <- ask
"GET" -> return dfl return $ fromParam =<< lookup name envParams
"HEAD" -> return dfl
"CONNECT" -> return dfl
"OPTIONS" -> return dfl formFileMaybe :: (Monad m) => Text -> FormT l m (Maybe (FileInfo FilePath))
"TRACE" -> return dfl formFileMaybe name = FormT do
_else -> action 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: -- vim:set ft=haskell sw=2 ts=2 et:
...@@ -90,7 +90,15 @@ where ...@@ -90,7 +90,15 @@ where
-- Return 'Nothing' if the locale is not supported. -- Return 'Nothing' if the locale is not supported.
-- --
localize :: (Monad m) => Locale -> a -> Maybe (HtmlT m ()) 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
-- | -- |
......
...@@ -23,6 +23,7 @@ extra-source-files: README.md ...@@ -23,6 +23,7 @@ extra-source-files: README.md
ghc-options: -Wall -Wcompat ghc-options: -Wall -Wcompat
default-extensions: default-extensions:
- ApplicativeDo
- BlockArguments - BlockArguments
- DeriveGeneric - DeriveGeneric
- FlexibleInstances - FlexibleInstances
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
# resolver: # resolver:
# name: custom-snapshot # name: custom-snapshot
# location: "./custom-snapshot.yaml" # location: "./custom-snapshot.yaml"
resolver: lts-13.20 resolver: lts-14.14
# User packages to be built. # User packages to be built.
# Various formats can be used as shown in the example below. # Various formats can be used as shown in the example below.
...@@ -41,8 +41,7 @@ packages: ...@@ -41,8 +41,7 @@ packages:
# Dependency packages to be pulled from upstream that are not in the resolver # Dependency packages to be pulled from upstream that are not in the resolver
# (e.g., acme-missiles-0.3) # (e.g., acme-missiles-0.3)
# extra-deps: [] # extra-deps: []
extra-deps: #extra-deps:
- category-printf-0.1.1.0
# Override default flag values for local packages and extra-deps # Override default flag values for local packages and extra-deps
# flags: {} # flags: {}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment