This tutorial includes: - 7 progressive lessons covering Elm fundamentals - Exercises with starter code - Solutions for exercises - Final project: Task Manager app with localStorage persistence Topics covered: - Basic syntax and types - Functions and functional programming concepts - The Elm Architecture (Model-View-Update) - Lists and Maybe types - HTTP requests and JSON decoding - Ports for JavaScript interop
857 lines
19 KiB
Markdown
857 lines
19 KiB
Markdown
# Lesson 7: Final Project - Building a Complete App
|
|
|
|
## Learning Goals
|
|
|
|
In this final lesson, you will:
|
|
- Apply everything you've learned
|
|
- Build a complete, functional application
|
|
- Understand how to structure larger Elm projects
|
|
- Learn about ports for JavaScript interop
|
|
|
|
## Project: Task Manager
|
|
|
|
We'll build a task manager with:
|
|
- Add, complete, and delete tasks
|
|
- Filter tasks (all, active, completed)
|
|
- Persist tasks to localStorage (using ports)
|
|
- Clean, modular code structure
|
|
|
|
## Setting Up
|
|
|
|
```bash
|
|
mkdir task-manager
|
|
cd task-manager
|
|
elm init
|
|
```
|
|
|
|
## The Complete Application
|
|
|
|
Create `src/Main.elm`:
|
|
|
|
```elm
|
|
port module Main exposing (main)
|
|
|
|
import Browser
|
|
import Html exposing (..)
|
|
import Html.Attributes exposing (..)
|
|
import Html.Events exposing (..)
|
|
import Json.Decode as Decode exposing (Decoder)
|
|
import Json.Encode as Encode
|
|
|
|
|
|
|
|
-- PORTS
|
|
|
|
|
|
port saveTasks : Encode.Value -> Cmd msg
|
|
|
|
|
|
port loadTasks : (Decode.Value -> msg) -> Sub msg
|
|
|
|
|
|
|
|
-- MODEL
|
|
|
|
|
|
type alias Task =
|
|
{ id : Int
|
|
, description : String
|
|
, completed : Bool
|
|
}
|
|
|
|
|
|
type Filter
|
|
= All
|
|
| Active
|
|
| Completed
|
|
|
|
|
|
type alias Model =
|
|
{ tasks : List Task
|
|
, newTask : String
|
|
, nextId : Int
|
|
, filter : Filter
|
|
}
|
|
|
|
|
|
init : Decode.Value -> ( Model, Cmd Msg )
|
|
init flags =
|
|
let
|
|
loadedTasks =
|
|
case Decode.decodeValue tasksDecoder flags of
|
|
Ok tasks ->
|
|
tasks
|
|
|
|
Err _ ->
|
|
[]
|
|
|
|
nextId =
|
|
case List.maximum (List.map .id loadedTasks) of
|
|
Just maxId ->
|
|
maxId + 1
|
|
|
|
Nothing ->
|
|
1
|
|
in
|
|
( { tasks = loadedTasks
|
|
, newTask = ""
|
|
, nextId = nextId
|
|
, filter = All
|
|
}
|
|
, Cmd.none
|
|
)
|
|
|
|
|
|
|
|
-- DECODERS & ENCODERS
|
|
|
|
|
|
taskDecoder : Decoder Task
|
|
taskDecoder =
|
|
Decode.map3 Task
|
|
(Decode.field "id" Decode.int)
|
|
(Decode.field "description" Decode.string)
|
|
(Decode.field "completed" Decode.bool)
|
|
|
|
|
|
tasksDecoder : Decoder (List Task)
|
|
tasksDecoder =
|
|
Decode.list taskDecoder
|
|
|
|
|
|
encodeTask : Task -> Encode.Value
|
|
encodeTask task =
|
|
Encode.object
|
|
[ ( "id", Encode.int task.id )
|
|
, ( "description", Encode.string task.description )
|
|
, ( "completed", Encode.bool task.completed )
|
|
]
|
|
|
|
|
|
encodeTasks : List Task -> Encode.Value
|
|
encodeTasks tasks =
|
|
Encode.list encodeTask tasks
|
|
|
|
|
|
|
|
-- UPDATE
|
|
|
|
|
|
type Msg
|
|
= UpdateNewTask String
|
|
| AddTask
|
|
| ToggleTask Int
|
|
| DeleteTask Int
|
|
| SetFilter Filter
|
|
| ClearCompleted
|
|
| LoadedTasks Decode.Value
|
|
|
|
|
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
update msg model =
|
|
case msg of
|
|
UpdateNewTask text ->
|
|
( { model | newTask = text }, Cmd.none )
|
|
|
|
AddTask ->
|
|
if String.isEmpty (String.trim model.newTask) then
|
|
( model, Cmd.none )
|
|
|
|
else
|
|
let
|
|
newTask =
|
|
{ id = model.nextId
|
|
, description = String.trim model.newTask
|
|
, completed = False
|
|
}
|
|
|
|
newTasks =
|
|
model.tasks ++ [ newTask ]
|
|
in
|
|
( { model
|
|
| tasks = newTasks
|
|
, newTask = ""
|
|
, nextId = model.nextId + 1
|
|
}
|
|
, saveTasks (encodeTasks newTasks)
|
|
)
|
|
|
|
ToggleTask id ->
|
|
let
|
|
toggleTask task =
|
|
if task.id == id then
|
|
{ task | completed = not task.completed }
|
|
|
|
else
|
|
task
|
|
|
|
newTasks =
|
|
List.map toggleTask model.tasks
|
|
in
|
|
( { model | tasks = newTasks }
|
|
, saveTasks (encodeTasks newTasks)
|
|
)
|
|
|
|
DeleteTask id ->
|
|
let
|
|
newTasks =
|
|
List.filter (\task -> task.id /= id) model.tasks
|
|
in
|
|
( { model | tasks = newTasks }
|
|
, saveTasks (encodeTasks newTasks)
|
|
)
|
|
|
|
SetFilter filter ->
|
|
( { model | filter = filter }, Cmd.none )
|
|
|
|
ClearCompleted ->
|
|
let
|
|
newTasks =
|
|
List.filter (\task -> not task.completed) model.tasks
|
|
in
|
|
( { model | tasks = newTasks }
|
|
, saveTasks (encodeTasks newTasks)
|
|
)
|
|
|
|
LoadedTasks value ->
|
|
case Decode.decodeValue tasksDecoder value of
|
|
Ok tasks ->
|
|
( { model | tasks = tasks }, Cmd.none )
|
|
|
|
Err _ ->
|
|
( model, Cmd.none )
|
|
|
|
|
|
|
|
-- VIEW
|
|
|
|
|
|
view : Model -> Html Msg
|
|
view model =
|
|
div [ class "container" ]
|
|
[ h1 [] [ text "Task Manager" ]
|
|
, viewInput model.newTask
|
|
, viewFilters model.filter
|
|
, viewTasks model
|
|
, viewFooter model
|
|
]
|
|
|
|
|
|
viewInput : String -> Html Msg
|
|
viewInput newTask =
|
|
div [ class "input-section" ]
|
|
[ input
|
|
[ placeholder "What needs to be done?"
|
|
, value newTask
|
|
, onInput UpdateNewTask
|
|
, onEnter AddTask
|
|
]
|
|
[]
|
|
, button [ onClick AddTask ] [ text "Add" ]
|
|
]
|
|
|
|
|
|
onEnter : Msg -> Attribute Msg
|
|
onEnter msg =
|
|
let
|
|
isEnter code =
|
|
if code == 13 then
|
|
Decode.succeed msg
|
|
|
|
else
|
|
Decode.fail "not ENTER"
|
|
in
|
|
on "keydown" (Decode.andThen isEnter (Decode.field "keyCode" Decode.int))
|
|
|
|
|
|
viewFilters : Filter -> Html Msg
|
|
viewFilters currentFilter =
|
|
div [ class "filters" ]
|
|
[ filterButton All currentFilter "All"
|
|
, filterButton Active currentFilter "Active"
|
|
, filterButton Completed currentFilter "Completed"
|
|
]
|
|
|
|
|
|
filterButton : Filter -> Filter -> String -> Html Msg
|
|
filterButton filter currentFilter label =
|
|
button
|
|
[ classList [ ( "active", filter == currentFilter ) ]
|
|
, onClick (SetFilter filter)
|
|
]
|
|
[ text label ]
|
|
|
|
|
|
viewTasks : Model -> Html Msg
|
|
viewTasks model =
|
|
let
|
|
filteredTasks =
|
|
filterTasks model.filter model.tasks
|
|
in
|
|
if List.isEmpty model.tasks then
|
|
p [ class "empty-message" ] [ text "No tasks yet. Add one above!" ]
|
|
|
|
else if List.isEmpty filteredTasks then
|
|
p [ class "empty-message" ] [ text "No tasks match this filter." ]
|
|
|
|
else
|
|
ul [ class "task-list" ]
|
|
(List.map viewTask filteredTasks)
|
|
|
|
|
|
filterTasks : Filter -> List Task -> List Task
|
|
filterTasks filter tasks =
|
|
case filter of
|
|
All ->
|
|
tasks
|
|
|
|
Active ->
|
|
List.filter (\t -> not t.completed) tasks
|
|
|
|
Completed ->
|
|
List.filter .completed tasks
|
|
|
|
|
|
viewTask : Task -> Html Msg
|
|
viewTask task =
|
|
li [ classList [ ( "completed", task.completed ) ] ]
|
|
[ input
|
|
[ type_ "checkbox"
|
|
, checked task.completed
|
|
, onClick (ToggleTask task.id)
|
|
]
|
|
[]
|
|
, span [ class "task-description" ] [ text task.description ]
|
|
, button
|
|
[ class "delete-btn"
|
|
, onClick (DeleteTask task.id)
|
|
]
|
|
[ text "x" ]
|
|
]
|
|
|
|
|
|
viewFooter : Model -> Html Msg
|
|
viewFooter model =
|
|
let
|
|
activeCount =
|
|
List.length (List.filter (\t -> not t.completed) model.tasks)
|
|
|
|
completedCount =
|
|
List.length (List.filter .completed model.tasks)
|
|
|
|
itemWord =
|
|
if activeCount == 1 then
|
|
"item"
|
|
|
|
else
|
|
"items"
|
|
in
|
|
if List.isEmpty model.tasks then
|
|
text ""
|
|
|
|
else
|
|
div [ class "footer" ]
|
|
[ span []
|
|
[ text (String.fromInt activeCount ++ " " ++ itemWord ++ " left")
|
|
]
|
|
, if completedCount > 0 then
|
|
button [ onClick ClearCompleted ]
|
|
[ text "Clear completed" ]
|
|
|
|
else
|
|
text ""
|
|
]
|
|
|
|
|
|
|
|
-- SUBSCRIPTIONS
|
|
|
|
|
|
subscriptions : Model -> Sub Msg
|
|
subscriptions _ =
|
|
loadTasks LoadedTasks
|
|
|
|
|
|
|
|
-- MAIN
|
|
|
|
|
|
main : Program Decode.Value Model Msg
|
|
main =
|
|
Browser.element
|
|
{ init = init
|
|
, update = update
|
|
, view = view
|
|
, subscriptions = subscriptions
|
|
}
|
|
```
|
|
|
|
## The HTML File with JavaScript
|
|
|
|
Create `index.html`:
|
|
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Task Manager</title>
|
|
<style>
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 40px 20px;
|
|
}
|
|
|
|
.container {
|
|
max-width: 500px;
|
|
margin: 0 auto;
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
|
padding: 30px;
|
|
}
|
|
|
|
h1 {
|
|
text-align: center;
|
|
color: #333;
|
|
margin-bottom: 30px;
|
|
font-size: 2rem;
|
|
}
|
|
|
|
.input-section {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.input-section input {
|
|
flex: 1;
|
|
padding: 12px 15px;
|
|
border: 2px solid #e0e0e0;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.input-section input:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
}
|
|
|
|
.input-section button {
|
|
padding: 12px 24px;
|
|
background: #667eea;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.input-section button:hover {
|
|
background: #5a67d8;
|
|
}
|
|
|
|
.filters {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
justify-content: center;
|
|
}
|
|
|
|
.filters button {
|
|
padding: 8px 16px;
|
|
border: 2px solid #e0e0e0;
|
|
background: white;
|
|
border-radius: 20px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.filters button:hover {
|
|
border-color: #667eea;
|
|
}
|
|
|
|
.filters button.active {
|
|
background: #667eea;
|
|
border-color: #667eea;
|
|
color: white;
|
|
}
|
|
|
|
.task-list {
|
|
list-style: none;
|
|
}
|
|
|
|
.task-list li {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 15px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.task-list li:hover {
|
|
background: #f9f9f9;
|
|
}
|
|
|
|
.task-list li.completed .task-description {
|
|
text-decoration: line-through;
|
|
color: #999;
|
|
}
|
|
|
|
.task-list input[type="checkbox"] {
|
|
width: 20px;
|
|
height: 20px;
|
|
margin-right: 15px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.task-description {
|
|
flex: 1;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.delete-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #ff6b6b;
|
|
font-size: 18px;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.task-list li:hover .delete-btn {
|
|
opacity: 1;
|
|
}
|
|
|
|
.empty-message {
|
|
text-align: center;
|
|
color: #999;
|
|
padding: 40px;
|
|
font-style: italic;
|
|
}
|
|
|
|
.footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-top: 20px;
|
|
padding-top: 15px;
|
|
border-top: 1px solid #f0f0f0;
|
|
color: #666;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.footer button {
|
|
background: none;
|
|
border: none;
|
|
color: #999;
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.footer button:hover {
|
|
color: #333;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app"></div>
|
|
|
|
<script src="elm.js"></script>
|
|
<script>
|
|
// Load saved tasks from localStorage
|
|
const savedTasks = localStorage.getItem('elm-tasks');
|
|
const initialTasks = savedTasks ? JSON.parse(savedTasks) : [];
|
|
|
|
// Initialize Elm app with saved tasks
|
|
const app = Elm.Main.init({
|
|
node: document.getElementById('app'),
|
|
flags: initialTasks
|
|
});
|
|
|
|
// Listen for save commands from Elm
|
|
app.ports.saveTasks.subscribe(function(tasks) {
|
|
localStorage.setItem('elm-tasks', JSON.stringify(tasks));
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
## Building and Running
|
|
|
|
```bash
|
|
# Compile Elm to JavaScript
|
|
elm make src/Main.elm --output=elm.js
|
|
|
|
# Open index.html in your browser
|
|
# On macOS: open index.html
|
|
# On Linux: xdg-open index.html
|
|
# On Windows: start index.html
|
|
|
|
# Or use a local server
|
|
python -m http.server 8000
|
|
# Then open http://localhost:8000
|
|
```
|
|
|
|
## Understanding Ports
|
|
|
|
Ports allow Elm to communicate with JavaScript for side effects Elm can't handle natively.
|
|
|
|
### Defining Ports
|
|
|
|
```elm
|
|
port module Main exposing (main)
|
|
|
|
-- Outgoing: Elm -> JavaScript
|
|
port saveTasks : Encode.Value -> Cmd msg
|
|
|
|
-- Incoming: JavaScript -> Elm
|
|
port loadTasks : (Decode.Value -> msg) -> Sub msg
|
|
```
|
|
|
|
### JavaScript Side
|
|
|
|
```javascript
|
|
// Subscribe to Elm's outgoing port
|
|
app.ports.saveTasks.subscribe(function(tasks) {
|
|
localStorage.setItem('elm-tasks', JSON.stringify(tasks));
|
|
});
|
|
|
|
// Send data to Elm's incoming port
|
|
app.ports.loadTasks.send(someData);
|
|
```
|
|
|
|
### Flags: Initial Data
|
|
|
|
```elm
|
|
-- Elm receives flags in init
|
|
init : Decode.Value -> ( Model, Cmd Msg )
|
|
init flags =
|
|
-- Decode flags (initial tasks from localStorage)
|
|
```
|
|
|
|
```javascript
|
|
// JavaScript passes flags at startup
|
|
Elm.Main.init({
|
|
node: document.getElementById('app'),
|
|
flags: initialTasks
|
|
});
|
|
```
|
|
|
|
## Code Organization Tips
|
|
|
|
### For Larger Apps
|
|
|
|
```
|
|
src/
|
|
├── Main.elm # Entry point
|
|
├── Model.elm # Model type and init
|
|
├── Update.elm # Update function and messages
|
|
├── View.elm # View functions
|
|
├── Types.elm # Shared types
|
|
├── Api.elm # HTTP requests
|
|
└── Ports.elm # Port declarations
|
|
```
|
|
|
|
### Splitting the Model
|
|
|
|
```elm
|
|
-- Types.elm
|
|
module Types exposing (..)
|
|
|
|
type alias Task =
|
|
{ id : Int
|
|
, description : String
|
|
, completed : Bool
|
|
}
|
|
|
|
type Filter
|
|
= All
|
|
| Active
|
|
| Completed
|
|
```
|
|
|
|
### Helper Functions
|
|
|
|
Extract common patterns:
|
|
|
|
```elm
|
|
-- Helpers.elm
|
|
module Helpers exposing (..)
|
|
|
|
updateTask : Int -> (Task -> Task) -> List Task -> List Task
|
|
updateTask targetId transform tasks =
|
|
List.map
|
|
(\task ->
|
|
if task.id == targetId then
|
|
transform task
|
|
else
|
|
task
|
|
)
|
|
tasks
|
|
```
|
|
|
|
## Testing Your Elm Code
|
|
|
|
### Install elm-test
|
|
|
|
```bash
|
|
npm install -g elm-test
|
|
elm-test init
|
|
```
|
|
|
|
### Writing Tests
|
|
|
|
```elm
|
|
-- tests/TaskTests.elm
|
|
module TaskTests exposing (..)
|
|
|
|
import Expect
|
|
import Test exposing (..)
|
|
import Main exposing (filterTasks, Filter(..))
|
|
|
|
|
|
suite : Test
|
|
suite =
|
|
describe "Task filtering"
|
|
[ test "All filter shows all tasks" <|
|
|
\_ ->
|
|
let
|
|
tasks =
|
|
[ { id = 1, description = "Task 1", completed = False }
|
|
, { id = 2, description = "Task 2", completed = True }
|
|
]
|
|
in
|
|
filterTasks All tasks
|
|
|> List.length
|
|
|> Expect.equal 2
|
|
|
|
, test "Active filter shows only incomplete" <|
|
|
\_ ->
|
|
let
|
|
tasks =
|
|
[ { id = 1, description = "Task 1", completed = False }
|
|
, { id = 2, description = "Task 2", completed = True }
|
|
]
|
|
in
|
|
filterTasks Active tasks
|
|
|> List.length
|
|
|> Expect.equal 1
|
|
]
|
|
```
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
elm-test
|
|
```
|
|
|
|
## Exercises
|
|
|
|
### Exercise 7.1: Add Edit Functionality
|
|
|
|
Add the ability to edit task descriptions:
|
|
1. Double-click a task to edit it
|
|
2. Press Enter to save
|
|
3. Press Escape to cancel
|
|
|
|
<details>
|
|
<summary>Hint</summary>
|
|
|
|
Add a new field to the model:
|
|
```elm
|
|
type alias Model =
|
|
{ tasks : List Task
|
|
, newTask : String
|
|
, nextId : Int
|
|
, filter : Filter
|
|
, editing : Maybe Int -- ID of task being edited
|
|
, editText : String
|
|
}
|
|
```
|
|
|
|
Add new messages:
|
|
```elm
|
|
type Msg
|
|
= ...
|
|
| StartEditing Int String
|
|
| UpdateEditText String
|
|
| SaveEdit
|
|
| CancelEdit
|
|
```
|
|
|
|
</details>
|
|
|
|
### Exercise 7.2: Add Due Dates
|
|
|
|
Extend tasks to have optional due dates:
|
|
1. Add a date input when creating tasks
|
|
2. Display due dates with tasks
|
|
3. Highlight overdue tasks
|
|
|
|
### Exercise 7.3: Add Categories
|
|
|
|
Add task categories:
|
|
1. Tasks can be tagged with a category
|
|
2. Filter by category
|
|
3. Add color coding
|
|
|
|
## Key Takeaways from This Tutorial
|
|
|
|
1. **Elm is purely functional** - All functions are pure, side effects are managed
|
|
2. **Types are your friend** - They catch bugs at compile time
|
|
3. **The Elm Architecture** - Model, View, Update provides clean structure
|
|
4. **Maybe eliminates nulls** - No more "undefined is not a function"
|
|
5. **JSON decoders** ensure type-safe API integration
|
|
6. **Ports** bridge Elm and JavaScript for browser APIs
|
|
|
|
## Where to Go From Here
|
|
|
|
### Resources
|
|
|
|
- [Official Elm Guide](https://guide.elm-lang.org/) - The canonical resource
|
|
- [Elm in Action](https://www.manning.com/books/elm-in-action) - Excellent book
|
|
- [Elm Packages](https://package.elm-lang.org/) - Browse available libraries
|
|
- [Elm Slack](https://elmlang.herokuapp.com/) - Friendly community
|
|
|
|
### Practice Projects
|
|
|
|
1. **Weather App** - Fetch and display weather data
|
|
2. **Markdown Preview** - Live markdown editor with preview
|
|
3. **Kanban Board** - Drag-and-drop task management
|
|
4. **Chat Application** - WebSocket integration with ports
|
|
|
|
### Advanced Topics to Explore
|
|
|
|
- **elm-ui** - Layout without CSS
|
|
- **elm-spa** - Single Page Applications
|
|
- **GraphQL with elm-graphql**
|
|
- **Web Components interop**
|
|
|
|
## Congratulations!
|
|
|
|
You've completed this Elm tutorial! You now have:
|
|
- A solid understanding of functional programming concepts
|
|
- Knowledge of Elm's type system
|
|
- Experience with The Elm Architecture
|
|
- Skills to build real-world applications
|
|
|
|
The best way to learn more is to build something. Pick a project and start coding!
|
|
|
|
---
|
|
|
|
[← Previous: Lesson 6](06-http-json.md) | [Back to README](../README.md)
|