├── .editorconfig ├── .gitignore ├── README.md ├── api.js ├── db.json ├── elm-package.json ├── package.json ├── screenshot.png ├── src ├── Main.elm ├── Messages.elm ├── Models.elm ├── Subscriptions.elm ├── Todos │ ├── Commands.elm │ ├── Edit.elm │ ├── List.elm │ ├── Messages.elm │ ├── Models.elm │ └── Update.elm ├── Update.elm ├── Utils.elm ├── View.elm ├── index.html └── index.js └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.{json,js}] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [elm-package.json] 13 | indent_style = tab 14 | indent_size = 4 15 | 16 | [*.elm] 17 | indent_style = space 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | elm-stuff 3 | node_modules 4 | npm-debug.log 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elm-todo-rest-api 2 | 3 | ![Todo Screenshot](screenshot.png) 4 | 5 | The official [elm-todomvc](https://github.com/evancz/elm-todomvc) uses localStorage to persist state. Since most 6 | real-world elm apps will interact with a web api to persist state, 7 | this is a simple elm todo app demonstrating interaction 8 | with a simple json api server. 9 | 10 | Additionally, the code here has about 10x the modularity of the official implementation. 11 | 12 | This app is sparse on css on purpose. 13 | 14 | The code is heavily-documented. If something is confusing, 15 | submit an issue or PR and I'll improve the documentation there if possible. 16 | 17 | ## Beginners 18 | 19 | I recommend following roughly this order for checking out the code: 20 | 21 | `Main → Models → Messages → Update → View` 22 | 23 | then in the `Todos` directory: 24 | 25 | `Todos.[Models → Messages → Update → Commands → View → Edit]` 26 | 27 | then if you're feeling confident: 28 | 29 | `Utils` 30 | 31 | ## Getting Started 32 | 33 | If you haven't installed Elm 0.18 yet, do so [here](http://elm-lang.org/install). 34 | 35 | elm-todo-rest-api 0.2.0 is compatible with **Elm 0.18**. If you're still on Elm 0.17, download elm-todo-rest-api 0.1.0 [here](https://github.com/andrewsuzuki/elm-todo-rest-api/releases/tag/0.1.0). 36 | 37 | First, clone this repository: 38 | 39 | ``` 40 | $ git clone https://github.com/andrewsuzuki/elm-todo-rest-api.git 41 | $ cd elm-todo-rest-api 42 | ``` 43 | 44 | Then install dependencies: 45 | 46 | ``` 47 | $ npm install 48 | $ elm package install 49 | ``` 50 | 51 | To play with the app, first start up the api: 52 | 53 | ``` 54 | $ npm run api 55 | ``` 56 | 57 | All changes will be reflected in `db.json`. 58 | 59 | Then, in a new terminal window run the following to start a hot-reloading dev server through webpack: 60 | 61 | ``` 62 | $ npm run dev 63 | ``` 64 | 65 | Visit [http://localhost:8000/](localhost:8000) in your browser and you should see the app! 66 | 67 | Make some changes and see what happens. 68 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | var jsonServer = require('json-server') 2 | 3 | // Returns an Express server 4 | var server = jsonServer.create() 5 | 6 | // Set default middlewares (logger, static, cors and no-cache) 7 | server.use(jsonServer.defaults()) 8 | 9 | var router = jsonServer.router('db.json') 10 | server.use(router) 11 | 12 | console.log('Listening at 4000') 13 | server.listen(4000) 14 | -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "todos": [ 3 | { 4 | "completed": false, 5 | "id": 1, 6 | "title": "buy milk" 7 | } 8 | ] 9 | } -------------------------------------------------------------------------------- /elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "summary": "elm todomvc with a stupid but persistent json rest api", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "src" 8 | ], 9 | "exposed-modules": [], 10 | "dependencies": { 11 | "elm-lang/core": "5.0.0 <= v < 6.0.0", 12 | "elm-lang/html": "2.0.0 <= v < 3.0.0", 13 | "elm-lang/http": "1.0.0 <= v < 2.0.0" 14 | }, 15 | "elm-version": "0.18.0 <= v < 0.19.0" 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elm-todo-rest-api", 3 | "version": "0.2.0", 4 | "description": "elm todomvc with a stupid but persistent json rest api", 5 | "main": "api.js", 6 | "scripts": { 7 | "api": "node api.js", 8 | "build": "webpack", 9 | "watch": "webpack --watch", 10 | "dev": "webpack-dev-server --port 8000" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/andrewsuzuki/elm-todomvc-with-api.git" 15 | }, 16 | "keywords": [ 17 | "elm", 18 | "todomvc", 19 | "todo", 20 | "app", 21 | "api", 22 | "ajax", 23 | "json", 24 | "persistent", 25 | "database", 26 | "effects", 27 | "tasks" 28 | ], 29 | "author": "Andrew Suzuki", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/andrewsuzuki/elm-todomvc-with-api/issues" 33 | }, 34 | "homepage": "https://github.com/andrewsuzuki/elm-todomvc-with-api#readme", 35 | "dependencies": { 36 | "css-loader": "^0.23.1", 37 | "elm-webpack-loader": "^3.0.3", 38 | "file-loader": "^0.9.0", 39 | "json-server": "^0.8.14", 40 | "lodash": "^4.13.1", 41 | "style-loader": "^0.13.1", 42 | "url-loader": "^0.5.7", 43 | "webpack": "^1.13.1", 44 | "webpack-dev-middleware": "^1.6.1", 45 | "webpack-dev-server": "^1.14.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewsuzuki/elm-todo-rest-api/45dd246ea89ee5e948d3e9ec0dfbfa1d36c597d5/screenshot.png -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (..) 2 | 3 | import Html 4 | import Messages exposing (..) 5 | import Models exposing (init, Model) 6 | import View exposing (view) 7 | import Update exposing (update) 8 | import Subscriptions exposing (subscriptions) 9 | 10 | 11 | -- this is the entry point into our application 12 | -- notice how there is no mention of "Todo" anywhere! 13 | -- everything having to do with Todos is modularized and encapsulated 14 | 15 | 16 | main : Program Never Model Msg 17 | main = 18 | Html.program 19 | { init = init 20 | , view = view 21 | , update = update 22 | , subscriptions = subscriptions 23 | } 24 | -------------------------------------------------------------------------------- /src/Messages.elm: -------------------------------------------------------------------------------- 1 | module Messages exposing (..) 2 | 3 | import Todos.Messages 4 | 5 | 6 | -- this is our "root" Msg, a union type representing 7 | -- the different actions that can be applied to the entire app state. 8 | -- any "tag" or "subtype" in a union type can have associated data 9 | -- additionally, any tag then becomes a function that accepts 10 | -- that same associated data. this can be seen in action 11 | -- later in the application. 12 | 13 | 14 | type Msg 15 | = -- "no operation" = do nothing. it's a good thing to include 16 | -- in any Msg type you create -- think of it as the "control" 17 | -- in a research project 18 | NoOp 19 | -- since we want to keep our app modular, we want to forward 20 | -- anything having to do with the concept of todos to a 21 | -- different Msg module, in this case Todos.Messages.Msg. 22 | -- since Todos.Messages.Msg is a union type itself, 23 | -- we need to surround it with TodosMsg here to prevent 24 | -- naming collisions. For example, if I had a NoOp in 25 | -- Todos.Message.Msg, then that would collide with the NoOp 26 | -- in *this* Msg. Therefore, it needs to be wrapped. 27 | | TodosMsg Todos.Messages.Msg 28 | -------------------------------------------------------------------------------- /src/Models.elm: -------------------------------------------------------------------------------- 1 | module Models exposing (..) 2 | 3 | -- notice how we can import the sub-types of a union type 4 | -- if we wanted to just import them all, we could use (..) 5 | -- but i think it's usually better to be explicit about imports 6 | -- unless you truly need everything (like in Todos.Update) 7 | 8 | import Messages exposing (Msg(TodosMsg)) 9 | import Todos.Models exposing (Todo, TodoEditView(None)) 10 | import Todos.Commands 11 | 12 | 13 | -- this is our "root" model 14 | -- all app state is stored in this record 15 | 16 | 17 | type alias Model = 18 | { todos : List Todo -- the actual list of Todos 19 | , todoEditView : TodoEditView 20 | } 21 | 22 | 23 | 24 | -- see Todos.Models.TodoEditView 25 | -- this is the initial state for the application 26 | -- this is supplied to Html.App.Program in Main 27 | 28 | 29 | init : ( Model, Cmd Msg ) 30 | init = 31 | let 32 | -- type aliases can be used as functions, with the arguments 33 | -- being in the same order they were declared in (above). 34 | -- here we're creating the initial model with an empty list of Todos 35 | -- and the "None" state for the todoEditView 36 | model = 37 | Model [] None 38 | 39 | -- ...instead we could have done: 40 | -- model = { todos = [], todoEditView = None } 41 | -- on initial load, we can use Cmd.batch to return a list 42 | -- of commands as a single command. 43 | -- here it's not really necessary since we only have one command 44 | cmds = 45 | Cmd.batch 46 | -- create a single command to fetch todos from api, 47 | -- mapping it with the "root" Messages.Msg.TodosMsg 48 | [ Cmd.map TodosMsg Todos.Commands.fetchAll ] 49 | in 50 | ( model, cmds ) 51 | -------------------------------------------------------------------------------- /src/Subscriptions.elm: -------------------------------------------------------------------------------- 1 | module Subscriptions exposing (..) 2 | 3 | import Models exposing (Model) 4 | import Messages exposing (Msg) 5 | 6 | 7 | -- these are our "root" subscriptions, 8 | -- but we don't need any for this application. 9 | 10 | 11 | subscriptions : Model -> Sub Msg 12 | subscriptions model = 13 | Sub.none 14 | 15 | 16 | 17 | -- for a good intro to subscriptions, see the elm guide and its "Time" example: 18 | -- http://guide.elm-lang.org/architecture/effects/time.html 19 | -------------------------------------------------------------------------------- /src/Todos/Commands.elm: -------------------------------------------------------------------------------- 1 | module Todos.Commands exposing (..) 2 | 3 | import Http 4 | import Task 5 | import Json.Decode 6 | import Json.Encode 7 | import String 8 | import Todos.Models exposing (Todo) 9 | import Todos.Messages exposing (Msg(..)) 10 | import Utils 11 | 12 | 13 | -- for a better introduction to commands, tasks, and Json.Decode, see here: 14 | -- http://www.elm-tutorial.org/en/06-fetching-resources/04-players-cmds.html 15 | 16 | 17 | resourceUrl : String 18 | resourceUrl = 19 | "http://localhost:4000/todos" 20 | 21 | 22 | 23 | -- take a todo id and return its endpoint 24 | 25 | 26 | singleUrl : Int -> String 27 | singleUrl id = 28 | String.join "/" [ resourceUrl, (toString id) ] 29 | 30 | 31 | 32 | -- 33 | -- Decoding 34 | -- 35 | -- yes, the json->elm conversion is a pain since a json string can be *anything* 36 | -- and elm is a strongly typed programming language. 37 | -- check out the json-to-elm project for writing quick json decoders 38 | -- https://github.com/eeue56/json-to-elm 39 | -- json decoder for todos list 40 | 41 | 42 | todosDecoder : Json.Decode.Decoder (List Todo) 43 | todosDecoder = 44 | -- notice how decoders are composable 45 | Json.Decode.list todoDecoder 46 | 47 | 48 | 49 | -- json decoder for single todo 50 | 51 | 52 | todoDecoder : Json.Decode.Decoder Todo 53 | todoDecoder = 54 | Json.Decode.map3 Todo 55 | (Json.Decode.field "id" Json.Decode.int) 56 | (Json.Decode.field "title" Json.Decode.string) 57 | (Json.Decode.field "completed" Json.Decode.bool) 58 | 59 | 60 | 61 | -- 62 | -- Encoding 63 | -- 64 | 65 | 66 | todoEncoder : String -> Bool -> Json.Encode.Value 67 | todoEncoder title completed = 68 | let 69 | encodings = 70 | [ ( "title", Json.Encode.string title ) 71 | , ( "completed", Json.Encode.bool completed ) 72 | ] 73 | in 74 | encodings 75 | |> Json.Encode.object 76 | 77 | 78 | 79 | -- 80 | -- Fetch 81 | -- 82 | -- fetch all todos 83 | 84 | 85 | fetchAll : Cmd Msg 86 | fetchAll = 87 | let 88 | request = Http.get resourceUrl todosDecoder 89 | in 90 | Http.send FetchAllDone request 91 | 92 | 93 | 94 | -- 95 | -- Create 96 | -- 97 | -- create todo 98 | 99 | 100 | create : String -> Cmd Msg 101 | create title = 102 | let 103 | task = Utils.postJson todoDecoder resourceUrl 104 | <| todoEncoder title False 105 | in 106 | Task.attempt CreateDone task 107 | 108 | 109 | 110 | -- 111 | -- Patch (Update) 112 | -- 113 | -- patch todo 114 | 115 | 116 | patch : Todo -> Cmd Msg 117 | patch { id, title, completed } = 118 | let 119 | task = Utils.patchJson todoDecoder (singleUrl id) 120 | <| todoEncoder title completed 121 | in 122 | Task.attempt PatchDone task 123 | 124 | 125 | 126 | -- 127 | -- Delete 128 | -- 129 | 130 | 131 | delete : Todo -> Cmd Msg 132 | delete todo = 133 | Task.attempt DeleteDone <| Utils.delete todo (singleUrl todo.id) 134 | -------------------------------------------------------------------------------- /src/Todos/Edit.elm: -------------------------------------------------------------------------------- 1 | module Todos.Edit exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (placeholder, value) 5 | import Html.Events exposing (onClick, onInput) 6 | import Todos.Messages exposing (Msg(ShowEditView, ChangeTitle, CreateOrPatch)) 7 | import Todos.Models exposing (Todo, TodoEditView(..)) 8 | 9 | 10 | -- this module contains the todo edit view 11 | -- it explains itself for the most part, it is just html 12 | 13 | 14 | view : TodoEditView -> Html Msg 15 | view ev = 16 | div [] 17 | -- handle the different cases of the TodoEditView 18 | [ case ev of 19 | None -> 20 | button 21 | -- on click, dispatch a message 22 | -- TodoEditView holds the value of the text box, 23 | -- so we can initialize it with an empty string 24 | -- notice how the <| operator is needed here 25 | -- to tell elm which arguments belong to what 26 | [ onClick <| ShowEditView <| New "" ] 27 | [ text "Create New Todo" ] 28 | 29 | New title -> 30 | div [] 31 | [ h2 [] [ text "New Todo" ] 32 | , editingInputs title 33 | ] 34 | 35 | Editing { title } -> 36 | div [] 37 | [ h2 [] [ text <| "Editing Todo: " ++ title ] 38 | , editingInputs title 39 | ] 40 | ] 41 | 42 | 43 | 44 | -- the "new" and "editing" forms are identical, 45 | -- so they can be extracted into a separate component (editingInputs) 46 | 47 | 48 | editingInputs : String -> Html Msg 49 | editingInputs title = 50 | div [] 51 | [ button 52 | [ onClick <| ShowEditView None ] 53 | [ text "Back" ] 54 | , input 55 | [ value title 56 | , placeholder "Title" 57 | , onInput ChangeTitle 58 | ] 59 | [] 60 | , button 61 | [ onClick CreateOrPatch ] 62 | [ text "Save" ] 63 | ] 64 | -------------------------------------------------------------------------------- /src/Todos/List.elm: -------------------------------------------------------------------------------- 1 | module Todos.List exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (style) 5 | import Html.Events exposing (onClick) 6 | import Todos.Messages exposing (Msg(ShowEditView, Revert, Complete, Patch, Delete, DeleteCompleted)) 7 | import Todos.Models exposing (Todo, TodoEditView(Editing)) 8 | 9 | 10 | -- this module contains the todo list view 11 | -- it explains itself for the most part, it is just html 12 | -- just some common styles for table cells (th and td) 13 | 14 | 15 | cellStyle : List ( String, String ) 16 | cellStyle = 17 | [ ( "textAlign", "left" ) 18 | , ( "padding", "10px" ) 19 | ] 20 | 21 | 22 | 23 | -- make a cell (th or td) with the pre-set style attribute 24 | 25 | 26 | cell el children = 27 | el [ style cellStyle ] children 28 | 29 | 30 | 31 | -- the main view here is a table with headers and body rows for each todo 32 | 33 | 34 | view : List Todo -> Html Msg 35 | view todos = 36 | div [] 37 | [ table [] 38 | [ thead [] 39 | [ cell th [ text "Id" ] 40 | , cell th [ text "Title" ] 41 | , cell th [ text "Completed?" ] 42 | , cell th [ text "Actions" ] 43 | ] 44 | -- below, we keep things modular by mapping a todo row view to every todo 45 | , tbody [] <| List.map todo <| todos 46 | -- note: 47 | -- instead, the above could have been: 48 | -- tbody [] (List.map todo todos) 49 | -- but, it does demonstrate a good use of the 50 | -- right-to-left function application operator 51 | ] 52 | , footer 53 | ] 54 | 55 | 56 | 57 | -- a single todo row 58 | 59 | 60 | todo : Todo -> Html Msg 61 | todo t = 62 | let 63 | -- destructure our todo 64 | { id, title, completed } = t 65 | 66 | -- decide on some UI text/actions based on todo completed status 67 | ( completedText, buttonText, buttonMsg ) = 68 | if completed then 69 | ( "Yes", "Revert", Revert ) 70 | else 71 | ( "No", "Done", Complete ) 72 | in 73 | tr [] 74 | [ cell td [ text <| toString id ] 75 | , cell td [ text title ] 76 | , cell td [ text completedText ] 77 | , cell td 78 | [ button 79 | [ onClick <| buttonMsg t ] 80 | [ text buttonText ] 81 | , button 82 | [ onClick <| ShowEditView <| Editing t ] 83 | [ text "Edit" ] 84 | , button 85 | [ onClick <| Delete t ] 86 | [ text "Delete" ] 87 | ] 88 | ] 89 | 90 | 91 | 92 | -- footer 93 | 94 | 95 | footer : Html Msg 96 | footer = 97 | div [] 98 | [ br [] [] 99 | , button 100 | [ onClick DeleteCompleted ] 101 | [ text "Clear Completed" ] 102 | ] 103 | -------------------------------------------------------------------------------- /src/Todos/Messages.elm: -------------------------------------------------------------------------------- 1 | module Todos.Messages exposing (..) 2 | 3 | import Http 4 | import Todos.Models exposing (Todo, TodoEditView) 5 | 6 | 7 | -- messages relevant to todos 8 | -- more thorough explanations can be 9 | -- found in the Todos.Update module 10 | 11 | 12 | type Msg 13 | = -- "no operation" 14 | NoOp 15 | -- http task success/fail messages 16 | -- (type variables with the response's return data) 17 | | Fail Http.Error 18 | | FetchAllDone (Result Http.Error (List Todo)) 19 | | CreateDone (Result Http.Error Todo) 20 | | PatchDone (Result Http.Error Todo) 21 | | DeleteDone (Result Http.Error Todo) 22 | -- these are relevant to the Todos.Edit view 23 | | ShowEditView TodoEditView 24 | | ChangeTitle String 25 | | CreateOrPatch 26 | -- these are relevant to the Todos.List view 27 | -- also, these trigger http commands 28 | | Complete Todo 29 | | Revert Todo 30 | | Patch Todo 31 | | Delete Todo 32 | | DeleteCompleted 33 | -------------------------------------------------------------------------------- /src/Todos/Models.elm: -------------------------------------------------------------------------------- 1 | module Todos.Models exposing (..) 2 | 3 | -- this is the model for a single Todo. 4 | -- note that the root Model has little knowledge on what a Todo "is"; 5 | -- it is encapsulated within the Todos namespace 6 | 7 | 8 | type alias Todo = 9 | { id : Int 10 | , title : String 11 | , completed : Bool 12 | } 13 | 14 | 15 | 16 | -- this is a union type representing the possible states of the edit view 17 | -- (nothing, new todo, or editing an existing todo) 18 | 19 | 20 | type TodoEditView 21 | = -- if None, then just show the "Create New Todo" button 22 | None 23 | -- the String here represents the value currently in the text box 24 | | New String 25 | -- similar, but here it's the Todo.title that holds the text box value 26 | | Editing Todo 27 | -------------------------------------------------------------------------------- /src/Todos/Update.elm: -------------------------------------------------------------------------------- 1 | module Todos.Update exposing (..) 2 | 3 | -- we want Msg along with all of its subtypes automatically (..) 4 | 5 | import Todos.Messages exposing (Msg(..)) 6 | import Todos.Models exposing (TodoEditView(..), Todo) 7 | import Todos.Commands 8 | import Utils 9 | 10 | 11 | -- handle messages relevant to model.todos 12 | 13 | 14 | update : Msg -> TodoEditView -> List Todo -> ( TodoEditView, List Todo, Cmd Msg ) 15 | update msg ev todos = 16 | case msg of 17 | -- "no operation" 18 | NoOp -> 19 | ( ev, todos, Cmd.none ) 20 | 21 | -- show a new edit view 22 | -- used by the "Edit", Create New Todo", and "Back" buttons 23 | ShowEditView nev -> 24 | ( nev, todos, Cmd.none ) 25 | 26 | -- if we're editing a new todo, then this updates the nested 27 | -- type variable representing the title 28 | -- if we're editing an existing todo, then this updated the 29 | -- title "payload" inside the nested type variable representing the todo 30 | ChangeTitle title -> 31 | let 32 | nev = 33 | case ev of 34 | None -> 35 | ev 36 | 37 | New _ -> 38 | New title 39 | 40 | Editing todo -> 41 | Editing { todo | title = title } 42 | in 43 | ( nev, todos, Cmd.none ) 44 | 45 | -- this is matched when there is an http error 46 | -- it gives us an Http.Error, but we don't need it, 47 | -- so we'll just use an underscore _ to denote that 48 | Fail _ -> 49 | -- disregard error; do nothing 50 | ( ev, todos, Cmd.none ) 51 | 52 | -- fetch all success 53 | FetchAllDone res -> 54 | case res of 55 | Result.Ok newTodos -> 56 | ( ev, newTodos, Cmd.none ) 57 | Result.Err _ -> 58 | ( ev, todos, Cmd.none ) 59 | 60 | -- create success...merge in the new todo 61 | CreateDone res -> 62 | case res of 63 | Result.Ok todo -> 64 | ( ev, Utils.mergeById todos todo, Cmd.none ) 65 | Result.Err _ -> 66 | ( ev, todos, Cmd.none ) 67 | 68 | -- patch success...merge in the new todo 69 | PatchDone res -> 70 | case res of 71 | Result.Ok newTodo -> 72 | ( ev, Utils.mergeById todos newTodo, Cmd.none ) 73 | Result.Err _ -> 74 | ( ev, todos, Cmd.none ) 75 | 76 | -- delete success...remove the old todo 77 | DeleteDone res -> 78 | case res of 79 | Result.Ok todo -> 80 | ( ev, Utils.removeById todos todo, Cmd.none ) 81 | Result.Err _ -> 82 | ( ev, todos, Cmd.none ) 83 | 84 | -- this is dispatched whenever the "save" button is clicked 85 | CreateOrPatch -> 86 | let 87 | cmd = 88 | case ev of 89 | None -> 90 | Cmd.none 91 | 92 | -- create a new todo 93 | New title -> 94 | Todos.Commands.create title 95 | 96 | -- patch an existing todo 97 | Editing todo -> 98 | Todos.Commands.patch todo 99 | 100 | -- exit edit view (using None) and give elm our command 101 | -- see note below in Complete 102 | in 103 | ( None, todos, cmd ) 104 | 105 | -- this is matched when "Done is clicked" 106 | Complete todo -> 107 | let 108 | -- make the new todo 109 | newTodo = 110 | { todo | completed = True } 111 | 112 | -- if we want optimistic updates, we can make the changes ourselves 113 | -- AND dispatch the patch command. 114 | -- newTodos = Utils.mergeById todos newTodo 115 | -- instead, we'll let PatchDone do the updating for us 116 | -- if this were a mobile application we might want 117 | -- to apply updates optimistically instead (since 118 | -- internet is usually slower) 119 | newTodos = 120 | todos 121 | in 122 | ( ev, newTodos, Todos.Commands.patch newTodo ) 123 | 124 | -- this is matched when "Revert" is clicked 125 | Revert todo -> 126 | let 127 | -- make the new todo 128 | newTodo = 129 | { todo | completed = False } 130 | 131 | -- see note above in Complete 132 | in 133 | ( ev, todos, Todos.Commands.patch newTodo ) 134 | 135 | -- this is a generic Patch for a todo that has already been altered 136 | Patch todo -> 137 | -- see note above in Complete 138 | ( ev, todos, Todos.Commands.patch todo ) 139 | 140 | -- this is matched when "Delete" is clicked 141 | Delete todo -> 142 | -- see note above in Complete 143 | ( ev, todos, Todos.Commands.delete todo ) 144 | 145 | -- this is matched when "Clear Completed" is clicked 146 | -- in the real world, this would probably be a separate endpoint 147 | -- but we'll just create separate delete commands for the 148 | -- todos that are completed 149 | DeleteCompleted -> 150 | let 151 | cmds = 152 | todos 153 | -- filter completed todos 154 | -- similar to clojure, we can use a "dot notation" 155 | -- to make a field-accessing function (.completed) 156 | |> List.filter .completed 157 | -- attach a delete command to each 158 | |> List.map Todos.Commands.delete 159 | in 160 | ( ev, todos, Cmd.batch cmds ) 161 | -------------------------------------------------------------------------------- /src/Update.elm: -------------------------------------------------------------------------------- 1 | module Update exposing (..) 2 | 3 | import Models exposing (Model) 4 | import Messages exposing (Msg(NoOp, TodosMsg)) 5 | import Todos.Update 6 | 7 | 8 | -- this is our "root" update, which is called many times 9 | -- throughout the lifecycle of the application, 10 | -- any time it receives a new Msg. 11 | -- when we receive a new Msg, 12 | -- we can decide how it affects the Model (app state), 13 | -- as well as which commands we might want to dispatch. 14 | -- notice that we must return a tuple of the new model and the command together! 15 | -- this is because elm, like pretty much all languages, can only return one value 16 | 17 | 18 | update : Msg -> Model -> ( Model, Cmd Msg ) 19 | update msg model = 20 | -- since Msg is a union type, we need to handle 21 | -- every single case in a case..of statement 22 | case msg of 23 | -- "no operation", so return the model exactly how we received it 24 | -- Cmd.none means "i don't want to run any commands this time" 25 | NoOp -> 26 | ( model, Cmd.none ) 27 | 28 | -- for anything having to do with Todos, use the todo-specific 29 | -- update function that we specify in Todos.Update 30 | -- for more info on why this needs to be done, see the root Messages.elm 31 | -- 32 | -- since TodosMsg has a "payload" (a Todos.Messages.Msg), 33 | -- we let a different update function very similar to this one 34 | -- handle any updates relevant to Todos. here we can unpack 35 | -- that subMsg right in the case. 36 | TodosMsg subMsg -> 37 | -- a let..in statement lets us do some "setup" by creating new vars 38 | -- which are limited in scope to the body in the "in" part of the statement 39 | let 40 | -- this is called "destructuring". since Todos.Update.update 41 | -- always returns a tuple with three elements, we can give names 42 | -- to those three elements easily 43 | ( newTodoEditView, newTodos, cmd ) = 44 | -- here we call Todos.Update.update with the subMsg 45 | -- and any parts of the root model it might want to update 46 | Todos.Update.update subMsg model.todoEditView model.todos 47 | 48 | -- since the model is an immutable record, we can create 49 | -- a new record while changing the values of it that 50 | -- Todos.Update.update might have changed 51 | -- the vertical bar ("|") can be read as "such that" 52 | newModel = 53 | { model | todoEditView = newTodoEditView, todos = newTodos } 54 | in 55 | -- return our updated model as well as any commands 56 | -- the sub-update gave us. We need to tag those commands first 57 | -- with the TodosMsg (see Messages.elm for explanation) 58 | ( newModel, Cmd.map TodosMsg cmd ) 59 | -------------------------------------------------------------------------------- /src/Utils.elm: -------------------------------------------------------------------------------- 1 | module Utils exposing (..) 2 | 3 | import Json.Decode 4 | import Json.Encode 5 | import Http 6 | import Task 7 | 8 | 9 | -- NOTE this file won't help you learn the elm architecture, 10 | -- but it will probably help you understand elm a bit more. 11 | -- this is a "record type", which is essentially a composable record 12 | -- see http://elm-lang.org/docs/records and scroll to the bottom 13 | -- it says "I match any record as long as it has an integer id field" 14 | 15 | 16 | type alias RecordWithId a = 17 | { a | id : Int } 18 | 19 | 20 | 21 | -- merge a new RecordWithId into an existing list of RecordWithIds, 22 | -- replacing the old RecordWithId(s) that have the same id, 23 | -- or if there aren't any existing then append it to the end 24 | 25 | 26 | mergeById : List (RecordWithId a) -> RecordWithId a -> List (RecordWithId a) 27 | mergeById existing new = 28 | let 29 | -- first, this is the reducing function. 30 | -- it takes an arbitrary existing record and compares 31 | -- it with the new record to be merged in, then decides 32 | -- if it should be replaced with the new record. 33 | -- it also tracks if that replacement has been made yet (found) 34 | merger = 35 | -- this is an anonymous function 36 | \candidate ( found, els ) -> 37 | if new.id == candidate.id then 38 | -- mark as found, disregard old (candidate) by conssing new 39 | -- to "cons" means to add to the head of the list 40 | ( True, new :: els ) 41 | else 42 | -- doesn't match, so just include it as-is 43 | ( found, candidate :: els ) 44 | 45 | -- destructure the result of the reduction 46 | ( found, coalesced ) = 47 | -- foldl is the same as "reduce" in js, lisp, etc 48 | -- there is also List.foldr for reducing from the right 49 | List.foldl merger ( False, [] ) existing 50 | 51 | -- if a replacement wasn't already made, then just cons the new one 52 | newListReversed = 53 | if found then 54 | coalesced 55 | else 56 | new :: coalesced 57 | in 58 | -- since the cons operator (::) adds to the head of the list, 59 | -- our original list is now in reverse. let's reverse it back to normal 60 | List.reverse newListReversed 61 | 62 | 63 | removeById : List (RecordWithId a) -> RecordWithId a -> List (RecordWithId a) 64 | removeById existing target = 65 | let 66 | -- another anonymous function 67 | filterFn = 68 | \a b -> 69 | a.id /= b.id 70 | in 71 | -- since functions in elm curry, we can use partial application 72 | -- to make a filterFn (filter function) that uses our target with (filterFn target) 73 | -- then you might think if the filtering function as effectively 74 | -- filterFn = \b -> target.id /= b.id 75 | -- (a function with just one parameter now) 76 | List.filter (filterFn target) existing 77 | 78 | 79 | 80 | -- Http supplies a util to post already, but it uses a plaintext 81 | -- content-type header, while we are sending json to our server. 82 | -- to make a reqest, we need a "decoder" to decode the return value, 83 | -- a url to make the request to, and a json value to send as the body 84 | 85 | 86 | postJson : Json.Decode.Decoder value -> String -> Json.Encode.Value -> Platform.Task Http.Error value 87 | postJson decoder url json = 88 | let 89 | body = Http.stringBody "application/json" (Json.Encode.encode 0 json) 90 | in 91 | Http.toTask (Http.post url body decoder) 92 | 93 | 94 | -- issue a PATCH request using a Json.Encode.Value 95 | -- see explanation in postJson above 96 | 97 | 98 | patchJson : Json.Decode.Decoder value -> String -> Json.Encode.Value -> Platform.Task Http.Error value 99 | patchJson decoder url json = 100 | let 101 | body = Http.stringBody "application/json" (Json.Encode.encode 0 json) 102 | 103 | request = Http.request 104 | { method = "PATCH" 105 | , headers = [] 106 | , url = url 107 | , body = body 108 | , expect = Http.expectJson decoder 109 | , timeout = Maybe.Nothing 110 | , withCredentials = False 111 | } 112 | in 113 | Http.toTask request 114 | 115 | 116 | 117 | -- issue a DELETE request 118 | -- see explanation in postJson above 119 | 120 | 121 | delete : a -> String -> Platform.Task Http.Error a 122 | delete a url = 123 | let 124 | decoder = 125 | -- since the api returns an empty object on delete success, 126 | -- let's have the success value be the value that was 127 | -- passed in originally so it can be used elsewhere 128 | -- to remove itself 129 | Json.Decode.succeed a 130 | 131 | request = Http.request 132 | { method = "DELETE" 133 | , headers = [] 134 | , url = url 135 | , body = Http.emptyBody 136 | , expect = Http.expectJson decoder 137 | , timeout = Maybe.Nothing 138 | , withCredentials = False 139 | } 140 | in 141 | Http.toTask request 142 | -------------------------------------------------------------------------------- /src/View.elm: -------------------------------------------------------------------------------- 1 | module View exposing (..) 2 | 3 | import Html exposing (..) 4 | import Html.Attributes exposing (style, href, target) 5 | import Html exposing(map) 6 | import Models exposing (Model) 7 | import Messages exposing (Msg(TodosMsg)) 8 | import Todos.Edit 9 | import Todos.List 10 | 11 | 12 | -- this is our "root" view. the entire appearance of our application 13 | -- is derived here from the app state (model) 14 | 15 | 16 | view : Model -> Html Msg 17 | view model = 18 | div [] 19 | [ siteHeader 20 | -- render the todos edit and list sub-views using pars of the model, 21 | -- then "tag" outgoing messages with TodosMsg 22 | , Html.map TodosMsg <| Todos.Edit.view model.todoEditView 23 | , br [] [] 24 | , Html.map TodosMsg <| Todos.List.view model.todos 25 | ] 26 | 27 | 28 | siteHeader : Html Msg 29 | siteHeader = 30 | header [] 31 | [ h1 [] [ text "Elm Todos" ] 32 | , p [] 33 | [ text "Built with " 34 | , atb "http://elm-lang.org" "Elm" 35 | , text " ♥" 36 | ] 37 | , p [] 38 | [ text "Created by Andrew Suzuki" 39 | , pipeDivider 40 | , atb "https://github.com/andrewsuzuki" "github" 41 | , pipeDivider 42 | , atb "https://github.com/andrewsuzuki/elm-todomvc-with-api" "source" 43 | ] 44 | ] 45 | 46 | 47 | pipeDivider : Html Msg 48 | pipeDivider = 49 | text " | " 50 | 51 | 52 | atb : String -> String -> Html Msg 53 | atb url title = 54 | a 55 | [ href url 56 | , target "_blank" 57 | ] 58 | [ text title ] 59 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Elm TodoMVC with API 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // require index.html so it gets copied to dist 4 | require('./index.html'); 5 | 6 | // use the webpack elm loader to load elm code directly 7 | var Elm = require('./Main.elm'); 8 | 9 | // find the root dom node we'll mount our app in 10 | var mountNode = document.getElementById('main'); 11 | 12 | // mount elm app 13 | var app = Elm.Main.embed(mountNode); 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | 3 | module.exports = { 4 | entry: { 5 | app: [ 6 | './src/index.js' 7 | ] 8 | }, 9 | 10 | output: { 11 | path: path.resolve(__dirname + '/dist'), 12 | filename: '[name].js', 13 | }, 14 | 15 | module: { 16 | loaders: [ 17 | { 18 | test: /\.(css|scss)$/, 19 | loaders: [ 20 | 'style-loader', 21 | 'css-loader', 22 | ] 23 | }, 24 | { 25 | test: /\.html$/, 26 | exclude: /node_modules/, 27 | loader: 'file?name=[name].[ext]', 28 | }, 29 | { 30 | test: /\.elm$/, 31 | exclude: [/elm-stuff/, /node_modules/], 32 | loader: 'elm-webpack', 33 | }, 34 | { 35 | test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, 36 | loader: 'url-loader?limit=10000&minetype=application/font-woff', 37 | }, 38 | { 39 | test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, 40 | loader: 'file-loader', 41 | }, 42 | ], 43 | 44 | noParse: /\.elm$/, 45 | }, 46 | 47 | devServer: { 48 | inline: true, 49 | stats: { colors: true }, 50 | }, 51 | 52 | 53 | }; 54 | --------------------------------------------------------------------------------