diff --git a/youido/examples/Example.hs b/youido/examples/Example.hs index fdaf6bb..aeb1fb3 100644 --- a/youido/examples/Example.hs +++ b/youido/examples/Example.hs @@ -1,23 +1,24 @@ -{-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE OverloadedStrings, ExistentialQuantification, ScopedTypeVariables, ExtendedDefaultRules, FlexibleContexts, TemplateHaskell, MultiParamTypeClasses, - OverloadedLabels, TypeOperators, DataKinds, DeriveGeneric, FlexibleInstances #-} + OverloadedLabels, TypeOperators, DataKinds, DeriveGeneric, + FlexibleInstances, TypeApplications, GeneralizedNewtypeDeriving #-} + +module Main where import Youido.Serve import Youido.Types import Youido.Dashdo +import SumTypeExample import Lucid import Lucid.Bootstrap import Lucid.Rdash import Numeric.Datasets.Gapminder import Numeric.Datasets import Control.Monad.Reader -import Control.Monad.State.Strict import Control.Concurrent.STM.TVar import Control.Concurrent.STM import Data.List (nub) -import Network.Wai -import Data.Text (Text, pack, unpack) +import Data.Text (Text, unpack) import Data.Maybe (fromMaybe) import qualified Data.Text as T import Data.Monoid @@ -27,9 +28,8 @@ import Lens.Micro.Platform import Dashdo.Elements import Dashdo.FlexibleInput import Dashdo.Types hiding (FormField) -import Data.Proxy import GHC.Generics -import Text.Digestive.View (View) +import Text.Digestive.View import qualified Data.Text.IO as TIO import qualified Text.Digestive as D @@ -58,20 +58,25 @@ instance Monad m => FromForm m TodoTag newtype Assignee = Assignee Int deriving (Generic, Show, Eq, Num) instance MonadIO m => FormField m Assignee where - fromFormField def = D.monadic $ liftIO $ do + fromFormField chs def = D.monadic $ liftIO $ do employees <- getEmployees - return $ D.choice employees def + return . D.choice employees $ def - renderField _ fieldName label view = div_ [class_ "form-group"] $ do + renderField _ _ fieldName label view = div_ [class_ "form-group"] $ do DL.label fieldName view (toHtml label) with (DL.inputSelect fieldName (toHtml <$> view)) -- DL.inputWithType typ_ attrs fieldName view) [class_ "form-control", autofocus_] DL.errorList fieldName (toHtml <$> view) +data Priority = None | Low | High | Custom { note :: Text, priorityNumber :: Int } + deriving (Show, Generic) +instance (Monad m) => FormField m Priority + data Todo = TodoItem { todoID :: Int, todo :: Text, assignee :: Assignee, + priority :: Priority, done :: Bool, tags :: [TodoTag] } deriving (Show, Generic) @@ -92,7 +97,7 @@ instance MonadIO m => FromRequest m Countries instance ToURL Countries -------------------------------------------------- type ExampleM = ReaderT (TVar ExampleState) IO -data ExampleState = ExampleState { todoState :: TodoList } +data ExampleState = ExampleState { todoState :: TodoList, quiz :: Quiz } -- readTodoState :: ExampleM TodoList readTodoState = ask >>= fmap todoState . liftIO . readTVarIO @@ -125,10 +130,10 @@ bubblesDD gapM = do -------------------------------------------------- -todoListEditForm :: MonadIO m => View Text -> HtmlT m () -todoListEditForm view = container_ $ do +todoListEditForm :: MonadIO m => Maybe TodoList -> View Text -> HtmlT m () +todoListEditForm mdef view = container_ $ do form_ [method_ "post", action_ (toURL $ UpdateTodoList FormLink)] $ do - renderForm (Proxy :: Proxy TodoList) view + renderSumForm Nothing mdef view button_ [type_ "submit"] "Save" todoH :: TodoR -> HtmlT ExampleM () @@ -140,7 +145,7 @@ todoH ListTodos = container_ $ do h4_ (toHtml titleT) a_ [type_ "button", class_ "btn btn-primary", href_ . toURL $ EditTodoList] "Edit List" - widget_ . widgetBody_ $ forM_ todosT $ \(TodoItem idT nameT assignT doneT tags) -> do + widget_ . widgetBody_ $ forM_ todosT $ \(TodoItem idT nameT assignT priorityT doneT tags) -> do let employee = fromMaybe "unknown" $ lookup assignT employees container_ $ do div_ $ do @@ -148,13 +153,14 @@ todoH ListTodos = container_ $ do show idT <> ". " <> (if doneT then "DONE: " else "TODO: ") <> unpack nameT - <> " (" <> unpack employee <> ") " + <> " (" <> unpack employee <> ") " + <> " [Priority: " <> show priorityT <> "]" <> unpack (if length tags == 0 then "" else " (" <> T.intercalate ", " (map tag tags) <> ")") todoH EditTodoList = do tdos <- readTodoState - todoListEditForm =<< (getView (Just tdos)) + todoListEditForm (Just tdos) =<< (getView (Just tdos)) todoH (UpdateTodoList (Form tdos)) = do atom <- ask @@ -164,18 +170,63 @@ todoH (UpdateTodoList (Form tdos)) = do todoH (UpdateTodoList (FormError v)) = do liftIO . putStrLn $ "UpdateTodoList error: " <> show (FormError v) - todoListEditForm v + todoListEditForm Nothing v + +-------------------------------------------------- + +readQuiz = fmap quiz . liftIO . readTVarIO =<< ask + +quizH ShowQuiz = do + Quiz nm favCol r <- readQuiz + br_ [] + h4_ (toHtml nm) + a_ [type_ "button", class_ "btn btn-primary", href_ $ toURL EditQuiz] "Edit" + br_ [] + a_ [type_ "button", class_ "btn btn-default", href_ $ toURL NewQuiz] "New" + container_ $ do + toHtml $ "Favourite colour: " <> show favCol <> ", " <> show r + +quizH NewQuiz = do + liftIO . putStrLn $ "* New quiz *" + quizEditForm Nothing =<< getView (Nothing :: Maybe Quiz) + +quizH EditQuiz = do + q <- readQuiz + liftIO . putStrLn $ "\n ****** Getting view for quiz\n" + quizEditForm (Just q) =<< getView (Just q) + +quizH (UpdateQuiz (Form q)) = do + liftIO . putStrLn $ "UpdateQuiz:" <> show q + state <- ask + liftIO . atomically $ + modifyTVar state $ \st -> st { quiz = q } + quizH ShowQuiz + +quizH (UpdateQuiz (FormError v)) = do + liftIO . putStrLn $ "UpdateQuiz error: " <> show (FormError v) + quizEditForm Nothing v + +quizEditForm :: Monad m => Maybe Quiz -> View Text -> HtmlT m () +quizEditForm mdef view = container_ $ do + form_ [method_ "post", action_ (toURL $ UpdateQuiz FormLink)] $ do + renderSumForm Nothing mdef view + button_ [type_ "submit"] "Save" + +-------------------------------------------------- initialTodos = TodoList "My todos" - [ TodoItem 1 "Make todo app" 1 False [TodoTag "dev", TodoTag "work"] - , TodoItem 2 "Have lunch" 2 False [TodoTag "personal"] - , TodoItem 3 "Buy bread" 3 True []] + [ TodoItem 1 "Make todo app" 1 High False [TodoTag "dev", TodoTag "work"] + , TodoItem 2 "Have lunch" 2 None False [TodoTag "personal"] + , TodoItem 3 "Buy bread" 3 (Custom "eventually" 1) True []] "A field after a subform" +initialQuiz = Quiz "A quiz" (Blue "navy" "extra thoughts") PreferNotToSay + sidebar = rdashSidebar "Youido Example" (return ()) [ ("Bubbles", "fas") *~ #bubbles :/ Initial , ("Counties", "fas") *~ Countries - , ("Todos", "fas") *~ ListTodos ] + , ("Todos", "fas") *~ ListTodos + , ("Sum Types", "fas") *~ ShowQuiz ] inHeader :: Text -> Html () inHeader js = do @@ -184,8 +235,8 @@ inHeader js = do main :: IO () main = do gapM <- getDataset gapminder - js <- TIO.readFile "form-repeat.js" - atom <- newTVarIO $ ExampleState initialTodos + js <- TIO.readFile "youido.js" + atom <- newTVarIO $ ExampleState initialTodos initialQuiz let runIt :: Bool -> ExampleM a -> IO a runIt _ todoM = runReaderT todoM atom @@ -197,3 +248,4 @@ main = do wrapper .= \_ -> rdashWrapper "Youido Example" (inHeader js) sidebar hHtmlT $ countryH gapM hHtmlT todoH + hHtmlT quizH diff --git a/youido/examples/SumTypeExample.hs b/youido/examples/SumTypeExample.hs new file mode 100644 index 0000000..bdc604b --- /dev/null +++ b/youido/examples/SumTypeExample.hs @@ -0,0 +1,37 @@ +{-# LANGUAGE DeriveGeneric, MultiParamTypeClasses, FlexibleInstances #-} + +module SumTypeExample where + +import Data.Text (Text) +-- import qualified Data.Text as T +import GHC.Generics +import Youido.Types +import Youido.Utils + +data Colour = Red + | Green { dark :: Bool } + | Blue { shade :: Text, extra :: Text } + deriving (Show, Generic) + +instance (Monad m) => FormField m Colour + +data Reason = PreferNotToSay | Explanation { explanation :: Text } + deriving (Show, Generic) +instance (Monad m) => FormField m Reason + +data Quiz = Quiz + { name :: Text + , favouriteColour :: Colour + , reason :: Reason + } deriving (Show, Generic) + +instance (Monad m) => FromForm m Quiz + +data QuizR = ShowQuiz + | NewQuiz + | EditQuiz + | UpdateQuiz (Form Quiz) + deriving (Show, Generic) + +instance (Monad m) => FromRequest m QuizR +instance ToURL QuizR diff --git a/youido/form-repeat.js b/youido/form-repeat.js deleted file mode 100644 index e5e3470..0000000 --- a/youido/form-repeat.js +++ /dev/null @@ -1,66 +0,0 @@ -var youidoItemClass = 'youido_multi_item'; -var youidoDummyItem = 'youido_dummy_item'; - -function youidoReplaceIndex(currentPath, pathRegexp, idx) { - return currentPath.replace(pathRegexp, '$1.' + idx); -} - -function youidoUpdatePaths($items, fieldName, fieldPath) { - // regex matches e.g. form.fieldName.0 and form.fieldName.-1 - var regex = new RegExp('^(' + fieldPath + ')\\.-?\\d+'); - var attrs = ['for', 'id', 'name']; - $items.each(function (idx) { - for (var j=0; j < attrs.length; j++) { - $(this).find("*[" + attrs[j] + "^='" + fieldPath + ".']") - .attr(attrs[j], function(i,old) { - return youidoReplaceIndex(old, regex, idx); - }); - } - }); -} - -function youidoGetFieldPath(itemsDiv) { - var indices = $(itemsDiv).children("input[id$='.indices']")[0]; - if (!!indices) { - return indices.id.replace(/\.indices$/, ''); - } else return null; -} - -function youidoUpdateIndices(fieldPath, newLength) { - var newVal = ''; - for (var i=0; i < newLength; i++) { - newVal = newVal + i; - if (i < newLength - 1) { - newVal = newVal + ','; - } - } - var indices = document.getElementById(fieldPath + '.indices'); - indices.setAttribute('value', newVal); -} - -function youidoUpdate($items, fieldName, fieldPath) { - var dummySel = "[id='" + fieldPath + '.' + youidoDummyItem + "']"; - var $itemsNoDummy = $items.not(dummySel); - youidoUpdatePaths($itemsNoDummy, fieldName, fieldPath); - youidoUpdateIndices(fieldPath, $itemsNoDummy.length); -} - -function youidoAddItem(itemsDiv, fieldName) { - var fieldPath = youidoGetFieldPath(itemsDiv); - var dummyId = fieldPath + '.' + youidoDummyItem; - var dummy = document.getElementById(dummyId); - var newItem = dummy.cloneNode(true); - newItem.setAttribute('style', 'display: inherit'); - newItem.setAttribute('id', newItem.getAttribute('id').replace(dummyId, '')); - var $items = $(itemsDiv).children('div.' + youidoItemClass); - $items[$items.length - 1].after(newItem); - $items.push(newItem); - youidoUpdate($items, fieldName, fieldPath); -} - -function youidoRemoveItem(item, fieldName) { - var itemsDiv = item.parentNode; - var fieldPath = youidoGetFieldPath(itemsDiv); - itemsDiv.removeChild(item); - youidoUpdate($(itemsDiv).children('div.' + youidoItemClass), fieldName, fieldPath); -} diff --git a/youido/lib/Youido/Types.hs b/youido/lib/Youido/Types.hs index 81a0389..e2da899 100644 --- a/youido/lib/Youido/Types.hs +++ b/youido/lib/Youido/Types.hs @@ -4,18 +4,24 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE DefaultSignatures #-} {-# LANGUAGE InstanceSigs #-} -{-# LANGUAGE OverloadedStrings, ScopedTypeVariables, DeriveGeneric, KindSignatures, DataKinds, TypeApplications, GADTs, - FlexibleInstances, MultiParamTypeClasses, OverloadedLabels, CPP, - TypeOperators, GeneralizedNewtypeDeriving, TemplateHaskell #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings, ScopedTypeVariables, DeriveGeneric, + KindSignatures, DataKinds, TypeApplications, GADTs, + FlexibleInstances, MultiParamTypeClasses, CPP, + OverloadedLabels, TypeOperators, UndecidableInstances, + GeneralizedNewtypeDeriving, TemplateHaskell, + AllowAmbiguousTypes, TypeFamilies #-} module Youido.Types where +import Youido.Utils import Network.Wai hiding (Response) import qualified Data.Text as T import Data.Text (Text, pack, unpack) import Data.Text.Read(signed, decimal) import qualified Data.Text.Lazy as TL import qualified Data.Text.Encoding as T +import Data.Maybe (maybeToList) import Control.Monad.State.Strict import Data.Monoid import GHC.TypeLits @@ -24,8 +30,10 @@ import Lucid import Data.Aeson hiding (defaultOptions) import Data.List.Split (split, dropInitBlank, keepDelimsL, whenElt) import Data.Char (toLower, isUpper) -import Data.List (intercalate) +import Data.List (intercalate, partition) import Data.Foldable (traverse_) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import GHC.OverloadedLabels import qualified Data.ByteString.Lazy as LBS import Data.ByteString (ByteString) @@ -34,27 +42,20 @@ import Data.Void import Lens.Micro.Platform hiding (to) import GHC.Generics import Lucid.PreEscaped -import Lucid.Bootstrap import Control.Applicative((<|>)) import Text.Parsec (optionMaybe, getState, putState) import Text.ParserCombinators.Parsec.Pos (incSourceLine) import Text.Parsec (ParsecT, runParserT, tokenPrim) -import Text.ParserCombinators.Parsec.Prim (unexpected, GenParser, getPosition, (), - many) +import Text.ParserCombinators.Parsec.Prim (unexpected, getPosition, (), many) import Text.Digestive.View (View(..)) import qualified Text.Digestive as D +import qualified Text.Digestive.Form.Internal as D +import qualified Text.Digestive.Types as D (FormInput(..)) import qualified Text.Digestive.Form.List as D - -import Control.Monad.Identity (Identity, runIdentity) - -import Data.Maybe (maybeToList) - import qualified Text.Digestive.Lucid.Html5 as DL -import Control.Monad.Trans.Class - -------------------------------------------------------------------------- --- PATHINFO --- Code copied from the web-routes library @@ -137,6 +138,35 @@ instance FromRequest m a => GFromRequest m (K1 i a) where instance ToURL (Form a) where toURLSegments _ = [""] +-- Map from form paths to (datatype name, constructor name) +-- Instructs formtree builder (fromForm* funcs) which path in the sum to build +type SumChoices = Map Text Text +notSelectedValue = "#not-selected" :: Text +renderCtorKey = "#render-now" :: Text + +mkSumChoices :: [(TL.Text, TL.Text)] -> SumChoices +mkSumChoices = M.fromList + . map (\(k,v) -> (TL.toStrict . snd $ TL.breakOnEnd "youido-sums." k, + TL.toStrict v)) + . filter (\(k,v) -> "youido-sums." `TL.isPrefixOf` k + && not (".-1." `TL.isInfixOf` k)) -- no dummy list + +-- Manually inserts errors for sum types where no selection was made +withSelectionErrors :: View Text -> SumChoices -> View Text +withSelectionErrors v@(View _ _ _ _ es _) choices = + v { viewErrors = selErrs ++ es } + where selErrs = + map (\(k,v) -> (D.toPath k, "Selection required")) + . M.toList $ M.filter ((==) notSelectedValue) choices + +annotateWithSumChoices :: View Text -> SumChoices -> View Text +annotateWithSumChoices v@(View _ _ _ input es _) choices = + v { viewInput = selInput ++ input + , viewErrors = selErrs ++ es } + where selInput = map (\(k,v) -> (D.toPath k ++ ["#choice"], D.TextInput v)) selected + selErrs = map (\(k,v) -> (D.toPath k, "Selection required")) notSelected + (selected, notSelected) = partition (\(k,v) -> v /= notSelectedValue) $ M.toList choices + instance (Monad m, FromForm m a) => FromRequest m (Form a) where requestParser = do result <- formResult @@ -153,27 +183,19 @@ instance (Monad m, FromForm m a) => FromRequest m (Form a) where lookupPath pars path = maybeToList $ D.TextInput <$> TL.toStrict <$> lookup (TL.fromStrict (D.fromPath path)) pars runPostForm :: [(TL.Text, TL.Text)] -> m (View Text, Maybe a) - runPostForm pars = D.postForm "top-level-form" form (postFormHandler pars) + runPostForm pars = do + (v, answer) <- D.postForm "top-level-form" form (postFormHandler pars) + let v' = annotateWithSumChoices v choices + return (v', if null (viewErrors v') then answer else Nothing) where postFormHandler :: (Monad m) => [(TL.Text, TL.Text)] -> D.FormEncType -> m (D.Env m) postFormHandler pars D.UrlEncoded = return $ \path -> (return $ lookupPath pars path) postFormHandler pars D.MultiPart = return $ const (return []) - + choices = mkSumChoices pars + opts = FromFormOptions $ + Just (SumOptions (M.filter ((/=) notSelectedValue) choices) Nothing "") form :: D.Form Text m a - form = fromForm Nothing - --- instance (Monad m, FromForm1 m a) => FromRequest m (QueryString a) where --- requestParser = do --- form <- requestParser --- let res = formToQueryString form --- case res of --- Nothing -> unexpected "failed" --- Just t -> return t - --- formToQueryString :: Form a -> Maybe (QueryString a) --- formToQueryString FormLink = Just $ QueryStringLink --- fromToQueryString (Form a) = Just $ QueryString a --- fromToQueryString _ = Nothing + form = fromForm' opts Nothing instance (ToURL a, ToURL b) => ToURL (a,b) where toURLSegments (a,b) = toURLSegments a <> toURLSegments b @@ -428,32 +450,72 @@ instance ToURL FormFields where --- FORM HANDLING -------------------------------------------------------------------------- -data Options = Options +-- TODO: rename to something like RenderOptions +data Options a = Options { fieldLabelModifier :: Text -> Text , constructorTagModifier :: Text -> Text + , sumConstructor :: Maybe Text -- | Rennders specifc ctor + , ctorMap :: SumChoices -- | (maybe.context.)fieldName -> Constructor + , viewOf :: Maybe a -- | Renders `a` when present } -defaultOptions :: Options -defaultOptions = Options id id +instance Functor Options where + fmap f o = o { viewOf = f <$> viewOf o } + +nextOpts :: Options a -> Options b +nextOpts o = o { viewOf = Nothing } + +defaultOptions :: Options a +defaultOptions = Options id id Nothing M.empty Nothing + +data SumOptions = SumOptions + { sumChoices :: SumChoices + , currentlyBuildingCtor :: Maybe Text + , currentContext :: Text + } deriving Show -genericFromForm :: (Generic a, PostFormG m (Rep a), Monad m) => D.Formlet Text m a -genericFromForm def = to <$> postFormG (from <$> def) +data FromFormOptions = FromFormOptions + { sumOpts :: Maybe SumOptions + } deriving Show -genericRenderForm :: (Generic a, PostFormG m (Rep a), Monad m) => Proxy a -> Options -> View Text -> HtmlT m () +mkSumOpts ch ctor = FromFormOptions $ Just (SumOptions ch (Just ctor) "") -- TODO: fix +mkSumOptsCtx ch ctor ctx = FromFormOptions $ Just (SumOptions ch (Just ctor) ctx) + +defaultFromFormOpts = FromFormOptions Nothing + +genericFromForm :: (Generic a, PostFormG m (Rep a), Monad m) + => FromFormOptions -> D.Formlet Text m a +genericFromForm opts def = to <$> postFormG opts (from <$> def) + +genericRenderForm :: (Generic a, PostFormG m (Rep a), Monad m) + => Proxy a -> Options a -> View Text -> HtmlT m () genericRenderForm p options view = do DL.errorList "" (toHtml <$> view) - renderFormG (from <$> p) options view + renderFormG (from <$> p) (from <$> options) view + +renderSumForm :: forall a m. (Monad m, Generic a, PostFormG m (Rep a)) + => Maybe (Options a) -> Maybe a -> View Text -> HtmlT m () +renderSumForm mopts mdef v = do + let opts = (maybe defaultOptions id mopts) { viewOf = mdef} + genericRenderForm (Proxy :: Proxy a) opts v class FromForm m a where + fromForm' :: FromFormOptions -> D.Formlet Text m a + default fromForm' :: + (Monad m, Generic a, PostFormG m (Rep a)) => FromFormOptions -> D.Formlet Text m a + fromForm' = genericFromForm + fromForm :: D.Formlet Text m a - default fromForm :: - (Monad m, Generic a, PostFormG m (Rep a)) => D.Formlet Text m a - fromForm def = to <$> postFormG (from <$> def) + fromForm = fromForm' defaultFromFormOpts + + renderForm' :: Proxy a -> Options a -> View Text -> HtmlT m () + default renderForm' :: (Monad m, Generic a, PostFormG m (Rep a)) + => Proxy a -> Options a -> View Text -> HtmlT m () + renderForm' = genericRenderForm - renderForm :: Proxy a -> View Text -> HtmlT m () - default renderForm :: (Monad m, Generic a, PostFormG m (Rep a)) => Proxy a -> View Text -> HtmlT m () - renderForm p = genericRenderForm p defaultOptions + renderForm :: Proxy a -> View Text -> HtmlT m () + renderForm p = renderForm' p defaultOptions getView :: Monad m => Maybe a -> m (View Text) getView def = D.getForm "top-level-form" $ fromForm def @@ -471,77 +533,211 @@ renderBootstrapInput typ_ attrs fieldName label view = div_ [class_ "form-group" [class_ "form-control", autofocus_] DL.errorList fieldName (toHtml <$> view) -class FormField m a where - fromFormField :: D.Formlet Text m a - - renderField :: Monad m => Proxy a -> Text -> Text -> View Text -> HtmlT m () - renderField _ = renderBootstrapInput "text" [] - --------------------------------------------------------------------------------- class PostFormG m f where - postFormG :: D.Formlet Text m (f a) - renderFormG :: Proxy (f a) -> Options -> View Text -> HtmlT m () + postFormG :: FromFormOptions -> D.Formlet Text m (f a) + renderFormG :: Proxy (f a) -> Options (f a) -> View Text -> HtmlT m () instance (Monad m, PostFormG m f) => PostFormG m (M1 D t f) where - postFormG def = M1 <$> (postFormG $ unM1 <$> def) - renderFormG _ = renderFormG (Proxy :: Proxy (f a)) + postFormG opts def = M1 <$> (postFormG opts $ unM1 <$> def) + renderFormG _ opts = renderFormG (Proxy :: Proxy (f a)) (unM1 <$> opts) + +instance (Monad m, Constructor c, PostFormG m f) => PostFormG m (M1 C c f) where + postFormG opts def = M1 <$> (postFormG opts $ unM1 <$> def) + renderFormG _ opts = renderFormG (Proxy :: Proxy (f a)) (unM1 <$> opts) -instance (Monad m, PostFormG m f) => PostFormG m (M1 C t f) where - postFormG def = M1 <$> (postFormG $ unM1 <$> def) - renderFormG _ = renderFormG (Proxy :: Proxy (f a)) +instance {-# OVERLAPS #-} (Monad m, Constructor c) => PostFormG m (M1 C c U1) where + postFormG _ _ = pure $ M1 U1 + renderFormG _ opts v = span_ [class_ $ "youido-u1" <> " youido-u1-" <> cname] "" + where cname = pack $ conName (undefined :: M1 C c U1 p) instance (Monad m, Selector t, FormField m a) => PostFormG m (M1 S t (K1 i a)) where - postFormG def = M1 .K1 <$> (fieldName D..:(fromFormField $ (unK1 . unM1 <$> def))) + postFormG opts def = + M1 . K1 <$> subformNm D..: (fromFormField opts' $ unK1 . unM1 <$> def) where - val :: M1 S t (K1 i a) r - val = undefined - - fieldName :: Text - fieldName = T.pack $ selName val - - renderFormG _ options view = renderField (Proxy :: Proxy a) fieldName (fieldLabelModifier options $ fieldName) view + fieldName = T.pack $ selName (undefined :: (M1 S t (K1 i a) r)) + mchoices = sumChoices <$> sumOpts opts + ctx = maybe "" (\c -> + let cc = currentContext c in + if "." `T.isSuffixOf` cc || cc == "" + then cc else cc <> ".") + $ sumOpts opts + k = ctx <> fieldName + mctor = M.lookup k =<< mchoices + subformNm = if fieldName /= "" then fieldName else "none" + newCtx = ctx <> fieldName + opts' = opts {sumOpts = Just $ SumOptions (maybe M.empty id mchoices) mctor newCtx} + + renderFormG _ options view = + renderField (Proxy :: Proxy a) (unK1 . unM1 <$> options) + fieldName (fieldLabelModifier options $ fieldName) view where - val :: M1 S t (K1 i a) r - val = undefined - - fieldName :: Text - fieldName = T.pack $ selName val + fieldName = T.pack $ selName (undefined :: M1 S t (K1 i a) r) instance (Monad m, PostFormG m f, PostFormG m g) => PostFormG m (f :*: g) where - postFormG (Just (def1 :*: def2)) = (:*:) <$> (postFormG $ Just def1) <*> (postFormG $ Just def2) - postFormG Nothing = (:*:) <$> (postFormG Nothing) <*> (postFormG Nothing) + postFormG opts (Just (def1 :*: def2)) = + (:*:) <$> (postFormG opts $ Just def1) <*> (postFormG opts $ Just def2) + postFormG opts Nothing = (:*:) <$> (postFormG opts Nothing) <*> (postFormG opts Nothing) renderFormG _ options view = do - renderFormG (Proxy :: Proxy (f a)) options view - renderFormG (Proxy :: Proxy (g a)) options view + renderFormG (Proxy :: Proxy (f a)) optsf view + renderFormG (Proxy :: Proxy (g a)) optsg view + where (optsf, optsg) = case viewOf options of + Just (ff :*: gg) -> (options {viewOf = Just ff}, options {viewOf = Just gg}) + _ -> (options {viewOf = Nothing}, options {viewOf = Nothing}) + +instance (Monad m, HasConName f, HasConName g, PostFormG m f, PostFormG m g) => PostFormG m (f :+: g) where + postFormG opts (Just (L1 def)) = L1 <$> postFormG opts (Just def) + postFormG opts (Just (R1 def)) = R1 <$> postFormG opts (Just def) + postFormG opts@(FromFormOptions (Just (SumOptions _ (Just ctor) _))) Nothing = + if hasConName @f ctor + then L1 <$> postFormG @m @f opts Nothing + else if hasConName @g ctor + then R1 <$> postFormG @m @g opts Nothing + else error $ "fromSumFormG, ctor not found: " <> unpack ctor + postFormG opts@(FromFormOptions (Just (SumOptions _ Nothing _))) Nothing = + error "possible youido bug: currentlyBuildingCtor opt must be present to build sum type" + + renderFormG _ opts v = do + case (\c -> (hasConName @f c, hasConName @g c)) <$> sumConstructor opts of + Just (True, _) -> renderFormG (Proxy :: Proxy (f ())) (nextOpts opts) v + Just (_, True) -> renderFormG (Proxy :: Proxy (g ())) (nextOpts opts) v + Just (False, False) -> DL.errorList "Selection is required" (toHtml <$> v) + Nothing -> error "Youido bug: absent sumConstructor opt is required to render sum type" + +preV :: View Text -> Text +preV (View nm ctx frm inp errs med) = mconcat + [ "viewName: ", nm, ", viewContext: ", D.fromPath ctx] + +-------------------- + +class FormField m a where + fromFormField :: FromFormOptions -> D.Formlet Text m a + renderField :: (Monad m) => Proxy a -> Options a -> Text -> Text -> View Text -> HtmlT m () + + default fromFormField :: (Monad m, Generic a, FormFieldG m (Rep a)) + => FromFormOptions -> D.Formlet Text m a + fromFormField = fromFormFieldG' + + default renderField :: (Monad m, Generic a, FormFieldG m (Rep a)) + => Proxy a -> Options a -> Text -> Text -> View Text -> HtmlT m () + renderField = renderFieldG' + +fromFormFieldG' :: forall a m. (Monad m, Generic a, FormFieldG m (Rep a)) + => FromFormOptions -> D.Formlet Text m a +fromFormFieldG' mctor mdef = to <$> fromFormFieldG mctor (from <$> mdef) + +renderFieldG' :: forall a s m. (Monad m, Generic a, FormFieldG m (Rep a)) + => Proxy a -> Options a -> Text -> Text -> View Text -> HtmlT m () +renderFieldG' _ opts = renderFieldG (Proxy :: Proxy (Rep a ())) (from <$> opts) + +class FormFieldG m f where + fromFormFieldG :: FromFormOptions -> Maybe (f p) -> D.Form Text m (f p) + renderFieldG :: (Monad m) => Proxy (f p) -> Options (f p) -> Text -> Text -> View Text -> HtmlT m () + +mkSumViews :: forall f m. (FormFieldG m f, Monad m) + => SumChoices -> D.Path -> [Text] -> m [View Text] +mkSumViews chs subviewPath ctors = + let newCtx = D.fromPath . tail . tail $ subviewPath in -- drops "dummy" and view name + traverse (\c -> D.getForm (D.fromPath subviewPath) + $ fromFormFieldG @m @f (mkSumOptsCtx chs c newCtx) Nothing) ctors + +findChoice :: Text -> [(D.Path, D.FormInput)] -> Maybe Text +findChoice fieldName input = + -- The input paths are relative to the subview, not absolute + case filter (\(k,_) -> k == choicePath) input of + [(_,D.TextInput ctor)] -> Just ctor + _ -> Nothing + where choicePath = [fieldName, "#choice"] + +instance (Monad m, PostFormG m f, + GetConNameG f, HasConName f, EnumCtors f, Datatype d, f ~ (g :+: h)) + => FormFieldG m (D1 d f) where + fromFormFieldG opts@(FromFormOptions (Just (SumOptions ch mctor ctx))) mdef = + case mdef of -- default value takes precedence over ctor + Just x -> postFormG (mkSumOptsCtx ch (getConNameG x) ctx) mdef + Nothing -> case mctor of + Just ctor -> postFormG (mkSumOptsCtx ch ctor ctx) Nothing + -- No default and no choice made => a dummy form is required + _ -> "disabled" D..: postFormG (mkSumOptsCtx ch (head $ enumCtors @f) ctx) Nothing + where nm = pack $ datatypeName (undefined :: D1 d f ()) + fromFormFieldG (FromFormOptions Nothing) _ = + error "possible youido bug: options required for rendering a sum type form" + + renderFieldG _ opts fldNm label v@(View viewNm ctx _ input es _) = do + let dtNm = pack $ datatypeName (undefined :: D1 d f ()) + ctors = enumCtors @f + onselect = jsCall "youidoSelectConstructor" + ["this", jsStr viewNm, jsStr fldNm] + defaultOpt = "Select one..." :: Text + selectedOpt = maybe defaultOpt id sumCtor + selAttr = \ctor -> if ctor == selectedOpt then [selected_ "true"] else [] + fieldPath = viewNm : ctx ++ [fldNm] + sumCtor = (getConNameG <$> viewOf opts) <|> findChoice fldNm input + fieldRef = D.fromPath fieldPath + ctorChoiceInputId = D.fromPath $ "youido-sums" : tail fieldPath + subviewPath = "dummy" : fieldPath + + views <- lift $ mkSumViews @(D1 d f) (ctorMap opts) subviewPath ctors + + div_ [class_ "form-group"] $ do + DL.label fldNm v $ toHtml label + select_ [onchange_ onselect, class_ "form-control"] $ do + option_ (selAttr defaultOpt ++ [disabled_ "true", value_ defaultOpt]) $ toHtml defaultOpt + flip traverse_ ctors $ \c -> option_ (selAttr c) $ toHtml c + DL.errorList fldNm (toHtml <$> v) + + input_ [ style_ "display: none" + , id_ ctorChoiceInputId + , name_ ctorChoiceInputId + , value_ $ maybe notSelectedValue id sumCtor] + + flip traverse_ (zip ctors views) $ \(ctor, sumv) -> + div_ [ style_ "display: none" + , class_ $ if hasU1ConName @f ctor then "youido-u1-container" else "" + , id_ ("youido-sum-dummy-" <> fieldRef <> "." <> ctor) + , data_ "constructor" ctor] $ + renderFormG (Proxy :: Proxy (f ())) + (opts {sumConstructor = Just ctor, viewOf = Nothing}) sumv + + div_ [ class_ "well container" + , id_ $ "youido-sum-real-container-" <> fieldRef + , style_ $ if null sumCtor || maybe False (hasU1ConName @f) sumCtor + then "display:none" else ""] $ + flip traverse_ sumCtor $ \con -> + renderFormG (Proxy :: Proxy (f ())) + (opts {sumConstructor = Just con, viewOf = Nothing}) + $ D.subView fldNm v instance Monad m => FormField m Text where - fromFormField = D.text + fromFormField _ = D.text + renderField _ _ = renderBootstrapInput "text" [] instance Monad m => FormField m Bool where - renderField _ fieldName label view = div_ [class_ "checkbox"] $ do + renderField _ _ fieldName label view = div_ [class_ "checkbox"] $ do DL.label fieldName view $ do with (DL.inputCheckbox fieldName (toHtml <$> view)) [autofocus_] toHtml label DL.errorList fieldName (toHtml <$> view) - fromFormField = D.bool + fromFormField _ = D.bool instance Monad m => FormField m Int where - fromFormField = D.stringRead "must be an integer" + fromFormField _ = D.stringRead "must be an integer" + renderField _ _ = renderBootstrapInput "text" [] instance Monad m => FormField m Double where - fromFormField = D.stringRead "must be a double" + fromFormField _ = D.stringRead "must be a double" - renderField _ = renderBootstrapInput "number" [] + renderField _ _ = renderBootstrapInput "number" [] renderItem :: forall m a. (FromForm m a, Monad m) - => Proxy a -> Text -> View Text -> HtmlT m () -renderItem _ onclickDelete v = do + => Proxy a -> Options a -> Text -> View Text -> HtmlT m () +renderItem p opts onclickDelete v = do div_ [class_ "youido_multi_item well container"] $ do - renderForm (Proxy :: Proxy a) v + renderForm' p opts v fieldButton "Delete" onclickDelete fieldButton :: Monad m => Text -> Text -> HtmlT m () @@ -554,9 +750,42 @@ jsCall fnName args = fnName <> "(" <> T.intercalate "," args <> ")" jsStr s = "'" <> s <> "'" +-- listIndices copied from Text.Digestive.Form because it's not exported +listIndices :: (Monad m, Monoid v) => [Int] -> D.Form v m [Int] +listIndices = fmap D.parseIndices . D.text . Just . D.unparseIndices + +listFromChoices :: (FromForm m a) => FromFormOptions -> [D.Form Text m a] +listFromChoices (FromFormOptions Nothing) = [] +listFromChoices opts@(FromFormOptions (Just (SumOptions ch _ ctx))) = + map f + $ flip zip [0..] + $ filter (\(k,_) -> ctx `T.isPrefixOf` k) + $ M.toList ch + where f ((path,ctor), idx) = fromForm' (mkOpts idx ctor) Nothing + mkOpts idx ctor = FromFormOptions $ Just + (SumOptions ch (Just ctor) (mkCtx ctx idx)) + +withCtx :: FromFormOptions -> Int -> FromFormOptions +withCtx os@(FromFormOptions Nothing) _ = os +withCtx os@(FromFormOptions (Just sopts)) idx = + os { sumOpts = Just $ sopts {currentContext = mkCtx (currentContext sopts) idx }} + +mkCtx :: Text -> Int -> Text +mkCtx ctx i = D.fromPath $ (D.toPath ctx) ++ [pack $ show i] + instance (FromForm m a, Monad m) => FormField m [a] where - fromFormField = D.listOf fromForm - renderField _ fieldName label view = do + fromFormField opts def = + D.List defList (D.indicesRef D..: listIndices ixs) + where + defList = D.DefaultList (fromForm' (withCtx opts (-1)) Nothing) items + ixs = maybe [0] (\xs -> [0 .. length xs - 1]) def + items = case def of + Just xs -> + map (\(defItem,i) -> fromForm' (withCtx opts i) $ Just defItem) + $ zip xs ixs + Nothing -> listFromChoices opts + + renderField _ opts fieldName label view = do let fieldPath = D.absolutePath fieldName view indicesPath = fieldPath ++ [D.indicesRef] indicesPathT = D.fromPath indicesPath @@ -578,12 +807,15 @@ instance (FromForm m a, Monad m) => FormField m [a] where -- a form when the list is empty let dummyView = D.makeListSubView fieldName (-1) view - dummy = renderItem (Proxy :: Proxy a) onclickDelete dummyView + dummy = renderItem (Proxy :: Proxy a) (nextOpts opts) onclickDelete dummyView with dummy [ style_ "display: none" , id_ (D.fromPath fieldPath <> ".youido_dummy_item")] - traverse_ (renderItem (Proxy :: Proxy a) onclickDelete) $ - D.listSubViews fieldName view + let subviews = D.listSubViews fieldName view + viewsOf = map Just <$> (viewOf opts) + flip traverse_ (zip subviews (maybe (repeat Nothing) id viewsOf)) $ \(subv, viewof) -> + renderItem (Proxy :: Proxy a) (opts {viewOf = viewof}) onclickDelete subv + fieldButton "Add new item" onclickAdd DL.errorList fieldName (toHtml <$> view) diff --git a/youido/lib/Youido/Utils.hs b/youido/lib/Youido/Utils.hs new file mode 100644 index 0000000..5c254b2 --- /dev/null +++ b/youido/lib/Youido/Utils.hs @@ -0,0 +1,76 @@ +{-# LANGUAGE KindSignatures, TypeApplications, TypeOperators, + ScopedTypeVariables, FlexibleInstances, AllowAmbiguousTypes, + DefaultSignatures, FlexibleContexts, OverloadedStrings #-} + +module Youido.Utils where + +import Data.Text (Text, pack) +import GHC.Generics + +-------------------------------------------------- + +data CtorSearchResult = + CtorNotFound + | CtorFoundLeft + | CtorFoundRight + deriving Show + +class HasConName (f :: * -> *) where + hasConName :: Text -> Bool + hasU1ConName :: Text -> Bool + +instance (HasConName f) => HasConName (D1 d f) where + hasConName = hasConName @f + hasU1ConName = hasU1ConName @f + +instance {-# OVERLAPPABLE #-} (Constructor c) => HasConName (C1 c f) where + hasConName = (==) . pack $ conName (undefined :: C1 c f ()) + hasU1ConName _ = False + +instance {-# OVERLAPS #-} (Constructor c) => HasConName (C1 c U1) where + hasConName = (==) . pack $ conName (undefined :: C1 c U1 ()) + hasU1ConName = hasConName @(C1 c U1) + +instance (HasConName f, HasConName g) => HasConName (f :+: g) where + hasConName c = hasConName @f c || hasConName @g c + hasU1ConName c = hasU1ConName @f c || hasU1ConName @g c + +-------------------------------------------------- + +class EnumCtors (f :: * -> *) where + enumCtors :: [Text] + +instance EnumCtors f => EnumCtors (D1 c f) where + enumCtors = enumCtors @f + +instance (EnumCtors f, EnumCtors g) => EnumCtors (f :+: g) where + enumCtors = enumCtors @f ++ enumCtors @g + +instance (Constructor c) => EnumCtors (M1 C c f) where + enumCtors = [pack $ conName (undefined :: M1 C c f ())] + +getctors :: forall f. EnumCtors f => [Text] +getctors = enumCtors @f + +-------------------------------------------------- + +class GetConName a where + getConName :: a -> Text + default getConName :: (Generic a, GetConNameG (Rep a)) => a -> Text + getConName = getConNameG' + +getConNameG' :: (Generic a, GetConNameG (Rep a)) => a -> Text +getConNameG' = getConNameG . from + +class GetConNameG f where + getConNameG :: f p -> Text + +instance (GetConNameG f) => GetConNameG (D1 d f) where + getConNameG = getConNameG . unM1 + +instance (GetConNameG f, GetConNameG g) => GetConNameG (f :+: g) where + getConNameG (L1 f) = getConNameG f + getConNameG (R1 g) = getConNameG g + +instance (Constructor c) => GetConNameG (C1 c f) where + getConNameG _ = pack $ conName (undefined :: C1 c f ()) diff --git a/youido/sum-select.js b/youido/sum-select.js new file mode 100644 index 0000000..e69de29 diff --git a/youido/youido.cabal b/youido/youido.cabal index 321663d..9057afe 100644 --- a/youido/youido.cabal +++ b/youido/youido.cabal @@ -10,7 +10,7 @@ maintainer: tomn@diffusionkinetics.com copyright: Tom Nielsen category: Web build-type: Simple -extra-source-files: form-repeat.js +extra-source-files: youido.js cabal-version: >=1.10 source-repository head @@ -30,6 +30,7 @@ library Youido.Serve Youido.Dashdo Youido.Authentication + Youido.Utils build-depends: base >= 4.6 && < 5 , text , lucid @@ -60,7 +61,9 @@ library , transformers Executable youido-example - main-is: examples/Example.hs + hs-source-dirs: examples + main-is: Example.hs + other-modules: SumTypeExample if flag(example) Buildable: True else diff --git a/youido/youido.js b/youido/youido.js new file mode 100644 index 0000000..b818313 --- /dev/null +++ b/youido/youido.js @@ -0,0 +1,138 @@ +// List-related constants +var youidoItemClass = 'youido_multi_item'; +var youidoDummyItem = 'youido_dummy_item'; + +// Sum-related constants +var youidoSumDummyPathPrefix = "dummy."; // prefix of paths in all invisible ctor subforms +var youidoSumDummyIdPrefix = "youido-sum-dummy-"; // for each invisible ctor subform +var youidoSumRealSubformIdPrefix = "youido-sum-real-subform-"; // for the visible ctor subform +var youidoSumRealContainerIdPrefix = "youido-sum-real-container-"; // for div around subform +var youidoSumChoiceIdPrefix = "youido-sums."; // prefix of elem id for invisible choice input +var youidoSumPrefixes = [youidoSumDummyPathPrefix, + youidoSumDummyIdPrefix, + youidoSumRealSubformIdPrefix, + youidoSumRealContainerIdPrefix, + youidoSumChoiceIdPrefix]; + +// +// List forms +// + +function youidoReplaceIndex(currentPath, pathRegexp, idx) { + return currentPath.replace(pathRegexp, '$1.' + idx); +} + +function youidoUpdatePaths($items, fieldName, fieldPath) { + // regex matches e.g. form.fieldName.0 and form.fieldName.-1 + var attrs = ['for', 'id', 'name']; + var prefixes = [fieldPath]; + for (var i=0; i < youidoSumPrefixes.length; i++) { + prefixes.push(youidoSumPrefixes[i] + fieldPath); + } + // the sum choice invisible input does not include the top level form name: + prefixes.push(youidoSumChoiceIdPrefix + fieldPath.replace(/^.+?\./, '')); + + $items.each(function (idx) { + for (var j=0; j < attrs.length; j++) { + for (var k=0; k < prefixes.length; k++) { + var regex = new RegExp('^(' + prefixes[k] + ')\\.-?\\d+'); + $(this).find("*[" + attrs[j] + "^='" + prefixes[k] + ".']") + .attr(attrs[j], function(i,old) { + return youidoReplaceIndex(old, regex, idx); + }); + } + } + }); +} + +function youidoGetFieldPath(itemsDiv) { + var indices = $(itemsDiv).children("input[id$='.indices']")[0]; + if (!!indices) { + return indices.id.replace(/\.indices$/, ''); + } else return null; +} + +function youidoUpdateIndices(fieldPath, newLength) { + var newVal = ''; + for (var i=0; i < newLength; i++) { + newVal = newVal + i; + if (i < newLength - 1) { + newVal = newVal + ','; + } + } + var indices = document.getElementById(fieldPath + '.indices'); + indices.setAttribute('value', newVal); +} + +function youidoUpdate($items, fieldName, fieldPath) { + var dummySel = "[id='" + fieldPath + '.' + youidoDummyItem + "']"; + var $itemsNoDummy = $items.not(dummySel); + youidoUpdatePaths($itemsNoDummy, fieldName, fieldPath); + youidoUpdateIndices(fieldPath, $itemsNoDummy.length); +} + +function youidoAddItem(itemsDiv, fieldName) { + var fieldPath = youidoGetFieldPath(itemsDiv); + var dummyId = fieldPath + '.' + youidoDummyItem; + var dummy = document.getElementById(dummyId); + var newItem = dummy.cloneNode(true); + newItem.setAttribute('style', 'display: inherit'); + newItem.setAttribute('id', newItem.getAttribute('id').replace(dummyId, '')); + var $items = $(itemsDiv).children('div.' + youidoItemClass); + $items[$items.length - 1].after(newItem); + $items.push(newItem); + youidoUpdate($items, fieldName, fieldPath); +} + +function youidoRemoveItem(item, fieldName) { + var itemsDiv = item.parentNode; + var fieldPath = youidoGetFieldPath(itemsDiv); + itemsDiv.removeChild(item); + youidoUpdate($(itemsDiv).children('div.' + youidoItemClass), fieldName, fieldPath); +} + +// +// ** Sum forms +// + +/* + Each constructor's subform is stored in an invisible div, the + digestive-functor paths of which are all prefixed with 'dummy.'. When + the user selects a constructor option, we clone the appropriate div, + make it visible, and strip the dummy prefix from all attributes. + */ +function youidoStripDummyPrefix(prefix, elem) { + var attrs = ['for', 'id', 'name']; + for (var i=0; i < attrs.length; i++) { + $(elem).find("*[" + attrs[i] + "^='" + prefix + "']") + .attr(attrs[i], function(i,old) { return old.slice(prefix.length); }); + } +} + +function youidoSelectConstructor(elem, viewName, fieldName) { + var ctor = elem.childNodes[elem.selectedIndex].text; + var fieldRef = elem.parentNode.firstChild.getAttribute('for'); + var fieldRefNoName = fieldRef.replace(viewName + '.', ''); + var dummyId = youidoSumDummyIdPrefix + fieldRef + '.' + ctor; + var dummy = document.getElementById(dummyId); + var real = dummy.cloneNode(true); + var container = document.getElementById(youidoSumRealContainerIdPrefix + fieldRef); + while (container.firstChild) { + container.removeChild(container.firstChild); + } + real.removeAttribute('id'); + real.style.display = 'inherit'; + youidoStripDummyPrefix(youidoSumDummyPathPrefix, real); + + var choiceId = youidoSumChoiceIdPrefix + fieldRefNoName; + var choice = document.getElementById(choiceId); + choice.value = ctor; + + if (real.className.indexOf("youido-u1-container") != -1) { + container.style.display = 'none'; + } else { + container.style.display = 'inherit'; + } + container.insertBefore(real, null); + return false; +}