Update tutorial various (#929)
- Fix missing screenshots - Move tutorial assets into source tree - Add ToC - Change codestyle to lowercase events - Change codestyle to only actions PascalCase - Rewrite to teach custom effects / subs - Use GitHub raw URLs for tutorial screenshots - Get rid of .prettierrc Authored-by: Zacharias Enochsson <zache@axis.com>
This commit is contained in:
parent
b8cc0faa8b
commit
cc7a0f427d
|
@ -80,7 +80,7 @@ $RECYCLE.BIN/
|
|||
ssl
|
||||
.idea
|
||||
nbproject
|
||||
|
||||
.prettierrc
|
||||
|
||||
############################
|
||||
# Node.js
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1 @@
|
|||
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"},"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"},"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}}
|
|
@ -0,0 +1 @@
|
|||
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
|||
{"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"},"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"},"132":{"title":"Art is dead","author":"Kat Stropher"}}
|
|
@ -0,0 +1 @@
|
|||
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}}
|
|
@ -0,0 +1 @@
|
|||
{"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
|||
{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
|||
{"113":{"title":"Ocean life is brutal","author":"Surphy McBrah"}}
|
|
@ -0,0 +1 @@
|
|||
{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
|||
{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"},"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"},"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
|||
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"}}
|
|
@ -0,0 +1 @@
|
|||
{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
|||
{"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
|||
{"115":{"title":"Life in space confirmed","author":"Nicholas Galilei"}}
|
|
@ -0,0 +1 @@
|
|||
{"132":{"title":"Art is dead","author":"Kat Stropher"},"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
|||
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"},"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"},"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
|||
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}}
|
|
@ -0,0 +1 @@
|
|||
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"},"119":{"title":"My family lives on a space station","author":"Farlow Cruz"},"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}}
|
|
@ -0,0 +1 @@
|
|||
{"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"},"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
|||
{"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"},"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"},"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"},"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"},"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}}
|
|
@ -0,0 +1 @@
|
|||
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
|||
{"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
|||
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
|||
{"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"}}
|
|
@ -0,0 +1 @@
|
|||
{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"},"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
|||
{"115":{"title":"Life in space confirmed","author":"Nicholas Galilei"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"},"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"},"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"},"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
|||
{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
|||
{"112":{"title":"The Ocean is Sinking","author":"Kat Stropher"},"113":{"title":"Ocean life is brutal","author":"Surphy McBrah"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"},"132":{"title":"Art is dead","author":"Kat Stropher"}}
|
|
@ -0,0 +1 @@
|
|||
{"113":{"title":"Ocean life is brutal","author":"Surphy McBrah"},"115":{"title":"Life in space confirmed","author":"Nicholas Galilei"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
|||
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
|||
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"},"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"}}
|
|
@ -0,0 +1 @@
|
|||
{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"},"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
|||
{"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"}}
|
|
@ -0,0 +1 @@
|
|||
{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}}
|
|
@ -0,0 +1 @@
|
|||
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
|||
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"}}
|
|
@ -0,0 +1 @@
|
|||
{"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"112":{"title":"The Ocean is Sinking","author":"Kat Stropher"},"113":{"title":"Ocean life is brutal","author":"Surphy McBrah"},"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}}
|
|
@ -0,0 +1 @@
|
|||
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"},"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"},"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"},"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"},"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"},"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
|||
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
|||
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
|||
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
|||
{"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
|||
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"},"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
|||
{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
|||
{"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"}}
|
|
@ -0,0 +1 @@
|
|||
{"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"},"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"}}
|
|
@ -0,0 +1 @@
|
|||
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
|||
{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"}}
|
|
@ -0,0 +1 @@
|
|||
{"112":{"title":"The Ocean is Sinking","author":"Kat Stropher"},"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"}}
|
|
@ -0,0 +1 @@
|
|||
{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"},"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
|||
{"115":{"title":"Life in space confirmed","author":"Nicholas Galilei"},"116":{"title":"City running out of parking space","author":"Dan Tannerson"},"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
|||
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"}}
|
|
@ -0,0 +1 @@
|
|||
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"},"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
|||
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"},"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"117":{"title":"Life in the city is still fun","author":"Greg Jenner"}}
|
|
@ -0,0 +1 @@
|
|||
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
|||
{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}}
|
|
@ -0,0 +1 @@
|
|||
{"112":{"title":"The Ocean is Sinking","author":"Kat Stropher"},"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"},"122":{"title":"The family that moved to a cave","author":"Ruth Starling"},"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"},"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"},"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
|||
{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
|||
{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"},"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"},"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
|||
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
|||
{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
|||
{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"},"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
|||
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"}}
|
|
@ -0,0 +1 @@
|
|||
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
|||
{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}}
|
|
@ -0,0 +1,202 @@
|
|||
.container {
|
||||
font-family: sans-serif;
|
||||
color: #666;
|
||||
background-color: #ccc;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 10px 340px 10px auto 10px;
|
||||
grid-template-rows: 10px 30px 10px 250px 10px 20px 10px;
|
||||
}
|
||||
|
||||
.filter {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
position: relative;
|
||||
line-height: 30px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.stories {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 4 / 5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.autoupdate {
|
||||
grid-column: 2/3;
|
||||
grid-row: 6/7;
|
||||
}
|
||||
|
||||
.story {
|
||||
grid-column: 4 / 5;
|
||||
grid-row: 2 / 7;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter .filter-word {
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
font-size: 16px;
|
||||
margin-left: 15px;
|
||||
}
|
||||
.filter input[type="text"] {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
top: 0;
|
||||
left: 50px;
|
||||
height: 30px;
|
||||
width: 245px;
|
||||
padding: 0;
|
||||
padding-left: 5px;
|
||||
padding-top: 2px;
|
||||
font-size: 16px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.filter button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 20px;
|
||||
border-radius: 4px;
|
||||
background: #ddd;
|
||||
border: 1px gray solid;
|
||||
box-shadow: 0px 0px 4px #888;
|
||||
}
|
||||
.filter button:hover {
|
||||
background: lemonchiffon;
|
||||
}
|
||||
.filter button:active {
|
||||
color: #666;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stories {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stories ul {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
list-style-type: none;
|
||||
background-color: #fff;
|
||||
box-shadow: inset 0px 0px 5px #333;
|
||||
border-radius: 4px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.stories li {
|
||||
border: solid 1px #ccc;
|
||||
border-radius: 6px;
|
||||
border-left-width: 7px;
|
||||
box-shadow: 0px 0px 5px #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stories li.reading {
|
||||
background-color: lemonchiffon;
|
||||
border-left-color: orange;
|
||||
}
|
||||
|
||||
.stories li.unread {
|
||||
border-left-color: cornflowerblue;
|
||||
}
|
||||
|
||||
.stories li:hover {
|
||||
background-color: lemonchiffon;
|
||||
}
|
||||
.stories p.title {
|
||||
margin: 10px 10px 0px 10px;
|
||||
}
|
||||
.stories em {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
.stories p.author {
|
||||
margin: 0px 10px 10px 20px;
|
||||
font-style: italic;
|
||||
font-family: serif;
|
||||
}
|
||||
.stories .loadscreen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
margin-left: -32px;
|
||||
}
|
||||
|
||||
.spinner:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
margin: 1px;
|
||||
border-radius: 50%;
|
||||
border: 5px solid #fff;
|
||||
border-color: #ccc transparent #ccc transparent;
|
||||
animation: spinner 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.autoupdate {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.story {
|
||||
background: #fff;
|
||||
padding: 50px;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
box-shadow: inset 0px 0px 5px #444;
|
||||
}
|
||||
|
||||
.story h1 {
|
||||
background-color: limegreen;
|
||||
color: #fff;
|
||||
margin: -40px -40px 40px -40px;
|
||||
padding: 50px 50px 20px 50px;
|
||||
}
|
||||
|
||||
.story p {
|
||||
text-align: justify;
|
||||
font-family: serif;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.story p.signature {
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
font-size: 1.1em;
|
||||
}
|
|
@ -1,915 +0,0 @@
|
|||
Tutorial
|
||||
===================================
|
||||
|
||||
Welcome! If you're new to Hyperapp, you've found the perfect place to start learning.
|
||||
|
||||
The Set-up
|
||||
-----------------------------------
|
||||
|
||||
Together we'll build a simple newsreader-like application. As we do, we'll work
|
||||
our way through the five core concepts: view, state, actions, effects and subscriptions.
|
||||
|
||||
To move things along, let's imagine we've already made a static version of the
|
||||
app we want to build, with this HTML:
|
||||
|
||||
|
||||
```html
|
||||
<div id="app" class="container">
|
||||
<div class="filter">
|
||||
Filter:
|
||||
<span class="filter-word">ocean</span>
|
||||
<button>✎</button>
|
||||
</div>
|
||||
<div class="stories">
|
||||
<ul>
|
||||
<li class="unread">
|
||||
<p class="title">The <em>Ocean </em>is Sinking</p>
|
||||
<p class="author">Kat Stropher</p>
|
||||
</li>
|
||||
<li class="reading">
|
||||
<p class="title"><em>Ocean </em>life is brutal</p>
|
||||
<p class="author">Surphy McBrah</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="title">
|
||||
Family friendly fun at the
|
||||
<em>ocean </em>exhibit
|
||||
</p>
|
||||
<p class="author">Guy Prosales</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="story">
|
||||
<h1>Ocean life is brutal</h1>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
|
||||
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
|
||||
aliquip ex ea commodo consequat.
|
||||
</p>
|
||||
<p class="signature">Surphy McBrah</p>
|
||||
</div>
|
||||
<div class="autoupdate">
|
||||
Auto update:
|
||||
<input type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
...and some CSS [here](https://zaceno.github.com/hatut/style.css).
|
||||
|
||||
It looks like this:
|
||||
|
||||
![Initial static mockup](https://user-images.githubusercontent.com/6243887/73389558-15d97580-42dd-11ea-90fa-f79a2c351fe8.png)
|
||||
|
||||
We'll start by making Hyperapp render the HTML for us. Then we will
|
||||
add dynamic behavior to all the widgets, including text input and
|
||||
dynamically fetching stories.
|
||||
|
||||
First, let's begin with the traditional "Hello World!"
|
||||
|
||||
Hello World
|
||||
------------------------------
|
||||
|
||||
Create this html file:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://zaceno.github.com/hatut/style.css">
|
||||
<script type="module">
|
||||
|
||||
// -- IMPORTS --
|
||||
|
||||
import {h, app} from "https://unpkg.com/hyperapp?module"
|
||||
|
||||
|
||||
|
||||
// -- ACTIONS --
|
||||
|
||||
|
||||
|
||||
// -- VIEWS ---
|
||||
|
||||
|
||||
|
||||
// -- RUN --
|
||||
|
||||
app({
|
||||
node: document.getElementById("app"),
|
||||
view: () => h("h1", {}, [
|
||||
"Hello ",
|
||||
h("i", {}, "World!")
|
||||
])
|
||||
})
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
> The section structure outlined in the comments is not important. It's
|
||||
> just a suggestion for how to organize the code we'll be
|
||||
> adding throughout the tutorial.
|
||||
|
||||
Open it in a browser, and you'll be greeted with an optimistic **Hello _World!_**.
|
||||
|
||||
View
|
||||
------------------------------------
|
||||
|
||||
Let's step through what just happened.
|
||||
|
||||
### Virtual Nodes
|
||||
|
||||
Hyperapp exports the `app` and `h` functions.
|
||||
`h` is for creating _virtual nodes_, which is to say: plain javascript objects
|
||||
which _represent_ DOM nodes.
|
||||
|
||||
The result of
|
||||
|
||||
```js
|
||||
h("h1", {}, [
|
||||
"Hello ",
|
||||
h("i", {}, "World!")
|
||||
])
|
||||
```
|
||||
|
||||
is a virtual node, representing
|
||||
|
||||
```html
|
||||
<h1>
|
||||
Hello
|
||||
<i>World!</i>
|
||||
</h1>
|
||||
```
|
||||
|
||||
### Rendering to the DOM
|
||||
|
||||
`app` is the function that runs our app. It is called with a single argument - an object
|
||||
which can take several properties. For now we're just concerned with `view` and `node. `
|
||||
|
||||
Hyperapp calls the `view` function which tells it the DOM structure we want, in the form
|
||||
of virtual nodes. Hyperapp proceeds to create it for us, replacing the node specified in `node`.
|
||||
|
||||
To render the HTML we want, change the `view` to:
|
||||
|
||||
```js
|
||||
view: () => h("div", {id: "app", class: "container"}, [
|
||||
h("div", {class: "filter"}, [
|
||||
" Filter: ",
|
||||
h("span", {class: "filter-word"}, "ocean"),
|
||||
h("button", {}, "\u270E")
|
||||
]),
|
||||
h("div", {class: "stories"}, [
|
||||
h("ul", {}, [
|
||||
h("li", {class: "unread"}, [
|
||||
h("p", {class: "title"}, [
|
||||
"The ",
|
||||
h("em", {}, "Ocean"),
|
||||
" is Sinking!"
|
||||
]),
|
||||
h("p", {class: "author"}, "Kat Stropher")
|
||||
]),
|
||||
h("li", {class: "reading"}, [
|
||||
h("p", {class: "title"}, [
|
||||
h("em", {}, "Ocean"),
|
||||
" life is brutal"
|
||||
]),
|
||||
h("p", {class: "author"}, "Surphy McBrah"),
|
||||
]),
|
||||
h("li", {}, [
|
||||
h("p", {class: "title"}, [
|
||||
"Family friendly fun at the ",
|
||||
h("em", {}, "ocean"),
|
||||
" exhibit"
|
||||
]),
|
||||
h("p", {class: "author"}, "Guy Prosales")
|
||||
])
|
||||
])
|
||||
]),
|
||||
h("div", {class: "story"}, [
|
||||
h("h1", {}, "Ocean life is brutal"),
|
||||
h("p", {}, `
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing
|
||||
elit, sed do eiusmod tempor incididunt ut labore et
|
||||
dolore magna aliqua. Ut enim ad minim veniam, quis
|
||||
nostrud exercitation ullamco laboris nisi ut aliquip
|
||||
ex ea commodo consequat.
|
||||
`),
|
||||
h("p", {class: "signature"}, "Surphy McBrah")
|
||||
]),
|
||||
h("div", {class: "autoupdate"}, [
|
||||
"Auto update: ",
|
||||
h("input", {type: "checkbox"})
|
||||
])
|
||||
]),
|
||||
```
|
||||
|
||||
Try it out to confirm that the result matches the screenshot above.
|
||||
|
||||
> In many frameworks it is common to write your views/templates
|
||||
> using syntax that looks like HTML. This is possible with Hyperapp as well.
|
||||
> [JSX](https://babeljs.io/docs/en/babel-plugin-transform-react-jsx)
|
||||
> can compile a HTML-like syntax into `h` calls at build-time. If you'd rather
|
||||
> not use a build system, [htm](https://github.com/developit/htm) does the same at run-time.
|
||||
>
|
||||
> In this tutorial we'll stick with `h` to keep it simple and close to the metal.
|
||||
|
||||
### Composing the view with reusable functions
|
||||
|
||||
The great thing about using plain functions to build up our virtual DOM
|
||||
is that we can break out repetitive or complicated parts into their own functions.
|
||||
|
||||
Add this function (in the "VIEWS" section):
|
||||
|
||||
```js
|
||||
const emphasize = (word, string) =>
|
||||
string.split(" ").map(x => {
|
||||
if (x.toLowerCase() === word.toLowerCase()) {
|
||||
return h("em", {}, x + " ")
|
||||
} else {
|
||||
return x + " "
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
It lets you change this:
|
||||
|
||||
```js
|
||||
...
|
||||
h("p", {class: "title"}, [
|
||||
"The ",
|
||||
h("em", {}, "Ocean"),
|
||||
" is Sinking!"
|
||||
]),
|
||||
...
|
||||
```
|
||||
|
||||
into this:
|
||||
|
||||
```js
|
||||
...
|
||||
h("p", {class: "title"}, emphasize("ocean",
|
||||
"The Ocean is Sinking"
|
||||
))
|
||||
...
|
||||
```
|
||||
|
||||
Story thumbnails are repeated several times, so encapsulate
|
||||
them in their own function:
|
||||
|
||||
```js
|
||||
const StoryThumbnail = props => h(
|
||||
"li",
|
||||
{class: {
|
||||
unread: props.unread,
|
||||
reading: props.reading,
|
||||
}},
|
||||
[
|
||||
h("p", {class: "title"}, emphasize(props.filter, props.title)),
|
||||
h("p", {class: "author"}, props.author)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
> The last example demonstrates a helpful feature of the `class` property. When
|
||||
> you set it to an object rather than a string, each key with a truthy value
|
||||
> will become a class in the class list.
|
||||
|
||||
Continue by creating functions for each section of the view:
|
||||
|
||||
```js
|
||||
|
||||
const StoryList = props => h("div", {class: "stories"}, [
|
||||
h("ul", {}, Object.keys(props.stories).map(id =>
|
||||
StoryThumbnail({
|
||||
id,
|
||||
title: props.stories[id].title,
|
||||
author: props.stories[id].author,
|
||||
unread: !props.stories[id].seen,
|
||||
reading: props.reading === id,
|
||||
filter: props.filter,
|
||||
})
|
||||
))
|
||||
])
|
||||
|
||||
const Filter = props => h("div", {class: "filter"}, [
|
||||
"Filter:",
|
||||
h("span", {class: "filter-word"}, props.filter),
|
||||
h("button", {}, "\u270E")
|
||||
])
|
||||
|
||||
const StoryDetail = props => h("div", {class: "story"}, [
|
||||
props && h("h1", {}, props.title),
|
||||
props && h("p", {}, `
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing
|
||||
elit, sed do eiusmod tempor incididunt ut labore et
|
||||
dolore magna aliqua. Ut enim ad minim veniam, qui
|
||||
nostrud exercitation ullamco laboris nisi ut aliquip
|
||||
ex ea commodo consequat.
|
||||
`),
|
||||
props && h("p", {class: "signature"}, props.author)
|
||||
])
|
||||
|
||||
const AutoUpdate = props => h("div", {class: "autoupdate"}, [
|
||||
"Auto update: ",
|
||||
h("input", {type: "checkbox"})
|
||||
])
|
||||
|
||||
const Container = content => h("div", {class: "container"}, content)
|
||||
|
||||
```
|
||||
|
||||
|
||||
With those the view can be written as:
|
||||
|
||||
```js
|
||||
view: () => Container([
|
||||
Filter({
|
||||
filter: "ocean"
|
||||
}),
|
||||
StoryList({
|
||||
stories: {
|
||||
"112": {
|
||||
title: "The Ocean is Sinking",
|
||||
author: "Kat Stropher",
|
||||
seen: false,
|
||||
},
|
||||
"113": {
|
||||
title: "Ocean life is brutal",
|
||||
author: "Surphy McBrah",
|
||||
seen: true,
|
||||
},
|
||||
"114": {
|
||||
title: "Family friendly fun at the ocean exhibit",
|
||||
author: "Guy Prosales",
|
||||
seen: true,
|
||||
}
|
||||
},
|
||||
reading: "113",
|
||||
filter: "ocean"
|
||||
}),
|
||||
StoryDetail({
|
||||
title: "Ocean life is brutal",
|
||||
author: "Surphy McBrah",
|
||||
}),
|
||||
AutoUpdate(),
|
||||
])
|
||||
```
|
||||
|
||||
What you see on the page should be exactly the same as before, because we haven't
|
||||
changed what `view` returns. Using basic functional composition, we were able to make
|
||||
the code a bit more manageable, and that's the only difference.
|
||||
|
||||
State
|
||||
-------------------------------
|
||||
|
||||
With all that view logic broken out in separate functions, `view` is starting to look like
|
||||
plain _data_. The next step is to fully separate data from the view.
|
||||
|
||||
Add an `init` property to your app, with this pure data:
|
||||
|
||||
```js
|
||||
init: {
|
||||
filter: "ocean",
|
||||
reading: "113",
|
||||
stories: {
|
||||
"112": {
|
||||
title: "The Ocean is Sinking",
|
||||
author: "Kat Stropher",
|
||||
seen: false,
|
||||
},
|
||||
"113": {
|
||||
title: "Ocean life is brutal",
|
||||
author: "Surphy McBrah",
|
||||
seen: true,
|
||||
},
|
||||
"114": {
|
||||
title: "Family friendly fun at the ocean exhibit",
|
||||
author: "Guy Prosales",
|
||||
seen: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
The value of `init` becomes the app's _state_. Hyperapp calls `view` with the state
|
||||
as an argument, so it can be reduced to:
|
||||
|
||||
```js
|
||||
view: state => Container([
|
||||
Filter(state),
|
||||
StoryList(state),
|
||||
StoryDetail(state.reading && state.stories[state.reading]),
|
||||
AutoUpdate(state),
|
||||
]),
|
||||
```
|
||||
|
||||
Visually, everything is _still_ the same. If you'd like to see a working example of the code so far, have a look [here](https://codesandbox.io/s/hyperapp-tutorial-step-1-gq662).
|
||||
|
||||
Actions
|
||||
---------------------
|
||||
|
||||
Now that we know all about rendering views, it's finally time for some _action_!
|
||||
|
||||
### Reacting to events in the DOM
|
||||
|
||||
The first bit of dynamic behavior we will add is so that when you click
|
||||
the pencil-button, a text input with the filter word appears.
|
||||
|
||||
Add an `onClick` property to the button in the filter view:
|
||||
|
||||
```js
|
||||
const Filter = props => h("div", {class: "filter"}, [
|
||||
"Filter:",
|
||||
h("span", {class: "filter-word"}, props.filter),
|
||||
h("button", { onClick: StartEditingFilter }, "\u270E") // <---
|
||||
])
|
||||
```
|
||||
|
||||
This makes Hyperapp bind a click-event handler on the button element, so
|
||||
that when the button is clicked, an action named `StartEditingFilter` is
|
||||
_dispatched_. Create the action in the "ACTIONS" section:
|
||||
|
||||
```js
|
||||
const StartEditingFilter = state => ({...state, editingFilter: true})
|
||||
```
|
||||
|
||||
Actions are just functions describing transformations of the state.
|
||||
This action keeps everything in the state the same except for `editingFilter`
|
||||
which it sets to `true`.
|
||||
|
||||
When Hyperapp dispatches an action, it replaces the old state with the new
|
||||
one calculated using the action. Then the DOM is modified to match what the
|
||||
view returns for this new state.
|
||||
|
||||
When `editingFilter` is true, we want to have a text input instead of a
|
||||
span with the filter word. We can express this in the `Filter` view using a
|
||||
ternary operator (`a ? b : c`).
|
||||
|
||||
```js
|
||||
const Filter = props => h("div", {class: "filter"}, [
|
||||
"Filter:",
|
||||
|
||||
props.editingFilter // <---
|
||||
? h("input", {type: "text", value: props.filter}) // <---
|
||||
: h("span", {class: "filter-word"}, props.filter),
|
||||
|
||||
h("button", { onClick: StartEditingFilter }, "\u270E")
|
||||
])
|
||||
```
|
||||
|
||||
Now, when you click the pencil button the text input appears. But we still need to add
|
||||
a way to go back. We need an action to `StopEditingFilter`, and a button to dispatch it.
|
||||
|
||||
Add the action:
|
||||
|
||||
```js
|
||||
const StopEditingFilter = state => ({...state, editingFilter: false})
|
||||
```
|
||||
|
||||
and update the `Filter` view again:
|
||||
|
||||
```js
|
||||
const Filter = props => h("div", {class: "filter"}, [
|
||||
"Filter:",
|
||||
|
||||
props.editingFilter
|
||||
? h("input", {type: "text", value: props.filter})
|
||||
: h("span", {class: "filter-word"}, props.filter),
|
||||
|
||||
props.editingFilter // <---
|
||||
? h("button", {onClick: StopEditingFilter}, "\u2713")
|
||||
: h("button", {onClick: StartEditingFilter}, "\u270E"), // <---
|
||||
])
|
||||
```
|
||||
|
||||
When you click the pencil button, it is replaced with a check-mark button that can take you back to the first state.
|
||||
|
||||
![Filter in edit mode](https://user-images.githubusercontent.com/6243887/73389562-1a059300-42dd-11ea-80ea-631c999d5f62.png)
|
||||
|
||||
|
||||
### Capturing event-data in actions
|
||||
|
||||
The next step is to use the input for editing the filter word. Whatever we
|
||||
type in the box should be emphasized in the story-list.
|
||||
|
||||
Update the `Filter` view yet again:
|
||||
|
||||
```js
|
||||
const Filter = props => h("div", {class: "filter"}, [
|
||||
"Filter:",
|
||||
|
||||
props.editingFilter
|
||||
? h("input", {
|
||||
type: "text",
|
||||
value: props.filter,
|
||||
onInput: SetFilter, // <----
|
||||
})
|
||||
: h("span", {class: "filter-word"}, props.filter),
|
||||
|
||||
props.editingFilter
|
||||
? h("button", {onClick: StopEditingFilter}, "\u2713")
|
||||
: h("button", {onClick: StartEditingFilter}, "\u270E"),
|
||||
])
|
||||
```
|
||||
|
||||
This will dispatch the `SetFilter` action everytime someone types in the input. Implement the action like this:
|
||||
|
||||
```js
|
||||
const SetFilter = (state, event) => ({...state, filter: event.target.value})
|
||||
```
|
||||
|
||||
The second argument to an action is known as the _payload_. Actions
|
||||
dispatched in response to an events on DOM elements receive the [event object](https://developer.mozilla.org/en-US/docs/Web/API/Event) for a payload. `event.target` refers to the input element in the DOM, and
|
||||
`event.target.value` refers to the current value entered into it.
|
||||
|
||||
Now see what happens when you erase "ocean" and type "friendly" instead:
|
||||
|
||||
![filtering other words](https://user-images.githubusercontent.com/6243887/73389567-1d991a00-42dd-11ea-9bf1-b1fc6b85b635.png)
|
||||
|
||||
|
||||
### Actions with custom payloads
|
||||
|
||||
Next up: selecting stories by clicking them in the list.
|
||||
|
||||
The following action sets the `reading` property in the state to a story-id, which amounts to "selecting" the story:
|
||||
|
||||
```js
|
||||
const SelectStory = (state, id) => ({...state, reading: id})
|
||||
```
|
||||
|
||||
It has a payload, but it's not an event object. It's a custom value telling us which
|
||||
story was clicked. How are actions dispatched with custom payloads? – Like this:
|
||||
|
||||
```js
|
||||
|
||||
const StoryThumbnail = props => h(
|
||||
"li",
|
||||
{
|
||||
onClick: [SelectStory, props.id], // <----
|
||||
class: {
|
||||
unread: props.unread,
|
||||
reading: props.reading,
|
||||
}
|
||||
},
|
||||
[
|
||||
h("p", {class: "title"}, emphasize(props.filter, props.title)),
|
||||
h("p", {class: "author"}, props.author)
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
Instead of just specifying the action, we give a length-2 array with the action first and the custom payload second.
|
||||
|
||||
Selecting stories works now, but the feature is not quite done. When a story is selected,
|
||||
we need to set its `seen` property to `true`, so we can highlight which stories the user has yet to read. Update the `SelectStory` action:
|
||||
|
||||
```js
|
||||
const SelectStory = (state, id) => ({
|
||||
...state, // keep all state the same, except for the following:
|
||||
reading: id,
|
||||
stories: {
|
||||
...state.stories, //keep stories the same, except for:
|
||||
[id]: {
|
||||
...state.stories[id], //keep this story the same, except for:
|
||||
seen: true,
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Now, when you select a blue-edged story it turns yellow because it is selected, and when you select something else,
|
||||
the edge turns gray to indicate you've read the story.
|
||||
|
||||
![all stories read](https://user-images.githubusercontent.com/6243887/73389573-20940a80-42dd-11ea-9b26-ebdad474b169.png)
|
||||
|
||||
|
||||
### Payload filters
|
||||
|
||||
There's one little thing we should fix about `SetFilter`. See how it's dependent on the complex `event` object?
|
||||
It would be easier to test and reuse if it were simply:
|
||||
|
||||
```js
|
||||
const SetFilter = (state, word) => ({...state, filter: word})
|
||||
```
|
||||
|
||||
But we don't know the word beforehand, so how can we set it as a custom payload? Change the `Filter` view
|
||||
again (last time - I promise!):
|
||||
|
||||
```js
|
||||
const Filter = props => h("div", {class: "filter"}, [
|
||||
"Filter:",
|
||||
|
||||
props.editingFilter
|
||||
? h("input", {
|
||||
type: "text",
|
||||
value: props.filter,
|
||||
onInput: [SetFilter, event => event.target.value], // <----
|
||||
})
|
||||
: h("span", {class: "filter-word"}, props.filter),
|
||||
|
||||
props.editingFilter
|
||||
? h("button", {onClick: StopEditingFilter}, "\u2713")
|
||||
: h("button", {onClick: StartEditingFilter}, "\u270E"),
|
||||
])
|
||||
```
|
||||
|
||||
When we give a _function_ as the custom payload, Hyperapp considers it a _payload filter_ and passes the default
|
||||
payload through it, providing the returned value as payload to the action.
|
||||
|
||||
> Payload filters are also useful when you need a payload that is a combination of custom data and event data
|
||||
|
||||
If you'd like to see a working example of the code so far, have a look [here](https://codesandbox.io/s/hyperapp-tutorial-step-2-5yv34).
|
||||
|
||||
Effects
|
||||
----------------------------
|
||||
|
||||
Until now, the list of stories has been defined in the state and doesn't change. What we really want is
|
||||
for stories matching the filter to be dynamically loaded. When we click the check-mark button
|
||||
(indicating we are done editing the filter), we want to query an API and display the stories it responds with.
|
||||
|
||||
### Actions can return effects
|
||||
|
||||
Add this import (to the "IMPORTS" section):
|
||||
|
||||
```js
|
||||
import {Http} from "https://unpkg.com/hyperapp-fx@next?module"
|
||||
```
|
||||
|
||||
Use the imported `Http` in the `StopEditingFilter` action like this:
|
||||
|
||||
```js
|
||||
const StopEditingFilter = state => [
|
||||
{
|
||||
...state,
|
||||
editingFilter: false,
|
||||
},
|
||||
Http({ // <---
|
||||
url: `https://zaceno.github.io/hatut/data/${state.filter.toLowerCase()}.json`, // <---
|
||||
response: "json", // <---
|
||||
action: GotStories, // <---
|
||||
})
|
||||
]
|
||||
```
|
||||
|
||||
The call to `Http(...)` does _not_ immediately execute the API request. `Http` is an _effect creator_. It returns
|
||||
an _effect_ bound to the options we provided.
|
||||
|
||||
When Hyperapp sees an action return an array, it takes the first element of the array to be the new state, and the rest to
|
||||
be _effects_. Effects are executed by Hyperapp as part of processing the action's return value.
|
||||
|
||||
> Hyperapp provides effect creators for many common situations. If you've got an unusual case or are working
|
||||
> with less common APIs you may need to implement your own effects. Don't worry - it's easy! See the
|
||||
> [API reference](./ref.md) for more information.
|
||||
|
||||
### Effects can dispatch actions
|
||||
|
||||
One of the options we passed to `Http` was `action: GotStories`. The way this effect works is that when the response comes
|
||||
back from the api, an action named `GotStories` (yet to be implemented) will be dispatched, with the response body as the payload.
|
||||
|
||||
The response body is in json, but the payload will be a javascript object, thanks to the parsing hint `response: "json"`. It will look like this (although the details depend on your filter of course):
|
||||
|
||||
```js
|
||||
{
|
||||
"112": {
|
||||
title: "The Ocean is Sinking",
|
||||
author: "Kat Stropher",
|
||||
},
|
||||
"113": {
|
||||
title: "Ocean life is brutal",
|
||||
author: "Surphy McBrah",
|
||||
},
|
||||
"114": {
|
||||
title: "Family friendly fun at the ocean exhibit",
|
||||
author: "Guy Prosales",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The job of `GotStories` is to load this data into the state, in place of the stories we already have there. As it
|
||||
does, it should take care to remember which story was selected, and which stories we have seen, if they were already
|
||||
in the previous state. This will be our most complex action yet, and it could look like this:
|
||||
|
||||
```js
|
||||
const GotStories = (state, response) => {
|
||||
const stories = {}
|
||||
Object.keys(response).forEach(id => {
|
||||
stories[id] = {...response[id], seen: false}
|
||||
if (state.stories[id] && state.stories[id].seen) {
|
||||
stories[id].seen = true
|
||||
}
|
||||
})
|
||||
const reading = stories[state.reading] ? state.reading : null
|
||||
return {
|
||||
...state,
|
||||
stories,
|
||||
reading,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Try it out! Enter "life" in the filter input. When you click the check-mark button some new
|
||||
stories are loaded – all with blue edges except for "Ocean life is brutal" because it is
|
||||
still selected.
|
||||
|
||||
![loaded other stories](https://user-images.githubusercontent.com/6243887/73389577-24279180-42dd-11ea-8b4a-b231f1c811c8.png)
|
||||
|
||||
|
||||
|
||||
|
||||
### Running effects on initialization
|
||||
|
||||
The next obvious step is to load the _initial_ stories from the API as well. Change init to this:
|
||||
|
||||
|
||||
```js
|
||||
init: [
|
||||
{
|
||||
editingFilter: false,
|
||||
autoUpdate: false,
|
||||
filter: "ocean",
|
||||
reading: null,
|
||||
stories: {}, // <---
|
||||
},
|
||||
Http({ // <---
|
||||
url: `https://zaceno.github.io/hatut/data/ocean.json`, // <---
|
||||
response: 'json', // <---
|
||||
action: GotStories, // <---
|
||||
})
|
||||
],
|
||||
```
|
||||
|
||||
Hyperapp treats the init-value the same way as it treats return values from actions. By adding the `Http` effect
|
||||
in `init`, the app will fire the API request immediately, so we don't need the stories in the state from the start.
|
||||
|
||||
![stories loaded from start](https://user-images.githubusercontent.com/6243887/73389586-2a1d7280-42dd-11ea-8642-f994c028a74f.png)
|
||||
|
||||
|
||||
### Tracking state for asynchronous effects
|
||||
|
||||
If we could display a spinner while we wait for stories to load, it would make for a smoother user experience. To
|
||||
do that, we will need a new state property to tell us if we're waiting for a repsonse - and
|
||||
consequently wether or not to render the spinner.
|
||||
|
||||
Create this action:
|
||||
|
||||
```js
|
||||
const FetchStories = state => [
|
||||
{...state, fetching: true},
|
||||
Http({
|
||||
url: `https://zaceno.github.io/hatut/data/${state.filter.toLowerCase()}.json`,
|
||||
response: 'json',
|
||||
action: GotStories,
|
||||
})
|
||||
]
|
||||
```
|
||||
|
||||
Instead of dispatching this action, we will use it to simplify `StopEditingFilter`:
|
||||
|
||||
```js
|
||||
const StopEditingFilter = state => FetchStories({...state, editingFilter: false})
|
||||
```
|
||||
|
||||
... and `init` as well:
|
||||
|
||||
```js
|
||||
init: FetchStories({
|
||||
editingFilter: false,
|
||||
autoUpdate: false,
|
||||
filter: "ocean",
|
||||
reading: null,
|
||||
stories: {},
|
||||
}),
|
||||
```
|
||||
|
||||
Now, when `StopEditingFilter` is dispatched, _and_ at initialization, the API call goes out and the
|
||||
`fetching` prop is set to `true`. Also, notice how we refactored out the repetitive use of `Http`.
|
||||
|
||||
We also need to set `fetching: false` in `GotStories`:
|
||||
|
||||
```js
|
||||
const GotStories = (state, response) => {
|
||||
const stories = {}
|
||||
Object.keys(response).forEach(id => {
|
||||
stories[id] = {...response[id], seen: false}
|
||||
if (state.stories[id] && state.stories[id].seen) {
|
||||
stories[id].seen = true
|
||||
}
|
||||
})
|
||||
const reading = stories[state.reading] ? state.reading : null
|
||||
return {
|
||||
...state,
|
||||
stories,
|
||||
reading,
|
||||
fetching: false, // <---
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With this, we know that when `fetching` is `true` we are waiting for a response, and should display
|
||||
the spinner in the `StoryList` view:
|
||||
|
||||
```js
|
||||
const StoryList = props => h("div", {class: "stories"}, [
|
||||
|
||||
props.fetching && h("div", {class: "loadscreen"}, [ // <---
|
||||
h("div", {class: "spinner"}) // <---
|
||||
]), // <---
|
||||
|
||||
h("ul", {}, Object.keys(props.stories).map(id =>
|
||||
StoryThumbnail({
|
||||
id,
|
||||
title: props.stories[id].title,
|
||||
author: props.stories[id].author,
|
||||
unread: !props.stories[id].seen,
|
||||
reading: props.reading === id,
|
||||
filter: props.filter
|
||||
})
|
||||
))
|
||||
])
|
||||
```
|
||||
|
||||
When the app loads, and when you change the filter, you should see the spinner appear until the stories are loaded.
|
||||
|
||||
![loading spinner](https://user-images.githubusercontent.com/6243887/73389594-2db0f980-42dd-11ea-8bf8-95b96e7337b1.png)
|
||||
|
||||
> If you aren't seeing the spinner, it might just be happening too fast. Try choking your network speed. In the Chrome
|
||||
> browser you can set your network speed to "slow 3g" under the network tab in the developer tools.
|
||||
|
||||
If you'd like to see a working example of the code so far, have a look [here](https://codesandbox.io/s/hyperapp-tutorial-step-3-2mmug).
|
||||
|
||||
Subscriptions
|
||||
-------------------------------------------------------------------
|
||||
|
||||
The last feature we'll add is to make our app periodically check for new stories matching the filter. There won't actually
|
||||
be any because it's not a real service, but you'll know it's happening when you see the spinner pop up every five
|
||||
seconds.
|
||||
|
||||
However, we want to make it opt-in. That's what the auto update checkbox at the bottom is for. We need a
|
||||
property in the state to track wether the box is checked or not.
|
||||
|
||||
Change the `AutoUpdate` view:
|
||||
|
||||
```js
|
||||
const AutoUpdate = props => h("div", {class: "autoupdate"}, [
|
||||
"Auto update: ",
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: props.autoUpdate, // <---
|
||||
onInput: ToggleAutoUpdate, // <---
|
||||
})
|
||||
])
|
||||
```
|
||||
|
||||
and implement the `ToggleAutoUpdate` action:
|
||||
|
||||
```js
|
||||
const ToggleAutoUpdate = state => ({...state, autoUpdate: !state.autoUpdate})
|
||||
```
|
||||
|
||||
Now we've got `autoUpdate` in the state tracking the checkbox. All we need now, is to set up `FetchStories`
|
||||
to be dispatched every five seconds when `autoUpdate` is `true`.
|
||||
|
||||
Import the `interval` _subscription creator_:
|
||||
|
||||
```js
|
||||
import {interval} from "https://unpkg.com/@hyperapp/time?module"
|
||||
```
|
||||
|
||||
Add a `subscriptions` property to your app, with a conditional declaration of `interval` like this:
|
||||
|
||||
```js
|
||||
subscriptions: state => [
|
||||
state.autoUpdate && interval(FetchStories, {delay: 5000})
|
||||
]
|
||||
```
|
||||
|
||||
Hyperapp will call `subscriptions` every time the state changes. If it notices a
|
||||
new subscription, it will be started, or if one has been removed it will be stopped.
|
||||
|
||||
The options we passed to the `interval` subscription state that `FetchStories` should be dispatched every five seconds. It
|
||||
will start when we check the auto update box, and stop when it is unchecked.
|
||||
|
||||
![auto updating](https://user-images.githubusercontent.com/6243887/73389603-3275ad80-42dd-11ea-9270-bc8be471db8b.png)
|
||||
|
||||
> As with effects, Hyperapp offers subscriptions for the most common cases, but you
|
||||
> may need to implement your own. Refer to the [API reference](./ref.md). Again,
|
||||
> it is no big deal - just not in scope for this tutorial.
|
||||
|
||||
If you'd like to see a working example of the final code, have a look [here](https://codesandbox.io/s/hyperapp-tutorial-step-4-8u9q8).
|
||||
|
||||
Conclusion
|
||||
------------------
|
||||
|
||||
Congratulations on completing this Hyperapp tutorial!
|
||||
|
||||
Along the way you've familiarized yourself with
|
||||
the core concepts: _view_, _state_, _actions_, _effects_ & _subscriptions_. And that's really all you need to
|
||||
build any web application.
|
||||
|
||||
So now, go build your dream app, or browse our [Examples](./examples.md) for more
|
||||
inspiration.
|
Loading…
Reference in New Issue