Initial commit: Elm tutorial for JavaScript developers

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
This commit is contained in:
2026-03-11 11:07:15 +00:00
commit 7d7986f3ab
16 changed files with 5342 additions and 0 deletions

856
lessons/07-final-project.md Normal file
View File

@@ -0,0 +1,856 @@
# 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)