├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── index.js ├── package-lock.json ├── package.json ├── packages.dhall ├── spago.dhall └── src ├── LocalStorage.js ├── LocalStorage.purs └── Todo ├── App.js ├── App.purs ├── Footer.purs ├── Task.purs └── View.purs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | .psc-ide-port 3 | .parcel-cache 4 | .cache/* 5 | .spago/* 6 | output/* 7 | dist/* 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Fabrizio Ferrai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PureScript `react-basic` TodoMVC 2 | 3 | An implementation of [TodoMVC](http://todomvc.com/) in [PureScript](http://www.purescript.org/) using the [`react-basic`](https://github.com/lumihq/purescript-react-basic) library. 4 | 5 | You can see this deployed [here at `purescript-react-basic-todomvc.netlify.com`](https://purescript-react-basic-todomvc.netlify.com/) 6 | 7 | ## Project structure 8 | 9 | - Entry point for the app is [`index.js`](https://github.com/f-f/purescript-react-basic-todomvc/blob/master/index.js), 10 | that imports React and just instantiates the `Todo.Main` component (defined in PureScript). 11 | This is where you might want to hook up more JS components in your project. 12 | - The tasklist is defined in the [`Main` component](https://github.com/f-f/purescript-react-basic-todomvc/blob/master/src/Todo/Main.purs). 13 | The list of tasks is kept in this component's `State`, together with some more things (e.g. the current selector, etc.) 14 | - The above component then creates a [`Task` component](https://github.com/f-f/purescript-react-basic-todomvc/blob/master/src/Todo/Task.purs) for every task. 15 | The only state we need to keep in it is the current edits for a focused `Task`. 16 | - Some things are achieved with a thin layer of JS FFI: [LocalStorage](https://github.com/f-f/purescript-react-basic-todomvc/blob/master/src/LocalStorage.js) and [routing](https://github.com/f-f/purescript-react-basic-todomvc/blob/master/src/Todo/App.js) 17 | 18 | ## Development 19 | 20 | ```bash 21 | ## Install npm dependencies, PureScript compiler, etc 22 | npm install 23 | 24 | ## Build the PureScript project 25 | npm run build 26 | 27 | ## Start the dev server with hot reload and stuff 28 | ## 29 | ## Note: Parcel has hot reload on JS files only, so if you'd like to reload 30 | ## when changing PureScript files, you have two options: 31 | ## - use an editor integration - this will run `purs ide` and recompile the files you edit 32 | ## - run `spago build --watch` in another terminal 33 | ## 34 | ## Note: the hot reload won't work if you change any FFI file, 35 | ## so you'll have to `yarn build` again in this case. 36 | npm start 37 | ``` 38 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PureScript • TodoMVC 6 | 7 | 8 | 16 | 17 | 18 | 19 |
20 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import createReactClass from 'create-react-class'; 5 | React.createClass = createReactClass; 6 | var App = require('./output/Todo.App'); 7 | 8 | function main() { 9 | const myComponent = ( 10 | 11 | ); 12 | 13 | ReactDOM.render(myComponent, document.getElementById('app')); 14 | } 15 | 16 | // HMR stuff 17 | // For more info see: https://parceljs.org/hmr.html 18 | if (module.hot) { 19 | module.hot.accept(function () { 20 | console.log('Reloaded, running main again'); 21 | main(); 22 | }); 23 | } 24 | 25 | console.log('Starting app'); 26 | main(); 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "purescript-react-basic-todomvc", 3 | "version": "1.0.0", 4 | "description": "TodoMVC with purescript-react-basic", 5 | "main": "dist/index.html", 6 | "repository": "https://github.com/f-f/purescript-react-basic-todomvc", 7 | "author": "Fabrizio Ferrai ", 8 | "license": "MIT", 9 | "dependencies": { 10 | "create-react-class": "^15.6.3", 11 | "react": "^16.13.1", 12 | "react-dom": "^16.13.1", 13 | "todomvc-app-css": "^2.1.2", 14 | "todomvc-common": "^1.0.5" 15 | }, 16 | "devDependencies": { 17 | "parcel": "^2.0.0-nightly.259", 18 | "purescript": "^0.13.6", 19 | "spago": "^0.15.2" 20 | }, 21 | "scripts": { 22 | "start": "npm run build && parcel index.html", 23 | "test": "echo \"Error: no test specified\" && exit 1", 24 | "install": "spago install", 25 | "build-production": "npm run build && parcel build index.html", 26 | "build": "spago build", 27 | "clean": "rm -rf .cache .spago .psci_modules node_modules output dist" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages.dhall: -------------------------------------------------------------------------------- 1 | {- 2 | Welcome to Spacchetti local packages! 3 | 4 | Below are instructions for how to edit this file for most use 5 | cases, so that you don't need to know Dhall to use it. 6 | 7 | ## Warning: Don't Move This Top-Level Comment! 8 | 9 | Due to how `dhall format` currently works, this comment's 10 | instructions cannot appear near corresponding sections below 11 | because `dhall format` will delete the comment. However, 12 | it will not delete a top-level comment like this one. 13 | 14 | ## Use Cases 15 | 16 | Most will want to do one or both of these options: 17 | 1. Override/Patch a package's dependency 18 | 2. Add a package not already in the default package set 19 | 20 | This file will continue to work whether you use one or both options. 21 | Instructions for each option are explained below. 22 | 23 | ### Overriding/Patching a package 24 | 25 | Purpose: 26 | - Change a package's dependency to a newer/older release than the 27 | default package set's release 28 | - Use your own modified version of some dependency that may 29 | include new API, changed API, removed API by 30 | using your custom git repo of the library rather than 31 | the package set's repo 32 | 33 | Syntax: 34 | Replace the overrides' "{=}" (an empty record) with the following idea 35 | The "//" or "⫽" means "merge these two records and 36 | when they have the same value, use the one on the right:" 37 | ------------------------------- 38 | let override = 39 | { packageName = 40 | upstream.packageName ⫽ { updateEntity1 = "new value", updateEntity2 = "new value" } 41 | , packageName = 42 | upstream.packageName ⫽ { version = "v4.0.0" } 43 | , packageName = 44 | upstream.packageName // { repo = "https://www.example.com/path/to/new/repo.git" } 45 | } 46 | ------------------------------- 47 | 48 | Example: 49 | ------------------------------- 50 | let overrides = 51 | { halogen = 52 | upstream.halogen ⫽ { version = "master" } 53 | , halogen-vdom = 54 | upstream.halogen-vdom ⫽ { version = "v4.0.0" } 55 | } 56 | ------------------------------- 57 | 58 | ### Additions 59 | 60 | Purpose: 61 | - Add packages that aren't alread included in the default package set 62 | 63 | Syntax: 64 | Replace the additions' "{=}" (an empty record) with the following idea: 65 | ------------------------------- 66 | let additions = 67 | { "package-name" = 68 | mkPackage 69 | [ "dependency1" 70 | , "dependency2" 71 | ] 72 | "https://example.com/path/to/git/repo.git" 73 | "tag ('v4.0.0') or branch ('master')" 74 | , "package-name" = 75 | mkPackage 76 | [ "dependency1" 77 | , "dependency2" 78 | ] 79 | "https://example.com/path/to/git/repo.git" 80 | "tag ('v4.0.0') or branch ('master')" 81 | , etc. 82 | } 83 | ------------------------------- 84 | 85 | Example: 86 | ------------------------------- 87 | let additions = 88 | { benchotron = 89 | mkPackage 90 | [ "arrays" 91 | , "exists" 92 | , "profunctor" 93 | , "strings" 94 | , "quickcheck" 95 | , "lcg" 96 | , "transformers" 97 | , "foldable-traversable" 98 | , "exceptions" 99 | , "node-fs" 100 | , "node-buffer" 101 | , "node-readline" 102 | , "datetime" 103 | , "now" 104 | ] 105 | "https://github.com/hdgarrood/purescript-benchotron.git" 106 | "v7.0.0" 107 | } 108 | ------------------------------- 109 | -} 110 | 111 | let mkPackage = 112 | https://raw.githubusercontent.com/spacchetti/spacchetti/20181209/src/mkPackage.dhall sha256:0b197efa1d397ace6eb46b243ff2d73a3da5638d8d0ac8473e8e4a8fc528cf57 113 | 114 | let upstream = 115 | https://github.com/purescript/package-sets/releases/download/psc-0.13.6-20200404/packages.dhall sha256:f239f2e215d0cbd5c203307701748581938f74c4c78f4aeffa32c11c131ef7b6 116 | 117 | let overrides = {=} 118 | 119 | let additions = {=} 120 | 121 | in upstream // overrides // additions 122 | -------------------------------------------------------------------------------- /spago.dhall: -------------------------------------------------------------------------------- 1 | { sources = [ "src/**/*.purs" ] 2 | , name = "purescript-react-basic-todomvc" 3 | , dependencies = 4 | [ "effect" 5 | , "console" 6 | , "foreign-generic" 7 | , "foreign" 8 | , "simple-json" 9 | , "generics-rep" 10 | , "psci-support" 11 | , "react-basic" 12 | , "prelude" 13 | , "strings" 14 | , "debug" 15 | ] 16 | , packages = ./packages.dhall 17 | } 18 | -------------------------------------------------------------------------------- /src/LocalStorage.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* 4 | It would be very tempting to write these as: 5 | exports.setItem_ = window.localStorage.setItem; 6 | 7 | However, it creates weird errors and does not work as expected. 8 | 9 | JavaScript, not even once. 10 | */ 11 | 12 | exports.setItem_ = function(k, v) { 13 | window.localStorage.setItem(k, v); 14 | }; 15 | 16 | exports.getItem_ = function(k) { 17 | return window.localStorage.getItem(k); 18 | }; 19 | 20 | exports.removeItem_ = function(k) { 21 | window.localStorage.removeItem(k); 22 | } 23 | -------------------------------------------------------------------------------- /src/LocalStorage.purs: -------------------------------------------------------------------------------- 1 | module LocalStorage where 2 | 3 | import Prelude 4 | 5 | import Data.Either (hush) 6 | import Data.Maybe (Maybe) 7 | import Data.Nullable (Nullable, toMaybe) 8 | import Effect (Effect) 9 | import Effect.Uncurried (EffectFn1, EffectFn2, runEffectFn1, runEffectFn2) 10 | import Simple.JSON (class ReadForeign, class WriteForeign) 11 | import Simple.JSON as JSON 12 | 13 | 14 | foreign import setItem_ :: EffectFn2 String String Unit 15 | foreign import getItem_ :: EffectFn1 String (Nullable String) 16 | foreign import removeItem_ :: EffectFn1 String Unit 17 | 18 | setItem :: forall a. WriteForeign a => String -> a -> Effect Unit 19 | setItem key val = runEffectFn2 setItem_ key (JSON.writeJSON val) 20 | 21 | getItem :: forall a. ReadForeign a => String -> Effect (Maybe a) 22 | getItem key = do 23 | itemString <- runEffectFn1 getItem_ key 24 | pure $ (hush <<< JSON.readJSON) =<< toMaybe itemString 25 | 26 | removeItem :: String -> Effect Unit 27 | removeItem = runEffectFn1 removeItem_ 28 | -------------------------------------------------------------------------------- /src/Todo/App.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | exports.startNavigation = function(navFn) { 4 | window.onhashchange = function () { 5 | // We double call here because navFn returns an effect 6 | // (which is just a wrapper function, so we unwrap) 7 | navFn(location.hash)(); 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/Todo/App.purs: -------------------------------------------------------------------------------- 1 | module Todo.App where 2 | 3 | import Prelude 4 | 5 | import Data.Array as Array 6 | import Data.Maybe (Maybe(..), fromMaybe) 7 | import Data.String as String 8 | import Effect (Effect) 9 | import Effect.Uncurried (EffectFn1, runEffectFn1) 10 | import LocalStorage as LocalStorage 11 | import React.Basic (JSX, StateUpdate(..), Self, runUpdate) 12 | import React.Basic as React 13 | import React.Basic.DOM as DOM 14 | import React.Basic.DOM.Events as DOM.Events 15 | import React.Basic.Events as Events 16 | import Todo.Footer (Visibility(..)) 17 | import Todo.Footer as Footer 18 | import Todo.Task (Task) 19 | import Todo.Task as Task 20 | import Todo.View (classy) 21 | 22 | 23 | -- | Hook to set the navigation function 24 | foreign import startNavigation :: EffectFn1 (String -> Effect Unit) Unit 25 | 26 | -- | The main component doesn't have any props since no one is passing them to us 27 | type Props = {} 28 | 29 | -- | State type: we keep the list of tasks, the state of the field, 30 | -- the current task visibility filter and the next id to assign. 31 | type State = 32 | { tasks :: Array Task 33 | , newTodo :: String 34 | , uid :: Int 35 | , visibility :: Visibility 36 | } 37 | 38 | type SetState = (State -> State) -> Effect Unit 39 | 40 | initialState :: State 41 | initialState = 42 | { tasks: [] 43 | , visibility: All 44 | , newTodo: "" 45 | , uid: 0 46 | } 47 | 48 | -- | The localStorage key under which we'll save the state of the app 49 | localStorageKey :: String 50 | localStorageKey = "todomvc-purescript-state" 51 | 52 | -- | Action to persist the state to LocalStorage 53 | saveState :: State -> Effect Unit 54 | saveState state = LocalStorage.setItem localStorageKey state 55 | 56 | data Action 57 | = EditNewTodo (Maybe String) 58 | | SubmitNewTodo String 59 | | TaskCheck Int 60 | | TaskUpdate Int String 61 | | TaskDelete Int 62 | | ClearCompleted 63 | | CheckAllTasks (Maybe Boolean) 64 | | UpdateVisibility Visibility 65 | | LoadState State 66 | | Noop 67 | 68 | component :: React.Component Props 69 | component = React.createComponent "App" 70 | 71 | type AppProps = {} 72 | 73 | app :: AppProps -> JSX 74 | app _ = React.make component 75 | { initialState 76 | , didMount 77 | , render 78 | , didUpdate 79 | } {} 80 | where 81 | send = runUpdate update 82 | 83 | -- This is the only place we can run stuff only at the first mount 84 | didMount self@{ state } = do 85 | let setVisibility visibility = 86 | send self (UpdateVisibility visibility) 87 | -- On first mount, we start the navigation: 88 | -- we have something super simple here, in which we match on 89 | -- the hash string and execute a side effect. 90 | -- For something fancier we might want to use a parser. 91 | let matchRoutes hash = case hash of 92 | "#/" -> setVisibility All 93 | "#/active" -> setVisibility Active 94 | "#/completed" -> setVisibility Completed 95 | otherwise -> pure unit 96 | runEffectFn1 startNavigation matchRoutes 97 | 98 | -- Then we try to read if we had some state persisted in LocalStorage 99 | -- If yes, we overwrite the state with it 100 | persisted <- LocalStorage.getItem localStorageKey 101 | case persisted of 102 | Just (oldState :: State) -> send self (LoadState oldState) 103 | _ -> pure unit 104 | 105 | didUpdate self _ = do 106 | -- Here we persist the state to LocalStorage 107 | -- called after every update call that resulted in a state change. 108 | saveState self.state 109 | 110 | update self action = 111 | case action of 112 | EditNewTodo val -> 113 | Update self.state { newTodo = fromMaybe "" val} 114 | 115 | SubmitNewTodo description -> 116 | let 117 | newTodo = 118 | { description: description 119 | , id: self.state.uid 120 | , completed: false 121 | } 122 | in 123 | Update self.state 124 | { newTodo = "" 125 | , tasks = Array.cons newTodo self.state.tasks 126 | , uid = self.state.uid + 1 127 | } 128 | 129 | TaskCheck id -> 130 | let 131 | negateCheck task = 132 | if task.id == id 133 | then task { completed = not task.completed } 134 | else task 135 | in 136 | Update self.state { tasks = map negateCheck self.state.tasks } 137 | 138 | TaskUpdate id newDescription -> 139 | let 140 | updateTask task = 141 | if task.id == id 142 | then task { description = newDescription } 143 | else task 144 | in 145 | Update self.state { tasks = map updateTask self.state.tasks } 146 | 147 | TaskDelete id -> 148 | let 149 | tasks' = Array.filter ((/=) id <<< _.id) self.state.tasks 150 | in 151 | Update self.state { tasks = tasks' } 152 | 153 | ClearCompleted -> 154 | let 155 | tasks' = Array.filter (not _.completed) self.state.tasks 156 | in 157 | Update self.state { tasks = tasks' } 158 | 159 | CheckAllTasks targetChecked -> 160 | let 161 | toggle task = task { completed = fromMaybe task.completed targetChecked } 162 | tasks' = map toggle self.state.tasks 163 | in 164 | Update self.state { tasks = tasks' } 165 | 166 | UpdateVisibility vis -> 167 | Update self.state { visibility = vis } 168 | 169 | LoadState loadedState -> 170 | Update loadedState 171 | 172 | Noop -> 173 | NoUpdate 174 | 175 | -- | Pure render function 176 | render :: Self Props State -> JSX 177 | render self = 178 | classy DOM.div "todomvc-wrapper" 179 | [ classy DOM.section "todoapp" 180 | [ taskEntry self.state.newTodo onEditNewTodo onKeyDown 181 | , taskList 182 | { tasks: self.state.tasks 183 | , visibility: self.state.visibility 184 | , onCheck: onTaskCheck 185 | , onDelete: onTaskDelete 186 | , onCommit: onTaskUpdate 187 | , checkAllTasks 188 | } 189 | , Footer.footer 190 | { tasks: self.state.tasks 191 | , onClearCompleted: clearCompleted 192 | , visibility: self.state.visibility 193 | } 194 | ] 195 | ] 196 | where 197 | -- | Handler for editing the newTodo field 198 | onEditNewTodo = 199 | DOM.Events.capture DOM.Events.targetValue (send self <<< EditNewTodo) 200 | 201 | -- | Handler for submitting a new task after pressing enter 202 | onKeyDown = 203 | Events.handler DOM.Events.key 204 | \key -> send self $ 205 | case key of 206 | Just "Enter" | hasNewDescription -> SubmitNewTodo newDescription 207 | _ -> Noop 208 | where 209 | newDescription = String.trim self.state.newTodo 210 | hasNewDescription = not (String.null newDescription) 211 | 212 | 213 | -- | Action to apply when a task gets checked: 214 | -- we go through the tasks and mark that one as completed 215 | onTaskCheck id = 216 | send self (TaskCheck id) 217 | 218 | -- | Action to apply when a task has been edited: 219 | -- we go through the tasks and edit the description of it with the new value 220 | onTaskUpdate id newDescription = 221 | send self (TaskUpdate id newDescription) 222 | 223 | -- | Action to apply when deleting a task: 224 | -- we go through the list and remove the one with the same `id` 225 | onTaskDelete id = 226 | send self (TaskDelete id) 227 | 228 | -- | Action to remove all completed tasks: filter the list by active ones 229 | clearCompleted = 230 | DOM.Events.capture_ (send self ClearCompleted) 231 | 232 | -- | Handler to check all tasks that are not completed 233 | checkAllTasks = 234 | Events.handler DOM.Events.targetChecked (send self <<< CheckAllTasks) 235 | 236 | -- | View for the newTodo input 237 | taskEntry :: String -> Events.EventHandler -> Events.EventHandler -> JSX 238 | taskEntry value onEdit onKeyDown = 239 | classy DOM.header "header" 240 | [ DOM.h1_ [ DOM.text "todos" ] 241 | , DOM.input attributes 242 | ] 243 | where 244 | attributes = 245 | { className: "new-todo" 246 | , placeholder: "What needs to be done?" 247 | , autoFocus: true 248 | , value: value 249 | , name: "newTodo" 250 | , onChange: onEdit 251 | , onKeyDown: onKeyDown 252 | } 253 | 254 | type TaskListProps = 255 | { tasks :: Array Task 256 | , visibility :: Visibility 257 | , onCheck :: Int -> Effect Unit 258 | , onDelete :: Int -> Effect Unit 259 | , onCommit :: Int -> String -> Effect Unit 260 | , checkAllTasks :: Events.EventHandler 261 | } 262 | 263 | -- | View for the list of tasks 264 | taskList :: TaskListProps -> JSX 265 | taskList { tasks, visibility, onCheck, onDelete, onCommit, checkAllTasks } = 266 | DOM.section 267 | { className: "main" 268 | , style: DOM.css { visibility: if Array.null tasks then "hidden" else "visible" } 269 | , children: 270 | [ DOM.input toggleAllAttributes 271 | , DOM.label { htmlFor: "toggle-all", children: [ DOM.text "Mark all as complete" ]} 272 | , classy DOM.ul "todo-list" (map taskView (Array.filter isVisible tasks)) 273 | ] 274 | } 275 | where 276 | toggleAllAttributes = 277 | { className: "toggle-all" 278 | , id: "toggle-all" 279 | , "type": "checkbox" 280 | , checked: Array.all _.completed tasks 281 | , onChange: checkAllTasks 282 | } 283 | 284 | -- | Is a task visible? 285 | isVisible task = case visibility of 286 | Completed -> task.completed 287 | Active -> not task.completed 288 | otherwise -> true 289 | 290 | -- | Wrapper around creating a new Task component for every task 291 | taskView task = 292 | Task.component 293 | { key: task.id 294 | , task: task 295 | , onCheck: onCheck task.id 296 | , onDelete: onDelete task.id 297 | , onCommit: onCommit task.id 298 | } 299 | -------------------------------------------------------------------------------- /src/Todo/Footer.purs: -------------------------------------------------------------------------------- 1 | module Todo.Footer where 2 | 3 | import Prelude 4 | 5 | import Data.Array as Array 6 | import Data.Generic.Rep (class Generic) 7 | import Data.Generic.Rep.Show (genericShow) 8 | import React.Basic (JSX) 9 | import React.Basic as React 10 | import React.Basic.DOM as DOM 11 | import React.Basic.Events as Events 12 | import Simple.JSON (class ReadForeign, class WriteForeign) 13 | import Foreign.Generic.EnumEncoding (genericDecodeEnum, genericEncodeEnum) 14 | import Data.String (toLower) 15 | import Todo.Task (Task) 16 | import Todo.View (classy) 17 | 18 | data Visibility 19 | = All 20 | | Completed 21 | | Active 22 | 23 | derive instance eqVisibility :: Eq Visibility 24 | derive instance genericVisibility :: Generic Visibility _ 25 | instance showVisibility :: Show Visibility where 26 | show = genericShow 27 | instance readVisibility :: ReadForeign Visibility where 28 | readImpl = genericDecodeEnum { constructorTagTransform: toLower } 29 | instance writeVisibility :: WriteForeign Visibility where 30 | writeImpl = genericEncodeEnum { constructorTagTransform: toLower } 31 | 32 | 33 | type Props = 34 | { tasks :: Array Task 35 | , onClearCompleted :: Events.EventHandler 36 | , visibility :: Visibility 37 | } 38 | 39 | component :: React.Component Props 40 | component = React.createComponent "Footer" 41 | 42 | footer :: Props -> JSX 43 | footer = React.makeStateless component render 44 | 45 | render :: Props -> JSX 46 | render props = 47 | let 48 | tasksCompleted = Array.length (Array.filter _.completed props.tasks) 49 | 50 | tasksLeft = Array.length props.tasks - tasksCompleted 51 | 52 | pluralizedItem = if tasksLeft == 1 then " item" else " items" 53 | in 54 | DOM.footer 55 | { className: "footer" 56 | , hidden: Array.null props.tasks 57 | , children: 58 | [ classy DOM.span "todo-count" 59 | [ DOM.strong_ [ DOM.text (show tasksLeft) ] 60 | , DOM.text (pluralizedItem <> " left") 61 | ] 62 | , classy DOM.ul "filters" 63 | [ changeVisibilityLink "#/" All props.visibility 64 | , DOM.text " " 65 | , changeVisibilityLink "#/active" Active props.visibility 66 | , DOM.text " " 67 | , changeVisibilityLink "#/completed" Completed props.visibility 68 | ] 69 | , DOM.button 70 | { className: "clear-completed" 71 | , hidden: tasksCompleted == 0 72 | , onClick: props.onClearCompleted 73 | , children: [ DOM.text "Clear completed" ] 74 | } 75 | ] 76 | } 77 | 78 | changeVisibilityLink :: String -> Visibility -> Visibility -> JSX 79 | changeVisibilityLink uri visibility actualVisibility = 80 | DOM.li_ 81 | -- TODO: maybe here we need an onClick? 82 | [ DOM.a 83 | { className: if visibility == actualVisibility then "selected" else "" 84 | , href: uri 85 | , children: [ DOM.text (show visibility) ] 86 | } 87 | ] 88 | -------------------------------------------------------------------------------- /src/Todo/Task.purs: -------------------------------------------------------------------------------- 1 | module Todo.Task where 2 | 3 | import Prelude 4 | 5 | import Data.Maybe (Maybe(..), fromMaybe, isJust) 6 | import Data.Monoid (guard) 7 | import Data.String as String 8 | import Effect (Effect) 9 | import React.Basic (JSX, StateUpdate(..)) 10 | import React.Basic as React 11 | import React.Basic.DOM as DOM 12 | import React.Basic.DOM.Events as DOM.Events 13 | import React.Basic.Events as Events 14 | import Todo.View (classy) 15 | 16 | -- | Type of our single Todo item 17 | type Task = 18 | { description :: String 19 | , id :: Int 20 | , completed :: Boolean 21 | } 22 | 23 | -- | Every component keeps track of the fact that it's being edited, 24 | -- and what's the new value 25 | type State = { edits :: Maybe String } 26 | 27 | -- | Callbacks that we pass into the component to update the main list 28 | -- in the parent's state when things happen. 29 | -- Note: the `key` here is needed so that React can disambiguate our items on render 30 | type Props = 31 | { key :: Int 32 | , task :: Task 33 | , onCheck :: Effect Unit 34 | , onDelete :: Effect Unit 35 | , onCommit :: String -> Effect Unit 36 | } 37 | 38 | data Action 39 | = Focus 40 | | Change (Maybe String) 41 | | KeyDown (Maybe String) 42 | | Commit 43 | 44 | type SetState = (State -> State) -> Effect Unit 45 | 46 | -- | We start in a "non editing" state 47 | initialState :: State 48 | initialState = { edits: Nothing } 49 | 50 | taskComponent :: React.Component Props 51 | taskComponent = React.createComponent "Task" 52 | 53 | component :: Props -> JSX 54 | component props = React.make taskComponent 55 | { render 56 | , initialState 57 | } props 58 | 59 | render :: React.Self Props State -> JSX 60 | render self@{state, props} = 61 | DOM.li 62 | { className: classNames 63 | , children: 64 | [ classy DOM.div "view" 65 | [ DOM.input 66 | { className: "toggle" 67 | , "type": "checkbox" 68 | , checked: props.task.completed 69 | , onChange: Events.handler_ props.onCheck 70 | } 71 | , DOM.label 72 | -- Set the field in edit mode when focused 73 | { onDoubleClick: DOM.Events.capture_ (send self Focus) 74 | , children: [ DOM.text description ] 75 | } 76 | , DOM.button 77 | { className: "destroy" 78 | , onClick: Events.handler_ props.onDelete 79 | } 80 | ] 81 | , DOM.input 82 | { className: "edit" 83 | , value: description 84 | , name: "title" 85 | -- Update the input field 86 | , onChange: DOM.Events.capture DOM.Events.targetValue (send self <<< Change) 87 | -- Commit our changes to the parent component once we're done editing 88 | , onBlur: DOM.Events.capture_ (send self Commit) 89 | -- Special case some keys that might be inserted: on Enter commit the changes, 90 | -- on Esc discard them - otherwise, type normally 91 | , onKeyDown: Events.handler DOM.Events.key (send self <<< KeyDown) 92 | } 93 | ] 94 | } 95 | where 96 | send = React.runUpdate \_self -> 97 | case _ of 98 | Focus -> 99 | Update $ self.state { edits = Just self.props.task.description } 100 | 101 | Change value -> 102 | Update (self.state { edits = value }) 103 | 104 | KeyDown key -> 105 | case key of 106 | Just "Escape" -> Update $ self.state { edits = Nothing } 107 | Just "Enter" -> commitAction 108 | _ -> NoUpdate 109 | 110 | Commit -> commitAction 111 | 112 | commitAction = 113 | let newDescription = String.trim $ fromMaybe "" self.state.edits 114 | in case newDescription of 115 | "" -> 116 | NoUpdate 117 | _ -> 118 | let 119 | state' = self.state { edits = Nothing } 120 | in 121 | UpdateAndSideEffects state' (const $ self.props.onCommit newDescription) 122 | 123 | classNames = String.joinWith " " 124 | [ guard (isJust state.edits) "editing" 125 | , guard props.task.completed "completed" 126 | ] 127 | 128 | -- | The description of the task is either the edited one if present, 129 | -- or the original description 130 | description = fromMaybe props.task.description state.edits 131 | -------------------------------------------------------------------------------- /src/Todo/View.purs: -------------------------------------------------------------------------------- 1 | module Todo.View where 2 | 3 | import React.Basic (JSX) 4 | 5 | classy 6 | :: ({ className :: String, children :: Array JSX } -> JSX) 7 | -> String 8 | -> (Array JSX -> JSX) 9 | classy element className children = element { className, children } 10 | --------------------------------------------------------------------------------