diff --git a/docs/public/content/examples.md b/docs/public/content/examples.md index 5edc4d2..8efbcae 100644 --- a/docs/public/content/examples.md +++ b/docs/public/content/examples.md @@ -1,31 +1,38 @@ # Examples -Prefer to learn by example? Wonderful! The source code for all of the examples on this site can be found in the Github repo's [examples](https://github.com/ryannhg/elm-spa/tree/main/examples) folder. +Prefer to learn by example? Wonderful! The source code for all of the examples on this site can be found in the GitHub repo's [examples](https://github.com/ryannhg/elm-spa/tree/main/examples) folder. ### Hello, world! Get an introduction to the framework with a simple app. -[![Hello, world!](/content/images/01-hello-world.png)](/examples/01-hello-world) +[![Example 1 screenshot](/content/images/01-hello-world.png)](/examples/01-hello-world) ### Pages Learn how pages and URL routing work together. -[![Hello, world!](/content/images/02-pages.png)](/examples/02-pages) +[![Example 2 screenshot](/content/images/02-pages.png)](/examples/02-pages) ### Local storage Use ports and local storage to persist data on refresh. -[![Hello, world!](/content/images/03-storage.png)](/examples/03-storage) +[![Example 3 screenshot](/content/images/03-storage.png)](/examples/03-storage) ### User authentication Explore the elm-spa's user authentication API. -[![Hello, world!](/content/images/04-authentication.png)](/examples/04-authentication) +[![Example 4 screenshot](/content/images/04-authentication.png)](/examples/04-authentication) +## Realworld example + +Implements the [Realworld app project](), inspired by Richard Feldman's "elm-spa-example" project. + +[![Realworld app screenshot](/content/images/realworld.png)](https://realworld.elm-spa.dev) + +Source code: [GitHub](https://github.com/ryannhg/elm-spa-realworld) ## More examples diff --git a/docs/public/content/examples/01-hello-world.md b/docs/public/content/examples/01-hello-world.md index ad15cd4..68ff757 100644 --- a/docs/public/content/examples/01-hello-world.md +++ b/docs/public/content/examples/01-hello-world.md @@ -1,5 +1,7 @@ # Hello, world! +__Source code__: [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/01-hello-world) + Welcome to __elm-spa__! This guide is a breakdown of the simplest project you can make: the "Hello, world!" example. ### Installation @@ -134,3 +136,7 @@ elm-spa build This command will also minify your `/dist/elm.js` file so it's production ready. + +--- + +__Next up:__ [Pages](./02-pages) \ No newline at end of file diff --git a/docs/public/content/examples/02-pages.md b/docs/public/content/examples/02-pages.md index 21afc4f..595cbdf 100644 --- a/docs/public/content/examples/02-pages.md +++ b/docs/public/content/examples/02-pages.md @@ -1,8 +1,9 @@ # Pages & routing +__Source code__: [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/01-pages) + This next guide will show you how pages, routing, and the `elm-spa add` command work together to automatically handle URLs in your __elm-spa__ application. -> You can see the source code in the [examples](https://github.com/ryannhg/elm-spa/tree/next/examples/02-pages) folder on GitHub. ### The setup @@ -202,3 +203,7 @@ After creating `style.css`, we can import the file in our `public/index.html` en Using the `` tag as shown above (with the leading slash!) imports our CSS file. All files in the `public` folder are available at the root of our web application. That means a file stored at `public/images/dog.png` would be at `http://localhost:1234/images/dog`, without including `public` in the URL at all. + +--- + +__Next up:__ [Storage](./03-storage) diff --git a/docs/public/content/examples/03-storage.md b/docs/public/content/examples/03-storage.md index ef00db8..cb0995d 100644 --- a/docs/public/content/examples/03-storage.md +++ b/docs/public/content/examples/03-storage.md @@ -1,3 +1,394 @@ # Storage -> Coming soon! \ No newline at end of file +__Source code__: [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/03-storage) + +Let's start by creating a new project with the __elm-spa__ CLI: + +```terminal +elm-spa new +``` + +## Creating a stateful page + +Let's create a simple interactive app, based on the official Elm [counter example](https://elm-lang.org/examples/buttons). The `elm-spa add` command will make this a breeze: + +```terminal +elm-spa add / sandbox +``` + +This will stub out the `init`, `update`, and `view` function for us, and wire them together with `Page.static` like this: + +```elm +-- src/Pages/Home_.elm + +page : Shared.Model -> Request -> Page.With Model Msg +page = + Page.static + { init = init + , update = update + , view = view + } +``` + +Let's add in the implementation from the counter example to get a working app! + + +### init + +```elm +-- src/Pages/Home_.elm + +type alias Model = + { counter : Int + } + +init : Model +init = + { counter = 0 + } +``` + +### update + +```elm +-- src/Pages/Home_.elm + +type Msg = Increment | Decrement + +update : Msg -> Model -> Model +update msg model = + case msg of + Increment -> + { model | counter = model.counter + 1 } + + Decrement -> + { model | counter = model.counter - 1 } +``` + +### view + +```elm +-- src/Pages/Home_.elm + +view : Model -> View Msg +view model = + { title = "Homepage" + , body = + [ Html.h1 [] [ Html.text "Local storage" ] + , Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ] + , Html.p [] [ Html.text ("Count: " ++ String.fromInt model.counter) ] + , Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ] + ] + } +``` + +After these functions are in place, we can spin up our server with the __elm-spa__ CLI: + +```terminal +elm-spa server +``` + +And this is what we should see at [http://localhost:1234](http://localhost:1234): + +![counter app](/content/images/03-storage.png) + +### Playing with the counter + +As we click the "+" and "-" buttons, the counter value should be working great. When we __refresh__ the page, the counter value is 0 again. + +Let's use local storage to keep the counter value around! + +## The JS side + +To do this, we'll be using [flags and ports](https://guide.elm-lang.org/interop/ports.html), a typesafe way to work with JavaScript without causing runtime errors in our Elm application! + +Let's edit `public/index.html` as a starting point: + +```html + + + + + + + + + + + + + + +``` + +Here we replace the inline `Elm.Main.init()` script generated by the `elm-spa new` command with a reference to a new file we'll create in `public/main.js` + +```js +// public/main.js + +const app = Elm.Main.init() + +// ... +``` + +At this point, nothing has changed yet, but we now have access to `app`– which will allow us to interact with our Elm app from the JS file! + +Let's add in some ports like this: + +```js +// public/main.js + +const app = Elm.Main.init({ + flags: JSON.parse(localStorage.getItem('storage')) +}) + +app.ports.save.subscribe(storage => { + localStorage.setItem('storage', JSON.stringify(storage)) + app.ports.load.send(storage) +}) +``` + +This JS code is doing a few things: + +1. __When our Elm app starts up,__ we pass in the current value of `localStorage` via flags. Initially, this will pass in `null`, because no data has been stored yet. + +2. We subscribe to the `save` port for events __from Elm__, which we'll wire up on the Elm side shortly. + +3. When Elm sends a `save` event, we'll store the data in localStorage (making it ready for the next time the app starts up!) as well as send a message back to Elm via the `load` port. + +## The Elm side + +None of this code is working yet, because we need to define these `save` and `load` ports on the Elm side too! + +Let's create a new file at `src/Storage.elm` that defines the ports referenced on the JS side: + +```elm +port module Storage exposing (..) + +import Json.Decode as Json + +port save : Json.Value -> Cmd msg + +port load : (Json.Value -> msg) -> Sub msg +``` + +Above, we've created a `port module` that defines our `save` and `load` ports. Next, we'll describe the data we want to store, as well as how to convert it to and from JSON: + +```elm +port module Storage exposing (..) + +import Json.Encode as Encode + +-- ... port definitions from before ... + +type alias Storage = + { counter : Int + } + + +-- Converting to JSON + +toJson : Storage -> Json.Value +toJson storage = + Encode.object + [ ( "counter", Encode.int storage.counter ) + ] + + +-- Converting from JSON + +fromJson : Json.Value -> Storage +fromJson value = + value + |> Json.decodeValue decoder + |> Result.withDefault initial + +decoder : Json.Decoder Storage +decoder = + Json.map Storage + (Json.field "counter" Json.int) + +initial : Storage +initial = + { counter = 0 + } +``` + +If this decoder stuff is new to you, please check out the [JSON section of the Elm guide](https://guide.elm-lang.org/effects/json.html). It will lay a solid foundation for understanding decoders and encode functions! + +### Sending data to JS + +For this example, we're going to define `increment` and `decrement` as side-effects because they change the state of the world. We'll be using the `save` port to send these events to JS: + +```elm +-- src/Storage.elm + +increment : Storage -> Cmd msg +increment storage = + { storage | counter = storage.counter + 1 } + |> toJson + |> save + +decrement : Storage -> Cmd msg +decrement storage = + { storage | counter = storage.counter - 1 } + |> toJson + |> save +``` + +This should look pretty similar to how our homepage handled the `Increment` and `Decrement` messages, but this time we use `toJson` and `save` to send an event for JS to handle. + +( As a last step, we'll revisit `Home_.elm` and swap out the old behavior with the new ) + +### Listening for data from JS + +We're going to add one final function to `Storage.elm` that will allow us to subscribe to events from the `load` port, that use's our `fromJson` function to safely parse the message we get back: + +```elm +onChange : (Storage -> msg) -> Sub msg +onChange fromStorage = + load (\json -> fromJson json |> fromStorage) +``` + +Here, the `onChange` function will allow the outside world to handle the `load` event without having to deal with raw JSON values by hand. + +That's it for this file- now we're ready to use our `Storage` module in our app! + +### Wiring up the shared state + +Let's eject `Shared.elm` by moving it from `.elm-spa/defaults` into our `src` folder. This will allow us to make local changes to it, as explained in the [shared state section](/guides/05-shared-state) of the guide. + +Our first step is to add `Storage` to our `Shared.Model`, so we can access `storage` from _any_ page in our application: + +```elm +-- src/Shared.elm + +import Storage + +type alias Model = + { storage : Storage + } +``` + +The `Shared.init` function is the __only place__ we have access to `Flags`, which is how JS passed in our initial value earlier. We can use `Storage.fromJson` to convert that raw JSON into our nice `Storage` type. + +```elm +-- src/Shared.elm + +init : Request -> Flags -> ( Model, Cmd Msg ) +init _ flags = + ( { storage = Storage.fromJson flags } + , Cmd.none + ) +``` + +Now let's listen for those `load` events from JS, so we can update the `Shared.Model` as soon as we get them. This code will use the `Storage.onChange` function we made to send a `Shared.Msg` to our `Shared.update` function: + +```elm +-- src/Shared.elm + +subscriptions : Request -> Model -> Sub Msg +subscriptions _ _ = + Storage.onChange StorageUpdated + + +type Msg + = StorageUpdated Storage + +update : Request -> Msg -> Model -> ( Model, Cmd Msg ) +update _ msg model = + case msg of + StorageUpdated storage -> + ( { model | storage = storage } + , Cmd.none + ) +``` + +That's all for `src/Shared.elm`. The last step is to upgrade our homepage to send side-effects instead of changing the data locally. + +### Upgrading Home_.elm + +To gain access to `Cmd msg`, we'll start by using `Page.element` instead of `Page.static`. The signature of our `init` and `update` functions will need to change to handle the new capabilities: + +Our `Model` no longer needs to track the state of the application. This means the `Home_.init` function won't be doing much at all: + +```elm +-- src/Pages/Home_.elm + +type alias Model = + {} + +init : ( Model, Cmd Msg ) +init = + ( {}, Cmd.none ) +``` + +This time around, the `update` function will need access to the current `Storage` value and use `Storage.increment` and `Storage.decrement` to send commands to the JS side. + + +```elm +-- src/Pages/Home_.elm + +type Msg + = Increment + | Decrement + +update : Storage -> Msg -> Model -> ( Model, Cmd Msg ) +update storage msg model = + case msg of + Increment -> + ( model + , Storage.increment storage + ) + + Decrement -> + ( model + , Storage.decrement storage + ) +``` + +When the `load` event comes in from JS, it triggers our `Storage.onChange` subscription. This updates the `storage` for us, meaning the `storage.counter` we get in our `view` will be the latest counter value. + +```elm +-- src/Pages/Home_.elm + +view : Storage -> Model -> View Msg +view storage _ = + { title = "Homepage" + , body = + [ Html.h1 [] [ Html.text "Local storage" ] + , Html.button [ Html.Events.onClick Increment ] [ Html.text "+" ] + , Html.p [] [ Html.text ("Count: " ++ String.fromInt storage.counter) ] + , Html.button [ Html.Events.onClick Decrement ] [ Html.text "-" ] + ] + } +``` + +We can use `Page.element` to wire all these things up, and even pass `Storage` into our `view` and `update` functions, which depend on the current value to do their thing: + + +```elm +-- src/Pages/Home_.elm + +page : Shared.Model -> Request -> Page.With Model Msg +page shared _ = + Page.element + { init = init + , update = update shared.storage + , view = view shared.storage + , subscriptions = \_ -> Sub.none + } +``` + +> Here, I've stubbed out `subscriptions` with an inline function, we won't be needing it, because Shared.subscriptions listens to `Storage.onChange` for us. + + +#### Hooray! + +In the browser, we now have a working counter app that persists on refresh. Even if you close the browser and open it up again, you'll see your previous counter value on the screen. + +As a reminder, all the source code for this example is available on [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/03-storage) + +--- + +__Next up:__ [User Authentication](./04-authentication) \ No newline at end of file diff --git a/docs/public/content/examples/04-authentication.md b/docs/public/content/examples/04-authentication.md index c4c3aba..36ab22c 100644 --- a/docs/public/content/examples/04-authentication.md +++ b/docs/public/content/examples/04-authentication.md @@ -1,5 +1,7 @@ # User authentication +__Source code__: [GitHub](https://github.com/ryannhg/elm-spa/tree/main/examples/04-authentication) + In a real world application, it's common to have the notion of a signed-in users. When it comes to routing, it's often useful to only allow signed-in users to visit specific pages. It would be wonderful if we could define logic in _one place_ that guarantees only signed-in users could view those pages: @@ -380,6 +382,30 @@ view user model = Now everything is working! Visiting the `/sign-in` page and clicking "Sign In" signs in the user and redirects to the homepage. Clicking "Sign out" on the homepage signs out the user, and our `Auth.elm` logic automatically redirects to the `SignIn` page. -#### But wait... +## Persisting the user -When we refresh the page, the user is signed out... how can we keep them signed in after refresh? Sounds like a job for [local storage](/examples/local-storage)! +When we refresh the page, the user is signed out... how can we keep them signed in after refresh? Let's tweak the `Storage.elm` file we made in the [last example](./03-storage): + +```elm +-- src/Storage.elm + +type alias Storage = + { user : Maybe User + } +``` + +If we store this on the `Shared.Model` we can ensure the user is still signed in after they refresh their browser, or visit the app later. + +```elm +-- src/Shared.elm + +type alias Model = + { storage : Storage + } +``` + +For more explanation of how this works, check out the [Storage example](./03-storage) in the last section, it will give a better basic understanding of how this mechanism works! + +--- + +__Next up:__ [More examples](/examples#more-examples) \ No newline at end of file diff --git a/docs/public/content/images/realworld.png b/docs/public/content/images/realworld.png new file mode 100644 index 0000000..94938c6 Binary files /dev/null and b/docs/public/content/images/realworld.png differ diff --git a/examples/03-local-storage/public/main.js b/examples/03-local-storage/public/main.js index 3a66a3e..2c36082 100644 --- a/examples/03-local-storage/public/main.js +++ b/examples/03-local-storage/public/main.js @@ -2,7 +2,7 @@ const app = Elm.Main.init({ flags: JSON.parse(localStorage.getItem('storage')) }) -app.ports.save_.subscribe(storage => { +app.ports.save.subscribe(storage => { localStorage.setItem('storage', JSON.stringify(storage)) - app.ports.load_.send(storage) + app.ports.load.send(storage) }) \ No newline at end of file diff --git a/examples/03-local-storage/src/Pages/Home_.elm b/examples/03-local-storage/src/Pages/Home_.elm index 06ca488..2de6c7e 100644 --- a/examples/03-local-storage/src/Pages/Home_.elm +++ b/examples/03-local-storage/src/Pages/Home_.elm @@ -1,18 +1,16 @@ module Pages.Home_ exposing (Model, Msg, init, page, update, view) -import Gen.Params.Home_ exposing (Params) -import Html exposing (Html) +import Html import Html.Events import Page -import Ports -import Request +import Request exposing (Request) import Shared import Storage exposing (Storage) import View exposing (View) -page : Shared.Model -> Request.With Params -> Page.With Model Msg -page shared req = +page : Shared.Model -> Request -> Page.With Model Msg +page shared _ = Page.element { init = init , update = update shared.storage @@ -48,12 +46,12 @@ update storage msg model = case msg of Increment -> ( model - , Ports.save (Storage.increment storage) + , Storage.increment storage ) Decrement -> ( model - , Ports.save (Storage.decrement storage) + , Storage.decrement storage ) @@ -62,7 +60,7 @@ update storage msg model = subscriptions : Model -> Sub Msg -subscriptions model = +subscriptions _ = Sub.none @@ -71,7 +69,7 @@ subscriptions model = view : Storage -> Model -> View Msg -view storage model = +view storage _ = { title = "Homepage" , body = [ Html.h1 [] [ Html.text "Local storage" ] diff --git a/examples/03-local-storage/src/Ports.elm b/examples/03-local-storage/src/Ports.elm deleted file mode 100644 index 358b96a..0000000 --- a/examples/03-local-storage/src/Ports.elm +++ /dev/null @@ -1,20 +0,0 @@ -port module Ports exposing (load, save) - -import Json.Decode as Json -import Storage exposing (Storage) - - -save : Storage -> Cmd msg -save = - Storage.save >> save_ - - -load : (Storage -> msg) -> Sub msg -load fromStorage = - load_ (\json -> Storage.load json |> fromStorage) - - -port save_ : Json.Value -> Cmd msg - - -port load_ : (Json.Value -> msg) -> Sub msg diff --git a/examples/03-local-storage/src/Shared.elm b/examples/03-local-storage/src/Shared.elm index a485d02..e3874f4 100644 --- a/examples/03-local-storage/src/Shared.elm +++ b/examples/03-local-storage/src/Shared.elm @@ -8,7 +8,6 @@ module Shared exposing ) import Json.Decode as Json -import Ports import Request exposing (Request) import Storage exposing (Storage) @@ -24,7 +23,7 @@ type alias Model = init : Request -> Flags -> ( Model, Cmd Msg ) init _ flags = - ( { storage = Storage.load flags } + ( { storage = Storage.fromJson flags } , Cmd.none ) @@ -37,9 +36,11 @@ update : Request -> Msg -> Model -> ( Model, Cmd Msg ) update _ msg model = case msg of StorageUpdated storage -> - ( { model | storage = storage }, Cmd.none ) + ( { model | storage = storage } + , Cmd.none + ) subscriptions : Request -> Model -> Sub Msg subscriptions _ _ = - Ports.load StorageUpdated + Storage.onChange StorageUpdated diff --git a/examples/03-local-storage/src/Storage.elm b/examples/03-local-storage/src/Storage.elm index 8c7d036..f530de5 100644 --- a/examples/03-local-storage/src/Storage.elm +++ b/examples/03-local-storage/src/Storage.elm @@ -1,11 +1,11 @@ -module Storage exposing - ( Storage, save, load +port module Storage exposing + ( Storage, fromJson, onChange , increment, decrement ) {-| -@docs Storage, save, load +@docs Storage, fromJson, onChange @docs increment, decrement -} @@ -14,13 +14,42 @@ import Json.Decode as Json import Json.Encode as Encode + +-- PORTS + + +port save : Json.Value -> Cmd msg + + +port load : (Json.Value -> msg) -> Sub msg + + + +-- STORAGE + + type alias Storage = { counter : Int } -load : Json.Value -> Storage -load json = + +-- Converting to JSON + + +toJson : Storage -> Json.Value +toJson storage = + Encode.object + [ ( "counter", Encode.int storage.counter ) + ] + + + +-- Converting from JSON + + +fromJson : Json.Value -> Storage +fromJson json = json |> Json.decodeValue decoder |> Result.withDefault init @@ -38,22 +67,28 @@ decoder = (Json.field "counter" Json.int) -save : Storage -> Json.Value -save storage = - Encode.object - [ ( "counter", Encode.int storage.counter ) - ] + +-- Updating storage - --- UPDATING STORAGE - - -increment : Storage -> Storage +increment : Storage -> Cmd msg increment storage = { storage | counter = storage.counter + 1 } + |> toJson + |> save -decrement : Storage -> Storage +decrement : Storage -> Cmd msg decrement storage = { storage | counter = storage.counter - 1 } + |> toJson + |> save + + + +-- LISTENING FOR STORAGE UPDATES + + +onChange : (Storage -> msg) -> Sub msg +onChange fromStorage = + load (\json -> fromJson json |> fromStorage)