From ea445ffd9e5b41e51a14eb1cb7738d837292e420 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Hamal=20Dvo=C5=99=C3=A1k?= <mordae@anilinux.org>
Date: Tue, 21 Jan 2020 21:36:17 +0100
Subject: [PATCH] Use Applicative on top of Monad for Forms
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

This is a completely different approach that merits further exploration.

Signed-off-by: Jan Hamal Dvořák <mordae@anilinux.org>
---
 hikaru.cabal       |   4 +-
 lib/Hikaru/Form.hs | 558 +++++++++++++++++++++++++++++++--------------
 package.yaml       |   1 -
 3 files changed, 391 insertions(+), 172 deletions(-)

diff --git a/hikaru.cabal b/hikaru.cabal
index 4694605..1624ede 100644
--- a/hikaru.cabal
+++ b/hikaru.cabal
@@ -4,7 +4,7 @@ cabal-version: 1.12
 --
 -- see: https://github.com/sol/hpack
 --
--- hash: b5ce075070487e21e8dc52b2afc5f1995311580f4978d92703fc77c3208c217c
+-- hash: 00d0ac3f76ad05002c67411c92f1e77aeee6a21668bc86d354917560a1f0ae2f
 
 name:           hikaru
 version:        0.1.0.0
@@ -46,7 +46,7 @@ library
       Paths_hikaru
   hs-source-dirs:
       lib
-  default-extensions: ApplicativeDo BlockArguments DeriveGeneric FlexibleInstances GeneralizedNewtypeDeriving LambdaCase MultiParamTypeClasses NamedFieldPuns NoImplicitPrelude OverloadedStrings RankNTypes RecordWildCards ScopedTypeVariables StandaloneDeriving DataKinds
+  default-extensions: 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 24e2ed1..4ace1e3 100644
--- a/lib/Hikaru/Form.hs
+++ b/lib/Hikaru/Form.hs
@@ -6,23 +6,36 @@ License     :  AGPL-3
 Maintainer  :  mordae@anilinux.org
 Stability   :  unstable
 Portability :  non-portable (ghc)
+
+This module provides applicative form handling.
 -}
 
 module Hikaru.Form
-  ( FormView(..)
-  , FormElement(..)
+  ( View(..)
   , FormNote(..)
-  , FormT
   , Form
   , newForm
   , getForm
   , postForm
-  , button
   , inputField
+  , inputField'
   , hiddenField
-  , textField
+  , hiddenField'
+  , textArea
+  , textArea'
   , selectField
+  , selectField'
+  , selectFieldEnum
+  , selectFieldEnum'
   , multiSelectField
+  , multiSelectField'
+  , opt
+  , req
+  , addNote
+  , addAttribute
+  , fieldCheck
+  , fieldValue
+  , hasErrors
   )
 where
   import BasePrelude
@@ -31,49 +44,66 @@ where
   import Control.Monad.State
   import Data.Text (Text)
   import Hikaru.Action
+  import Hikaru.Localize
   import Hikaru.Types
+  import Lucid
 
 
-  data FormView l
-    = FormView
-      { formElements   :: [FormElement l]
-      , formNotes      :: [FormNote l]
-      }
+  -- Form Types --------------------------------------------------------------
 
 
-  data FormElement l
-    = HiddenField
-      { elemName       :: Text
-      , elemValue      :: Maybe Text
+  data View l
+    = FormFields
+      { viewFields     :: [View l]
+      , viewNotes      :: [FormNote l]
       }
-    | Button
-      { elemName       :: Text
-      , elemLabel      :: l
-      , elemAttrs      :: [(Text, Dynamic)]
+    | HiddenField
+      { viewName       :: Text
+      , viewValue      :: Maybe Text
+      , viewNotes      :: [FormNote l]
+      , viewAttrs      :: [(Text, Dynamic)]
       }
     | InputField
-      { elemName       :: Text
-      , elemLabel      :: l
-      , elemValue      :: Maybe Text
-      , elemNotes      :: [FormNote l]
-      , elemAttrs      :: [(Text, Dynamic)]
+      { viewName       :: Text
+      , viewLabel      :: l
+      , viewValue      :: Maybe Text
+      , viewNotes      :: [FormNote l]
+      , viewAttrs      :: [(Text, Dynamic)]
       }
-    | TextField
-      { elemName       :: Text
-      , elemLabel      :: l
-      , elemValue      :: Maybe Text
-      , elemNotes      :: [FormNote l]
-      , elemAttrs      :: [(Text, Dynamic)]
+    | TextArea
+      { viewName       :: Text
+      , viewLabel      :: l
+      , viewValue      :: Maybe Text
+      , viewNotes      :: [FormNote l]
+      , viewAttrs      :: [(Text, Dynamic)]
       }
     | SelectField
-      { elemName       :: Text
-      , elemLabel      :: l
-      , elemOptions    :: [(Text, l, Bool)]
-      , elemNotes      :: [FormNote l]
-      , elemAttrs      :: [(Text, Dynamic)]
-      , elemMulti      :: Bool
+      { viewName       :: Text
+      , viewLabel      :: l
+      , viewOptions    :: [(Text, l, Bool)]
+      , viewNotes      :: [FormNote l]
+      , viewAttrs      :: [(Text, Dynamic)]
+      , viewMulti      :: Bool
       }
 
+  instance Semigroup (View l) where
+    FormFields [] [] <> view = view
+    view <> FormFields [] [] = view
+
+    FormFields fs1 ns1 <> FormFields fs2 ns2
+      = FormFields (fs1 <> fs2) (ns1 <> ns2)
+
+    FormFields fs ns <> view
+      = FormFields (fs <> [view]) ns
+
+    view <> FormFields fs ns
+      = FormFields ([view] <> fs) ns
+
+    v1 <> v2 = FormFields ([v1, v2]) []
+
+  instance Monoid (View l) where
+    mempty = FormFields [] []
+
 
   data FormNote l
     = NoteError
@@ -87,221 +117,411 @@ where
       }
     deriving (Eq, Ord)
 
+  instance (ToHtml l) => ToHtml (FormNote l) where
+    toHtml    = toHtml . noteLabel
+    toHtmlRaw = toHtmlRaw . noteLabel
+
+  instance (Localized l) => Localized (FormNote l) where
+    localize lang = localize lang . noteLabel
+
 
   newtype FormT l m a
     = FormT
-      { unFormT        :: ReaderT Env (StateT (FormView l) m) a
+      { unFormT        :: ReaderT (Env l) (StateT (View l) m) a
       }
-    deriving (Functor, Applicative, Monad)
+    deriving (Monad, Applicative, Functor)
+
+  deriving instance (Monad m) => MonadReader (Env l) (FormT l m)
+  deriving instance (Monad m) => MonadState (View l) (FormT l m)
 
+  instance MonadTrans (FormT l) where
+    lift = FormT . lift . lift
+
+
+  newtype Form l m a
+    = Form
+      { unForm         :: FormT l m (Maybe a)
+      }
 
-  type Form l m a = Maybe a -> FormT l m (Maybe a)
+  instance (Monad m) => Functor (Form l m) where
+    fmap f Form{..} = Form do
+      x <- unForm
+      return $ fmap f x
 
+  instance (Monad m) => Applicative (Form l m) where
+    pure x = Form $ return $ Just x
 
-  data Env
+    Form{unForm = unFormL} <*> Form{unForm = unFormR} = Form do
+      l <- unFormL
+      r <- unFormR
+      return $ l <*> r
+
+  instance MonadTrans (Form l) where
+    lift = Form . fmap Just . lift
+
+
+  data Env l
     = Env
       { envPrefix      :: [Text]
       , envParams      :: [(Text, Text)]
       , envFiles       :: [(Text, FileInfo FilePath)]
-      , envRunChecks   :: Bool
+      , envCheck       :: Bool
       }
 
-  emptyEnv :: Env
-  emptyEnv = Env { envPrefix    = []
-                 , envParams    = []
-                 , envFiles     = []
-                 , envRunChecks = False
-                 }
+
+  newtype FieldT l a m b
+    = FieldT
+      { unFieldT       :: ReaderT (Bool, Maybe a) (StateT (View l) m) b
+      }
+    deriving (Monad, Applicative, Functor)
+
+  instance MonadTrans (FieldT l a) where
+    lift = FieldT . lift . lift
 
 
   -- |
-  -- Build an unchecked form.
+  -- Build a fresh form without using any request data.
   --
-  newForm :: (MonadAction m) => Text -> FormT l m a -> m (FormView l)
-  newForm name form = do
-    (_, view) <- runForm form emptyEnv { envPrefix = [name] }
-    return view
+  newForm :: (MonadAction m) => Text -> Form l m a -> m (View l)
+  newForm name = flip execStateT view . flip runReaderT env . unFormT . unForm
+    where
+      view = FormFields [] []
+      env  = Env { envPrefix = [name]
+                 , envParams = []
+                 , envFiles  = []
+                 , envCheck  = False
+                 }
 
 
   -- |
-  -- Build & process a checked form with parameters in the query string.
+  -- Process the form using parameters in the query string.
   --
-  getForm :: (MonadAction m) => Text -> FormT l m a -> m (a, FormView l)
+  getForm :: (MonadAction m) => Text -> Form l m a -> m (View l, Maybe a)
   getForm name form = do
     params <- getParams
 
-    runForm form emptyEnv { envPrefix    = [name]
-                          , envParams    = params
-                          , envRunChecks = True
-                          }
+    let view = FormFields [] []
+        env  = Env { envPrefix = [name]
+                   , envParams = filter (("" /=) . snd) params
+                   , envFiles  = []
+                   , envCheck  = True
+                   }
+
+    (value, view') <- runStateT (runReaderT (unFormT $ unForm form) env) view
+
+    if hasErrors view'
+       then return (view', Nothing)
+       else return (view', value)
 
 
   -- |
-  -- Build & process a checked form with parameters in the form fields.
+  -- Process the form using parameters in the request body.
   --
-  postForm :: (MonadAction m) => Text -> FormT l m a -> m (a, FormView l)
+  postForm :: (MonadAction m) => Text -> Form l m a -> m (View l, Maybe a)
   postForm name form = do
     fields <- getFields
     files  <- getFiles
 
-    runForm form emptyEnv { envPrefix    = [name]
-                          , envParams    = fields
-                          , envFiles     = files
-                          , envRunChecks = True
-                          }
+    let view = FormFields [] []
+        env  = Env { envPrefix = [name]
+                   , envParams = filter (("" /=) . snd) fields
+                   , envFiles  = files
+                   , envCheck  = True
+                   }
+
+    (value, view') <- runStateT (runReaderT (unFormT $ unForm form) env) view
+
+    if hasErrors view'
+       then return (view', Nothing)
+       else return (view', value)
 
 
   -- |
-  -- Unwrap the transformer stack and run the form.
+  -- TODO
   --
-  runForm :: (MonadAction m) => FormT l m a -> Env -> m (a, FormView l)
-  runForm form env = runStateT (runReaderT (unFormT form) env)
-                               FormView { formElements = []
-                                        , formNotes    = []
-                                        }
+  hiddenField' :: (Monad m, ToParam a, FromParam a)
+               => Text -> FieldT l a m b -> Form l m a
+  hiddenField' name field = hiddenField name Nothing field
 
 
+  -- |
+  -- TODO
+  --
+  hiddenField :: (Monad m, ToParam a, FromParam a)
+              => Text -> Maybe a -> FieldT l a m b -> Form l m a
+  hiddenField name orig field = Form do
+    name' <- makeName name
+    value <- formParamMaybe name'
 
-  button :: (Monad m) => Text -> l -> FormT l m Bool
-  button name label = do
-    fullName <- makeName name
+    let value' = value <|> orig
+        view   = HiddenField { viewName  = name'
+                             , viewValue = toParam <$> value'
+                             , viewNotes = []
+                             , viewAttrs = []
+                             }
 
-    appendElement
-      Button { elemName  = fullName
-             , elemLabel = label
-             , elemAttrs = []
-             }
+    view' <- runFieldT field value' view
 
-    value <- formParamMaybe name
-    case value of
-      Nothing -> return False
-      Just () -> return True
+    modify (<> view')
+    return value'
 
 
+  -- |
+  -- TODO
+  --
+  inputField' :: (Monad m, ToParam a, FromParam a)
+              => Text -> l -> FieldT l a m b -> Form l m a
+  inputField' name label field = inputField name label Nothing field
+
+
+  -- |
+  -- TODO
+  --
   inputField :: (Monad m, ToParam a, FromParam a)
-             => Text -> l -> Form l m a
-  inputField name label orig = do
-    fullName  <- makeName name
-    textValue <- formParamMaybe fullName
+             => Text -> l -> Maybe a -> FieldT l a m b -> Form l m a
+  inputField name label orig field = Form do
+    name' <- makeName name
+    value <- formParamMaybe name'
 
-    let textOrig = toParam <$> orig
-     in appendElement
-          InputField { elemName  = fullName
-                     , elemLabel = label
-                     , elemValue = textValue <|> textOrig
-                     , elemAttrs = []
-                     , elemNotes = []
-                     }
+    let value' = value <|> orig
+        view   = InputField { viewName  = name'
+                            , viewLabel = label
+                            , viewValue = toParam <$> value'
+                            , viewNotes = []
+                            , viewAttrs = []
+                            }
 
-    formParamMaybe fullName
+    view' <- runFieldT field value' view
 
+    modify (<> view')
+    return value'
 
-  hiddenField :: (Monad m, ToParam a, FromParam a)
-              => Text -> Form l m a
-  hiddenField name orig = do
-    fullName  <- makeName name
-    textValue <- formParamMaybe fullName
 
-    let textOrig = toParam <$> orig
-     in appendElement
-          HiddenField { elemName  = fullName
-                      , elemValue = textValue <|> textOrig
-                      }
+  -- |
+  -- TODO
+  --
+  textArea' :: (Monad m, ToParam a, FromParam a)
+            => Text -> l -> FieldT l a m b -> Form l m a
+  textArea' name label field = textArea name label Nothing field
+
 
-    formParamMaybe fullName
+  -- |
+  -- TODO
+  --
+  textArea :: (Monad m, ToParam a, FromParam a)
+            => Text -> l -> Maybe a -> FieldT l a m b -> Form l m a
+  textArea name label orig field = Form do
+    name' <- makeName name
+    value <- formParamMaybe name'
+
+    let value' = value <|> orig
+        view   = TextArea { viewName  = name'
+                          , viewLabel = label
+                          , viewValue = toParam <$> value'
+                          , viewNotes = []
+                          , viewAttrs = []
+                          }
 
+    view' <- runFieldT field value' view
 
-  textField :: (Monad m, ToParam a, FromParam a)
-            => Text -> l -> Form l m a
-  textField name label orig = do
-    fullName  <- makeName name
-    textValue <- formParamMaybe fullName
+    modify (<> view')
+    return value'
 
-    let textOrig = toParam <$> orig
-     in appendElement
-          TextField { elemName  = fullName
-                    , elemLabel = label
-                    , elemValue = textValue <|> textOrig
-                    , elemAttrs = []
-                    , elemNotes = []
-                    }
 
-    formParamMaybe fullName
+  -- |
+  -- TODO
+  --
+  selectField' :: (Monad m, ToParam a, FromParam a, Eq a)
+               => Text -> l -> (a -> l) -> [a]
+               -> FieldT l a m b -> Form l m a
+  selectField' name label optlabel options field
+    = selectField name label optlabel options Nothing field
 
 
+  -- |
+  -- TODO
+  --
   selectField :: (Monad m, ToParam a, FromParam a, Eq a)
-              => Text -> l -> (a -> l) -> [a] -> Form l m a
-  selectField name label optlabel options orig = do
-    fullName  <- makeName name
-    value     <- formParamMaybe fullName
-
-    let textOptions = [ (toParam o, optlabel o, isSel o) | o <- options ]
-        isSel o     = Just o == (value <|> orig)
-     in appendElement
-          SelectField { elemName    = fullName
-                      , elemLabel   = label
-                      , elemOptions = textOptions
-                      , elemAttrs   = []
-                      , elemNotes   = []
-                      , elemMulti   = False
-                      }
-
-    case value of
-      Just v -> if v `elem` options
-                   then return value
-                   else return Nothing
-      Nothing -> return Nothing
+              => Text -> l -> (a -> l) -> [a] -> Maybe a
+              -> FieldT l a m b -> Form l m a
+  selectField name label optlabel options orig field = Form do
+    name' <- makeName name
+    value <- formParamMaybe name'
+
+    let value' = value <|> orig
+        opts   = [ (toParam x, optlabel x, Just x == value) | x <- options ]
+        view   = SelectField { viewName    = name'
+                             , viewLabel   = label
+                             , viewOptions = opts
+                             , viewNotes   = []
+                             , viewAttrs   = []
+                             , viewMulti   = False
+                             }
+
+    view' <- runFieldT field value' view
 
+    modify (<> view')
+    return value'
 
+
+  -- |
+  -- TODO
+  --
+  selectFieldEnum' :: (Monad m, ToParam a, FromParam a, Eq a, Bounded a, Enum a)
+                   => Text -> l -> (a -> l) -> FieldT l a m b -> Form l m a
+  selectFieldEnum' name label optlabel field
+    = selectFieldEnum name label optlabel Nothing field
+
+
+  -- |
+  -- Alternative to the 'selectField' for enumerable, bounded value types.
+  --
+  -- TODO: Example
+  --
+  selectFieldEnum :: (Monad m, ToParam a, FromParam a, Eq a, Bounded a, Enum a)
+                  => Text -> l -> (a -> l) -> Maybe a
+                  -> FieldT l a m b -> Form l m a
+  selectFieldEnum name label optlabel orig field
+    = selectField name label optlabel [minBound..maxBound] orig field
+
+
+  -- |
+  -- TODO
+  --
+  multiSelectField' :: (Monad m, ToParam a, FromParam a, Eq a)
+                    => Text -> l -> (a -> l) -> [a]
+                    -> FieldT l [a] m b -> Form l m [a]
+  multiSelectField' name label optlabel options field
+    = multiSelectField name label optlabel options Nothing field
+
+
+  -- |
+  -- TODO
+  --
   multiSelectField :: (Monad m, ToParam a, FromParam a, Eq a)
-                   => Text -> l -> (a -> l) -> [a] -> [a] -> FormT l m [a]
-  multiSelectField name label optlabel options orig = do
-    fullName     <- makeName name
-    values       <- formParamList fullName
+                   => Text -> l -> (a -> l) -> [a] -> Maybe [a]
+                   -> FieldT l [a] m b -> Form l m [a]
+  multiSelectField name label optlabel options orig field = Form do
+    name'  <- makeName name
+    params <- formParams name'
+
+    let found  = nub $ params <> fromMaybe [] orig
+        opts   = [ (toParam x, optlabel x, x `elem` found) | x <- options ]
+        view   = SelectField { viewName    = name'
+                             , viewLabel   = label
+                             , viewOptions = opts
+                             , viewNotes   = []
+                             , viewAttrs   = []
+                             , viewMulti   = False
+                             }
+
+    view' <- runFieldT field (Just found) view
+
+    modify (<> view')
+    return $ Just found
+
+
+  -- |
+  -- TODO
+  --
+  opt :: (Monad m) => FieldT l a m ()
+  opt = return ()
+
+
+  -- |
+  -- TODO
+  --
+  req :: (Monad m) => l -> FieldT l a m ()
+  req label = do
+    shouldCheck <- fieldCheck
+
+    if shouldCheck
+       then do
+         value <- fieldValue
+         case value of
+           Nothing -> addNote $ NoteError label
+           Just _v -> return ()
+
+        else do
+          return ()
 
-    let textOptions = [ (toParam o, optlabel o, isSel o) | o <- options ]
-        isSel o     = o `elem` (values <|> orig)
-     in appendElement
-          SelectField { elemName     = fullName
-                      , elemLabel    = label
-                      , elemOptions  = textOptions
-                      , elemAttrs    = []
-                      , elemNotes    = []
-                      , elemMulti    = True
-                      }
 
-    return $ [ v | v <- values, v `elem` options ]
+  -- |
+  -- TODO
+  --
+  addNote :: (Monad m) => FormNote l -> FieldT l a m ()
+  addNote note = FieldT do
+    modify \view -> view { viewNotes = viewNotes view <> [note] }
+
+
+  -- |
+  -- TODO
+  --
+  addAttribute :: (Monad m, Typeable v) => Text -> v -> FieldT l a m ()
+  addAttribute name value = FieldT do
+    modify \view ->
+      view { viewAttrs = viewAttrs view <> [(name, toDyn value)] }
+
+
+  -- |
+  -- TODO
+  --
+  fieldCheck :: (Monad m) => FieldT l a m Bool
+  fieldCheck = FieldT (fst <$> ask)
+
+
+  -- |
+  -- TODO
+  --
+  fieldValue :: (Monad m) => FieldT l a m (Maybe a)
+  fieldValue = FieldT (snd <$> ask)
+
+
+  -- |
+  -- Determine whether the view has any (possibly nested) errors.
+  --
+  hasErrors :: View l -> Bool
+  hasErrors FormFields{..} = any isErrorNote viewNotes || any hasErrors viewFields
+  hasErrors view           = any isErrorNote (viewNotes view)
 
 
   -- Form Internals ---------------------------------------------------------
 
 
-  appendElement :: (Monad m) => FormElement l -> FormT l m ()
-  appendElement element = FormT do
-    view@FormView{formElements} <- get
-    put $ view { formElements = formElements <> [element] }
+  runFieldT :: (Monad m) => FieldT l a m b -> Maybe a -> View l
+             -> FormT l m (View l)
+  runFieldT field value view = do
+    Env{envCheck} <- ask
+    lift $ execStateT (runReaderT (unFieldT field) (envCheck, value)) view
 
 
-  formParamMaybe :: (Monad m, FromParam a) => Text -> FormT l m (Maybe a)
-  formParamMaybe name = FormT do
-    Env{envParams} <- ask
-    return $ fromParam =<< lookup name envParams
+  isErrorNote :: FormNote l -> Bool
+  isErrorNote NoteError{} = True
+  isErrorNote _           = False
 
 
-  formParamList :: (Monad m, FromParam a) => Text -> FormT l m [a]
-  formParamList name = FormT do
+  formParamMaybe :: (Monad m, FromParam a) => Text -> FormT l m (Maybe a)
+  formParamMaybe name = do
     Env{envParams} <- ask
-    return $ mapMaybe (fromParam . snd) $ filter ((name ==) . fst) $ envParams
+    return $ fromParam =<< lookup name envParams
 
 
   formFileMaybe :: (Monad m) => Text -> FormT l m (Maybe (FileInfo FilePath))
-  formFileMaybe name = FormT do
+  formFileMaybe name = do
     Env{envFiles} <- ask
     return $ lookup name envFiles
 
 
+  formParams :: (Monad m, FromParam a) => Text -> FormT l m [a]
+  formParams name = do
+    Env{envParams} <- ask
+    let match = (name ==) . fst
+        conv  = fromParam . snd
+     in return $ mapMaybe conv $ filter match $ envParams
+
+
   makeName :: (Monad m) => Text -> FormT l m Text
-  makeName name = FormT do
+  makeName name = do
     Env{envPrefix} <- ask
     return $ mconcat $ intersperse "." $ reverse (name : envPrefix)
 
diff --git a/package.yaml b/package.yaml
index 91bfee6..e4e6ef6 100644
--- a/package.yaml
+++ b/package.yaml
@@ -23,7 +23,6 @@ extra-source-files: README.md
 
 ghc-options: -Wall -Wcompat
 default-extensions:
-  - ApplicativeDo
   - BlockArguments
   - DeriveGeneric
   - FlexibleInstances
-- 
GitLab