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:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Elm compilation output
|
||||||
|
elm-stuff/
|
||||||
|
elm.js
|
||||||
|
|
||||||
|
# Editor files
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Node (if using npm for tooling)
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
/build/
|
||||||
|
/dist/
|
||||||
122
README.md
Normal file
122
README.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Elm Tutorial for JavaScript Developers
|
||||||
|
|
||||||
|
Welcome! This tutorial is designed for developers with JavaScript experience who want to learn Elm. We'll leverage your existing knowledge while introducing functional programming concepts gradually.
|
||||||
|
|
||||||
|
## What is Elm?
|
||||||
|
|
||||||
|
Elm is a functional programming language that compiles to JavaScript. It's known for:
|
||||||
|
|
||||||
|
- **No runtime exceptions** - The compiler catches errors before your code runs
|
||||||
|
- **Friendly error messages** - Best-in-class compiler messages that help you fix issues
|
||||||
|
- **Enforced semantic versioning** - Package updates never break your code unexpectedly
|
||||||
|
- **Small bundle sizes** - Elm produces highly optimized JavaScript
|
||||||
|
|
||||||
|
## Why Learn Elm?
|
||||||
|
|
||||||
|
As a JavaScript developer, Elm will teach you:
|
||||||
|
|
||||||
|
1. Pure functional programming patterns
|
||||||
|
2. Strong static typing (that actually helps rather than hinders)
|
||||||
|
3. Immutable data structures
|
||||||
|
4. A clean architecture pattern (The Elm Architecture inspired Redux!)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Basic JavaScript knowledge
|
||||||
|
- Familiarity with HTML
|
||||||
|
- A code editor (VS Code recommended with the Elm extension)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Install Elm
|
||||||
|
|
||||||
|
**macOS (Homebrew):**
|
||||||
|
```bash
|
||||||
|
brew install elm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux/macOS (npm):**
|
||||||
|
```bash
|
||||||
|
npm install -g elm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
Download the installer from [elm-lang.org](https://guide.elm-lang.org/install/elm.html)
|
||||||
|
|
||||||
|
### 2. Verify Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
elm --version
|
||||||
|
# Should output something like: 0.19.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install Helpful Tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Elm formatter (like Prettier for Elm)
|
||||||
|
npm install -g elm-format
|
||||||
|
|
||||||
|
# Elm test runner
|
||||||
|
npm install -g elm-test
|
||||||
|
|
||||||
|
# Development server with hot reloading
|
||||||
|
npm install -g elm-live
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Editor Setup
|
||||||
|
|
||||||
|
For VS Code, install the "Elm" extension by Elm tooling. This gives you:
|
||||||
|
- Syntax highlighting
|
||||||
|
- Auto-formatting on save
|
||||||
|
- Inline error messages
|
||||||
|
- Go to definition
|
||||||
|
|
||||||
|
## Tutorial Structure
|
||||||
|
|
||||||
|
| Lesson | Topic | Key Concepts |
|
||||||
|
|--------|-------|--------------|
|
||||||
|
| 1 | [Introduction & First Program](lessons/01-introduction.md) | REPL, Hello World, elm.json |
|
||||||
|
| 2 | [Basic Syntax & Types](lessons/02-basics.md) | Values, Types, Type Annotations |
|
||||||
|
| 3 | [Functions](lessons/03-functions.md) | Pure Functions, Currying, Pipes |
|
||||||
|
| 4 | [The Elm Architecture](lessons/04-tea.md) | Model, View, Update |
|
||||||
|
| 5 | [Lists & Maybe](lessons/05-lists-maybe.md) | Collections, Null Safety |
|
||||||
|
| 6 | [HTTP & JSON](lessons/06-http-json.md) | Commands, Decoders |
|
||||||
|
| 7 | [Final Project](lessons/07-final-project.md) | Building a Complete App |
|
||||||
|
|
||||||
|
## How to Use This Tutorial
|
||||||
|
|
||||||
|
1. **Read each lesson** in order - concepts build on each other
|
||||||
|
2. **Type the code yourself** - don't just copy-paste
|
||||||
|
3. **Complete the exercises** - they reinforce learning
|
||||||
|
4. **Experiment** - break things, see what errors you get
|
||||||
|
|
||||||
|
## Quick Reference: JavaScript to Elm
|
||||||
|
|
||||||
|
Here's a preview of how familiar JavaScript concepts translate to Elm:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// JavaScript
|
||||||
|
const name = "Alice";
|
||||||
|
const add = (a, b) => a + b;
|
||||||
|
const numbers = [1, 2, 3];
|
||||||
|
const doubled = numbers.map(n => n * 2);
|
||||||
|
```
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Elm
|
||||||
|
name = "Alice"
|
||||||
|
add a b = a + b
|
||||||
|
numbers = [1, 2, 3]
|
||||||
|
doubled = List.map (\n -> n * 2) numbers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- [Official Elm Guide](https://guide.elm-lang.org/)
|
||||||
|
- [Elm Slack](https://elmlang.herokuapp.com/)
|
||||||
|
- [Elm Discourse](https://discourse.elm-lang.org/)
|
||||||
|
- [Elm Packages](https://package.elm-lang.org/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ready? Let's start with [Lesson 1: Introduction](lessons/01-introduction.md)!
|
||||||
25
exercises/01-hello-world/src/Main.elm
Normal file
25
exercises/01-hello-world/src/Main.elm
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
{-| Exercise 1: Hello World
|
||||||
|
|
||||||
|
Complete the exercises below by modifying this file.
|
||||||
|
|
||||||
|
Exercise 1.1: Change the greeting to say "Hello, [Your Name]!"
|
||||||
|
Exercise 1.2: Make the greeting ALL CAPS using String.toUpper
|
||||||
|
Exercise 1.3: Add a second paragraph with your programming experience
|
||||||
|
|
||||||
|
Run with: elm reactor
|
||||||
|
Then open http://localhost:8000/src/Main.elm
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Html exposing (Html, div, h1, p, text)
|
||||||
|
|
||||||
|
|
||||||
|
main : Html msg
|
||||||
|
main =
|
||||||
|
div []
|
||||||
|
[ h1 [] [ text "Hello, Elm!" ]
|
||||||
|
|
||||||
|
-- Add more elements here for Exercise 1.3
|
||||||
|
]
|
||||||
81
exercises/02-counter/src/Main.elm
Normal file
81
exercises/02-counter/src/Main.elm
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
{-| Exercise 2: Counter
|
||||||
|
|
||||||
|
This is a basic counter application. Your tasks:
|
||||||
|
|
||||||
|
Exercise 2.1: Add a "Reset" button that sets the count back to 0
|
||||||
|
Exercise 2.2: Add a "Double" button that doubles the current count
|
||||||
|
Exercise 2.3: Prevent the count from going below 0
|
||||||
|
|
||||||
|
Run with: elm reactor
|
||||||
|
Then open http://localhost:8000/src/Main.elm
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, button, div, text)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ count : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= Increment
|
||||||
|
| Decrement
|
||||||
|
-- Add more messages here for the exercises
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
Increment ->
|
||||||
|
{ model | count = model.count + 1 }
|
||||||
|
|
||||||
|
Decrement ->
|
||||||
|
{ model | count = model.count - 1 }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ button [ onClick Decrement ] [ text "-" ]
|
||||||
|
, div [] [ text (String.fromInt model.count) ]
|
||||||
|
, button [ onClick Increment ] [ text "+" ]
|
||||||
|
|
||||||
|
-- Add more buttons here
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
|
||||||
|
main : Program () Model Msg
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
100
exercises/03-temperature/src/Main.elm
Normal file
100
exercises/03-temperature/src/Main.elm
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
{-| Exercise 3: Temperature Converter
|
||||||
|
|
||||||
|
Build a temperature converter:
|
||||||
|
|
||||||
|
Exercise 3.1: Convert Celsius to Fahrenheit (F = C × 9/5 + 32)
|
||||||
|
Exercise 3.2: Add Fahrenheit to Celsius conversion
|
||||||
|
Exercise 3.3: Handle invalid input gracefully
|
||||||
|
|
||||||
|
Run with: elm reactor
|
||||||
|
Then open http://localhost:8000/src/Main.elm
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, div, input, text)
|
||||||
|
import Html.Attributes exposing (placeholder, value)
|
||||||
|
import Html.Events exposing (onInput)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ celsius : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ celsius = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= UpdateCelsius String
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
UpdateCelsius value ->
|
||||||
|
{ model | celsius = value }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HELPER FUNCTIONS
|
||||||
|
|
||||||
|
|
||||||
|
celsiusToFahrenheit : Float -> Float
|
||||||
|
celsiusToFahrenheit c =
|
||||||
|
-- TODO: Implement the conversion formula
|
||||||
|
0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
let
|
||||||
|
fahrenheit =
|
||||||
|
case String.toFloat model.celsius of
|
||||||
|
Just c ->
|
||||||
|
-- TODO: Call celsiusToFahrenheit and format the result
|
||||||
|
"TODO"
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"Enter a valid number"
|
||||||
|
in
|
||||||
|
div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Enter Celsius"
|
||||||
|
, value model.celsius
|
||||||
|
, onInput UpdateCelsius
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, text " °C = "
|
||||||
|
, text fahrenheit
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
|
||||||
|
main : Program () Model Msg
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
274
lessons/01-introduction.md
Normal file
274
lessons/01-introduction.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# Lesson 1: Introduction & Your First Elm Program
|
||||||
|
|
||||||
|
## Learning Goals
|
||||||
|
|
||||||
|
By the end of this lesson, you will:
|
||||||
|
- Understand the Elm REPL
|
||||||
|
- Create your first Elm project
|
||||||
|
- Compile and run Elm code
|
||||||
|
- Understand the project structure
|
||||||
|
|
||||||
|
## The Elm REPL
|
||||||
|
|
||||||
|
Like Node.js, Elm has a REPL (Read-Eval-Print Loop) for experimenting with code.
|
||||||
|
|
||||||
|
Start it by running:
|
||||||
|
```bash
|
||||||
|
elm repl
|
||||||
|
```
|
||||||
|
|
||||||
|
Try these expressions:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
> 1 + 1
|
||||||
|
2 : number
|
||||||
|
|
||||||
|
> "Hello" ++ " World"
|
||||||
|
"Hello World" : String
|
||||||
|
|
||||||
|
> String.length "Elm"
|
||||||
|
3 : Int
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice something different from JavaScript? Elm tells you the **type** of each result!
|
||||||
|
|
||||||
|
### Key Differences from JavaScript Console
|
||||||
|
|
||||||
|
| JavaScript | Elm REPL |
|
||||||
|
|-----------|----------|
|
||||||
|
| `"a" + "b"` | `"a" ++ "b"` (string concat uses `++`) |
|
||||||
|
| `1 + "1"` = `"11"` | Error! Can't mix types |
|
||||||
|
| `console.log(x)` | Just type the expression |
|
||||||
|
|
||||||
|
Type `:exit` to leave the REPL.
|
||||||
|
|
||||||
|
## Your First Elm Project
|
||||||
|
|
||||||
|
Let's create a real project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir hello-elm
|
||||||
|
cd hello-elm
|
||||||
|
elm init
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
```
|
||||||
|
hello-elm/
|
||||||
|
├── elm.json # Project configuration (like package.json)
|
||||||
|
└── src/ # Your Elm source files go here
|
||||||
|
```
|
||||||
|
|
||||||
|
### Understanding elm.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "application",
|
||||||
|
"source-directories": ["src"],
|
||||||
|
"elm-version": "0.19.1",
|
||||||
|
"dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"elm/browser": "1.0.2",
|
||||||
|
"elm/core": "1.0.5",
|
||||||
|
"elm/html": "1.0.0"
|
||||||
|
},
|
||||||
|
"indirect": { ... }
|
||||||
|
},
|
||||||
|
"test-dependencies": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comparison to JavaScript:**
|
||||||
|
- `source-directories` is like setting up your bundler's entry points
|
||||||
|
- `dependencies` works like `package.json` but with direct/indirect separation
|
||||||
|
- Unlike npm, Elm enforces **semantic versioning automatically**
|
||||||
|
|
||||||
|
## Hello World
|
||||||
|
|
||||||
|
Create `src/Main.elm`:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Html exposing (text)
|
||||||
|
|
||||||
|
|
||||||
|
main =
|
||||||
|
text "Hello, Elm!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Let's break this down:
|
||||||
|
|
||||||
|
### 1. Module Declaration
|
||||||
|
```elm
|
||||||
|
module Main exposing (main)
|
||||||
|
```
|
||||||
|
- Every Elm file is a **module**
|
||||||
|
- `exposing (main)` says "make `main` available to other modules"
|
||||||
|
- **JavaScript equivalent:** `export { main }`
|
||||||
|
|
||||||
|
### 2. Imports
|
||||||
|
```elm
|
||||||
|
import Html exposing (text)
|
||||||
|
```
|
||||||
|
- Import the `Html` module
|
||||||
|
- `exposing (text)` lets us use `text` directly instead of `Html.text`
|
||||||
|
- **JavaScript equivalent:** `import { text } from 'html'`
|
||||||
|
|
||||||
|
### 3. The main Function
|
||||||
|
```elm
|
||||||
|
main =
|
||||||
|
text "Hello, Elm!"
|
||||||
|
```
|
||||||
|
- `main` is the entry point (like `ReactDOM.render` or your app's root)
|
||||||
|
- `text` creates an HTML text node
|
||||||
|
- No `return` keyword needed - the last expression is the return value
|
||||||
|
|
||||||
|
## Running Your Code
|
||||||
|
|
||||||
|
### Option 1: Elm Reactor (Development Server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
elm reactor
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:8000 and click on `src/Main.elm`.
|
||||||
|
|
||||||
|
### Option 2: Compile to HTML
|
||||||
|
|
||||||
|
```bash
|
||||||
|
elm make src/Main.elm
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates an `index.html` file you can open in a browser.
|
||||||
|
|
||||||
|
### Option 3: Elm Live (Hot Reloading)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
elm-live src/Main.elm -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
This opens your browser and reloads on file changes (like webpack-dev-server).
|
||||||
|
|
||||||
|
## Exercise 1.1: Experiment in the REPL
|
||||||
|
|
||||||
|
Start `elm repl` and try:
|
||||||
|
|
||||||
|
1. Calculate `123 * 456`
|
||||||
|
2. Concatenate three strings: "I", " love", " Elm"
|
||||||
|
3. Find the length of "functional programming"
|
||||||
|
4. Try `1 + "hello"` - what happens?
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solutions</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
> 123 * 456
|
||||||
|
56088 : number
|
||||||
|
|
||||||
|
> "I" ++ " love" ++ " Elm"
|
||||||
|
"I love Elm" : String
|
||||||
|
|
||||||
|
> String.length "functional programming"
|
||||||
|
22 : Int
|
||||||
|
|
||||||
|
> 1 + "hello"
|
||||||
|
-- TYPE MISMATCH --
|
||||||
|
The (+) operator is expecting both arguments to be numbers...
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 1.2: Modify Hello World
|
||||||
|
|
||||||
|
1. Change the greeting to include your name
|
||||||
|
2. Try using `String.toUpper` to make it ALL CAPS
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Html exposing (text)
|
||||||
|
|
||||||
|
|
||||||
|
main =
|
||||||
|
text (String.toUpper "Hello, Your Name!")
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 1.3: Multiple Elements
|
||||||
|
|
||||||
|
We need to use `Html.div` to group multiple elements:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Html exposing (div, h1, p, text)
|
||||||
|
|
||||||
|
|
||||||
|
main =
|
||||||
|
div []
|
||||||
|
[ h1 [] [ text "Welcome to Elm" ]
|
||||||
|
, p [] [ text "This is a paragraph" ]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
The pattern is: `element attributes children`
|
||||||
|
- `div []` - a div with no attributes
|
||||||
|
- `[ h1 ..., p ... ]` - list of children
|
||||||
|
|
||||||
|
Create a page with:
|
||||||
|
1. A heading with your name
|
||||||
|
2. A paragraph about why you're learning Elm
|
||||||
|
3. Another paragraph about your programming background
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Html exposing (div, h1, p, text)
|
||||||
|
|
||||||
|
|
||||||
|
main =
|
||||||
|
div []
|
||||||
|
[ h1 [] [ text "Hi, I'm Learning Elm!" ]
|
||||||
|
, p [] [ text "I want to learn functional programming." ]
|
||||||
|
, p [] [ text "I have experience with JavaScript." ]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Key Takeaways
|
||||||
|
|
||||||
|
1. **Elm is typed** - The compiler tells you types and catches errors early
|
||||||
|
2. **No semicolons or braces** - Whitespace and indentation matter
|
||||||
|
3. **Everything is an expression** - No statements, everything returns a value
|
||||||
|
4. **String concatenation uses `++`** - Not `+` like JavaScript
|
||||||
|
5. **HTML is just functions** - `div`, `h1`, `p` are all functions
|
||||||
|
|
||||||
|
## Common Mistakes from JavaScript Developers
|
||||||
|
|
||||||
|
| Mistake | Why It Happens | Elm Way |
|
||||||
|
|---------|---------------|---------|
|
||||||
|
| Using `+` for strings | JavaScript habit | Use `++` |
|
||||||
|
| Forgetting type annotations | JS doesn't have them | Optional but recommended |
|
||||||
|
| Using `return` | JS function habit | Last expression is returned |
|
||||||
|
| Using `{}` for blocks | JS syntax | Use indentation |
|
||||||
|
|
||||||
|
## What's Next?
|
||||||
|
|
||||||
|
In [Lesson 2](02-basics.md), we'll dive deeper into Elm's type system and learn about:
|
||||||
|
- Primitive types (Int, Float, String, Bool)
|
||||||
|
- Type annotations
|
||||||
|
- Records (like JavaScript objects, but better!)
|
||||||
|
- Custom types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Next: Lesson 2 - Basic Syntax & Types →](02-basics.md)
|
||||||
485
lessons/02-basics.md
Normal file
485
lessons/02-basics.md
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
# Lesson 2: Basic Syntax & Types
|
||||||
|
|
||||||
|
## Learning Goals
|
||||||
|
|
||||||
|
By the end of this lesson, you will:
|
||||||
|
- Understand Elm's primitive types
|
||||||
|
- Write type annotations
|
||||||
|
- Work with records (Elm's "objects")
|
||||||
|
- Create type aliases
|
||||||
|
- Understand union types (a powerful feature JS doesn't have!)
|
||||||
|
|
||||||
|
## Primitive Types
|
||||||
|
|
||||||
|
### Numbers
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Integers
|
||||||
|
age : Int
|
||||||
|
age = 30
|
||||||
|
|
||||||
|
-- Floating point
|
||||||
|
price : Float
|
||||||
|
price = 19.99
|
||||||
|
|
||||||
|
-- The 'number' type works with both
|
||||||
|
double : number -> number
|
||||||
|
double n = n * 2
|
||||||
|
|
||||||
|
double 5 -- 10 : number
|
||||||
|
double 5.5 -- 11.0 : Float
|
||||||
|
```
|
||||||
|
|
||||||
|
**JavaScript comparison:**
|
||||||
|
```javascript
|
||||||
|
// JavaScript - just "number"
|
||||||
|
const age = 30;
|
||||||
|
const price = 19.99;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strings
|
||||||
|
|
||||||
|
```elm
|
||||||
|
name : String
|
||||||
|
name = "Alice"
|
||||||
|
|
||||||
|
-- Multi-line strings use triple quotes
|
||||||
|
poem : String
|
||||||
|
poem = """
|
||||||
|
Roses are red,
|
||||||
|
Violets are blue,
|
||||||
|
Elm has no nulls,
|
||||||
|
And neither should you!
|
||||||
|
"""
|
||||||
|
|
||||||
|
-- String operations
|
||||||
|
String.length "hello" -- 5
|
||||||
|
String.reverse "hello" -- "olleh"
|
||||||
|
String.toUpper "hello" -- "HELLO"
|
||||||
|
"Hello" ++ " World" -- "Hello World"
|
||||||
|
```
|
||||||
|
|
||||||
|
**JavaScript comparison:**
|
||||||
|
```javascript
|
||||||
|
const name = "Alice";
|
||||||
|
const poem = `
|
||||||
|
Roses are red...
|
||||||
|
`;
|
||||||
|
"Hello" + " World" // Uses + not ++
|
||||||
|
```
|
||||||
|
|
||||||
|
### Booleans
|
||||||
|
|
||||||
|
```elm
|
||||||
|
isActive : Bool
|
||||||
|
isActive = True
|
||||||
|
|
||||||
|
-- Note: True and False are capitalized!
|
||||||
|
-- Comparison operators
|
||||||
|
5 > 3 -- True
|
||||||
|
5 == 5 -- True (equality uses ==, same as JS)
|
||||||
|
5 /= 3 -- True (not equal uses /= not !==)
|
||||||
|
|
||||||
|
-- Boolean operators
|
||||||
|
True && False -- False
|
||||||
|
True || False -- True
|
||||||
|
not True -- False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key difference:** Not-equal is `/=` in Elm, not `!==` like JavaScript.
|
||||||
|
|
||||||
|
### Characters
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Single characters use single quotes
|
||||||
|
letter : Char
|
||||||
|
letter = 'A'
|
||||||
|
|
||||||
|
-- Strings use double quotes
|
||||||
|
word : String
|
||||||
|
word = "ABC"
|
||||||
|
```
|
||||||
|
|
||||||
|
JavaScript doesn't have a separate character type.
|
||||||
|
|
||||||
|
## Type Annotations
|
||||||
|
|
||||||
|
Type annotations are **optional** but highly recommended:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Without annotation (Elm infers the type)
|
||||||
|
add a b = a + b
|
||||||
|
|
||||||
|
-- With annotation (clearer and self-documenting)
|
||||||
|
add : Int -> Int -> Int
|
||||||
|
add a b = a + b
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading Type Annotations
|
||||||
|
|
||||||
|
```elm
|
||||||
|
greet : String -> String
|
||||||
|
-- ^input ^output
|
||||||
|
greet name = "Hello, " ++ name
|
||||||
|
|
||||||
|
add : Int -> Int -> Int
|
||||||
|
-- ^first ^second ^result
|
||||||
|
add a b = a + b
|
||||||
|
```
|
||||||
|
|
||||||
|
Think of `->` as "then returns". So `Int -> Int -> Int` reads as:
|
||||||
|
"Takes an Int, then an Int, then returns an Int"
|
||||||
|
|
||||||
|
## Records: Elm's Objects
|
||||||
|
|
||||||
|
Records are like JavaScript objects, but **immutable** and **typed**.
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Defining a record
|
||||||
|
person =
|
||||||
|
{ name = "Alice"
|
||||||
|
, age = 30
|
||||||
|
, email = "alice@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Accessing fields (dot notation, like JS)
|
||||||
|
person.name -- "Alice"
|
||||||
|
person.age -- 30
|
||||||
|
|
||||||
|
-- Or using accessor functions
|
||||||
|
.name person -- "Alice"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Record Type Annotations
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Inline type
|
||||||
|
alice : { name : String, age : Int }
|
||||||
|
alice = { name = "Alice", age = 30 }
|
||||||
|
|
||||||
|
-- With type alias (preferred for reuse)
|
||||||
|
type alias Person =
|
||||||
|
{ name : String
|
||||||
|
, age : Int
|
||||||
|
, email : String
|
||||||
|
}
|
||||||
|
|
||||||
|
bob : Person
|
||||||
|
bob =
|
||||||
|
{ name = "Bob"
|
||||||
|
, age = 25
|
||||||
|
, email = "bob@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating Records
|
||||||
|
|
||||||
|
In Elm, you can't mutate records. Instead, you create a new one:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript way (mutates)
|
||||||
|
-- person.age = 31;
|
||||||
|
|
||||||
|
-- Elm way (creates new record)
|
||||||
|
olderPerson = { person | age = 31 }
|
||||||
|
|
||||||
|
-- Update multiple fields
|
||||||
|
updatedPerson =
|
||||||
|
{ person
|
||||||
|
| age = 31
|
||||||
|
, email = "newemail@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The original `person` is unchanged! This is **immutability**.
|
||||||
|
|
||||||
|
### Why Immutability Matters
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// JavaScript - Mutation causes bugs
|
||||||
|
const user = { name: "Alice", score: 100 };
|
||||||
|
doSomething(user);
|
||||||
|
console.log(user.score); // Who knows? doSomething might have changed it!
|
||||||
|
```
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Elm - No surprises
|
||||||
|
user = { name = "Alice", score = 100 }
|
||||||
|
newUser = doSomething user
|
||||||
|
user.score -- Still 100, guaranteed!
|
||||||
|
newUser.score -- Whatever doSomething returned
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Aliases
|
||||||
|
|
||||||
|
Type aliases give names to types:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type alias Person =
|
||||||
|
{ name : String
|
||||||
|
, age : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
type alias Point =
|
||||||
|
{ x : Float
|
||||||
|
, y : Float
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Use them in annotations
|
||||||
|
distance : Point -> Point -> Float
|
||||||
|
distance p1 p2 =
|
||||||
|
sqrt ((p2.x - p1.x)^2 + (p2.y - p1.y)^2)
|
||||||
|
```
|
||||||
|
|
||||||
|
Type aliases also create **constructor functions**:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type alias Person =
|
||||||
|
{ name : String
|
||||||
|
, age : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
-- This automatically creates:
|
||||||
|
-- Person : String -> Int -> Person
|
||||||
|
|
||||||
|
alice = Person "Alice" 30
|
||||||
|
-- Same as: { name = "Alice", age = 30 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Types (Union Types)
|
||||||
|
|
||||||
|
This is where Elm gets really powerful. Custom types let you define your own types with specific variants:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Color
|
||||||
|
= Red
|
||||||
|
| Green
|
||||||
|
| Blue
|
||||||
|
|
||||||
|
type Status
|
||||||
|
= Loading
|
||||||
|
| Success String
|
||||||
|
| Error String
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Custom Types
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Status
|
||||||
|
= Loading
|
||||||
|
| Success String
|
||||||
|
| Error String
|
||||||
|
|
||||||
|
showStatus : Status -> String
|
||||||
|
showStatus status =
|
||||||
|
case status of
|
||||||
|
Loading ->
|
||||||
|
"Loading..."
|
||||||
|
|
||||||
|
Success message ->
|
||||||
|
"Success: " ++ message
|
||||||
|
|
||||||
|
Error errorMsg ->
|
||||||
|
"Error: " ++ errorMsg
|
||||||
|
|
||||||
|
showStatus Loading -- "Loading..."
|
||||||
|
showStatus (Success "Done!") -- "Success: Done!"
|
||||||
|
showStatus (Error "Not found") -- "Error: Not found"
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Comparison
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// JavaScript - using strings (error-prone)
|
||||||
|
const status = "loading";
|
||||||
|
if (status === "loading") { ... }
|
||||||
|
if (status === "laoding") { ... } // Typo! No error
|
||||||
|
|
||||||
|
// JavaScript - using objects
|
||||||
|
const status = { type: "success", message: "Done" };
|
||||||
|
```
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Elm - compiler catches typos
|
||||||
|
case status of
|
||||||
|
Laoding -> ... -- COMPILE ERROR: Laoding is not defined
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maybe: Handling Missing Values
|
||||||
|
|
||||||
|
Elm has no `null` or `undefined`. Instead, it uses the `Maybe` type:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Maybe a
|
||||||
|
= Just a
|
||||||
|
| Nothing
|
||||||
|
|
||||||
|
-- Example: Safe dictionary lookup
|
||||||
|
Dict.get "name" myDict -- Returns Maybe String
|
||||||
|
|
||||||
|
-- You MUST handle both cases
|
||||||
|
case Dict.get "name" myDict of
|
||||||
|
Just value ->
|
||||||
|
"Found: " ++ value
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"Not found"
|
||||||
|
```
|
||||||
|
|
||||||
|
**This eliminates null pointer exceptions entirely!**
|
||||||
|
|
||||||
|
## Exercise 2.1: Define a Record Type
|
||||||
|
|
||||||
|
Create a `type alias` for a `Book` with:
|
||||||
|
- title (String)
|
||||||
|
- author (String)
|
||||||
|
- pages (Int)
|
||||||
|
- isRead (Bool)
|
||||||
|
|
||||||
|
Then create two book records.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type alias Book =
|
||||||
|
{ title : String
|
||||||
|
, author : String
|
||||||
|
, pages : Int
|
||||||
|
, isRead : Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
book1 : Book
|
||||||
|
book1 =
|
||||||
|
{ title = "The Elm Guide"
|
||||||
|
, author = "Evan Czaplicki"
|
||||||
|
, pages = 150
|
||||||
|
, isRead = True
|
||||||
|
}
|
||||||
|
|
||||||
|
book2 : Book
|
||||||
|
book2 = Book "Learn You a Haskell" "Miran Lipovaca" 400 False
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 2.2: Update a Record
|
||||||
|
|
||||||
|
Given this record:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
player =
|
||||||
|
{ name = "Hero"
|
||||||
|
, health = 100
|
||||||
|
, score = 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a new record where:
|
||||||
|
1. health is reduced to 80
|
||||||
|
2. score is increased to 50
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
updatedPlayer =
|
||||||
|
{ player
|
||||||
|
| health = 80
|
||||||
|
, score = 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 2.3: Create a Custom Type
|
||||||
|
|
||||||
|
Create a `TrafficLight` type with Red, Yellow, and Green variants.
|
||||||
|
Write a function `canGo : TrafficLight -> Bool` that returns True only for Green.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type TrafficLight
|
||||||
|
= Red
|
||||||
|
| Yellow
|
||||||
|
| Green
|
||||||
|
|
||||||
|
|
||||||
|
canGo : TrafficLight -> Bool
|
||||||
|
canGo light =
|
||||||
|
case light of
|
||||||
|
Green ->
|
||||||
|
True
|
||||||
|
|
||||||
|
Yellow ->
|
||||||
|
False
|
||||||
|
|
||||||
|
Red ->
|
||||||
|
False
|
||||||
|
|
||||||
|
-- Or more concisely:
|
||||||
|
canGo2 : TrafficLight -> Bool
|
||||||
|
canGo2 light =
|
||||||
|
light == Green
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 2.4: Maybe Practice
|
||||||
|
|
||||||
|
Write a function that takes a `Maybe Int` and returns the value doubled, or 0 if Nothing:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
doubleOrZero : Maybe Int -> Int
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
doubleOrZero : Maybe Int -> Int
|
||||||
|
doubleOrZero maybeNum =
|
||||||
|
case maybeNum of
|
||||||
|
Just n ->
|
||||||
|
n * 2
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
0
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Key Takeaways
|
||||||
|
|
||||||
|
1. **Elm has distinct Int and Float types** - Unlike JavaScript's single "number"
|
||||||
|
2. **Type annotations are documentation** - They make code self-explanatory
|
||||||
|
3. **Records are immutable** - Use update syntax `{ record | field = value }`
|
||||||
|
4. **Type aliases** create reusable type names AND constructor functions
|
||||||
|
5. **Custom types** replace string constants and are type-safe
|
||||||
|
6. **Maybe replaces null** - Forces you to handle missing values explicitly
|
||||||
|
|
||||||
|
## JavaScript to Elm Cheatsheet
|
||||||
|
|
||||||
|
| JavaScript | Elm |
|
||||||
|
|-----------|-----|
|
||||||
|
| `{}` | Record `{ field = value }` |
|
||||||
|
| `obj.field` | Same: `record.field` |
|
||||||
|
| `{...obj, field: newVal}` | `{ record \| field = newVal }` |
|
||||||
|
| `null` / `undefined` | `Maybe` type |
|
||||||
|
| String constants | Custom types |
|
||||||
|
| `===` | `==` |
|
||||||
|
| `!==` | `/=` |
|
||||||
|
|
||||||
|
## What's Next?
|
||||||
|
|
||||||
|
In [Lesson 3](03-functions.md), we'll explore:
|
||||||
|
- Pure functions
|
||||||
|
- Higher-order functions
|
||||||
|
- Currying and partial application
|
||||||
|
- The pipe operator
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Previous: Lesson 1](01-introduction.md) | [Next: Lesson 3 - Functions →](03-functions.md)
|
||||||
498
lessons/03-functions.md
Normal file
498
lessons/03-functions.md
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
# Lesson 3: Functions
|
||||||
|
|
||||||
|
## Learning Goals
|
||||||
|
|
||||||
|
By the end of this lesson, you will:
|
||||||
|
- Write pure functions
|
||||||
|
- Understand currying and partial application
|
||||||
|
- Use higher-order functions (map, filter, reduce)
|
||||||
|
- Use the pipe operator for readable code
|
||||||
|
- Understand function composition
|
||||||
|
|
||||||
|
## Defining Functions
|
||||||
|
|
||||||
|
### Basic Syntax
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript
|
||||||
|
// const greet = (name) => "Hello, " + name;
|
||||||
|
|
||||||
|
-- Elm
|
||||||
|
greet : String -> String
|
||||||
|
greet name =
|
||||||
|
"Hello, " ++ name
|
||||||
|
```
|
||||||
|
|
||||||
|
Key differences:
|
||||||
|
- No parentheses around parameters
|
||||||
|
- No `return` keyword
|
||||||
|
- No curly braces
|
||||||
|
- Last expression is automatically returned
|
||||||
|
|
||||||
|
### Multiple Parameters
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript
|
||||||
|
// const add = (a, b) => a + b;
|
||||||
|
|
||||||
|
-- Elm
|
||||||
|
add : Int -> Int -> Int
|
||||||
|
add a b =
|
||||||
|
a + b
|
||||||
|
|
||||||
|
-- Using it
|
||||||
|
add 5 3 -- 8 (no parentheses or commas!)
|
||||||
|
```
|
||||||
|
|
||||||
|
### If-Then-Else
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript
|
||||||
|
// const abs = x => x < 0 ? -x : x;
|
||||||
|
|
||||||
|
-- Elm
|
||||||
|
abs : Int -> Int
|
||||||
|
abs x =
|
||||||
|
if x < 0 then
|
||||||
|
-x
|
||||||
|
else
|
||||||
|
x
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** In Elm, `if` is an **expression** that returns a value, not a statement. Both branches must return the same type.
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- This won't compile:
|
||||||
|
invalid x =
|
||||||
|
if x > 0 then
|
||||||
|
"positive"
|
||||||
|
-- Missing else! What would it return?
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pure Functions
|
||||||
|
|
||||||
|
In Elm, ALL functions are **pure**:
|
||||||
|
|
||||||
|
1. **Same input → Same output** (always)
|
||||||
|
2. **No side effects** (no I/O, no mutation, no randomness)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// JavaScript - Impure function
|
||||||
|
let counter = 0;
|
||||||
|
function increment() {
|
||||||
|
counter += 1; // Side effect: modifies external state
|
||||||
|
return counter;
|
||||||
|
}
|
||||||
|
increment(); // 1
|
||||||
|
increment(); // 2 - Different result!
|
||||||
|
```
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Elm - Pure function
|
||||||
|
increment : Int -> Int
|
||||||
|
increment counter =
|
||||||
|
counter + 1
|
||||||
|
|
||||||
|
increment 0 -- 1
|
||||||
|
increment 0 -- 1 (always!)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits of Pure Functions
|
||||||
|
|
||||||
|
1. **Testable** - No mocks needed, just input/output
|
||||||
|
2. **Cacheable** - Same input = same output, so cache results
|
||||||
|
3. **Predictable** - No hidden state changes
|
||||||
|
4. **Parallelizable** - Safe to run simultaneously
|
||||||
|
|
||||||
|
## Currying and Partial Application
|
||||||
|
|
||||||
|
### What is Currying?
|
||||||
|
|
||||||
|
In Elm, every function takes exactly one argument. A function with "multiple arguments" is actually a chain of functions.
|
||||||
|
|
||||||
|
```elm
|
||||||
|
add : Int -> Int -> Int
|
||||||
|
add a b = a + b
|
||||||
|
|
||||||
|
-- This is actually:
|
||||||
|
add : Int -> (Int -> Int)
|
||||||
|
-- A function that takes an Int and returns a function that takes an Int
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partial Application
|
||||||
|
|
||||||
|
You can apply some arguments now and the rest later:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
add : Int -> Int -> Int
|
||||||
|
add a b = a + b
|
||||||
|
|
||||||
|
addFive : Int -> Int
|
||||||
|
addFive = add 5 -- Partially applied!
|
||||||
|
|
||||||
|
addFive 3 -- 8
|
||||||
|
addFive 10 -- 15
|
||||||
|
```
|
||||||
|
|
||||||
|
**JavaScript equivalent:**
|
||||||
|
```javascript
|
||||||
|
const add = a => b => a + b;
|
||||||
|
const addFive = add(5);
|
||||||
|
addFive(3); // 8
|
||||||
|
```
|
||||||
|
|
||||||
|
### Practical Example
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- A formatting function
|
||||||
|
format : String -> String -> String
|
||||||
|
format prefix text =
|
||||||
|
prefix ++ ": " ++ text
|
||||||
|
|
||||||
|
-- Create specialized formatters
|
||||||
|
formatError : String -> String
|
||||||
|
formatError = format "ERROR"
|
||||||
|
|
||||||
|
formatWarning : String -> String
|
||||||
|
formatWarning = format "WARNING"
|
||||||
|
|
||||||
|
formatError "File not found" -- "ERROR: File not found"
|
||||||
|
formatWarning "Low memory" -- "WARNING: Low memory"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anonymous Functions (Lambdas)
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript
|
||||||
|
// const double = x => x * 2;
|
||||||
|
// numbers.map(x => x * 2);
|
||||||
|
|
||||||
|
-- Elm
|
||||||
|
double = \x -> x * 2
|
||||||
|
List.map (\x -> x * 2) numbers
|
||||||
|
```
|
||||||
|
|
||||||
|
The `\` represents the Greek letter lambda (λ).
|
||||||
|
|
||||||
|
### Multiple Parameters
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript
|
||||||
|
// const add = (a, b) => a + b;
|
||||||
|
|
||||||
|
-- Elm
|
||||||
|
add = \a b -> a + b
|
||||||
|
-- Or equivalently
|
||||||
|
add = \a -> \b -> a + b
|
||||||
|
```
|
||||||
|
|
||||||
|
## Higher-Order Functions
|
||||||
|
|
||||||
|
Higher-order functions take or return other functions.
|
||||||
|
|
||||||
|
### List.map (like Array.map)
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript
|
||||||
|
// [1, 2, 3].map(x => x * 2)
|
||||||
|
|
||||||
|
-- Elm
|
||||||
|
List.map (\x -> x * 2) [1, 2, 3] -- [2, 4, 6]
|
||||||
|
|
||||||
|
-- Or with a named function
|
||||||
|
double x = x * 2
|
||||||
|
List.map double [1, 2, 3] -- [2, 4, 6]
|
||||||
|
```
|
||||||
|
|
||||||
|
### List.filter (like Array.filter)
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript
|
||||||
|
// [1, 2, 3, 4, 5].filter(x => x > 3)
|
||||||
|
|
||||||
|
-- Elm
|
||||||
|
List.filter (\x -> x > 3) [1, 2, 3, 4, 5] -- [4, 5]
|
||||||
|
```
|
||||||
|
|
||||||
|
### List.foldl / List.foldr (like Array.reduce)
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript
|
||||||
|
// [1, 2, 3].reduce((sum, x) => sum + x, 0)
|
||||||
|
|
||||||
|
-- Elm
|
||||||
|
List.foldl (\x sum -> sum + x) 0 [1, 2, 3] -- 6
|
||||||
|
|
||||||
|
-- Or using the (+) operator as a function
|
||||||
|
List.foldl (+) 0 [1, 2, 3] -- 6
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Arguments are in different order than JavaScript!
|
||||||
|
- `foldl` goes left-to-right
|
||||||
|
- `foldr` goes right-to-left
|
||||||
|
|
||||||
|
### Other Useful Functions
|
||||||
|
|
||||||
|
```elm
|
||||||
|
List.head [1, 2, 3] -- Just 1 (returns Maybe!)
|
||||||
|
List.tail [1, 2, 3] -- Just [2, 3]
|
||||||
|
List.take 2 [1, 2, 3, 4] -- [1, 2]
|
||||||
|
List.drop 2 [1, 2, 3, 4] -- [3, 4]
|
||||||
|
List.reverse [1, 2, 3] -- [3, 2, 1]
|
||||||
|
List.sort [3, 1, 2] -- [1, 2, 3]
|
||||||
|
List.member 2 [1, 2, 3] -- True
|
||||||
|
List.length [1, 2, 3] -- 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Pipe Operator |>
|
||||||
|
|
||||||
|
The pipe operator makes chains of functions readable:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Without pipes (hard to read, inside-out)
|
||||||
|
String.toUpper (String.trim (String.reverse " hello "))
|
||||||
|
|
||||||
|
-- With pipes (clear data flow)
|
||||||
|
" hello "
|
||||||
|
|> String.reverse
|
||||||
|
|> String.trim
|
||||||
|
|> String.toUpper
|
||||||
|
-- Result: "OLLEH"
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:** `x |> f` is the same as `f x`
|
||||||
|
|
||||||
|
### JavaScript Comparison
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// JavaScript method chaining (only works with methods)
|
||||||
|
" hello ".split("").reverse().join("").trim().toUpperCase()
|
||||||
|
|
||||||
|
// JavaScript pipe (proposed, not standard)
|
||||||
|
" hello " |> reverse |> trim |> toUpperCase
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complex Example
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Get the names of users over 21, sorted
|
||||||
|
users
|
||||||
|
|> List.filter (\user -> user.age > 21)
|
||||||
|
|> List.map .name
|
||||||
|
|> List.sort
|
||||||
|
```
|
||||||
|
|
||||||
|
## Function Composition
|
||||||
|
|
||||||
|
Combine functions into new functions:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- The >> operator (left to right)
|
||||||
|
addOne = (+) 1
|
||||||
|
double = (*) 2
|
||||||
|
|
||||||
|
addOneThenDouble : Int -> Int
|
||||||
|
addOneThenDouble = addOne >> double
|
||||||
|
|
||||||
|
addOneThenDouble 5 -- 12 (5+1=6, 6*2=12)
|
||||||
|
|
||||||
|
-- The << operator (right to left)
|
||||||
|
doubleThenAddOne : Int -> Int
|
||||||
|
doubleThenAddOne = addOne << double
|
||||||
|
|
||||||
|
doubleThenAddOne 5 -- 11 (5*2=10, 10+1=11)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pipe vs Composition
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Pipe: Apply to a specific value
|
||||||
|
5 |> addOne |> double -- 12
|
||||||
|
|
||||||
|
-- Composition: Create a new function
|
||||||
|
addOneThenDouble = addOne >> double
|
||||||
|
addOneThenDouble 5 -- 12
|
||||||
|
```
|
||||||
|
|
||||||
|
## Let Expressions
|
||||||
|
|
||||||
|
Use `let...in` for local bindings:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JavaScript
|
||||||
|
// function circleArea(radius) {
|
||||||
|
// const pi = 3.14159;
|
||||||
|
// const squared = radius * radius;
|
||||||
|
// return pi * squared;
|
||||||
|
// }
|
||||||
|
|
||||||
|
-- Elm
|
||||||
|
circleArea : Float -> Float
|
||||||
|
circleArea radius =
|
||||||
|
let
|
||||||
|
pi = 3.14159
|
||||||
|
squared = radius * radius
|
||||||
|
in
|
||||||
|
pi * squared
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also define helper functions:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
pythagoras : Float -> Float -> Float
|
||||||
|
pythagoras a b =
|
||||||
|
let
|
||||||
|
square x = x * x
|
||||||
|
in
|
||||||
|
sqrt (square a + square b)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exercise 3.1: Basic Functions
|
||||||
|
|
||||||
|
Write these functions:
|
||||||
|
|
||||||
|
1. `isEven : Int -> Bool` - Returns True if number is even
|
||||||
|
2. `exclaim : String -> String` - Adds "!" to the end of a string
|
||||||
|
3. `greet : String -> String -> String` - Takes a greeting and a name
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
isEven : Int -> Bool
|
||||||
|
isEven n =
|
||||||
|
modBy 2 n == 0
|
||||||
|
|
||||||
|
|
||||||
|
exclaim : String -> String
|
||||||
|
exclaim text =
|
||||||
|
text ++ "!"
|
||||||
|
|
||||||
|
|
||||||
|
greet : String -> String -> String
|
||||||
|
greet greeting name =
|
||||||
|
greeting ++ ", " ++ name ++ "!"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 3.2: Higher-Order Functions
|
||||||
|
|
||||||
|
Given this list:
|
||||||
|
```elm
|
||||||
|
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
```
|
||||||
|
|
||||||
|
Use List functions to:
|
||||||
|
1. Get only the odd numbers
|
||||||
|
2. Square each number
|
||||||
|
3. Get the sum of all numbers
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- 1. Odd numbers
|
||||||
|
List.filter (\n -> modBy 2 n /= 0) numbers
|
||||||
|
-- [1, 3, 5, 7, 9]
|
||||||
|
|
||||||
|
-- 2. Squared
|
||||||
|
List.map (\n -> n * n) numbers
|
||||||
|
-- [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
|
||||||
|
|
||||||
|
-- 3. Sum
|
||||||
|
List.foldl (+) 0 numbers
|
||||||
|
-- Or: List.sum numbers
|
||||||
|
-- 55
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 3.3: Pipe Operator
|
||||||
|
|
||||||
|
Rewrite this using the pipe operator:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
String.fromInt (List.length (List.filter isEven (List.range 1 100)))
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
List.range 1 100
|
||||||
|
|> List.filter isEven
|
||||||
|
|> List.length
|
||||||
|
|> String.fromInt
|
||||||
|
-- "50"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 3.4: Partial Application
|
||||||
|
|
||||||
|
Create a `greetUser` function by partially applying this:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
greet : String -> String -> String
|
||||||
|
greet greeting name =
|
||||||
|
greeting ++ ", " ++ name ++ "!"
|
||||||
|
```
|
||||||
|
|
||||||
|
So that `greetUser "Alice"` returns `"Hello, Alice!"`
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
greetUser : String -> String
|
||||||
|
greetUser = greet "Hello"
|
||||||
|
|
||||||
|
greetUser "Alice" -- "Hello, Alice!"
|
||||||
|
greetUser "Bob" -- "Hello, Bob!"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Key Takeaways
|
||||||
|
|
||||||
|
1. **All Elm functions are pure** - Same input always gives same output
|
||||||
|
2. **All functions are curried** - Multi-arg functions are chains of single-arg functions
|
||||||
|
3. **Partial application** lets you create specialized functions
|
||||||
|
4. **The pipe operator `|>`** makes data transformation chains readable
|
||||||
|
5. **Higher-order functions** like `map`, `filter`, `foldl` are your primary tools
|
||||||
|
6. **`let...in`** provides local bindings within functions
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Transform a list
|
||||||
|
items |> List.map transform
|
||||||
|
|
||||||
|
-- Filter and transform
|
||||||
|
items
|
||||||
|
|> List.filter condition
|
||||||
|
|> List.map transform
|
||||||
|
|
||||||
|
-- Find something
|
||||||
|
items
|
||||||
|
|> List.filter condition
|
||||||
|
|> List.head
|
||||||
|
|
||||||
|
-- Aggregate
|
||||||
|
items
|
||||||
|
|> List.foldl combiner initial
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Next?
|
||||||
|
|
||||||
|
In [Lesson 4](04-tea.md), we'll learn The Elm Architecture (TEA):
|
||||||
|
- Model: Your application state
|
||||||
|
- View: Turning state into HTML
|
||||||
|
- Update: Handling messages and updating state
|
||||||
|
|
||||||
|
This is the heart of Elm applications and inspired Redux!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Previous: Lesson 2](02-basics.md) | [Next: Lesson 4 - The Elm Architecture →](04-tea.md)
|
||||||
729
lessons/04-tea.md
Normal file
729
lessons/04-tea.md
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
# Lesson 4: The Elm Architecture (TEA)
|
||||||
|
|
||||||
|
## Learning Goals
|
||||||
|
|
||||||
|
By the end of this lesson, you will:
|
||||||
|
- Understand the Model-View-Update pattern
|
||||||
|
- Handle user input events
|
||||||
|
- Build interactive applications
|
||||||
|
- Understand how Elm inspired Redux
|
||||||
|
|
||||||
|
## What is The Elm Architecture?
|
||||||
|
|
||||||
|
The Elm Architecture (TEA) is a pattern for building web applications. It has three parts:
|
||||||
|
|
||||||
|
1. **Model** - Your application's state (the data)
|
||||||
|
2. **View** - A function that turns the model into HTML
|
||||||
|
3. **Update** - A function that updates the model based on messages
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ User clicks button │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────┐ │
|
||||||
|
│ │ Message │ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Update │───▶│ Model │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────┐ │
|
||||||
|
│ │ View │ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────┐ │
|
||||||
|
│ │ HTML │───┘
|
||||||
|
│ └──────────────┘
|
||||||
|
│
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redux Connection
|
||||||
|
|
||||||
|
If you've used Redux, you already know TEA!
|
||||||
|
|
||||||
|
| Elm | Redux |
|
||||||
|
|-----|-------|
|
||||||
|
| Model | State/Store |
|
||||||
|
| Msg | Action |
|
||||||
|
| Update | Reducer |
|
||||||
|
| View | React Component |
|
||||||
|
|
||||||
|
## Your First Interactive App: Counter
|
||||||
|
|
||||||
|
Let's build a counter that you can increment and decrement.
|
||||||
|
|
||||||
|
Create a new file `src/Counter.elm`:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module Counter exposing (main)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, button, div, text)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ count : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= Increment
|
||||||
|
| Decrement
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
Increment ->
|
||||||
|
{ model | count = model.count + 1 }
|
||||||
|
|
||||||
|
Decrement ->
|
||||||
|
{ model | count = model.count - 1 }
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ button [ onClick Decrement ] [ text "-" ]
|
||||||
|
, div [] [ text (String.fromInt model.count) ]
|
||||||
|
, button [ onClick Increment ] [ text "+" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it with:
|
||||||
|
```bash
|
||||||
|
elm reactor
|
||||||
|
```
|
||||||
|
Then open http://localhost:8000/src/Counter.elm
|
||||||
|
|
||||||
|
### Breaking It Down
|
||||||
|
|
||||||
|
#### 1. The Model
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type alias Model =
|
||||||
|
{ count : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ count = 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The model is just a record holding your state. `init` is the initial state.
|
||||||
|
|
||||||
|
**JavaScript/Redux equivalent:**
|
||||||
|
```javascript
|
||||||
|
const initialState = { count: 0 };
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Messages (Msg)
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Msg
|
||||||
|
= Increment
|
||||||
|
| Decrement
|
||||||
|
```
|
||||||
|
|
||||||
|
Messages describe things that can happen. They're like Redux action types, but type-safe.
|
||||||
|
|
||||||
|
**JavaScript/Redux equivalent:**
|
||||||
|
```javascript
|
||||||
|
const INCREMENT = 'INCREMENT';
|
||||||
|
const DECREMENT = 'DECREMENT';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. The Update Function
|
||||||
|
|
||||||
|
```elm
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
Increment ->
|
||||||
|
{ model | count = model.count + 1 }
|
||||||
|
|
||||||
|
Decrement ->
|
||||||
|
{ model | count = model.count - 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Takes a message and the current model, returns a new model. This is a **pure function** - no side effects!
|
||||||
|
|
||||||
|
**JavaScript/Redux equivalent:**
|
||||||
|
```javascript
|
||||||
|
function reducer(state, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case INCREMENT:
|
||||||
|
return { ...state, count: state.count + 1 };
|
||||||
|
case DECREMENT:
|
||||||
|
return { ...state, count: state.count - 1 };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. The View Function
|
||||||
|
|
||||||
|
```elm
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ button [ onClick Decrement ] [ text "-" ]
|
||||||
|
, div [] [ text (String.fromInt model.count) ]
|
||||||
|
, button [ onClick Increment ] [ text "+" ]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
A pure function that takes the model and returns HTML. The `Html Msg` type means "HTML that can produce Msg values."
|
||||||
|
|
||||||
|
**React equivalent:**
|
||||||
|
```jsx
|
||||||
|
function Counter({ count, dispatch }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
|
||||||
|
<div>{count}</div>
|
||||||
|
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Wiring It Together
|
||||||
|
|
||||||
|
```elm
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Browser.sandbox` connects everything. Elm handles:
|
||||||
|
- Calling `view` whenever the model changes
|
||||||
|
- Routing user events to `update`
|
||||||
|
- Re-rendering efficiently (virtual DOM)
|
||||||
|
|
||||||
|
## HTML in Elm
|
||||||
|
|
||||||
|
Every HTML element follows this pattern:
|
||||||
|
```elm
|
||||||
|
element attributes children
|
||||||
|
```
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- <div class="container">Hello</div>
|
||||||
|
div [ class "container" ] [ text "Hello" ]
|
||||||
|
|
||||||
|
-- <button id="submit" disabled>Submit</button>
|
||||||
|
button [ id "submit", disabled True ] [ text "Submit" ]
|
||||||
|
|
||||||
|
-- <input type="text" value="hello" />
|
||||||
|
input [ type_ "text", value "hello" ] []
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Some attributes like `type` use underscores (`type_`) because they conflict with Elm keywords.
|
||||||
|
|
||||||
|
### Common Attributes
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
|
||||||
|
-- Styling
|
||||||
|
class "my-class"
|
||||||
|
style "color" "red"
|
||||||
|
id "my-id"
|
||||||
|
|
||||||
|
-- Form inputs
|
||||||
|
value "text"
|
||||||
|
placeholder "Enter text..."
|
||||||
|
disabled True
|
||||||
|
checked True
|
||||||
|
|
||||||
|
-- Links and images
|
||||||
|
href "https://elm-lang.org"
|
||||||
|
src "image.png"
|
||||||
|
alt "Description"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Events
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Html.Events exposing (..)
|
||||||
|
|
||||||
|
onClick Msg -- Button clicks
|
||||||
|
onInput (String -> Msg) -- Text input
|
||||||
|
onSubmit Msg -- Form submission
|
||||||
|
onMouseEnter Msg -- Mouse enter
|
||||||
|
onMouseLeave Msg -- Mouse leave
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 2: Text Input
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module TextInput exposing (main)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, div, input, text)
|
||||||
|
import Html.Attributes exposing (placeholder, value)
|
||||||
|
import Html.Events exposing (onInput)
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ content : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ content = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= Change String
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
Change newContent ->
|
||||||
|
{ model | content = newContent }
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Type something..."
|
||||||
|
, value model.content
|
||||||
|
, onInput Change
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, div [] [ text ("You typed: " ++ model.content) ]
|
||||||
|
, div [] [ text ("Length: " ++ String.fromInt (String.length model.content)) ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Point: Messages with Data
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Msg
|
||||||
|
= Change String -- This message carries a String!
|
||||||
|
|
||||||
|
-- onInput sends the input's value with the message
|
||||||
|
onInput Change -- When input changes, send: Change "new value"
|
||||||
|
|
||||||
|
-- In update, extract the data
|
||||||
|
Change newContent ->
|
||||||
|
{ model | content = newContent }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example 3: Todo List
|
||||||
|
|
||||||
|
A more complex example with a list of items:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module TodoList exposing (main)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, button, div, input, li, text, ul)
|
||||||
|
import Html.Attributes exposing (placeholder, value)
|
||||||
|
import Html.Events exposing (onClick, onInput)
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ todos : List String
|
||||||
|
, newTodo : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ todos = []
|
||||||
|
, newTodo = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= UpdateNewTodo String
|
||||||
|
| AddTodo
|
||||||
|
| RemoveTodo Int
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
UpdateNewTodo text ->
|
||||||
|
{ model | newTodo = text }
|
||||||
|
|
||||||
|
AddTodo ->
|
||||||
|
if String.isEmpty model.newTodo then
|
||||||
|
model
|
||||||
|
else
|
||||||
|
{ model
|
||||||
|
| todos = model.todos ++ [ model.newTodo ]
|
||||||
|
, newTodo = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveTodo index ->
|
||||||
|
{ model
|
||||||
|
| todos = removeAt index model.todos
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
removeAt : Int -> List a -> List a
|
||||||
|
removeAt index list =
|
||||||
|
List.take index list ++ List.drop (index + 1) list
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Add a todo..."
|
||||||
|
, value model.newTodo
|
||||||
|
, onInput UpdateNewTodo
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, button [ onClick AddTodo ] [ text "Add" ]
|
||||||
|
]
|
||||||
|
, ul [] (List.indexedMap viewTodo model.todos)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewTodo : Int -> String -> Html Msg
|
||||||
|
viewTodo index todo =
|
||||||
|
li []
|
||||||
|
[ text todo
|
||||||
|
, button [ onClick (RemoveTodo index) ] [ text "x" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Concepts
|
||||||
|
|
||||||
|
1. **List.indexedMap** - Like map, but gives you the index too
|
||||||
|
2. **Messages with multiple types of data** - `RemoveTodo Int` carries which item to remove
|
||||||
|
3. **Helper functions** - `removeAt` and `viewTodo` keep code organized
|
||||||
|
|
||||||
|
## Exercise 4.1: Add Features to Counter
|
||||||
|
|
||||||
|
Enhance the counter with:
|
||||||
|
1. A "Reset" button that sets count to 0
|
||||||
|
2. A "Double" button that doubles the count
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Msg
|
||||||
|
= Increment
|
||||||
|
| Decrement
|
||||||
|
| Reset
|
||||||
|
| Double
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
Increment ->
|
||||||
|
{ model | count = model.count + 1 }
|
||||||
|
|
||||||
|
Decrement ->
|
||||||
|
{ model | count = model.count - 1 }
|
||||||
|
|
||||||
|
Reset ->
|
||||||
|
{ model | count = 0 }
|
||||||
|
|
||||||
|
Double ->
|
||||||
|
{ model | count = model.count * 2 }
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ button [ onClick Decrement ] [ text "-" ]
|
||||||
|
, div [] [ text (String.fromInt model.count) ]
|
||||||
|
, button [ onClick Increment ] [ text "+" ]
|
||||||
|
, button [ onClick Reset ] [ text "Reset" ]
|
||||||
|
, button [ onClick Double ] [ text "Double" ]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 4.2: Temperature Converter
|
||||||
|
|
||||||
|
Build a Celsius to Fahrenheit converter:
|
||||||
|
- Input field for Celsius
|
||||||
|
- Display the Fahrenheit equivalent
|
||||||
|
- Formula: F = C × 9/5 + 32
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module TempConverter exposing (main)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, div, input, text)
|
||||||
|
import Html.Attributes exposing (placeholder, value)
|
||||||
|
import Html.Events exposing (onInput)
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ celsius : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ celsius = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= UpdateCelsius String
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
UpdateCelsius value ->
|
||||||
|
{ model | celsius = value }
|
||||||
|
|
||||||
|
|
||||||
|
celsiusToFahrenheit : Float -> Float
|
||||||
|
celsiusToFahrenheit c =
|
||||||
|
c * 9 / 5 + 32
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
let
|
||||||
|
fahrenheit =
|
||||||
|
case String.toFloat model.celsius of
|
||||||
|
Just c ->
|
||||||
|
String.fromFloat (celsiusToFahrenheit c) ++ "°F"
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"Enter a valid number"
|
||||||
|
in
|
||||||
|
div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Celsius"
|
||||||
|
, value model.celsius
|
||||||
|
, onInput UpdateCelsius
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, text " °C = "
|
||||||
|
, text fahrenheit
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 4.3: Signup Form
|
||||||
|
|
||||||
|
Build a signup form with:
|
||||||
|
- Name field
|
||||||
|
- Email field
|
||||||
|
- Password field
|
||||||
|
- "Sign Up" button
|
||||||
|
- Display "Welcome, {name}!" after clicking Sign Up
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module Signup exposing (main)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, button, div, input, text)
|
||||||
|
import Html.Attributes exposing (placeholder, type_, value)
|
||||||
|
import Html.Events exposing (onClick, onInput)
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ name : String
|
||||||
|
, email : String
|
||||||
|
, password : String
|
||||||
|
, submitted : Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ name = ""
|
||||||
|
, email = ""
|
||||||
|
, password = ""
|
||||||
|
, submitted = False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= UpdateName String
|
||||||
|
| UpdateEmail String
|
||||||
|
| UpdatePassword String
|
||||||
|
| Submit
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
UpdateName value ->
|
||||||
|
{ model | name = value }
|
||||||
|
|
||||||
|
UpdateEmail value ->
|
||||||
|
{ model | email = value }
|
||||||
|
|
||||||
|
UpdatePassword value ->
|
||||||
|
{ model | password = value }
|
||||||
|
|
||||||
|
Submit ->
|
||||||
|
{ model | submitted = True }
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
if model.submitted then
|
||||||
|
div [] [ text ("Welcome, " ++ model.name ++ "!") ]
|
||||||
|
else
|
||||||
|
div []
|
||||||
|
[ div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Name"
|
||||||
|
, value model.name
|
||||||
|
, onInput UpdateName
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Email"
|
||||||
|
, type_ "email"
|
||||||
|
, value model.email
|
||||||
|
, onInput UpdateEmail
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Password"
|
||||||
|
, type_ "password"
|
||||||
|
, value model.password
|
||||||
|
, onInput UpdatePassword
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, button [ onClick Submit ] [ text "Sign Up" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Key Takeaways
|
||||||
|
|
||||||
|
1. **TEA has three parts:** Model, View, Update
|
||||||
|
2. **Model** holds all your application state
|
||||||
|
3. **View** is a pure function: Model → HTML
|
||||||
|
4. **Update** is a pure function: Msg → Model → Model
|
||||||
|
5. **Messages** describe events and can carry data
|
||||||
|
6. **Browser.sandbox** wires everything together
|
||||||
|
7. **HTML is just functions** with attributes and children
|
||||||
|
|
||||||
|
## The Elm Architecture Benefits
|
||||||
|
|
||||||
|
1. **No unexpected state changes** - All updates go through update
|
||||||
|
2. **Time-travel debugging** - Elm debugger lets you replay messages
|
||||||
|
3. **Easy testing** - Pure functions with no side effects
|
||||||
|
4. **Predictable** - Given a model and message, you know the result
|
||||||
|
|
||||||
|
## What's Next?
|
||||||
|
|
||||||
|
In [Lesson 5](05-lists-maybe.md), we'll dive deeper into:
|
||||||
|
- Working with Lists
|
||||||
|
- The Maybe type for handling missing values
|
||||||
|
- Pattern matching techniques
|
||||||
|
- Common List operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Previous: Lesson 3](03-functions.md) | [Next: Lesson 5 - Lists & Maybe →](05-lists-maybe.md)
|
||||||
533
lessons/05-lists-maybe.md
Normal file
533
lessons/05-lists-maybe.md
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
# Lesson 5: Lists & Maybe
|
||||||
|
|
||||||
|
## Learning Goals
|
||||||
|
|
||||||
|
By the end of this lesson, you will:
|
||||||
|
- Master List operations in Elm
|
||||||
|
- Understand the Maybe type deeply
|
||||||
|
- Handle missing values safely
|
||||||
|
- Use pattern matching effectively
|
||||||
|
- Work with Result for error handling
|
||||||
|
|
||||||
|
## Lists in Elm
|
||||||
|
|
||||||
|
Lists in Elm are **linked lists** (not arrays like JavaScript).
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Creating lists
|
||||||
|
numbers : List Int
|
||||||
|
numbers = [1, 2, 3, 4, 5]
|
||||||
|
|
||||||
|
strings : List String
|
||||||
|
strings = ["apple", "banana", "cherry"]
|
||||||
|
|
||||||
|
empty : List a
|
||||||
|
empty = []
|
||||||
|
|
||||||
|
-- Using cons operator (::) to prepend
|
||||||
|
moreNumbers = 0 :: numbers -- [0, 1, 2, 3, 4, 5]
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript Comparison
|
||||||
|
|
||||||
|
| JavaScript | Elm |
|
||||||
|
|-----------|-----|
|
||||||
|
| `[1, 2, 3]` | `[1, 2, 3]` |
|
||||||
|
| `arr.push(4)` | Creates new list: `arr ++ [4]` |
|
||||||
|
| `arr.unshift(0)` | `0 :: arr` (prepend) |
|
||||||
|
| `arr[0]` | `List.head arr` (returns Maybe!) |
|
||||||
|
| `arr.length` | `List.length arr` |
|
||||||
|
|
||||||
|
### Important: Lists Are Homogeneous
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- This works
|
||||||
|
numbers = [1, 2, 3]
|
||||||
|
|
||||||
|
-- This does NOT compile
|
||||||
|
mixed = [1, "hello", True] -- ERROR: all elements must be same type
|
||||||
|
```
|
||||||
|
|
||||||
|
JavaScript allows mixed arrays, Elm doesn't. This catches bugs early!
|
||||||
|
|
||||||
|
## Essential List Functions
|
||||||
|
|
||||||
|
### Creating Lists
|
||||||
|
|
||||||
|
```elm
|
||||||
|
List.range 1 5 -- [1, 2, 3, 4, 5]
|
||||||
|
List.repeat 3 "hi" -- ["hi", "hi", "hi"]
|
||||||
|
List.singleton 42 -- [42]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transforming Lists
|
||||||
|
|
||||||
|
```elm
|
||||||
|
List.map (\x -> x * 2) [1, 2, 3]
|
||||||
|
-- [2, 4, 6]
|
||||||
|
|
||||||
|
List.map String.toUpper ["a", "b", "c"]
|
||||||
|
-- ["A", "B", "C"]
|
||||||
|
|
||||||
|
-- With index
|
||||||
|
List.indexedMap (\i x -> String.fromInt i ++ ": " ++ x) ["a", "b"]
|
||||||
|
-- ["0: a", "1: b"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
|
||||||
|
```elm
|
||||||
|
List.filter (\x -> x > 2) [1, 2, 3, 4, 5]
|
||||||
|
-- [3, 4, 5]
|
||||||
|
|
||||||
|
List.filter String.isEmpty ["", "hello", "", "world"]
|
||||||
|
-- ["", ""]
|
||||||
|
|
||||||
|
-- Partition: split into two lists
|
||||||
|
List.partition (\x -> x > 2) [1, 2, 3, 4, 5]
|
||||||
|
-- ([3, 4, 5], [1, 2])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combining Lists
|
||||||
|
|
||||||
|
```elm
|
||||||
|
[1, 2] ++ [3, 4] -- [1, 2, 3, 4]
|
||||||
|
List.concat [[1,2], [3,4]] -- [1, 2, 3, 4]
|
||||||
|
List.intersperse 0 [1,2,3] -- [1, 0, 2, 0, 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reducing (Folding)
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Sum
|
||||||
|
List.foldl (+) 0 [1, 2, 3, 4]
|
||||||
|
-- 10
|
||||||
|
|
||||||
|
-- Product
|
||||||
|
List.foldl (*) 1 [1, 2, 3, 4]
|
||||||
|
-- 24
|
||||||
|
|
||||||
|
-- Build a string
|
||||||
|
List.foldl (\item acc -> acc ++ item) "" ["a", "b", "c"]
|
||||||
|
-- "abc"
|
||||||
|
|
||||||
|
-- Convenience functions
|
||||||
|
List.sum [1, 2, 3, 4] -- 10
|
||||||
|
List.product [1, 2, 3, 4] -- 24
|
||||||
|
List.maximum [1, 5, 3] -- Just 5 (returns Maybe!)
|
||||||
|
List.minimum [1, 5, 3] -- Just 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Elements
|
||||||
|
|
||||||
|
```elm
|
||||||
|
List.head [1, 2, 3] -- Just 1
|
||||||
|
List.tail [1, 2, 3] -- Just [2, 3]
|
||||||
|
List.head [] -- Nothing
|
||||||
|
List.take 2 [1, 2, 3, 4] -- [1, 2]
|
||||||
|
List.drop 2 [1, 2, 3, 4] -- [3, 4]
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice `head` and `tail` return `Maybe`! This is Elm protecting you.
|
||||||
|
|
||||||
|
## The Maybe Type
|
||||||
|
|
||||||
|
`Maybe` is Elm's solution to null/undefined. It forces you to handle missing values.
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Maybe a
|
||||||
|
= Just a
|
||||||
|
| Nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why Maybe?
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// JavaScript - Runtime crash waiting to happen
|
||||||
|
function getFirst(arr) {
|
||||||
|
return arr[0]; // undefined if empty!
|
||||||
|
}
|
||||||
|
const first = getFirst([]);
|
||||||
|
console.log(first.toUpperCase()); // CRASH!
|
||||||
|
```
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Elm - Must handle both cases
|
||||||
|
getFirst : List a -> Maybe a
|
||||||
|
getFirst list =
|
||||||
|
List.head list
|
||||||
|
|
||||||
|
case getFirst [] of
|
||||||
|
Just value ->
|
||||||
|
String.toUpper value -- Safe!
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"No value" -- Must handle this case
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with Maybe
|
||||||
|
|
||||||
|
#### Pattern Matching (Most Common)
|
||||||
|
|
||||||
|
```elm
|
||||||
|
showResult : Maybe Int -> String
|
||||||
|
showResult maybeNum =
|
||||||
|
case maybeNum of
|
||||||
|
Just n ->
|
||||||
|
"The number is " ++ String.fromInt n
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"No number found"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Maybe.withDefault
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Provide a default value
|
||||||
|
Maybe.withDefault 0 (Just 5) -- 5
|
||||||
|
Maybe.withDefault 0 Nothing -- 0
|
||||||
|
|
||||||
|
-- Useful pattern
|
||||||
|
name = Maybe.withDefault "Anonymous" maybeName
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Maybe.map
|
||||||
|
|
||||||
|
Transform the value inside if it exists:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
Maybe.map (\x -> x * 2) (Just 5) -- Just 10
|
||||||
|
Maybe.map (\x -> x * 2) Nothing -- Nothing
|
||||||
|
|
||||||
|
-- Chain operations
|
||||||
|
Just "hello"
|
||||||
|
|> Maybe.map String.toUpper
|
||||||
|
|> Maybe.map String.reverse
|
||||||
|
-- Just "OLLEH"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Maybe.andThen
|
||||||
|
|
||||||
|
Chain operations that might fail:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- String.toInt returns Maybe Int
|
||||||
|
String.toInt "42" -- Just 42
|
||||||
|
String.toInt "hello" -- Nothing
|
||||||
|
|
||||||
|
-- Chain fallible operations
|
||||||
|
parseAndDouble : String -> Maybe Int
|
||||||
|
parseAndDouble str =
|
||||||
|
String.toInt str
|
||||||
|
|> Maybe.andThen (\n -> Just (n * 2))
|
||||||
|
|
||||||
|
parseAndDouble "5" -- Just 10
|
||||||
|
parseAndDouble "hello" -- Nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Maybe in Practice
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type alias User =
|
||||||
|
{ name : String
|
||||||
|
, email : Maybe String -- Email is optional
|
||||||
|
, age : Maybe Int -- Age is optional
|
||||||
|
}
|
||||||
|
|
||||||
|
displayEmail : User -> String
|
||||||
|
displayEmail user =
|
||||||
|
case user.email of
|
||||||
|
Just email ->
|
||||||
|
email
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"No email provided"
|
||||||
|
|
||||||
|
-- Or more concisely:
|
||||||
|
displayEmail2 : User -> String
|
||||||
|
displayEmail2 user =
|
||||||
|
Maybe.withDefault "No email provided" user.email
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern Matching
|
||||||
|
|
||||||
|
Pattern matching is a powerful way to destructure data.
|
||||||
|
|
||||||
|
### On Lists
|
||||||
|
|
||||||
|
```elm
|
||||||
|
describeList : List a -> String
|
||||||
|
describeList list =
|
||||||
|
case list of
|
||||||
|
[] ->
|
||||||
|
"Empty list"
|
||||||
|
|
||||||
|
[x] ->
|
||||||
|
"Single element"
|
||||||
|
|
||||||
|
[x, y] ->
|
||||||
|
"Two elements"
|
||||||
|
|
||||||
|
x :: rest ->
|
||||||
|
"Starts with something, has more"
|
||||||
|
```
|
||||||
|
|
||||||
|
### On Maybe
|
||||||
|
|
||||||
|
```elm
|
||||||
|
case maybeName of
|
||||||
|
Just name ->
|
||||||
|
"Hello, " ++ name
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"Hello, stranger"
|
||||||
|
```
|
||||||
|
|
||||||
|
### On Custom Types
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Status
|
||||||
|
= Loading
|
||||||
|
| Success String
|
||||||
|
| Error String Int
|
||||||
|
|
||||||
|
showStatus : Status -> String
|
||||||
|
showStatus status =
|
||||||
|
case status of
|
||||||
|
Loading ->
|
||||||
|
"Loading..."
|
||||||
|
|
||||||
|
Success message ->
|
||||||
|
"Success: " ++ message
|
||||||
|
|
||||||
|
Error message code ->
|
||||||
|
"Error " ++ String.fromInt code ++ ": " ++ message
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wildcards
|
||||||
|
|
||||||
|
Use `_` to ignore values:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
isLoading : Status -> Bool
|
||||||
|
isLoading status =
|
||||||
|
case status of
|
||||||
|
Loading ->
|
||||||
|
True
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
False -- Matches Success and Error
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Result Type
|
||||||
|
|
||||||
|
`Result` is like `Maybe` but carries error information:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Result error value
|
||||||
|
= Ok value
|
||||||
|
| Err error
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: Parsing Numbers
|
||||||
|
|
||||||
|
```elm
|
||||||
|
parseAge : String -> Result String Int
|
||||||
|
parseAge input =
|
||||||
|
case String.toInt input of
|
||||||
|
Just age ->
|
||||||
|
if age < 0 then
|
||||||
|
Err "Age cannot be negative"
|
||||||
|
else if age > 150 then
|
||||||
|
Err "Age seems unrealistic"
|
||||||
|
else
|
||||||
|
Ok age
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Err "Please enter a valid number"
|
||||||
|
|
||||||
|
-- Using it
|
||||||
|
case parseAge userInput of
|
||||||
|
Ok age ->
|
||||||
|
"Your age is " ++ String.fromInt age
|
||||||
|
|
||||||
|
Err errorMessage ->
|
||||||
|
"Invalid: " ++ errorMessage
|
||||||
|
```
|
||||||
|
|
||||||
|
### Result Functions
|
||||||
|
|
||||||
|
```elm
|
||||||
|
Result.withDefault 0 (Ok 5) -- 5
|
||||||
|
Result.withDefault 0 (Err "oops") -- 0
|
||||||
|
|
||||||
|
Result.map (\x -> x * 2) (Ok 5) -- Ok 10
|
||||||
|
Result.map (\x -> x * 2) (Err "x") -- Err "x"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exercise 5.1: List Operations
|
||||||
|
|
||||||
|
Given this list of numbers:
|
||||||
|
```elm
|
||||||
|
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
```
|
||||||
|
|
||||||
|
Write expressions to:
|
||||||
|
1. Get all even numbers
|
||||||
|
2. Square each number
|
||||||
|
3. Get the sum of squares of even numbers
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- 1. Even numbers
|
||||||
|
evens = List.filter (\n -> modBy 2 n == 0) numbers
|
||||||
|
-- [2, 4, 6, 8, 10]
|
||||||
|
|
||||||
|
-- 2. Square each
|
||||||
|
squares = List.map (\n -> n * n) numbers
|
||||||
|
-- [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
|
||||||
|
|
||||||
|
-- 3. Sum of squares of evens
|
||||||
|
sumOfSquaresOfEvens =
|
||||||
|
numbers
|
||||||
|
|> List.filter (\n -> modBy 2 n == 0)
|
||||||
|
|> List.map (\n -> n * n)
|
||||||
|
|> List.sum
|
||||||
|
-- 220
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 5.2: Safe Division
|
||||||
|
|
||||||
|
Write a function `safeDivide : Int -> Int -> Maybe Int` that:
|
||||||
|
- Returns `Nothing` if dividing by zero
|
||||||
|
- Returns `Just result` otherwise
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
safeDivide : Int -> Int -> Maybe Int
|
||||||
|
safeDivide numerator denominator =
|
||||||
|
if denominator == 0 then
|
||||||
|
Nothing
|
||||||
|
else
|
||||||
|
Just (numerator // denominator)
|
||||||
|
|
||||||
|
safeDivide 10 2 -- Just 5
|
||||||
|
safeDivide 10 0 -- Nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 5.3: User Lookup
|
||||||
|
|
||||||
|
Given this data structure:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type alias User =
|
||||||
|
{ id : Int
|
||||||
|
, name : String
|
||||||
|
}
|
||||||
|
|
||||||
|
users : List User
|
||||||
|
users =
|
||||||
|
[ { id = 1, name = "Alice" }
|
||||||
|
, { id = 2, name = "Bob" }
|
||||||
|
, { id = 3, name = "Charlie" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `findUser : Int -> Maybe User` that finds a user by ID.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
findUser : Int -> Maybe User
|
||||||
|
findUser id =
|
||||||
|
users
|
||||||
|
|> List.filter (\user -> user.id == id)
|
||||||
|
|> List.head
|
||||||
|
|
||||||
|
-- Or using List.find (from List.Extra package)
|
||||||
|
-- findUser id = List.Extra.find (\user -> user.id == id) users
|
||||||
|
|
||||||
|
findUser 2 -- Just { id = 2, name = "Bob" }
|
||||||
|
findUser 99 -- Nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 5.4: Chain Maybe Operations
|
||||||
|
|
||||||
|
Write a function that:
|
||||||
|
1. Takes a list of strings
|
||||||
|
2. Gets the first element
|
||||||
|
3. Converts it to an integer
|
||||||
|
4. Doubles it
|
||||||
|
|
||||||
|
```elm
|
||||||
|
processFirst : List String -> Maybe Int
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
processFirst : List String -> Maybe Int
|
||||||
|
processFirst strings =
|
||||||
|
strings
|
||||||
|
|> List.head
|
||||||
|
|> Maybe.andThen String.toInt
|
||||||
|
|> Maybe.map (\n -> n * 2)
|
||||||
|
|
||||||
|
processFirst ["42", "hello"] -- Just 84
|
||||||
|
processFirst ["hello", "42"] -- Nothing (can't parse)
|
||||||
|
processFirst [] -- Nothing (empty list)
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Key Takeaways
|
||||||
|
|
||||||
|
1. **Lists are homogeneous** - All elements must be the same type
|
||||||
|
2. **Lists are immutable** - Operations return new lists
|
||||||
|
3. **Maybe replaces null** - Forces explicit handling of missing values
|
||||||
|
4. **Result carries error info** - Better than just Nothing
|
||||||
|
5. **Pattern matching** is powerful for destructuring data
|
||||||
|
6. **Use `|>` with Maybe.map and Maybe.andThen** for clean chains
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Safely get and transform
|
||||||
|
List.head items
|
||||||
|
|> Maybe.map transform
|
||||||
|
|> Maybe.withDefault defaultValue
|
||||||
|
|
||||||
|
-- Chain fallible operations
|
||||||
|
input
|
||||||
|
|> step1
|
||||||
|
|> Maybe.andThen step2
|
||||||
|
|> Maybe.andThen step3
|
||||||
|
|
||||||
|
-- Filter and transform in one go
|
||||||
|
items
|
||||||
|
|> List.filterMap toMaybe
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Next?
|
||||||
|
|
||||||
|
In [Lesson 6](06-http-json.md), we'll learn about:
|
||||||
|
- HTTP requests
|
||||||
|
- JSON decoding
|
||||||
|
- Commands and subscriptions
|
||||||
|
- Side effects in Elm
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Previous: Lesson 4](04-tea.md) | [Next: Lesson 6 - HTTP & JSON →](06-http-json.md)
|
||||||
840
lessons/06-http-json.md
Normal file
840
lessons/06-http-json.md
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
# Lesson 6: HTTP & JSON
|
||||||
|
|
||||||
|
## Learning Goals
|
||||||
|
|
||||||
|
By the end of this lesson, you will:
|
||||||
|
- Understand Commands (Cmd) in Elm
|
||||||
|
- Make HTTP requests
|
||||||
|
- Decode JSON responses
|
||||||
|
- Handle loading states and errors
|
||||||
|
- Understand how Elm manages side effects
|
||||||
|
|
||||||
|
## Side Effects in Elm
|
||||||
|
|
||||||
|
Remember: Elm functions are **pure** - they can't do side effects directly. So how do we:
|
||||||
|
- Make HTTP requests?
|
||||||
|
- Generate random numbers?
|
||||||
|
- Get the current time?
|
||||||
|
- Write to local storage?
|
||||||
|
|
||||||
|
Answer: **Commands (Cmd)**
|
||||||
|
|
||||||
|
### The Command Pattern
|
||||||
|
|
||||||
|
Instead of performing side effects, you **describe** them:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- Instead of actually making a request:
|
||||||
|
-- response = http.get("https://api.example.com") -- NOT how Elm works
|
||||||
|
|
||||||
|
-- You return a command describing what you want:
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
FetchData ->
|
||||||
|
( model, Http.get { url = "...", expect = ... } )
|
||||||
|
-- Returns the model AND a command
|
||||||
|
```
|
||||||
|
|
||||||
|
Elm runtime executes the command and sends the result back as a message.
|
||||||
|
|
||||||
|
## Browser.element: Elm with Commands
|
||||||
|
|
||||||
|
We need to upgrade from `Browser.sandbox` to `Browser.element`:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
main =
|
||||||
|
Browser.element
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Differences
|
||||||
|
|
||||||
|
| Browser.sandbox | Browser.element |
|
||||||
|
|-----------------|-----------------|
|
||||||
|
| `init : Model` | `init : flags -> (Model, Cmd Msg)` |
|
||||||
|
| `update : Msg -> Model -> Model` | `update : Msg -> Model -> (Model, Cmd Msg)` |
|
||||||
|
| No side effects | Can perform side effects via Cmd |
|
||||||
|
|
||||||
|
## Your First HTTP Request
|
||||||
|
|
||||||
|
Let's fetch a random quote from an API.
|
||||||
|
|
||||||
|
### Step 1: Set Up the Project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir http-example
|
||||||
|
cd http-example
|
||||||
|
elm init
|
||||||
|
elm install elm/http
|
||||||
|
elm install elm/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: The Complete Example
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, button, div, p, text)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Loading
|
||||||
|
| Success String
|
||||||
|
| Failure String
|
||||||
|
|
||||||
|
|
||||||
|
init : () -> ( Model, Cmd Msg )
|
||||||
|
init _ =
|
||||||
|
( Loading, fetchQuote )
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= GotQuote (Result Http.Error String)
|
||||||
|
| FetchNewQuote
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
GotQuote result ->
|
||||||
|
case result of
|
||||||
|
Ok quote ->
|
||||||
|
( Success quote, Cmd.none )
|
||||||
|
|
||||||
|
Err error ->
|
||||||
|
( Failure (errorToString error), Cmd.none )
|
||||||
|
|
||||||
|
FetchNewQuote ->
|
||||||
|
( Loading, fetchQuote )
|
||||||
|
|
||||||
|
|
||||||
|
errorToString : Http.Error -> String
|
||||||
|
errorToString error =
|
||||||
|
case error of
|
||||||
|
Http.BadUrl url ->
|
||||||
|
"Bad URL: " ++ url
|
||||||
|
|
||||||
|
Http.Timeout ->
|
||||||
|
"Request timed out"
|
||||||
|
|
||||||
|
Http.NetworkError ->
|
||||||
|
"Network error"
|
||||||
|
|
||||||
|
Http.BadStatus status ->
|
||||||
|
"Bad status: " ++ String.fromInt status
|
||||||
|
|
||||||
|
Http.BadBody message ->
|
||||||
|
"Bad body: " ++ message
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
fetchQuote : Cmd Msg
|
||||||
|
fetchQuote =
|
||||||
|
Http.get
|
||||||
|
{ url = "https://api.quotable.io/random"
|
||||||
|
, expect = Http.expectJson GotQuote quoteDecoder
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
quoteDecoder : Json.Decode.Decoder String
|
||||||
|
quoteDecoder =
|
||||||
|
Json.Decode.field "content" Json.Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ case model of
|
||||||
|
Loading ->
|
||||||
|
text "Loading..."
|
||||||
|
|
||||||
|
Success quote ->
|
||||||
|
div []
|
||||||
|
[ p [] [ text quote ]
|
||||||
|
, button [ onClick FetchNewQuote ] [ text "New Quote" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
Failure error ->
|
||||||
|
div []
|
||||||
|
[ p [] [ text ("Error: " ++ error) ]
|
||||||
|
, button [ onClick FetchNewQuote ] [ text "Try Again" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions _ =
|
||||||
|
Sub.none
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
main =
|
||||||
|
Browser.element
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breaking It Down
|
||||||
|
|
||||||
|
#### The Model as State Machine
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type Model
|
||||||
|
= Loading
|
||||||
|
| Success String
|
||||||
|
| Failure String
|
||||||
|
```
|
||||||
|
|
||||||
|
Using a custom type for the model means we can only be in ONE state at a time. No more:
|
||||||
|
```javascript
|
||||||
|
// JavaScript
|
||||||
|
{ isLoading: false, hasError: true, data: null } // Confusing!
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Init Returns a Command
|
||||||
|
|
||||||
|
```elm
|
||||||
|
init : () -> ( Model, Cmd Msg )
|
||||||
|
init _ =
|
||||||
|
( Loading, fetchQuote ) -- Start in Loading state, fetch immediately
|
||||||
|
```
|
||||||
|
|
||||||
|
The `()` is called "unit" - it means no flags are passed. We return a **tuple** of model and command.
|
||||||
|
|
||||||
|
#### Update Returns a Command
|
||||||
|
|
||||||
|
```elm
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
GotQuote result ->
|
||||||
|
case result of
|
||||||
|
Ok quote ->
|
||||||
|
( Success quote, Cmd.none ) -- No more commands
|
||||||
|
|
||||||
|
Err error ->
|
||||||
|
( Failure (errorToString error), Cmd.none )
|
||||||
|
```
|
||||||
|
|
||||||
|
`Cmd.none` means "no command to execute."
|
||||||
|
|
||||||
|
#### The HTTP Request
|
||||||
|
|
||||||
|
```elm
|
||||||
|
fetchQuote : Cmd Msg
|
||||||
|
fetchQuote =
|
||||||
|
Http.get
|
||||||
|
{ url = "https://api.quotable.io/random"
|
||||||
|
, expect = Http.expectJson GotQuote quoteDecoder
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Http.get` creates a GET request command
|
||||||
|
- `expect` tells Elm what to do with the response
|
||||||
|
- `GotQuote` is the message constructor that will wrap the result
|
||||||
|
- `quoteDecoder` parses the JSON
|
||||||
|
|
||||||
|
## JSON Decoding
|
||||||
|
|
||||||
|
Elm doesn't just convert JSON to any type automatically. You must define a **decoder** that describes the JSON structure.
|
||||||
|
|
||||||
|
### Why Decoders?
|
||||||
|
|
||||||
|
JavaScript:
|
||||||
|
```javascript
|
||||||
|
const data = JSON.parse(response);
|
||||||
|
console.log(data.user.name); // Might crash if structure is wrong!
|
||||||
|
```
|
||||||
|
|
||||||
|
Elm: The decoder either succeeds with the exact type you expect, or fails gracefully.
|
||||||
|
|
||||||
|
### Basic Decoders
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Json.Decode exposing (Decoder, string, int, float, bool)
|
||||||
|
|
||||||
|
-- Primitive decoders
|
||||||
|
string : Decoder String -- Decodes "hello" to "hello"
|
||||||
|
int : Decoder Int -- Decodes 42 to 42
|
||||||
|
float : Decoder Float -- Decodes 3.14 to 3.14
|
||||||
|
bool : Decoder Bool -- Decodes true to True
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decoding Objects
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Json.Decode exposing (Decoder, field, string, int)
|
||||||
|
|
||||||
|
-- JSON: { "name": "Alice" }
|
||||||
|
nameDecoder : Decoder String
|
||||||
|
nameDecoder =
|
||||||
|
field "name" string
|
||||||
|
|
||||||
|
-- JSON: { "user": { "name": "Alice" } }
|
||||||
|
nestedNameDecoder : Decoder String
|
||||||
|
nestedNameDecoder =
|
||||||
|
field "user" (field "name" string)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decoding Multiple Fields
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Json.Decode exposing (Decoder, map2, field, string, int)
|
||||||
|
|
||||||
|
type alias User =
|
||||||
|
{ name : String
|
||||||
|
, age : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
-- JSON: { "name": "Alice", "age": 30 }
|
||||||
|
userDecoder : Decoder User
|
||||||
|
userDecoder =
|
||||||
|
map2 User
|
||||||
|
(field "name" string)
|
||||||
|
(field "age" int)
|
||||||
|
```
|
||||||
|
|
||||||
|
`map2` takes:
|
||||||
|
1. A constructor function (User)
|
||||||
|
2. Decoder for first field
|
||||||
|
3. Decoder for second field
|
||||||
|
|
||||||
|
For more fields, use `map3`, `map4`, etc., or the `elm/json-decode-pipeline` package.
|
||||||
|
|
||||||
|
### Using Pipeline Style (Recommended)
|
||||||
|
|
||||||
|
Install:
|
||||||
|
```bash
|
||||||
|
elm install NoRedInk/elm-json-decode-pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Json.Decode exposing (Decoder, string, int)
|
||||||
|
import Json.Decode.Pipeline exposing (required, optional)
|
||||||
|
|
||||||
|
type alias User =
|
||||||
|
{ name : String
|
||||||
|
, age : Int
|
||||||
|
, email : Maybe String
|
||||||
|
}
|
||||||
|
|
||||||
|
userDecoder : Decoder User
|
||||||
|
userDecoder =
|
||||||
|
Json.Decode.succeed User
|
||||||
|
|> required "name" string
|
||||||
|
|> required "age" int
|
||||||
|
|> optional "email" (Json.Decode.map Just string) Nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decoding Lists
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Json.Decode exposing (Decoder, list, string)
|
||||||
|
|
||||||
|
-- JSON: ["apple", "banana", "cherry"]
|
||||||
|
fruitsDecoder : Decoder (List String)
|
||||||
|
fruitsDecoder =
|
||||||
|
list string
|
||||||
|
|
||||||
|
-- JSON: [{ "name": "Alice" }, { "name": "Bob" }]
|
||||||
|
usersDecoder : Decoder (List User)
|
||||||
|
usersDecoder =
|
||||||
|
list userDecoder
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decoding with Maybe
|
||||||
|
|
||||||
|
```elm
|
||||||
|
-- JSON: { "name": "Alice", "nickname": null }
|
||||||
|
-- Or: { "name": "Alice" } (nickname missing)
|
||||||
|
|
||||||
|
type alias Person =
|
||||||
|
{ name : String
|
||||||
|
, nickname : Maybe String
|
||||||
|
}
|
||||||
|
|
||||||
|
personDecoder : Decoder Person
|
||||||
|
personDecoder =
|
||||||
|
map2 Person
|
||||||
|
(field "name" string)
|
||||||
|
(Json.Decode.maybe (field "nickname" string))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Decoders
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Json.Decode exposing (decodeString)
|
||||||
|
|
||||||
|
result = decodeString userDecoder """{"name": "Alice", "age": 30}"""
|
||||||
|
-- Ok { name = "Alice", age = 30 }
|
||||||
|
|
||||||
|
badResult = decodeString userDecoder """{"name": "Alice"}"""
|
||||||
|
-- Err ... (missing field "age")
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST Requests with JSON Body
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Http
|
||||||
|
import Json.Encode as Encode
|
||||||
|
|
||||||
|
createUser : String -> Int -> Cmd Msg
|
||||||
|
createUser name age =
|
||||||
|
Http.post
|
||||||
|
{ url = "https://api.example.com/users"
|
||||||
|
, body = Http.jsonBody (encodeUser name age)
|
||||||
|
, expect = Http.expectJson GotNewUser userDecoder
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
encodeUser : String -> Int -> Encode.Value
|
||||||
|
encodeUser name age =
|
||||||
|
Encode.object
|
||||||
|
[ ( "name", Encode.string name )
|
||||||
|
, ( "age", Encode.int age )
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Encoding
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Json.Encode as Encode
|
||||||
|
|
||||||
|
-- Primitives
|
||||||
|
Encode.string "hello" -- "hello"
|
||||||
|
Encode.int 42 -- 42
|
||||||
|
Encode.float 3.14 -- 3.14
|
||||||
|
Encode.bool True -- true
|
||||||
|
Encode.null -- null
|
||||||
|
|
||||||
|
-- Objects
|
||||||
|
Encode.object
|
||||||
|
[ ( "name", Encode.string "Alice" )
|
||||||
|
, ( "age", Encode.int 30 )
|
||||||
|
]
|
||||||
|
-- { "name": "Alice", "age": 30 }
|
||||||
|
|
||||||
|
-- Lists
|
||||||
|
Encode.list Encode.int [1, 2, 3]
|
||||||
|
-- [1, 2, 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example: GitHub User Search
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module GitHubSearch exposing (main)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, button, div, img, input, li, text, ul)
|
||||||
|
import Html.Attributes exposing (placeholder, src, value, width)
|
||||||
|
import Html.Events exposing (onClick, onInput)
|
||||||
|
import Http
|
||||||
|
import Json.Decode exposing (Decoder, field, int, list, string)
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
type alias User =
|
||||||
|
{ login : String
|
||||||
|
, avatarUrl : String
|
||||||
|
, id : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Initial
|
||||||
|
| Loading String
|
||||||
|
| Success String (List User)
|
||||||
|
| Failure String String
|
||||||
|
|
||||||
|
|
||||||
|
init : () -> ( Model, Cmd Msg )
|
||||||
|
init _ =
|
||||||
|
( Initial, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= UpdateSearch String
|
||||||
|
| Search
|
||||||
|
| GotUsers (Result Http.Error (List User))
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
UpdateSearch query ->
|
||||||
|
case model of
|
||||||
|
Initial ->
|
||||||
|
( Initial, Cmd.none )
|
||||||
|
|
||||||
|
Loading q ->
|
||||||
|
( Loading query, Cmd.none )
|
||||||
|
|
||||||
|
Success _ users ->
|
||||||
|
( Success query users, Cmd.none )
|
||||||
|
|
||||||
|
Failure _ error ->
|
||||||
|
( Failure query error, Cmd.none )
|
||||||
|
|
||||||
|
Search ->
|
||||||
|
let
|
||||||
|
query =
|
||||||
|
getQuery model
|
||||||
|
in
|
||||||
|
if String.isEmpty query then
|
||||||
|
( model, Cmd.none )
|
||||||
|
else
|
||||||
|
( Loading query, searchUsers query )
|
||||||
|
|
||||||
|
GotUsers result ->
|
||||||
|
let
|
||||||
|
query =
|
||||||
|
getQuery model
|
||||||
|
in
|
||||||
|
case result of
|
||||||
|
Ok users ->
|
||||||
|
( Success query users, Cmd.none )
|
||||||
|
|
||||||
|
Err error ->
|
||||||
|
( Failure query (errorToString error), Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
getQuery : Model -> String
|
||||||
|
getQuery model =
|
||||||
|
case model of
|
||||||
|
Initial ->
|
||||||
|
""
|
||||||
|
|
||||||
|
Loading q ->
|
||||||
|
q
|
||||||
|
|
||||||
|
Success q _ ->
|
||||||
|
q
|
||||||
|
|
||||||
|
Failure q _ ->
|
||||||
|
q
|
||||||
|
|
||||||
|
|
||||||
|
errorToString : Http.Error -> String
|
||||||
|
errorToString error =
|
||||||
|
case error of
|
||||||
|
Http.BadUrl url ->
|
||||||
|
"Bad URL: " ++ url
|
||||||
|
|
||||||
|
Http.Timeout ->
|
||||||
|
"Request timed out"
|
||||||
|
|
||||||
|
Http.NetworkError ->
|
||||||
|
"Network error"
|
||||||
|
|
||||||
|
Http.BadStatus status ->
|
||||||
|
"Bad status: " ++ String.fromInt status
|
||||||
|
|
||||||
|
Http.BadBody message ->
|
||||||
|
"Bad body: " ++ message
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
searchUsers : String -> Cmd Msg
|
||||||
|
searchUsers query =
|
||||||
|
Http.get
|
||||||
|
{ url = "https://api.github.com/search/users?q=" ++ query
|
||||||
|
, expect = Http.expectJson GotUsers usersDecoder
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
usersDecoder : Decoder (List User)
|
||||||
|
usersDecoder =
|
||||||
|
field "items" (list userDecoder)
|
||||||
|
|
||||||
|
|
||||||
|
userDecoder : Decoder User
|
||||||
|
userDecoder =
|
||||||
|
Json.Decode.map3 User
|
||||||
|
(field "login" string)
|
||||||
|
(field "avatar_url" string)
|
||||||
|
(field "id" int)
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Search GitHub users..."
|
||||||
|
, value (getQuery model)
|
||||||
|
, onInput UpdateSearch
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, button [ onClick Search ] [ text "Search" ]
|
||||||
|
]
|
||||||
|
, viewResults model
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewResults : Model -> Html Msg
|
||||||
|
viewResults model =
|
||||||
|
case model of
|
||||||
|
Initial ->
|
||||||
|
div [] [ text "Enter a search term" ]
|
||||||
|
|
||||||
|
Loading _ ->
|
||||||
|
div [] [ text "Searching..." ]
|
||||||
|
|
||||||
|
Success _ users ->
|
||||||
|
if List.isEmpty users then
|
||||||
|
div [] [ text "No users found" ]
|
||||||
|
else
|
||||||
|
ul [] (List.map viewUser users)
|
||||||
|
|
||||||
|
Failure _ error ->
|
||||||
|
div [] [ text ("Error: " ++ error) ]
|
||||||
|
|
||||||
|
|
||||||
|
viewUser : User -> Html Msg
|
||||||
|
viewUser user =
|
||||||
|
li []
|
||||||
|
[ img [ src user.avatarUrl, width 50 ] []
|
||||||
|
, text (" " ++ user.login)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions _ =
|
||||||
|
Sub.none
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
main =
|
||||||
|
Browser.element
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exercise 6.1: Basic HTTP Request
|
||||||
|
|
||||||
|
Create an app that:
|
||||||
|
1. Fetches a random joke from `https://official-joke-api.appspot.com/random_joke`
|
||||||
|
2. Displays the setup and punchline
|
||||||
|
3. Has a "New Joke" button
|
||||||
|
|
||||||
|
The API returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "general",
|
||||||
|
"setup": "Why did the chicken...",
|
||||||
|
"punchline": "To get to the other side!",
|
||||||
|
"id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
module JokeApp exposing (main)
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, button, div, p, text)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import Json.Decode exposing (Decoder, field, string)
|
||||||
|
|
||||||
|
|
||||||
|
type alias Joke =
|
||||||
|
{ setup : String
|
||||||
|
, punchline : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Loading
|
||||||
|
| Success Joke
|
||||||
|
| Failure String
|
||||||
|
|
||||||
|
|
||||||
|
init : () -> ( Model, Cmd Msg )
|
||||||
|
init _ =
|
||||||
|
( Loading, fetchJoke )
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= GotJoke (Result Http.Error Joke)
|
||||||
|
| FetchNewJoke
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
GotJoke result ->
|
||||||
|
case result of
|
||||||
|
Ok joke ->
|
||||||
|
( Success joke, Cmd.none )
|
||||||
|
|
||||||
|
Err _ ->
|
||||||
|
( Failure "Failed to fetch joke", Cmd.none )
|
||||||
|
|
||||||
|
FetchNewJoke ->
|
||||||
|
( Loading, fetchJoke )
|
||||||
|
|
||||||
|
|
||||||
|
fetchJoke : Cmd Msg
|
||||||
|
fetchJoke =
|
||||||
|
Http.get
|
||||||
|
{ url = "https://official-joke-api.appspot.com/random_joke"
|
||||||
|
, expect = Http.expectJson GotJoke jokeDecoder
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
jokeDecoder : Decoder Joke
|
||||||
|
jokeDecoder =
|
||||||
|
Json.Decode.map2 Joke
|
||||||
|
(field "setup" string)
|
||||||
|
(field "punchline" string)
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ case model of
|
||||||
|
Loading ->
|
||||||
|
text "Loading..."
|
||||||
|
|
||||||
|
Success joke ->
|
||||||
|
div []
|
||||||
|
[ p [] [ text joke.setup ]
|
||||||
|
, p [] [ text joke.punchline ]
|
||||||
|
, button [ onClick FetchNewJoke ] [ text "New Joke" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
Failure error ->
|
||||||
|
div []
|
||||||
|
[ text error
|
||||||
|
, button [ onClick FetchNewJoke ] [ text "Try Again" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions _ =
|
||||||
|
Sub.none
|
||||||
|
|
||||||
|
|
||||||
|
main =
|
||||||
|
Browser.element
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exercise 6.2: Nested JSON Decoding
|
||||||
|
|
||||||
|
Write a decoder for this JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"profile": {
|
||||||
|
"name": "Alice",
|
||||||
|
"bio": "Elm enthusiast"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"followers": 100,
|
||||||
|
"following": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Into this type:
|
||||||
|
|
||||||
|
```elm
|
||||||
|
type alias UserInfo =
|
||||||
|
{ name : String
|
||||||
|
, bio : String
|
||||||
|
, followers : Int
|
||||||
|
, following : Int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Solution</summary>
|
||||||
|
|
||||||
|
```elm
|
||||||
|
import Json.Decode exposing (Decoder, field, int, map4, string)
|
||||||
|
|
||||||
|
|
||||||
|
userInfoDecoder : Decoder UserInfo
|
||||||
|
userInfoDecoder =
|
||||||
|
map4 UserInfo
|
||||||
|
(field "user" (field "profile" (field "name" string)))
|
||||||
|
(field "user" (field "profile" (field "bio" string)))
|
||||||
|
(field "user" (field "stats" (field "followers" int)))
|
||||||
|
(field "user" (field "stats" (field "following" int)))
|
||||||
|
|
||||||
|
|
||||||
|
-- Or using Json.Decode.at for cleaner nested access:
|
||||||
|
userInfoDecoder2 : Decoder UserInfo
|
||||||
|
userInfoDecoder2 =
|
||||||
|
map4 UserInfo
|
||||||
|
(Json.Decode.at [ "user", "profile", "name" ] string)
|
||||||
|
(Json.Decode.at [ "user", "profile", "bio" ] string)
|
||||||
|
(Json.Decode.at [ "user", "stats", "followers" ] int)
|
||||||
|
(Json.Decode.at [ "user", "stats", "following" ] int)
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Key Takeaways
|
||||||
|
|
||||||
|
1. **Commands describe side effects** - Elm runtime performs them
|
||||||
|
2. **HTTP requests return Results** - Success or failure
|
||||||
|
3. **JSON decoders are type-safe** - Compiler ensures correct parsing
|
||||||
|
4. **Model states** - Use custom types to represent Loading/Success/Failure
|
||||||
|
5. **Update returns (Model, Cmd)** - New state plus commands to run
|
||||||
|
|
||||||
|
## What's Next?
|
||||||
|
|
||||||
|
In [Lesson 7](07-final-project.md), we'll build a complete application:
|
||||||
|
- A todo list with persistence
|
||||||
|
- Multiple pages
|
||||||
|
- All the concepts combined!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Previous: Lesson 5](05-lists-maybe.md) | [Next: Lesson 7 - Final Project →](07-final-project.md)
|
||||||
856
lessons/07-final-project.md
Normal file
856
lessons/07-final-project.md
Normal 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)
|
||||||
197
projects/task-manager/index.html
Normal file
197
projects/task-manager/index.html
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Task Manager - Elm Tutorial Final Project</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>
|
||||||
370
projects/task-manager/src/Main.elm
Normal file
370
projects/task-manager/src/Main.elm
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
port module Main exposing (main)
|
||||||
|
|
||||||
|
{-| Task Manager - Final Project
|
||||||
|
|
||||||
|
A complete task manager application demonstrating:
|
||||||
|
- The Elm Architecture
|
||||||
|
- Custom types for state management
|
||||||
|
- Ports for localStorage persistence
|
||||||
|
- List operations
|
||||||
|
- JSON encoding/decoding
|
||||||
|
|
||||||
|
To run:
|
||||||
|
1. elm make src/Main.elm --output=elm.js
|
||||||
|
2. Open index.html in a browser
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
85
solutions/02-counter/src/Main.elm
Normal file
85
solutions/02-counter/src/Main.elm
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
{-| Counter Solution
|
||||||
|
|
||||||
|
This is the solution to Exercise 2 with:
|
||||||
|
- Reset button
|
||||||
|
- Double button
|
||||||
|
- Prevents count from going below 0
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, button, div, text)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ count : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= Increment
|
||||||
|
| Decrement
|
||||||
|
| Reset
|
||||||
|
| Double
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
Increment ->
|
||||||
|
{ model | count = model.count + 1 }
|
||||||
|
|
||||||
|
Decrement ->
|
||||||
|
-- Prevent going below 0
|
||||||
|
{ model | count = max 0 (model.count - 1) }
|
||||||
|
|
||||||
|
Reset ->
|
||||||
|
{ model | count = 0 }
|
||||||
|
|
||||||
|
Double ->
|
||||||
|
{ model | count = model.count * 2 }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ button [ onClick Decrement ] [ text "-" ]
|
||||||
|
, div [] [ text (String.fromInt model.count) ]
|
||||||
|
, button [ onClick Increment ] [ text "+" ]
|
||||||
|
, button [ onClick Reset ] [ text "Reset" ]
|
||||||
|
, button [ onClick Double ] [ text "Double" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
|
||||||
|
main : Program () Model Msg
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
124
solutions/03-temperature/src/Main.elm
Normal file
124
solutions/03-temperature/src/Main.elm
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
{-| Temperature Converter Solution
|
||||||
|
|
||||||
|
Converts between Celsius and Fahrenheit
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Browser
|
||||||
|
import Html exposing (Html, div, input, text)
|
||||||
|
import Html.Attributes exposing (placeholder, value)
|
||||||
|
import Html.Events exposing (onInput)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ celsius : String
|
||||||
|
, fahrenheit : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Model
|
||||||
|
init =
|
||||||
|
{ celsius = ""
|
||||||
|
, fahrenheit = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= UpdateCelsius String
|
||||||
|
| UpdateFahrenheit String
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> Model
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
UpdateCelsius value ->
|
||||||
|
{ model
|
||||||
|
| celsius = value
|
||||||
|
, fahrenheit =
|
||||||
|
case String.toFloat value of
|
||||||
|
Just c ->
|
||||||
|
String.fromFloat (celsiusToFahrenheit c)
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateFahrenheit value ->
|
||||||
|
{ model
|
||||||
|
| fahrenheit = value
|
||||||
|
, celsius =
|
||||||
|
case String.toFloat value of
|
||||||
|
Just f ->
|
||||||
|
String.fromFloat (fahrenheitToCelsius f)
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HELPER FUNCTIONS
|
||||||
|
|
||||||
|
|
||||||
|
celsiusToFahrenheit : Float -> Float
|
||||||
|
celsiusToFahrenheit c =
|
||||||
|
c * 9 / 5 + 32
|
||||||
|
|
||||||
|
|
||||||
|
fahrenheitToCelsius : Float -> Float
|
||||||
|
fahrenheitToCelsius f =
|
||||||
|
(f - 32) * 5 / 9
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Html Msg
|
||||||
|
view model =
|
||||||
|
div []
|
||||||
|
[ div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Celsius"
|
||||||
|
, value model.celsius
|
||||||
|
, onInput UpdateCelsius
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, text " °C"
|
||||||
|
]
|
||||||
|
, div []
|
||||||
|
[ text " = "
|
||||||
|
]
|
||||||
|
, div []
|
||||||
|
[ input
|
||||||
|
[ placeholder "Fahrenheit"
|
||||||
|
, value model.fahrenheit
|
||||||
|
, onInput UpdateFahrenheit
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
, text " °F"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
|
||||||
|
main : Program () Model Msg
|
||||||
|
main =
|
||||||
|
Browser.sandbox
|
||||||
|
{ init = init
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user