├── .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 |
--------------------------------------------------------------------------------