diff --git a/examples/Example/App.hs b/examples/Example/App.hs index ca5dfa4..e5a82da 100644 --- a/examples/Example/App.hs +++ b/examples/Example/App.hs @@ -58,7 +58,8 @@ import Example.Page.State.Effects qualified as Effects import Example.Page.State.Query qualified as Query import Example.Page.State.Sessions qualified as Sessions import Example.Page.Test qualified as Test -import Example.Page.Todo qualified as Todo +import Example.Page.Todos.Todo qualified as Todo +import Example.Page.Todos.TodoCSS qualified as TodoCSS import Example.Style qualified as Style import Example.View.Layout as Layout (example, exampleLayout, sourceLink) import Foreign.Store (Store (..), lookupStore, readStore, storeAction, withStore) @@ -142,6 +143,7 @@ app config users count = do router Interactivity = runPage Interactivity.page router (Examples BigExamples) = redirect $ routeUri (Examples Todos) router (Examples Todos) = runPage Todo.page + router (Examples TodosCSS) = runPage TodoCSS.page router Javascript = runPage Javascript.page router OAuth2 = runPage OAuth2.page router OAuth2Authenticate = OAuth2.handleRedirect diff --git a/examples/Example/AppRoute.hs b/examples/Example/AppRoute.hs index fd58b0a..4af101a 100644 --- a/examples/Example/AppRoute.hs +++ b/examples/Example/AppRoute.hs @@ -68,6 +68,7 @@ instance Route ContactRoute where data ExamplesRoute = BigExamples | Todos + | TodosCSS -- A version using the CSS from TodoMVC project deriving (Eq, Generic, Show) instance Route ExamplesRoute where baseRoute = Just BigExamples diff --git a/examples/Example/Effects/Todos.hs b/examples/Example/Effects/Todos.hs index 8352a4f..4c29f41 100644 --- a/examples/Example/Effects/Todos.hs +++ b/examples/Example/Effects/Todos.hs @@ -17,6 +17,7 @@ newtype AllTodos = AllTodos (Map TodoId Todo) instance Session AllTodos where sessionKey = "todos" + cookiePath = Just "/examples" -- share data between both pages instance Default AllTodos where def = AllTodos mempty diff --git a/examples/Example/Page/Todos/Shared.hs b/examples/Example/Page/Todos/Shared.hs new file mode 100644 index 0000000..bb90d10 --- /dev/null +++ b/examples/Example/Page/Todos/Shared.hs @@ -0,0 +1,88 @@ +{-# LANGUAGE LambdaCase #-} + +module Example.Page.Todos.Shared + ( pluralize + , FilterTodo (..) + , TodoForm (..) + , AllTodos (..) + , TodosAction (..) + , updateTodos + , TodoAction (..) + , updateTodo + ) where + +import Data.Text (Text) +import Effectful +import Example.Effects.Todos (Todo (..), Todos) +import Example.Effects.Todos qualified as Todos +import Web.Hyperbole as Hyperbole hiding (Action, update) + +data FilterTodo + = FilterAll + | Active + | Completed + deriving (Eq, Generic, ToJSON, FromJSON) + +data TodoForm f = TodoForm + { task :: Field f Text + } + deriving (Generic, FromFormF, GenFields FieldName) + +pluralize :: Int -> Text -> Text -> Text +pluralize n singular plural = + if n == 1 + then + singular + else + plural + +data AllTodos = AllTodos + deriving (Generic, ViewId) + +data TodosAction + = ClearCompleted + | Filter FilterTodo + | SubmitTodo + | ToggleAll FilterTodo + | SetCompleted FilterTodo Todo Bool + | Destroy FilterTodo Todo + deriving (Generic, ViewAction) + +updateTodos :: (Todos :> es, Hyperbole :> es) => TodosAction -> Eff es [Todo] +updateTodos = \case + SubmitTodo -> do + TodoForm task <- formData @(TodoForm Identity) + _ <- Todos.create task + Todos.loadAll + ToggleAll filt -> do + todos <- filteredTodos filt + Todos.toggleAll todos + ClearCompleted -> do + Todos.clearCompleted + Filter filt -> do + filteredTodos filt + SetCompleted filt todo completed -> do + _ <- Todos.setCompleted completed todo + filteredTodos filt + Destroy filt todo -> do + Todos.clear todo + filteredTodos filt + where + filteredTodos filt = + filter (isFilter filt) <$> Todos.loadAll + + isFilter filt todo = + case filt of + FilterAll -> True + Active -> not todo.completed + Completed -> todo.completed + +data TodoAction + = SubmitEdit Todo + deriving (Generic, ViewAction, ToJSON, FromJSON) + +updateTodo :: (Todos :> es, Hyperbole :> es) => TodoAction -> Eff es Todo +updateTodo = \case + SubmitEdit todo -> do + TodoForm task <- formData @(TodoForm Identity) + Todos.setTask task todo diff --git a/examples/Example/Page/Todo.hs b/examples/Example/Page/Todos/Todo.hs similarity index 58% rename from examples/Example/Page/Todo.hs rename to examples/Example/Page/Todos/Todo.hs index efb4f89..4438b24 100644 --- a/examples/Example/Page/Todo.hs +++ b/examples/Example/Page/Todos/Todo.hs @@ -1,16 +1,17 @@ {-# LANGUAGE DerivingVia #-} -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE UndecidableInstances #-} -module Example.Page.Todo where +module Example.Page.Todos.Todo where import Control.Monad (forM_) -import Data.Text (Text, pack) +import Data.Text (pack) import Effectful import Example.AppRoute qualified as Route import Example.Colors import Example.Effects.Todos (Todo (..), TodoId, Todos, runTodosSession) import Example.Effects.Todos qualified as Todos +import Example.Page.Todos.Shared (FilterTodo (Active, Completed, FilterAll), TodoForm (..)) +import Example.Page.Todos.Shared qualified as Shared import Example.Style qualified as Style import Example.View.Icon qualified as Icon import Example.View.Inputs (toggleCheckbox) @@ -25,6 +26,7 @@ page = do example "Todos" "Example/Page/Todo.hs" $ do col ~ embed $ hyper AllTodos $ todosView FilterAll todos +-- Keep this, it's used for documentation (+ usable via the REPL, see main below) simplePage :: (Todos :> es) => Eff es (Page '[AllTodos, TodoView]) simplePage = do todos <- Todos.loadAll @@ -39,49 +41,23 @@ data AllTodos = AllTodos instance (Todos :> es) => HyperView AllTodos es where type Require AllTodos = '[TodoView] - data Action AllTodos - = ClearCompleted - | Filter FilterTodo - | SubmitTodo - | ToggleAll FilterTodo - | SetCompleted FilterTodo Todo Bool - deriving (Generic, ViewAction) - - update = \case - SubmitTodo -> do - TodoForm task <- formData @(TodoForm Identity) - _ <- Todos.create task - todos <- Todos.loadAll - pure $ todosView FilterAll todos - ToggleAll filt -> do - todos <- filteredTodos filt - updated <- Todos.toggleAll todos - pure $ todosView filt updated - ClearCompleted -> do - todos <- Todos.clearCompleted - pure $ todosView FilterAll todos - Filter filt -> do - todos <- filteredTodos filt - pure $ todosView filt todos - SetCompleted filt todo completed -> do - _ <- Todos.setCompleted completed todo - todos <- filteredTodos filt - pure $ todosView filt todos - where - filteredTodos filt = - filter (isFilter filt) <$> Todos.loadAll - - isFilter filt todo = - case filt of - FilterAll -> True - Active -> not todo.completed - Completed -> todo.completed - -data FilterTodo - = FilterAll - | Active - | Completed - deriving (Eq, Generic, ToJSON, FromJSON) + newtype Action AllTodos = MkTodosAction Shared.TodosAction + deriving newtype (Generic, ViewAction) + + update (MkTodosAction action) = do + case action of + Shared.ClearCompleted -> + todosView FilterAll <$> Shared.updateTodos Shared.ClearCompleted + Shared.SubmitTodo -> + todosView FilterAll <$> Shared.updateTodos Shared.SubmitTodo + Shared.Filter f -> + todosView f <$> Shared.updateTodos (Shared.Filter f) + Shared.ToggleAll f -> + todosView f <$> Shared.updateTodos (Shared.ToggleAll f) + Shared.SetCompleted f t b -> + todosView f <$> Shared.updateTodos (Shared.SetCompleted f t b) + Shared.Destroy f t -> + todosView f <$> Shared.updateTodos (Shared.Destroy f t) todosView :: FilterTodo -> [Todo] -> View AllTodos () todosView filt todos = do @@ -96,16 +72,11 @@ todoForm filt = do let f :: TodoForm FieldName = fieldNames row ~ border 1 $ do el ~ pad 8 $ do - button (ToggleAll filt) Icon.chevronDown ~ width 32 . hover (color Primary) - form SubmitTodo ~ grow $ do + button (MkTodosAction $ Shared.ToggleAll filt) Icon.chevronDown ~ width 32 . hover (color Primary) + form (MkTodosAction Shared.SubmitTodo) ~ grow $ do field f.task $ do input TextInput ~ pad 12 @ placeholder "What needs to be done?" . value "" -data TodoForm f = TodoForm - { task :: Field f Text - } - deriving (Generic, FromFormF, GenFields FieldName) - statusBar :: FilterTodo -> [Todo] -> View AllTodos () statusBar filt todos = do row ~ pad 10 . color SecondaryLight $ do @@ -119,10 +90,10 @@ statusBar filt todos = do filterButton Active "Active" filterButton Completed "Completed" space - button ClearCompleted ~ hover (color Primary) $ "Clear completed" + button (MkTodosAction Shared.ClearCompleted) ~ hover (color Primary) $ "Clear completed" where filterButton f = - button (Filter f) ~ selectedFilter f . pad (XY 4 0) . rounded 2 + button (MkTodosAction $ Shared.Filter f) ~ selectedFilter f . pad (XY 4 0) . rounded 2 selectedFilter f = if f == filt then border 1 else id @@ -136,34 +107,45 @@ instance (Todos :> es) => HyperView TodoView es where data Action TodoView = Edit FilterTodo Todo - | SubmitEdit FilterTodo Todo + | MkTodoAction FilterTodo Shared.TodoAction deriving (Generic, ViewAction) update (Edit filt todo) = do pure $ todoEditView filt todo - update (SubmitEdit filt todo) = do - TodoForm task <- formData @(TodoForm Identity) - updated <- Todos.setTask task todo - pure $ todoView filt updated + update (MkTodoAction filt action) = + todoView filt <$> Shared.updateTodo action todoView :: FilterTodo -> Todo -> View TodoView () todoView filt todo = do - row ~ border (TRBL 0 0 1 0) . pad 10 $ do + row ~ border (TRBL 0 0 1 0) . pad 10 . showDestroyOnHover $ do + target AllTodos $ do + toggleCheckbox (MkTodosAction . Shared.SetCompleted filt todo) todo.completed + el (text todo.task) @ onDblClick (Edit filt todo) ~ completed . pad (XY 18 4) . grow target AllTodos $ do - toggleCheckbox (SetCompleted filt todo) todo.completed - el (text todo.task) @ onDblClick (Edit filt todo) ~ completed . pad (XY 18 4) + button (MkTodosAction $ Shared.Destroy filt todo) "✕" ~ cls "destroy-btn" . opacity 0 . hover (color Primary) . pad 4 where completed = if todo.completed then Style.strikethrough else id + showDestroyOnHover = + css + "todo-row" + ".todo-row:hover > .destroy-btn" + (declarations (opacity 100)) todoEditView :: FilterTodo -> Todo -> View TodoView () todoEditView filt todo = do let f = fieldNames @TodoForm row ~ border (TRBL 0 0 1 0) . pad 10 $ do - form (SubmitEdit filt todo) ~ pad (TRBL 0 0 0 46) $ do + form (MkTodoAction filt $ Shared.SubmitEdit todo) ~ pad (TRBL 0 0 0 46) $ do field f.task $ do input TextInput @ value todo.task . autofocus ~ pad 4 +{- +You may try this in the REPL for simple tests: + +bash> cabal repl exe:examples lib:hyperbole +ghci> Todo.main +-} main :: IO () main = do run 3000 $ do - liveApp (basicDocument "Example") (runTodosSession $ runPage page) + liveApp (basicDocument "Todo (simple)") (runTodosSession $ runPage simplePage) diff --git a/examples/Example/Page/Todos/TodoCSS.hs b/examples/Example/Page/Todos/TodoCSS.hs new file mode 100644 index 0000000..719880d --- /dev/null +++ b/examples/Example/Page/Todos/TodoCSS.hs @@ -0,0 +1,246 @@ +{-# LANGUAGE UndecidableInstances #-} + +module Example.Page.Todos.TodoCSS (page) where + +import Example.Effects.Todos (Todo, TodoId, Todos) + +import Control.Monad (forM_) +import Data.Bool (bool) +import Data.Text qualified as T +import Example.Effects.Todos qualified as Todos +import Example.Page.Todos.Shared (FilterTodo (..), TodoForm (..), pluralize) +import Example.Page.Todos.Shared qualified as Shared +import Web.Hyperbole as Hyperbole +import Web.Hyperbole.HyperView.Forms (Input (Input)) + +{- + +To make the CSS version work and overcome the default CSS reset, we tweaked the output slightly via a few style tags here and there: + +only need to add one manual rule to the footer, to override the CSS reset + +- main title + - override its absolute positioning +- read-only item: + - restore border-bottom (a visual separator) +- first footer + - add bottom padding +- second footer + - restore default user-agent p tags margin + +-} + +page :: (Todos :> es) => Eff es (Page '[AllTodos, TodoView]) +page = do + todos <- Todos.loadAll + pure $ do + div' $ do + -- Alternative stylesheet at: https://todomvc.com/examples/javascript-es6/dist/app.css + -- Reference implementation at: https://todomvc.com/examples/javascript-es6/dist/ + stylesheet "https://cdn.jsdelivr.net/npm/todomvc-app-css@2.4.3/index.min.css" + section @ class_ "todoapp" $ do + hyper AllTodos $ todosView FilterAll todos + footer @ class_ "info" $ do + let p_ = p @ style' "margin: 1em auto" + p_ "Double-click to edit a todo" + p_ $ do + span' "Go back to the " + a @ att "href" "/examples" . style' "color: #b83f45" $ "examples" + +--- TodosView ---------------------------------------------------------------------------- + +data AllTodos = AllTodos + deriving (Generic, ViewId) + +instance (Todos :> es) => HyperView AllTodos es where + type Require AllTodos = '[TodoView] + + newtype Action AllTodos = MkTodosAction Shared.TodosAction + deriving newtype (Generic, ViewAction) + + update (MkTodosAction action) = do + case action of + Shared.ClearCompleted -> + todosView FilterAll <$> Shared.updateTodos Shared.ClearCompleted + Shared.SubmitTodo -> + todosView FilterAll <$> Shared.updateTodos Shared.SubmitTodo + Shared.Filter f -> + todosView f <$> Shared.updateTodos (Shared.Filter f) + Shared.ToggleAll f -> + todosView f <$> Shared.updateTodos (Shared.ToggleAll f) + Shared.SetCompleted f t b -> + todosView f <$> Shared.updateTodos (Shared.SetCompleted f t b) + Shared.Destroy f t -> + todosView f <$> Shared.updateTodos (Shared.Destroy f t) + +todosView :: FilterTodo -> [Todo] -> View AllTodos () +todosView filt todos = do + header @ class_ "header" $ do + h1 @ style' "top:-80px" $ text "todos" + todoForm + main' @ class_ "main" $ do + div' @ class_ "toggle-all-container" $ do + input' + @ class_ "toggle-all" + . att "id" "toggle-all" + . att "type" "checkbox" + + label' + @ class_ "toggle-all-label" + . att "for" "toggle-all" + . onClick (MkTodosAction $ Shared.ToggleAll filt) + $ text "Mark all as complete" + + ul' @ class_ "todo-list" $ do + forM_ todos $ \todo -> do + hyper (TodoView todo.id) $ todoView filt todo + + statusBar filt todos + +todoForm :: View AllTodos () +todoForm = do + let f :: TodoForm FieldName = fieldNames + form (MkTodosAction Shared.SubmitTodo) $ do + field f.task $ do + Input (FieldName nm) <- context + input' -- we use a custom input field, because the Hyperbole one overrides autocomplete + @ class_ "new-todo" + {- + -- . autofocus + FIXME: turning off autofocus, that "steals" the focus on item click. + FIXME: to solve this, we could either store the "initially focused" state and track that boolean, or use buttons + FIXME: but since this example is meant to match as close as possible to the original CSS version + FIXME: and not diverge too much from the other todo example, I'm leaving as-is. + -} + . att "autocomplete" "off" + . placeholder "What needs to be done?" + . value "" + . name nm -- because we use a custom field, we must provide this param for the library + +statusBar :: FilterTodo -> [Todo] -> View AllTodos () +statusBar filt todos = do + footer @ class_ "footer" . style' "padding-bottom: 30px" $ do + let numLeft = length $ filter (\t -> not t.completed) todos + span' @ class_ "todo-count" $ do + text $ + mconcat + [ T.pack $ show numLeft + , " " + , pluralize numLeft "item" "items" + , " " + , "left!" + ] + space + ul' @ class_ "filters" $ do + filterLi FilterAll "All" + filterLi Active "Active" + filterLi Completed "Completed" + space + button (MkTodosAction Shared.ClearCompleted) @ class_ "clear-completed" $ "Clear completed" + where + filterLi f str = + li' @ class_ "filter" . selectedFilter f $ do + a + @ onClick (MkTodosAction $ Shared.Filter f) + . att "href" "" -- harmless empty href is for the CSS + $ text str + selectedFilter f = + if f == filt then class_ "selected" else id + +--- TodoView ---------------------------------------------------------------------------- + +data TodoView = TodoView TodoId + deriving (Generic, ViewId) + +instance (Todos :> es) => HyperView TodoView es where + type Require TodoView = '[AllTodos] + + data Action TodoView + = Edit FilterTodo Todo + | MkTodoAction FilterTodo Shared.TodoAction + deriving (Generic, ViewAction) + + update (Edit filt todo) = do + pure $ todoEditView filt todo + update (MkTodoAction filt action) = do + todoView filt <$> Shared.updateTodo action + +todoView :: FilterTodo -> Todo -> View TodoView () +todoView filt todo = do + li' + @ bool id (class_ "completed") todo.completed + . style' "border-bottom: 1px solid #ededed" + $ do + div' @ class_ "view" $ do + target AllTodos $ do + input' + @ class_ "toggle" + . att "type" "checkbox" + . onClick (MkTodosAction $ Shared.SetCompleted filt todo $ not todo.completed) + . checked todo.completed + + label' @ class_ "label" . onDblClick (Edit filt todo) $ do + text todo.task + + target AllTodos $ do + button (MkTodosAction $ Shared.Destroy filt todo) @ class_ "destroy" $ "" + +todoEditView :: FilterTodo -> Todo -> View TodoView () +todoEditView filt todo = do + let f = fieldNames @TodoForm + li' @ class_ "editing" $ do + form (MkTodoAction filt $ Shared.SubmitEdit todo) $ do + let taskField = Input f.task + -- Instead of using the `field` FormField wrapper, we add the context manually + -- and use a custom input field for maximum control over the generated HTML + let Input (FieldName fn) = taskField + addContext taskField $ do + input' + @ class_ "edit" + . value todo.task + . autofocus + . name fn -- because we use a custom input, we must provide this param for the library + +--- Helpers ---------------------------------------------------------------------------- + +div' :: View c () -> View c () +div' = tag "div" + +span' :: View c () -> View c () +span' = tag "span" + +section :: View c () -> View c () +section = tag "section" + +header :: View c () -> View c () +header = tag "header" + +main' :: View c () -> View c () +main' = tag "main" + +h1 :: View c () -> View c () +h1 = tag "h1" + +p :: View c () -> View c () +p = tag "p" + +label' :: View c () -> View c () +label' = tag "label" + +input' :: View c () +input' = tag "input" none + +a :: View c () -> View c () +a = tag "a" + +ul' :: View c () -> View c () +ul' = tag "ul" + +li' :: View c () -> View c () +li' = tag "li" + +footer :: View c () -> View c () +footer = tag "footer" + +style' :: (Attributable h) => AttValue -> Attributes h -> Attributes h +style' = att "style" diff --git a/examples/Example/View/Layout.hs b/examples/Example/View/Layout.hs index 44a50e6..fd44bd5 100644 --- a/examples/Example/View/Layout.hs +++ b/examples/Example/View/Layout.hs @@ -116,6 +116,7 @@ exampleMenu current = do where completeExamples = do exampleLink (Examples Todos) ~ sub + exampleLink (Examples TodosCSS) ~ sub exampleLink (Contacts ContactsAll) ~ sub -- link "/query?key=value" lnk "Query Params" @@ -144,6 +145,7 @@ routeTitle (Data Filter) = "Filters" routeTitle (Data Autocomplete) = "Autocomplete" routeTitle Errors = "Error Handling" routeTitle (Examples Todos) = "TodoMVC" +routeTitle (Examples TodosCSS) = "TodoMVC (CSS version)" routeTitle (Examples BigExamples) = "Large Examples" routeTitle OAuth2 = "OAuth2" routeTitle r = cs $ toWords $ fromHumps $ show r diff --git a/examples/examples.cabal b/examples/examples.cabal index cb9eb4a..9f0d3ea 100644 --- a/examples/examples.cabal +++ b/examples/examples.cabal @@ -75,7 +75,9 @@ executable examples Example.Page.State.Query Example.Page.State.Sessions Example.Page.Test - Example.Page.Todo + Example.Page.Todos.Shared + Example.Page.Todos.Todo + Example.Page.Todos.TodoCSS Example.Style Example.View.Icon Example.View.Inputs diff --git a/src/Web/Hyperbole.hs b/src/Web/Hyperbole.hs index ba4bf10..705dc58 100644 --- a/src/Web/Hyperbole.hs +++ b/src/Web/Hyperbole.hs @@ -626,7 +626,7 @@ From [Example.Page.Todo](https://docs.hyperbole.live/todos): @ {\-# LANGUAGE UndecidableInstances #-\} -#EMBED Example/Page/Todo.hs simplePage +#EMBED Example/Page/Todos/Todo.hs simplePage @ We run a custom effect in our Application just like any other. Here we implementing our custom effect using 'Hyperbole' 'sessions', but you could write a different runner that connects to a database instead.