From 239e5e3a43bfb6b2e73381cee9192b995a879628 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Tue, 25 Mar 2025 19:15:39 +0100 Subject: [PATCH 01/11] Add alternative todo page with CSS (rebased) --- examples/Example/App.hs | 4 +- examples/Example/AppRoute.hs | 1 + examples/Example/Page/Todos/Shared.hs | 30 +++ examples/Example/Page/{ => Todos}/Todo.hs | 80 +++---- examples/Example/Page/Todos/TodoCSS.hs | 274 ++++++++++++++++++++++ examples/Example/View/Layout.hs | 2 + examples/examples.cabal | 3 +- 7 files changed, 343 insertions(+), 51 deletions(-) create mode 100644 examples/Example/Page/Todos/Shared.hs rename examples/Example/Page/{ => Todos}/Todo.hs (66%) create mode 100644 examples/Example/Page/Todos/TodoCSS.hs diff --git a/examples/Example/App.hs b/examples/Example/App.hs index ca5dfa45..e5a82da2 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 fd58b0a3..4af101a4 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/Page/Todos/Shared.hs b/examples/Example/Page/Todos/Shared.hs new file mode 100644 index 00000000..13ecda22 --- /dev/null +++ b/examples/Example/Page/Todos/Shared.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE UndecidableInstances #-} + +module Example.Page.Todos.Shared + ( pluralize + , FilterTodo (..) + , TodoForm (..) + ) where + +import Data.Text (Text) +import Web.Hyperbole + +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 diff --git a/examples/Example/Page/Todo.hs b/examples/Example/Page/Todos/Todo.hs similarity index 66% rename from examples/Example/Page/Todo.hs rename to examples/Example/Page/Todos/Todo.hs index efb4f89e..cf4ce9dd 100644 --- a/examples/Example/Page/Todo.hs +++ b/examples/Example/Page/Todos/Todo.hs @@ -2,36 +2,34 @@ {-# 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 (Todo (..), TodoId, Todos) import Example.Effects.Todos qualified as Todos +import Example.Page.Todos.Shared (FilterTodo (..), TodoForm (..)) import Example.Style qualified as Style import Example.View.Icon qualified as Icon import Example.View.Inputs (toggleCheckbox) -import Example.View.Layout -import Web.Atomic.CSS +import Example.View.Layout (exampleLayout) import Web.Hyperbole as Hyperbole page :: (Todos :> es) => Eff es (Page '[AllTodos, TodoView]) page = do todos <- Todos.loadAll - pure $ exampleLayout (Route.Examples Route.Todos) $ do - example "Todos" "Example/Page/Todo.hs" $ do - col ~ embed $ hyper AllTodos $ todosView FilterAll todos + pure $ exampleLayout Route.Todos $ do + col (gap 10 . grow) $ do + row id $ do + space + el (Style.h1 . pad 10) "Todos" + space + hyper AllTodos $ todosView FilterAll todos -simplePage :: (Todos :> es) => Eff es (Page '[AllTodos, TodoView]) -simplePage = do - todos <- Todos.loadAll - pure $ do - hyper AllTodos $ todosView FilterAll todos - ---- AllTodos ---------------------------------------------------------------------------- +--- TodosView ---------------------------------------------------------------------------- data AllTodos = AllTodos deriving (Generic, ViewId) @@ -77,16 +75,10 @@ instance (Todos :> es) => HyperView AllTodos es where Active -> not todo.completed Completed -> todo.completed -data FilterTodo - = FilterAll - | Active - | Completed - deriving (Eq, Generic, ToJSON, FromJSON) - todosView :: FilterTodo -> [Todo] -> View AllTodos () todosView filt todos = do todoForm filt - col $ do + col id $ do forM_ todos $ \todo -> do hyper (TodoView todo.id) $ todoView filt todo statusBar filt todos @@ -94,35 +86,30 @@ todosView filt todos = do todoForm :: FilterTodo -> View AllTodos () 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 - 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) + row (border 1) $ do + el (pad 8) $ do + button (ToggleAll filt) (width 32 . hover (color Primary)) Icon.chevronDown + form SubmitTodo grow $ do + field f.task id $ do + input TextInput (pad 12 . placeholder "What needs to be done?" . value "") statusBar :: FilterTodo -> [Todo] -> View AllTodos () statusBar filt todos = do - row ~ pad 10 . color SecondaryLight $ do + row (pad 10 . color SecondaryLight) $ do let numLeft = length $ filter (\t -> not t.completed) todos - el $ do + el_ $ do text $ pack (show numLeft) text " items left!" space - row ~ gap 10 $ do + row (gap 10) $ do filterButton FilterAll "All" filterButton Active "Active" filterButton Completed "Completed" space - button ClearCompleted ~ hover (color Primary) $ "Clear completed" + button ClearCompleted (hover (color Primary)) "Clear completed" where filterButton f = - button (Filter f) ~ selectedFilter f . pad (XY 4 0) . rounded 2 + button (Filter f) (selectedFilter f . pad (XY 4 0) . rounded 2) selectedFilter f = if f == filt then border 1 else id @@ -148,22 +135,17 @@ instance (Todos :> es) => HyperView TodoView es where 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) $ do target AllTodos $ do toggleCheckbox (SetCompleted filt todo) todo.completed - el (text todo.task) @ onDblClick (Edit filt todo) ~ completed . pad (XY 18 4) + el (completed . pad (XY 18 4) . onDblClick (Edit filt todo)) $ text todo.task where completed = if todo.completed then Style.strikethrough else id 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 - field f.task $ do - input TextInput @ value todo.task . autofocus ~ pad 4 - -main :: IO () -main = do - run 3000 $ do - liveApp (basicDocument "Example") (runTodosSession $ runPage page) + row (border (TRBL 0 0 1 0) . pad 10) $ do + form (SubmitEdit filt todo) (pad (TRBL 0 0 0 46)) $ do + field f.task id $ do + input TextInput (pad 4 . value todo.task . autofocus) diff --git a/examples/Example/Page/Todos/TodoCSS.hs b/examples/Example/Page/Todos/TodoCSS.hs new file mode 100644 index 00000000..4b98547c --- /dev/null +++ b/examples/Example/Page/Todos/TodoCSS.hs @@ -0,0 +1,274 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE UndecidableInstances #-} + +module Example.Page.Todos.TodoCSS (page) where + +import Control.Monad (forM_) +import Data.Bool (bool) +import Data.Text qualified as T + +import Example.Effects.Todos (Todo, TodoId, Todos) +import Example.Effects.Todos qualified as Todos +import Example.Page.Todos.Shared (FilterTodo (..), TodoForm (..), pluralize) +import Web.Hyperbole as Hyperbole +import Web.Hyperbole.View.Forms (Input (Input)) +import Web.View.Style (extClass) +import Web.View.Types (AttValue) + +{- + +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' id $ do + stylesheet "https://cdn.jsdelivr.net/npm/todomvc-app-css@2.4.3/index.min.css" + section (extClass "todoapp") $ do + hyper AllTodos $ todosView FilterAll todos + footer (extClass "info") $ do + let p_ = p (style' "margin: 1em auto") + p_ "Double-click to edit a todo" + p_ $ do + span' id "Go back to the " + a + ( att "href" "/" + . style' "color: #b83f45" + ) + "index" + +--- TodosView ---------------------------------------------------------------------------- + +data AllTodos = AllTodos + deriving (Generic, ViewId) + +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 + +todosView :: FilterTodo -> [Todo] -> View AllTodos () +todosView filt todos = do + header (extClass "header") $ do + h1 (style' "top:-80px") $ text "todos" + todoForm + main' (extClass "main") $ do + div' (extClass "toggle-all-container") $ do + input' + ( extClass "toggle-all" + . att "id" "toggle-all" + . att "type" "checkbox" + ) + label' + ( extClass "toggle-all-label" + . att "for" "toggle-all" + . onClick (ToggleAll filt) + ) + (text "Mark all as complete") + ul' (extClass "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 SubmitTodo grow $ do + field f.task id $ do + Input (FieldName nm) <- context + input' -- we use a custom input field, because the Hyperbole one overrides autocomplete + ( extClass "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 (extClass "footer" . style' "padding-bottom: 30px") $ do + let numLeft = length $ filter (\t -> not t.completed) todos + span' (extClass "todo-count") $ do + text $ + mconcat + [ T.pack $ show numLeft + , " " + , pluralize numLeft "item" "items" + , " " + , "left!" + ] + space + ul' (extClass "filters") $ do + filterLi FilterAll "All" + filterLi Active "Active" + filterLi Completed "Completed" + space + button ClearCompleted (extClass "clear-completed") "Clear completed" + where + filterLi f str = + li' (extClass "filter" . selectedFilter f) $ do + a + ( onClick (Filter f) + . att "href" "" -- harmless empty href is for the CSS + ) + (text str) + selectedFilter f = + if f == filt then extClass "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 + | SubmitEdit FilterTodo Todo + 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 + +todoView :: FilterTodo -> Todo -> View TodoView () +todoView filt todo = do + li' + ( onDblClick (Edit filt todo) + . bool id (extClass "completed") todo.completed + . style' "border-bottom: 1px solid #ededed" + ) + $ do + div' (extClass "view") $ do + target AllTodos $ do + input' + ( extClass "toggle" + . att "type" "checkbox" + . onClick (SetCompleted filt todo $ not todo.completed) + . checked todo.completed + ) + label' (extClass "label") $ do + text todo.task + +todoEditView :: FilterTodo -> Todo -> View TodoView () +todoEditView filt todo = do + let f = fieldNames @TodoForm + li' (extClass "editing") $ do + form (SubmitEdit filt todo) id $ 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' + ( extClass "edit" + . value todo.task + . autofocus + . extClass "hello" + . name fn -- because we use a custom input, we must provide this param for the library + ) + +--- Helpers ---------------------------------------------------------------------------- + +div' :: Mod c -> View c () -> View c () +div' = tag "div" + +span' :: Mod c -> View c () -> View c () +span' = tag "span" + +section :: Mod c -> View c () -> View c () +section = tag "section" + +header :: Mod c -> View c () -> View c () +header = tag "header" + +main' :: Mod c -> View c () -> View c () +main' = tag "main" + +h1 :: Mod c -> View c () -> View c () +h1 = tag "h1" + +p :: Mod c -> View c () -> View c () +p = tag "p" + +label' :: Mod c -> View c () -> View c () +label' = tag "label" + +input' :: Mod c -> View c () +input' m = tag "input" m "" + +a :: Mod c -> View c () -> View c () +a = tag "a" + +ul' :: Mod c -> View c () -> View c () +ul' = tag "ul" + +li' :: Mod c -> View c () -> View c () +li' = tag "li" + +footer :: Mod c -> View c () -> View c () +footer = tag "footer" + +style' :: AttValue -> Mod c +style' = att "style" \ No newline at end of file diff --git a/examples/Example/View/Layout.hs b/examples/Example/View/Layout.hs index 44a50e6d..fd44bd56 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 cb9eb4aa..2124453a 100644 --- a/examples/examples.cabal +++ b/examples/examples.cabal @@ -75,7 +75,8 @@ executable examples Example.Page.State.Query Example.Page.State.Sessions Example.Page.Test - Example.Page.Todo + Example.Page.Todos.Todo + Example.Page.Todos.TodoCSS Example.Style Example.View.Icon Example.View.Inputs From bebc1b4cfc9a1da29c6b6dad2032d5f24dfcecb4 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sat, 9 Aug 2025 18:57:59 +0200 Subject: [PATCH 02/11] Make todomvc CSS example compatible with latest hyperbole version --- examples/Example/Page/Todos/Shared.hs | 3 - examples/Example/Page/Todos/Todo.hs | 64 ++++----- examples/Example/Page/Todos/TodoCSS.hs | 186 ++++++++++++------------- examples/examples.cabal | 1 + hyperbole.cabal | 1 + 5 files changed, 126 insertions(+), 129 deletions(-) diff --git a/examples/Example/Page/Todos/Shared.hs b/examples/Example/Page/Todos/Shared.hs index 13ecda22..3f5ff3be 100644 --- a/examples/Example/Page/Todos/Shared.hs +++ b/examples/Example/Page/Todos/Shared.hs @@ -1,6 +1,3 @@ -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE UndecidableInstances #-} - module Example.Page.Todos.Shared ( pluralize , FilterTodo (..) diff --git a/examples/Example/Page/Todos/Todo.hs b/examples/Example/Page/Todos/Todo.hs index cf4ce9dd..cc844b1b 100644 --- a/examples/Example/Page/Todos/Todo.hs +++ b/examples/Example/Page/Todos/Todo.hs @@ -4,30 +4,32 @@ module Example.Page.Todos.Todo where +import Example.Effects.Todos (Todo (..), TodoId, Todos, runTodosSession) +import Example.Effects.Todos qualified as Todos + import Control.Monad (forM_) -import Data.Text (pack) -import Effectful +import Data.Text (Text, pack) import Example.AppRoute qualified as Route import Example.Colors -import Example.Effects.Todos (Todo (..), TodoId, Todos) -import Example.Effects.Todos qualified as Todos -import Example.Page.Todos.Shared (FilterTodo (..), TodoForm (..)) + +import Example.Page.Todos.Shared + import Example.Style qualified as Style + import Example.View.Icon qualified as Icon + import Example.View.Inputs (toggleCheckbox) -import Example.View.Layout (exampleLayout) + +import Example.View.Layout +import Web.Atomic.CSS import Web.Hyperbole as Hyperbole page :: (Todos :> es) => Eff es (Page '[AllTodos, TodoView]) page = do todos <- Todos.loadAll - pure $ exampleLayout Route.Todos $ do - col (gap 10 . grow) $ do - row id $ do - space - el (Style.h1 . pad 10) "Todos" - space - hyper AllTodos $ todosView FilterAll todos + pure $ exampleLayout (Route.Examples Route.Todos) $ do + example "Todos" "Example/Page/Todo.hs" $ do + col ~ embed $ hyper AllTodos $ todosView FilterAll todos --- TodosView ---------------------------------------------------------------------------- @@ -78,7 +80,7 @@ instance (Todos :> es) => HyperView AllTodos es where todosView :: FilterTodo -> [Todo] -> View AllTodos () todosView filt todos = do todoForm filt - col id $ do + col $ do forM_ todos $ \todo -> do hyper (TodoView todo.id) $ todoView filt todo statusBar filt todos @@ -86,30 +88,30 @@ todosView filt todos = do todoForm :: FilterTodo -> View AllTodos () todoForm filt = do let f :: TodoForm FieldName = fieldNames - row (border 1) $ do - el (pad 8) $ do - button (ToggleAll filt) (width 32 . hover (color Primary)) Icon.chevronDown - form SubmitTodo grow $ do - field f.task id $ do - input TextInput (pad 12 . placeholder "What needs to be done?" . value "") + row ~ border 1 $ do + el ~ pad 8 $ do + button (ToggleAll filt) Icon.chevronDown ~ width 32 . hover (color Primary) + form SubmitTodo ~ grow $ do + field f.task $ do + input TextInput ~ pad 12 @ placeholder "What needs to be done?" . value "" statusBar :: FilterTodo -> [Todo] -> View AllTodos () statusBar filt todos = do - row (pad 10 . color SecondaryLight) $ do + row ~ pad 10 . color SecondaryLight $ do let numLeft = length $ filter (\t -> not t.completed) todos - el_ $ do + el $ do text $ pack (show numLeft) text " items left!" space - row (gap 10) $ do + row ~ gap 10 $ do filterButton FilterAll "All" filterButton Active "Active" filterButton Completed "Completed" space - button ClearCompleted (hover (color Primary)) "Clear completed" + button ClearCompleted ~ hover (color Primary) $ "Clear completed" where filterButton f = - button (Filter f) (selectedFilter f . pad (XY 4 0) . rounded 2) + button (Filter f) ~ selectedFilter f . pad (XY 4 0) . rounded 2 selectedFilter f = if f == filt then border 1 else id @@ -135,17 +137,17 @@ instance (Todos :> es) => HyperView TodoView es where 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 $ do target AllTodos $ do toggleCheckbox (SetCompleted filt todo) todo.completed - el (completed . pad (XY 18 4) . onDblClick (Edit filt todo)) $ text todo.task + el (text todo.task) @ onDblClick (Edit filt todo) ~ completed . pad (XY 18 4) where completed = if todo.completed then Style.strikethrough else id 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 - field f.task id $ do - input TextInput (pad 4 . value todo.task . autofocus) + row ~ border (TRBL 0 0 1 0) . pad 10 $ do + form (SubmitEdit filt todo) ~ pad (TRBL 0 0 0 46) $ do + field f.task $ do + input TextInput @ value todo.task . autofocus ~ pad 4 diff --git a/examples/Example/Page/Todos/TodoCSS.hs b/examples/Example/Page/Todos/TodoCSS.hs index 4b98547c..999adab0 100644 --- a/examples/Example/Page/Todos/TodoCSS.hs +++ b/examples/Example/Page/Todos/TodoCSS.hs @@ -3,17 +3,15 @@ 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 (Todo, TodoId, Todos) import Example.Effects.Todos qualified as Todos import Example.Page.Todos.Shared (FilterTodo (..), TodoForm (..), pluralize) import Web.Hyperbole as Hyperbole -import Web.Hyperbole.View.Forms (Input (Input)) -import Web.View.Style (extClass) -import Web.View.Types (AttValue) +import Web.Hyperbole.HyperView.Forms (Input (Input)) {- @@ -36,20 +34,19 @@ page :: (Todos :> es) => Eff es (Page '[AllTodos, TodoView]) page = do todos <- Todos.loadAll pure $ do - div' id $ do - stylesheet "https://cdn.jsdelivr.net/npm/todomvc-app-css@2.4.3/index.min.css" - section (extClass "todoapp") $ do + div' $ do + -- stylesheet "https://cdn.jsdelivr.net/npm/todomvc-app-css@2.4.3/index.min.css" + -- Original implementation at: + -- https://todomvc.com/examples/javascript-es6/dist/ + stylesheet "https://todomvc.com/examples/javascript-es6/dist/app.css" + section @ class_ "todoapp" $ do hyper AllTodos $ todosView FilterAll todos - footer (extClass "info") $ do - let p_ = p (style' "margin: 1em auto") + footer @ class_ "info" $ do + let p_ = p @ style' "margin: 1em auto" p_ "Double-click to edit a todo" p_ $ do - span' id "Go back to the " - a - ( att "href" "/" - . style' "color: #b83f45" - ) - "index" + span' "Go back to the " + a @ att "href" "/examples" . style' "color: #b83f45" $ "examples" --- TodosView ---------------------------------------------------------------------------- @@ -99,53 +96,53 @@ instance (Todos :> es) => HyperView AllTodos es where todosView :: FilterTodo -> [Todo] -> View AllTodos () todosView filt todos = do - header (extClass "header") $ do - h1 (style' "top:-80px") $ text "todos" + header @ class_ "header" $ do + h1 @ style' "top:-80px" $ text "todos" todoForm - main' (extClass "main") $ do - div' (extClass "toggle-all-container") $ do + main' @ class_ "main" $ do + div' @ class_ "toggle-all-container" $ do input' - ( extClass "toggle-all" - . att "id" "toggle-all" - . att "type" "checkbox" - ) + @ class_ "toggle-all" + . att "id" "toggle-all" + . att "type" "checkbox" + label' - ( extClass "toggle-all-label" - . att "for" "toggle-all" - . onClick (ToggleAll filt) - ) - (text "Mark all as complete") - ul' (extClass "todo-list") $ do - forM_ todos $ \todo -> do - hyper (TodoView todo.id) $ todoView filt todo - statusBar filt todos + @ class_ "toggle-all-label" + . att "for" "toggle-all" + . onClick (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 SubmitTodo grow $ do - field f.task id $ do + form SubmitTodo $ do + field f.task $ do Input (FieldName nm) <- context input' -- we use a custom input field, because the Hyperbole one overrides autocomplete - ( extClass "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 - ) + @ 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 (extClass "footer" . style' "padding-bottom: 30px") $ do + footer @ class_ "footer" . style' "padding-bottom: 30px" $ do let numLeft = length $ filter (\t -> not t.completed) todos - span' (extClass "todo-count") $ do + span' @ class_ "todo-count" $ do text $ mconcat [ T.pack $ show numLeft @@ -155,22 +152,21 @@ statusBar filt todos = do , "left!" ] space - ul' (extClass "filters") $ do + ul' @ class_ "filters" $ do filterLi FilterAll "All" filterLi Active "Active" filterLi Completed "Completed" space - button ClearCompleted (extClass "clear-completed") "Clear completed" + button ClearCompleted @ class_ "clear-completed" $ "Clear completed" where filterLi f str = - li' (extClass "filter" . selectedFilter f) $ do + li' @ class_ "filter" . selectedFilter f $ do a - ( onClick (Filter f) - . att "href" "" -- harmless empty href is for the CSS - ) - (text str) + @ onClick (Filter f) + . att "href" "" -- harmless empty href is for the CSS + $ text str selectedFilter f = - if f == filt then extClass "selected" else id + if f == filt then class_ "selected" else id --- TodoView ---------------------------------------------------------------------------- @@ -195,80 +191,80 @@ instance (Todos :> es) => HyperView TodoView es where todoView :: FilterTodo -> Todo -> View TodoView () todoView filt todo = do li' - ( onDblClick (Edit filt todo) - . bool id (extClass "completed") todo.completed - . style' "border-bottom: 1px solid #ededed" - ) + @ onDblClick (Edit filt todo) + . bool id (class_ "completed") todo.completed + . style' "border-bottom: 1px solid #ededed" $ do - div' (extClass "view") $ do + div' @ class_ "view" $ do target AllTodos $ do input' - ( extClass "toggle" - . att "type" "checkbox" - . onClick (SetCompleted filt todo $ not todo.completed) - . checked todo.completed - ) - label' (extClass "label") $ do - text todo.task + @ class_ "toggle" + . att "type" "checkbox" + . onClick (SetCompleted filt todo $ not todo.completed) + . checked todo.completed + + label' @ class_ "label" $ do + text todo.task + + -- FIXME: create a Destroy action variant (I think it's missing from the original impl) + button (SetCompleted filt todo $ not todo.completed) @ class_ "destroy" $ "" todoEditView :: FilterTodo -> Todo -> View TodoView () todoEditView filt todo = do let f = fieldNames @TodoForm - li' (extClass "editing") $ do - form (SubmitEdit filt todo) id $ do - let taskField = (Input f.task) + li' @ class_ "editing" $ do + form (SubmitEdit filt 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' - ( extClass "edit" - . value todo.task - . autofocus - . extClass "hello" - . name fn -- because we use a custom input, we must provide this param for the library - ) + @ class_ "edit" + . value todo.task + . autofocus + . name fn -- because we use a custom input, we must provide this param for the library --- Helpers ---------------------------------------------------------------------------- -div' :: Mod c -> View c () -> View c () +div' :: View c () -> View c () div' = tag "div" -span' :: Mod c -> View c () -> View c () +span' :: View c () -> View c () span' = tag "span" -section :: Mod c -> View c () -> View c () +section :: View c () -> View c () section = tag "section" -header :: Mod c -> View c () -> View c () +header :: View c () -> View c () header = tag "header" -main' :: Mod c -> View c () -> View c () +main' :: View c () -> View c () main' = tag "main" -h1 :: Mod c -> View c () -> View c () +h1 :: View c () -> View c () h1 = tag "h1" -p :: Mod c -> View c () -> View c () +p :: View c () -> View c () p = tag "p" -label' :: Mod c -> View c () -> View c () +label' :: View c () -> View c () label' = tag "label" -input' :: Mod c -> View c () -input' m = tag "input" m "" +input' :: View c () +input' = tag "input" none -a :: Mod c -> View c () -> View c () +a :: View c () -> View c () a = tag "a" -ul' :: Mod c -> View c () -> View c () +ul' :: View c () -> View c () ul' = tag "ul" -li' :: Mod c -> View c () -> View c () +li' :: View c () -> View c () li' = tag "li" -footer :: Mod c -> View c () -> View c () +footer :: View c () -> View c () footer = tag "footer" -style' :: AttValue -> Mod c -style' = att "style" \ No newline at end of file +style' :: (Attributable h) => AttValue -> Attributes h -> Attributes h +style' = att "style" diff --git a/examples/examples.cabal b/examples/examples.cabal index 2124453a..9f0d3ea4 100644 --- a/examples/examples.cabal +++ b/examples/examples.cabal @@ -75,6 +75,7 @@ executable examples Example.Page.State.Query Example.Page.State.Sessions Example.Page.Test + Example.Page.Todos.Shared Example.Page.Todos.Todo Example.Page.Todos.TodoCSS Example.Style diff --git a/hyperbole.cabal b/hyperbole.cabal index 750288e3..18071fd6 100644 --- a/hyperbole.cabal +++ b/hyperbole.cabal @@ -140,6 +140,7 @@ test-suite test DerivingStrategies DeriveAnyClass ghc-options: -Wall -fdefer-typed-holes -threaded -rtsopts -with-rtsopts=-N -F -pgmF=skeletest-preprocessor + build-tool-depends: skeletest:skeletest-preprocessor build-depends: aeson >=2.2.0.0 , atomic-css ==0.1.* From 685d19a81a6ccbd67dfc5a3458c9a7e533709b27 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sun, 10 Aug 2025 08:31:38 +0200 Subject: [PATCH 03/11] Bring back simplePage --- examples/Example/Page/Todos/Todo.hs | 18 ++++++++++++++++++ src/Web/Hyperbole.hs | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/examples/Example/Page/Todos/Todo.hs b/examples/Example/Page/Todos/Todo.hs index cc844b1b..8155535b 100644 --- a/examples/Example/Page/Todos/Todo.hs +++ b/examples/Example/Page/Todos/Todo.hs @@ -31,6 +31,13 @@ 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 + pure $ do + hyper AllTodos $ todosView FilterAll todos + --- TodosView ---------------------------------------------------------------------------- data AllTodos = AllTodos @@ -151,3 +158,14 @@ todoEditView filt todo = do form (SubmitEdit filt 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 3008 $ do + liveApp (basicDocument "Todo (simple)") (runTodosSession $ runPage simplePage) diff --git a/src/Web/Hyperbole.hs b/src/Web/Hyperbole.hs index ba4bf106..705dc58c 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. From 5b37bf4863bb23ed5bbcaff72c57bf7a55150d94 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sun, 10 Aug 2025 08:57:25 +0200 Subject: [PATCH 04/11] Minimize git diff --- examples/Example/Page/Todos/Todo.hs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/examples/Example/Page/Todos/Todo.hs b/examples/Example/Page/Todos/Todo.hs index 8155535b..37f5ba20 100644 --- a/examples/Example/Page/Todos/Todo.hs +++ b/examples/Example/Page/Todos/Todo.hs @@ -4,22 +4,17 @@ module Example.Page.Todos.Todo where -import Example.Effects.Todos (Todo (..), TodoId, Todos, runTodosSession) -import Example.Effects.Todos qualified as Todos - import Control.Monad (forM_) import Data.Text (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 - import Example.Style qualified as Style - import Example.View.Icon qualified as Icon - import Example.View.Inputs (toggleCheckbox) - import Example.View.Layout import Web.Atomic.CSS import Web.Hyperbole as Hyperbole @@ -38,7 +33,7 @@ simplePage = do pure $ do hyper AllTodos $ todosView FilterAll todos ---- TodosView ---------------------------------------------------------------------------- +--- AllTodos ---------------------------------------------------------------------------- data AllTodos = AllTodos deriving (Generic, ViewId) @@ -167,5 +162,5 @@ ghci> Todo.main -} main :: IO () main = do - run 3008 $ do + run 3000 $ do liveApp (basicDocument "Todo (simple)") (runTodosSession $ runPage simplePage) From e7d95c5087c9c1b12320d335fa0c00c916ac2f5a Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sun, 10 Aug 2025 09:06:51 +0200 Subject: [PATCH 05/11] Tweak CSS URL --- examples/Example/Page/Todos/TodoCSS.hs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/Example/Page/Todos/TodoCSS.hs b/examples/Example/Page/Todos/TodoCSS.hs index 999adab0..c0b635f2 100644 --- a/examples/Example/Page/Todos/TodoCSS.hs +++ b/examples/Example/Page/Todos/TodoCSS.hs @@ -35,10 +35,9 @@ page = do todos <- Todos.loadAll pure $ do div' $ do - -- stylesheet "https://cdn.jsdelivr.net/npm/todomvc-app-css@2.4.3/index.min.css" - -- Original implementation at: - -- https://todomvc.com/examples/javascript-es6/dist/ - stylesheet "https://todomvc.com/examples/javascript-es6/dist/app.css" + -- 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 From 3350e16d8d208af3c4c92871b86791ca15b70d81 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sun, 10 Aug 2025 09:39:49 +0200 Subject: [PATCH 06/11] Extract common update logic --- examples/Example/Page/Todos/Shared.hs | 59 ++++++++++++++++++- examples/Example/Page/Todos/Todo.hs | 78 +++++++++----------------- examples/Example/Page/Todos/TodoCSS.hs | 76 +++++++++---------------- 3 files changed, 111 insertions(+), 102 deletions(-) diff --git a/examples/Example/Page/Todos/Shared.hs b/examples/Example/Page/Todos/Shared.hs index 3f5ff3be..d16e2f54 100644 --- a/examples/Example/Page/Todos/Shared.hs +++ b/examples/Example/Page/Todos/Shared.hs @@ -1,11 +1,21 @@ +{-# LANGUAGE LambdaCase #-} + module Example.Page.Todos.Shared ( pluralize , FilterTodo (..) , TodoForm (..) + , AllTodos (..) + , TodosAction (..) + , updateTodos + , TodoAction (..) + , updateTodo ) where import Data.Text (Text) -import Web.Hyperbole +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 @@ -25,3 +35,50 @@ pluralize n singular plural = singular else plural + +data AllTodos = AllTodos + deriving (Generic, ViewId) + +data TodosAction + = ClearCompleted + | Filter FilterTodo + | SubmitTodo + | ToggleAll FilterTodo + | SetCompleted FilterTodo Todo Bool + 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 + 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/Todos/Todo.hs b/examples/Example/Page/Todos/Todo.hs index 37f5ba20..94271549 100644 --- a/examples/Example/Page/Todos/Todo.hs +++ b/examples/Example/Page/Todos/Todo.hs @@ -1,17 +1,17 @@ {-# LANGUAGE DerivingVia #-} -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE UndecidableInstances #-} 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 +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) @@ -41,43 +41,21 @@ 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 + newtype Action AllTodos = MkAction Shared.TodosAction + deriving newtype (Generic, ViewAction) + + update (MkAction 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) todosView :: FilterTodo -> [Todo] -> View AllTodos () todosView filt todos = do @@ -92,8 +70,8 @@ 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 (MkAction $ Shared.ToggleAll filt) Icon.chevronDown ~ width 32 . hover (color Primary) + form (MkAction Shared.SubmitTodo) ~ grow $ do field f.task $ do input TextInput ~ pad 12 @ placeholder "What needs to be done?" . value "" @@ -110,10 +88,10 @@ statusBar filt todos = do filterButton Active "Active" filterButton Completed "Completed" space - button ClearCompleted ~ hover (color Primary) $ "Clear completed" + button (MkAction Shared.ClearCompleted) ~ hover (color Primary) $ "Clear completed" where filterButton f = - button (Filter f) ~ selectedFilter f . pad (XY 4 0) . rounded 2 + button (MkAction $ Shared.Filter f) ~ selectedFilter f . pad (XY 4 0) . rounded 2 selectedFilter f = if f == filt then border 1 else id @@ -127,21 +105,19 @@ instance (Todos :> es) => HyperView TodoView es where data Action TodoView = Edit FilterTodo Todo - | SubmitEdit FilterTodo Todo + | MkTodoViewAction 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 (MkTodoViewAction 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 target AllTodos $ do - toggleCheckbox (SetCompleted filt todo) todo.completed + toggleCheckbox (MkAction . Shared.SetCompleted filt todo) todo.completed el (text todo.task) @ onDblClick (Edit filt todo) ~ completed . pad (XY 18 4) where completed = if todo.completed then Style.strikethrough else id @@ -150,7 +126,7 @@ 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 (MkTodoViewAction filt $ Shared.SubmitEdit todo) ~ pad (TRBL 0 0 0 46) $ do field f.task $ do input TextInput @ value todo.task . autofocus ~ pad 4 diff --git a/examples/Example/Page/Todos/TodoCSS.hs b/examples/Example/Page/Todos/TodoCSS.hs index c0b635f2..bf44d36b 100644 --- a/examples/Example/Page/Todos/TodoCSS.hs +++ b/examples/Example/Page/Todos/TodoCSS.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE LambdaCase #-} {-# LANGUAGE UndecidableInstances #-} module Example.Page.Todos.TodoCSS (page) where @@ -10,6 +9,7 @@ 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)) @@ -55,43 +55,21 @@ 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 + newtype Action AllTodos = MkAction Shared.TodosAction + deriving newtype (Generic, ViewAction) + + update (MkAction 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) todosView :: FilterTodo -> [Todo] -> View AllTodos () todosView filt todos = do @@ -108,7 +86,7 @@ todosView filt todos = do label' @ class_ "toggle-all-label" . att "for" "toggle-all" - . onClick (ToggleAll filt) + . onClick (MkAction $ Shared.ToggleAll filt) $ text "Mark all as complete" ul' @ class_ "todo-list" $ do @@ -120,7 +98,7 @@ todosView filt todos = do todoForm :: View AllTodos () todoForm = do let f :: TodoForm FieldName = fieldNames - form SubmitTodo $ do + form (MkAction Shared.SubmitTodo) $ do field f.task $ do Input (FieldName nm) <- context input' -- we use a custom input field, because the Hyperbole one overrides autocomplete @@ -156,12 +134,12 @@ statusBar filt todos = do filterLi Active "Active" filterLi Completed "Completed" space - button ClearCompleted @ class_ "clear-completed" $ "Clear completed" + button (MkAction Shared.ClearCompleted) @ class_ "clear-completed" $ "Clear completed" where filterLi f str = li' @ class_ "filter" . selectedFilter f $ do a - @ onClick (Filter f) + @ onClick (MkAction $ Shared.Filter f) . att "href" "" -- harmless empty href is for the CSS $ text str selectedFilter f = @@ -177,15 +155,13 @@ instance (Todos :> es) => HyperView TodoView es where data Action TodoView = Edit FilterTodo Todo - | SubmitEdit FilterTodo Todo + | MkTodoViewAction 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 (MkTodoViewAction filt action) = do + todoView filt <$> Shared.updateTodo action todoView :: FilterTodo -> Todo -> View TodoView () todoView filt todo = do @@ -199,20 +175,20 @@ todoView filt todo = do input' @ class_ "toggle" . att "type" "checkbox" - . onClick (SetCompleted filt todo $ not todo.completed) + . onClick (MkAction $ Shared.SetCompleted filt todo $ not todo.completed) . checked todo.completed label' @ class_ "label" $ do text todo.task -- FIXME: create a Destroy action variant (I think it's missing from the original impl) - button (SetCompleted filt todo $ not todo.completed) @ class_ "destroy" $ "" + button (MkAction $ Shared.SetCompleted filt todo $ not todo.completed) @ class_ "destroy" $ "" todoEditView :: FilterTodo -> Todo -> View TodoView () todoEditView filt todo = do let f = fieldNames @TodoForm li' @ class_ "editing" $ do - form (SubmitEdit filt todo) $ do + form (MkTodoViewAction 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 From 8dcaeec351c183282d29bbaedc9827c40213fbc9 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sun, 10 Aug 2025 12:04:52 +0200 Subject: [PATCH 07/11] Share data between both pages --- examples/Example/Effects/Todos.hs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/Example/Effects/Todos.hs b/examples/Example/Effects/Todos.hs index 8352a4ff..4c29f416 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 From ccb600a41a3907ac0fb304595d46254f6614a6b5 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sun, 10 Aug 2025 12:35:55 +0200 Subject: [PATCH 08/11] Implement destroy --- examples/Example/Page/Todos/Shared.hs | 4 ++++ examples/Example/Page/Todos/Todo.hs | 13 +++++++++++-- examples/Example/Page/Todos/TodoCSS.hs | 5 +++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/examples/Example/Page/Todos/Shared.hs b/examples/Example/Page/Todos/Shared.hs index d16e2f54..bb90d102 100644 --- a/examples/Example/Page/Todos/Shared.hs +++ b/examples/Example/Page/Todos/Shared.hs @@ -45,6 +45,7 @@ data TodosAction | SubmitTodo | ToggleAll FilterTodo | SetCompleted FilterTodo Todo Bool + | Destroy FilterTodo Todo deriving (Generic, ViewAction) updateTodos :: (Todos :> es, Hyperbole :> es) => TodosAction -> Eff es [Todo] @@ -63,6 +64,9 @@ updateTodos = \case 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 diff --git a/examples/Example/Page/Todos/Todo.hs b/examples/Example/Page/Todos/Todo.hs index 94271549..2f6ffe8a 100644 --- a/examples/Example/Page/Todos/Todo.hs +++ b/examples/Example/Page/Todos/Todo.hs @@ -56,6 +56,8 @@ instance (Todos :> es) => HyperView AllTodos es where 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 @@ -115,12 +117,19 @@ instance (Todos :> es) => HyperView TodoView es where 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 (MkAction . Shared.SetCompleted filt todo) todo.completed - el (text todo.task) @ onDblClick (Edit filt todo) ~ completed . pad (XY 18 4) + el (text todo.task) @ onDblClick (Edit filt todo) ~ completed . pad (XY 18 4) . grow + target AllTodos $ do + button (MkAction $ 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 diff --git a/examples/Example/Page/Todos/TodoCSS.hs b/examples/Example/Page/Todos/TodoCSS.hs index bf44d36b..51b86f60 100644 --- a/examples/Example/Page/Todos/TodoCSS.hs +++ b/examples/Example/Page/Todos/TodoCSS.hs @@ -70,6 +70,8 @@ instance (Todos :> es) => HyperView AllTodos es where 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 @@ -181,8 +183,7 @@ todoView filt todo = do label' @ class_ "label" $ do text todo.task - -- FIXME: create a Destroy action variant (I think it's missing from the original impl) - button (MkAction $ Shared.SetCompleted filt todo $ not todo.completed) @ class_ "destroy" $ "" + button (MkAction $ Shared.Destroy filt todo) @ class_ "destroy" $ "" todoEditView :: FilterTodo -> Todo -> View TodoView () todoEditView filt todo = do From fff7fe82e08b22d76f192a87e59bb9596ff642ec Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sun, 10 Aug 2025 12:45:06 +0200 Subject: [PATCH 09/11] Fix dbl click crash due to stale action --- examples/Example/Page/Todos/TodoCSS.hs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/Example/Page/Todos/TodoCSS.hs b/examples/Example/Page/Todos/TodoCSS.hs index 51b86f60..6f5721aa 100644 --- a/examples/Example/Page/Todos/TodoCSS.hs +++ b/examples/Example/Page/Todos/TodoCSS.hs @@ -168,8 +168,7 @@ instance (Todos :> es) => HyperView TodoView es where todoView :: FilterTodo -> Todo -> View TodoView () todoView filt todo = do li' - @ onDblClick (Edit filt todo) - . bool id (class_ "completed") todo.completed + @ bool id (class_ "completed") todo.completed . style' "border-bottom: 1px solid #ededed" $ do div' @ class_ "view" $ do @@ -180,9 +179,10 @@ todoView filt todo = do . onClick (MkAction $ Shared.SetCompleted filt todo $ not todo.completed) . checked todo.completed - label' @ class_ "label" $ do - text todo.task + label' @ class_ "label" . onDblClick (Edit filt todo) $ do + text todo.task + target AllTodos $ do button (MkAction $ Shared.Destroy filt todo) @ class_ "destroy" $ "" todoEditView :: FilterTodo -> Todo -> View TodoView () From 36be2710e8975c5bf058be384b355a3c7ef1f19a Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sun, 10 Aug 2025 12:53:12 +0200 Subject: [PATCH 10/11] Use consistent naming --- examples/Example/Page/Todos/Todo.hs | 22 +++++++++++----------- examples/Example/Page/Todos/TodoCSS.hs | 22 +++++++++++----------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/examples/Example/Page/Todos/Todo.hs b/examples/Example/Page/Todos/Todo.hs index 2f6ffe8a..4438b244 100644 --- a/examples/Example/Page/Todos/Todo.hs +++ b/examples/Example/Page/Todos/Todo.hs @@ -41,10 +41,10 @@ data AllTodos = AllTodos instance (Todos :> es) => HyperView AllTodos es where type Require AllTodos = '[TodoView] - newtype Action AllTodos = MkAction Shared.TodosAction + newtype Action AllTodos = MkTodosAction Shared.TodosAction deriving newtype (Generic, ViewAction) - update (MkAction action) = do + update (MkTodosAction action) = do case action of Shared.ClearCompleted -> todosView FilterAll <$> Shared.updateTodos Shared.ClearCompleted @@ -72,8 +72,8 @@ todoForm filt = do let f :: TodoForm FieldName = fieldNames row ~ border 1 $ do el ~ pad 8 $ do - button (MkAction $ Shared.ToggleAll filt) Icon.chevronDown ~ width 32 . hover (color Primary) - form (MkAction Shared.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 "" @@ -90,10 +90,10 @@ statusBar filt todos = do filterButton Active "Active" filterButton Completed "Completed" space - button (MkAction Shared.ClearCompleted) ~ hover (color Primary) $ "Clear completed" + button (MkTodosAction Shared.ClearCompleted) ~ hover (color Primary) $ "Clear completed" where filterButton f = - button (MkAction $ Shared.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 @@ -107,22 +107,22 @@ instance (Todos :> es) => HyperView TodoView es where data Action TodoView = Edit FilterTodo Todo - | MkTodoViewAction FilterTodo Shared.TodoAction + | MkTodoAction FilterTodo Shared.TodoAction deriving (Generic, ViewAction) update (Edit filt todo) = do pure $ todoEditView filt todo - update (MkTodoViewAction filt action) = + 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 . showDestroyOnHover $ do target AllTodos $ do - toggleCheckbox (MkAction . Shared.SetCompleted filt todo) todo.completed + 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 - button (MkAction $ Shared.Destroy filt todo) "✕" ~ cls "destroy-btn" . opacity 0 . hover (color Primary) . pad 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 = @@ -135,7 +135,7 @@ todoEditView :: FilterTodo -> Todo -> View TodoView () todoEditView filt todo = do let f = fieldNames @TodoForm row ~ border (TRBL 0 0 1 0) . pad 10 $ do - form (MkTodoViewAction filt $ Shared.SubmitEdit 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 diff --git a/examples/Example/Page/Todos/TodoCSS.hs b/examples/Example/Page/Todos/TodoCSS.hs index 6f5721aa..719880d4 100644 --- a/examples/Example/Page/Todos/TodoCSS.hs +++ b/examples/Example/Page/Todos/TodoCSS.hs @@ -55,10 +55,10 @@ data AllTodos = AllTodos instance (Todos :> es) => HyperView AllTodos es where type Require AllTodos = '[TodoView] - newtype Action AllTodos = MkAction Shared.TodosAction + newtype Action AllTodos = MkTodosAction Shared.TodosAction deriving newtype (Generic, ViewAction) - update (MkAction action) = do + update (MkTodosAction action) = do case action of Shared.ClearCompleted -> todosView FilterAll <$> Shared.updateTodos Shared.ClearCompleted @@ -88,7 +88,7 @@ todosView filt todos = do label' @ class_ "toggle-all-label" . att "for" "toggle-all" - . onClick (MkAction $ Shared.ToggleAll filt) + . onClick (MkTodosAction $ Shared.ToggleAll filt) $ text "Mark all as complete" ul' @ class_ "todo-list" $ do @@ -100,7 +100,7 @@ todosView filt todos = do todoForm :: View AllTodos () todoForm = do let f :: TodoForm FieldName = fieldNames - form (MkAction Shared.SubmitTodo) $ do + 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 @@ -136,12 +136,12 @@ statusBar filt todos = do filterLi Active "Active" filterLi Completed "Completed" space - button (MkAction Shared.ClearCompleted) @ class_ "clear-completed" $ "Clear completed" + button (MkTodosAction Shared.ClearCompleted) @ class_ "clear-completed" $ "Clear completed" where filterLi f str = li' @ class_ "filter" . selectedFilter f $ do a - @ onClick (MkAction $ Shared.Filter f) + @ onClick (MkTodosAction $ Shared.Filter f) . att "href" "" -- harmless empty href is for the CSS $ text str selectedFilter f = @@ -157,12 +157,12 @@ instance (Todos :> es) => HyperView TodoView es where data Action TodoView = Edit FilterTodo Todo - | MkTodoViewAction FilterTodo Shared.TodoAction + | MkTodoAction FilterTodo Shared.TodoAction deriving (Generic, ViewAction) update (Edit filt todo) = do pure $ todoEditView filt todo - update (MkTodoViewAction filt action) = do + update (MkTodoAction filt action) = do todoView filt <$> Shared.updateTodo action todoView :: FilterTodo -> Todo -> View TodoView () @@ -176,20 +176,20 @@ todoView filt todo = do input' @ class_ "toggle" . att "type" "checkbox" - . onClick (MkAction $ Shared.SetCompleted filt todo $ not todo.completed) + . 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 (MkAction $ Shared.Destroy filt todo) @ class_ "destroy" $ "" + 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 (MkTodoViewAction filt $ Shared.SubmitEdit todo) $ 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 From 7dafb6d296c8a68a460120fa930fe6eb36f7a894 Mon Sep 17 00:00:00 2001 From: Benjamin Thomas Date: Sun, 10 Aug 2025 13:01:27 +0200 Subject: [PATCH 11/11] Remove not PR related --- hyperbole.cabal | 1 - 1 file changed, 1 deletion(-) diff --git a/hyperbole.cabal b/hyperbole.cabal index 18071fd6..750288e3 100644 --- a/hyperbole.cabal +++ b/hyperbole.cabal @@ -140,7 +140,6 @@ test-suite test DerivingStrategies DeriveAnyClass ghc-options: -Wall -fdefer-typed-holes -threaded -rtsopts -with-rtsopts=-N -F -pgmF=skeletest-preprocessor - build-tool-depends: skeletest:skeletest-preprocessor build-depends: aeson >=2.2.0.0 , atomic-css ==0.1.*