diff --git a/docs/.gitignore b/docs/.gitignore index ec7642a..ff42dec 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -80,7 +80,7 @@ $RECYCLE.BIN/ ssl .idea nbproject - +.prettierrc ############################ # Node.js diff --git a/docs/src/pages/Tutorial/tutorial.md b/docs/src/pages/Tutorial/tutorial.md index e0c0d04..662ed04 100644 --- a/docs/src/pages/Tutorial/tutorial.md +++ b/docs/src/pages/Tutorial/tutorial.md @@ -1,11 +1,31 @@ -# tutorial - -=================================== - Welcome! If you're new to Hyperapp, you've found the perfect place to start learning. -The Set-up ------------------------------------ +Table of contents + +- [The Set-up](#setup) +- [Hello World](#helloworld) +- [View](#view) + - [Virtual Nodes](#virtualnodes) + - [Rendering to the DOM](#rendertodom) + - [Composing the view with reusable functions](#composingview) +- [State](#state) +- [Actions](#actions) + - [Reacting to events in the DOM](#reacting) + - [Capturing event-data in actions](#eventdata) + - [Actions with custom payloads](#custompayloads) + - [Payload filters](#payloadfilters) +- [Effects](#effects) + - [Declaring effects in actions](#declaringeffects) + - [Effect functions and `dispatch`](#effectfunctions) + - [Running effects on initialization](#effectsoninit) + - [Effect creators](#effectcreators) + - [Tracking state for ansynchronous effects](#trackingasync) +- [Subscriptions](#subscriptions) + - [Subscription functions](#subscriptionfunctions) + - [Subscribing](#subscribing) +- [Conclusion](#conclusion) + +## 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. @@ -13,7 +33,6 @@ our way through the five core concepts: view, state, actions, effects and subscr 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
@@ -43,10 +62,10 @@ app we want to build, with this HTML:

Ocean life is brutal

- 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. + 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.

Surphy McBrah

@@ -57,54 +76,46 @@ app we want to build, with this HTML:
``` -...and some CSS [here](https://zaceno.github.com/hatut/style.css). +...and some CSS [here](https://hyperapp.dev/tutorial-assets/style.css). It looks like this: +![what it looks like](https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut1.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 +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 ------------------------------- +## Hello World Create this html file: ```html - + - + - +
@@ -117,39 +128,35 @@ app({ Open it in a browser, and you'll be greeted with an optimistic **Hello _World!_**. -View ------------------------------------- +## View Let's step through what just happened. -### Virtual Nodes +### 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 +The result of ```js -h("h1", {}, [ - "Hello ", - h("i", {}, "World!") -]) +h("h1", {}, ["Hello ", h("i", {}, "World!")]) ``` is a virtual node, representing ```html

- Hello + Hello World!

``` -### Rendering to the DOM +### 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. ` +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`. @@ -212,13 +219,13 @@ 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) +> [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 +### 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. @@ -233,10 +240,10 @@ const emphasize = (word, string) => } else { return x + " " } - }) + }) ``` -It lets you change this: +It lets you change this: ```js ... @@ -262,17 +269,20 @@ 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) - ] -) +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 @@ -282,90 +292,100 @@ const StoryThumbnail = props => h( 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 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 filterView = props => + h("div", { class: "filter" }, [ + "Filter:", + h("span", { class: "filter-word" }, props.filter), + h("button", {}, "\u270E"), + ]) -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", {}, ` +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) -]) + ` + ), + 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) +const autoUpdateView = 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, +view: () => + container([ + filterView({ + 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, + }, }, - "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(), -]) + reading: "113", + filter: "ocean", + }), + storyDetail({ + title: "Ocean life is brutal", + author: "Surphy McBrah", + }), + autoUpdateView(), + ]) ``` 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 -------------------------------- +## 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. @@ -400,34 +420,34 @@ The value of `init` becomes the app's _state_. Hyperapp calls `view` with the st 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), + view: state => container([ + filterView(state), + storyList(state), + storyDetail(state.reading && state.stories[state.reading]), + autoUpdateView(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 ---------------------- +## Actions Now that we know all about rendering views, it's finally time for some _action_! -### Reacting to events in the DOM +### 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: +Add an `onclick` property to the button in `filterView`: ```js -const Filter = props => h("div", {class: "filter"}, [ - "Filter:", - h("span", {class: "filter-word"}, props.filter), - h("button", { onClick: StartEditingFilter }, "\u270E") // <--- -]) +const filterView = 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 @@ -435,7 +455,7 @@ 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}) +const StartEditingFilter = state => ({ ...state, editingFilter: true }) ``` Actions are just functions describing transformations of the state. @@ -446,81 +466,83 @@ 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 +When `editingFilter` is true, we want to have a text input instead of a +span with the filter word. We can express this in `filterView` 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") -]) +const filterView = 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 +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}) +const StopEditingFilter = state => ({ ...state, editingFilter: false }) ``` -and update the `Filter` view again: +and update `filterView` 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), +const filterView = props => + h("div", { class: "filter" }, [ + "Filter:", - props.editingFilter // <--- - ? h("button", {onClick: StopEditingFilter}, "\u2713") - : h("button", {onClick: StartEditingFilter}, "\u270E"), // <--- -]) + 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. +![editing filter word](https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut2.png) - - -### Capturing event-data in actions +### 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: +Update `filterView` 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), +const filterView = props => + h("div", { class: "filter" }, [ + "Filter:", - props.editingFilter - ? h("button", {onClick: StopEditingFilter}, "\u2713") - : h("button", {onClick: StartEditingFilter}, "\u270E"), -]) + 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}) +const SetFilter = (state, event) => ({ ...state, filter: event.target.value }) ``` The second argument to an action is known as the _payload_. Actions @@ -529,38 +551,37 @@ dispatched in response to an events on DOM elements receive the [event object](h Now see what happens when you erase "ocean" and type "friendly" instead: +![typed friendly in filter](https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut3.png) - - -### Actions with custom payloads +### 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}) +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) - ] -) +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. @@ -575,47 +596,47 @@ const SelectStory = (state, id) => ({ stories: { ...state.stories, //keep stories the same, except for: [id]: { - ...state.stories[id], //keep this story the same, except for: + ...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. +![read stories are gray](https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut4.png) +### Payload filters - -### Payload filters - -There's one little thing we should fix about `SetFilter`. See how it's dependent on the complex `event` object? +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}) +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 +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), +const filterView = props => + h("div", { class: "filter" }, [ + "Filter:", - props.editingFilter - ? h("button", {onClick: StopEditingFilter}, "\u2713") - : h("button", {onClick: StartEditingFilter}, "\u270E"), -]) + 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 @@ -625,107 +646,118 @@ payload through it, providing the returned value as payload to the action. 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 ----------------------------- +## 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. +So far, the list of stories has been defined in the state and doesn't change. What we really want is +when we're done changing the filter-word, stories matching it should be loaded. -### Actions can return effects - -Add this import (to the "IMPORTS" section): +Before looking at how we make the request for new stories, one thing is for sure: when new stories +come back they need to go into the state, and the only way to modify the state is through an action. +So we're definitely going to need the following action: ```js -import {Http} from "https:/unpkg.com/hyperapp-fx@next?module" +const GotStories = (state, stories) => ({ + ...state, + + // replace old stories with new, + // but keep the 'seen' value if it exists + stories: Object.keys(stories) + .map(id => [ + id, + { + ...stories[id], + seen: state.stories[id] && state.stories[id].seen, + }, + ]) + .reduce((o, [id, story]) => ((o[id] = story), o), {}), + + // in case the current story is in the new list as well, + // keep it selected, Otherwise select nothing + reading: stories[state.reading] ? state.reading : null, +}) ``` -Use the imported `Http` in the `StopEditingFilter` action like this: +### Declaring effects in actions -```js +Our request for new stories should go out once we're done editing the filter, which is to say: when we click +the check-mark button and `StopEditingFilter` is dispatched. When an action needs to do something +besides transforming the state, that "something" is called an _effect_. To associate an effect +with `StopEditingFilter`, make it return an array 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, // <--- - }) + + // effect declarations go here: // ] ``` -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 an action returns an array, Hyperapp understands that the first item is the new state we want, and +the rest are _effect declarations_. Hyperapp takes care of running all declared effects once the state +has been updated. -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]() 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): +Add this effect declaration: ```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 { +const StopEditingFilter = state => [ + { ...state, - stories, - reading, - } -} + editingFilter: false, + }, + + // effect declarations go here: // + [ + fetchJSONData, + { + url: `https://hyperapp.dev/tutorial-assets/stories/${state.filter.toLowerCase()}.json`, + onresponse: GotStories, + }, + ], +] ``` -Try it out! Enter "life" in the filter input. When you click the check-mark button some new +The first item in an effect declaration – here `fetchJSONData` – is the +_effect function_ that we want Hyperapp to call. The second item contains +the options we want passed to effect function when it's called. Here, we are +telling `fetchJSONData` where the stories for the current filter are, and +to dispatch them as payload to `GotStories`, on response. + +### Effect functions and `dispatch` + +Now we just need to implement `fetchJSONData`. Type this in the "EFFECTS & SUBSCRIPTIONS" section: + +```js +const fetchJSONData = (dispatch, options) => + fetch(options.url) + .then(response => response.json()) + .then(data => dispatch(options.onresponse, data)) + .catch(() => dispatch(options.onresponse, {})) +``` + +> It's a good practice to write your effect functions generically like this, rather than +> hardcoding options. That way it can be used for multiple situations, even by others +> if you chose to publish it. +> +> ...speaking of which: make sure to check out the available effects published by members of the +> Hyperapp community, and perhaps save yourself some trouble implementing everything yourself. + +When Hyperapp calls an effect function, it passes the `dispatch` function to it as the first +argument. `dispatch` is how effect functions are able to "report back" to the app, by dispatching +actions (first argument) with payloads (second argument) + +Now, go ahead and 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. +![fetched life stories](https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut5.png) - - - - -### Running effects on initialization +### 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: [ { @@ -733,182 +765,219 @@ The next obvious step is to load the _initial_ stories from the API as well. Cha autoUpdate: false, filter: "ocean", reading: null, - stories: {}, // <--- + stories: {}, // <--- }, - Http({ // <--- - url: `https://zaceno.github.io/hatut/data/ocean.json`, // <--- - response: 'json', // <--- - action: GotStories, // <--- - }) + [ // <--- + fetchJSONData, // <--- + { // <--- + url: `https://hyperapp.dev/tutorial-assets/stories/ocean.json`, // <--- + onresponse: 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. +The point here is that init works just like the return value of an action, including +calling effects when it is given as an array. If you reload the page you'll see +(after a moment) that all the same stories appear, despite them not existing in +the state initially. +![fresh stories on init](https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut6.png) +### Effect creators - -### 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: +However, repeating the effect declaration in all its gory detail like this +is not ideal, so lets add this _effect creator_ ```js -const FetchStories = state => [ - {...state, fetching: true}, - Http({ - url: `https://zaceno.github.io/hatut/data/${state.filter.toLowerCase()}.json`, - response: 'json', - action: GotStories, - }) +const storyLoader = searchWord => [ + fetchJSONData, + { + url: `https://hyperapp.dev/tutorial-assets/stories/${searchWord.toLowerCase()}.json`, + onresponse: 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: +Now we can simplify `StopEditingFilter` like this: ```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 { +const StopEditingFilter = state => [ + { ...state, - stories, - reading, - fetching: false, // <--- - } + editingFilter: false, + }, + storyLoader(state.filter), +] +``` + +... and `init:` like this: + +```js + init: [ + { + editingFilter: false, + autoUpdate: false, + filter: "ocean", + reading: null, + stories: {}, + }, + storyLoader("ocean") + ], +``` + +### 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. We'll need a state property to tell us wether or not we're currently `fetching`, and we'll use this action to keep track of it: + +```js +const SetFetching = (state, fetching) => ({ ...state, fetching }) +``` + +Update `storyLoader` to tell `fetchJSONData` about `SetFetching` + +```js +const storyLoader = searchWord => [ + fetchJSONData, + { + url: `https://hyperapp.dev/tutorial-assets/stories/${searchWord.toLowerCase()}.json`, + onresponse: GotStories, + onstart: [SetFetching, true], // <---- + onfinish: [SetFetching, false], // <---- + }, +] +``` + +Finally update `fetchJSONData` to use the new `onstart` and `onfinish` options to notify when fetches start and end: + +```js +const fetchJSONData = (dispatch, options) => { + dispatch(options.onstart) // <--- + fetch(options.url) + .then(response => response.json()) + .then(data => dispatch(options.onresponse, data)) + .catch(() => dispatch(options.onresponse, {})) + .finally(() => dispatch(options.onfinish)) // <--- } ``` -With this, we know that when `fetching` is `true` we are waiting for a response, and should display -the spinner in the `StoryList` view: +With that, our state prop `fetching` will always tell us wether or not we are fetching. +Use that to show a spinner when we are fetching, in `storyList`: ```js -const StoryList = props => h("div", {class: "stories"}, [ +const storyList = props => + h("div", { class: "stories" }, [ + // show spinner overlay if fetching + props.fetching && + h("div", { class: "loadscreen" }, [h("div", { class: "spinner" })]), - 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 - }) - )) -]) + 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. - +![spinner](https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut7.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 -------------------------------------------------------------------- +## 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. +The last feature we'll add is one where the user can opt in to have the app check every five seconds for new +stories matching the current filter. (There won't actually be any new stories, 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: +First let's keep track of wether or not the user wants this auto-update feature on. Create a new action: ```js -const AutoUpdate = props => h("div", {class: "autoupdate"}, [ - "Auto update: ", - h("input", { - type: "checkbox", - checked: props.autoUpdate, // <--- - onInput: ToggleAutoUpdate, // <--- - }) -]) +const ToggleAutoUpdate = state => ({ ...state, autoUpdate: !state.autoUpdate }) ``` -and implement the `ToggleAutoUpdate` action: +Dispatch it in response to checking the checkbox in `autoUpdateView`: ```js -const ToggleAutoUpdate = state => ({...state, autoUpdate: !state.autoUpdate}) +const autoUpdateView = props => + h("div", { class: "autoupdate" }, [ + "Auto update: ", + h("input", { + type: "checkbox", + checked: props.autoUpdate, // <--- + oninput: ToggleAutoUpdate, // <--- + }), + ]) ``` -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`. +With that, the state property `autoUpdate` will tell us wether or not the Auto-update checkbox is checked. -Import the `interval` _subscription creator_: +### Subscription functions + +We need a _subscription function_ capable of dispatching actions at a given interval. Implement +`intervalSubscription` in the "EFFECTS & SUBSCRIPTIONS" section: ```js -import {interval} from "https://unpkg.com/@hyperapp/time?module" +const intervalSubscription = (dispatch, options) => { + const interval = setInterval(() => dispatch(options.action), options.time) + return () => clearInterval(interval) +} ``` -Add a `subscriptions` property to your app, with a conditional declaration of `interval` like this: +Just like an effect function, this function will be called by Hyperapp with `dispatch` and given options. It +will start an interval listener, and every `options.time` milliseconds, it will dispatch the given action. The +main difference to an effect function is that a subscription function returns a function so hyperapp knows +how to stop the subscription. + +> As with effects, you may find a suitable subscription already published +> in the Hyperapp community. + +### Subscribing + +We could create a new action for updating stories, but since `StopEditingFilter` already does what we want, we'll +use it here too. Add a `subscription` property to the app: ```js - subscriptions: state => [ - state.autoUpdate && interval(FetchStories, {delay: 5000}) - ] +subscriptions: state => [ + state.autoUpdate && + !state.editingFilter && [ + intervalSubscription, + { + time: 5000, //milliseconds, + action: StopEditingFilter, + }, + ], +] ``` -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. +Just like for `view`, hyperapp will run `subscriptions` with the new state every time it changes, to get +a list of subscription-declarations that should be active. In our case, whenever the Auto Update checkbox is +checked and we are _not_ busy editing the filter, our interval subscription will be active. -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 update](https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut8.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](). Again, -> it is no big deal - just not in scope for this tutorial. +Hyperapp will only stop or start subscriptions when the declaration changes +from one state to the next. Subscriptions are _not_ stopped and started _every_ time the state changes. 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 ------------------- +## 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. - - diff --git a/docs/static/tutorial-assets/stories/a.json b/docs/static/tutorial-assets/stories/a.json new file mode 100755 index 0000000..d56d270 --- /dev/null +++ b/docs/static/tutorial-assets/stories/a.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/an.json b/docs/static/tutorial-assets/stories/an.json new file mode 100755 index 0000000..24ffbbe --- /dev/null +++ b/docs/static/tutorial-assets/stories/an.json @@ -0,0 +1 @@ +{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/art.json b/docs/static/tutorial-assets/stories/art.json new file mode 100755 index 0000000..242fcc7 --- /dev/null +++ b/docs/static/tutorial-assets/stories/art.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/at.json b/docs/static/tutorial-assets/stories/at.json new file mode 100755 index 0000000..ab88930 --- /dev/null +++ b/docs/static/tutorial-assets/stories/at.json @@ -0,0 +1 @@ +{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/back.json b/docs/static/tutorial-assets/stories/back.json new file mode 100755 index 0000000..98194f7 --- /dev/null +++ b/docs/static/tutorial-assets/stories/back.json @@ -0,0 +1 @@ +{"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/because.json b/docs/static/tutorial-assets/stories/because.json new file mode 100755 index 0000000..be4ad2c --- /dev/null +++ b/docs/static/tutorial-assets/stories/because.json @@ -0,0 +1 @@ +{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/brutal.json b/docs/static/tutorial-assets/stories/brutal.json new file mode 100755 index 0000000..79f9817 --- /dev/null +++ b/docs/static/tutorial-assets/stories/brutal.json @@ -0,0 +1 @@ +{"113":{"title":"Ocean life is brutal","author":"Surphy McBrah"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/cancelled.json b/docs/static/tutorial-assets/stories/cancelled.json new file mode 100755 index 0000000..1859840 --- /dev/null +++ b/docs/static/tutorial-assets/stories/cancelled.json @@ -0,0 +1 @@ +{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/cave.json b/docs/static/tutorial-assets/stories/cave.json new file mode 100755 index 0000000..14da4d0 --- /dev/null +++ b/docs/static/tutorial-assets/stories/cave.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/central.json b/docs/static/tutorial-assets/stories/central.json new file mode 100755 index 0000000..43243ae --- /dev/null +++ b/docs/static/tutorial-assets/stories/central.json @@ -0,0 +1 @@ +{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/chef.json b/docs/static/tutorial-assets/stories/chef.json new file mode 100755 index 0000000..3103171 --- /dev/null +++ b/docs/static/tutorial-assets/stories/chef.json @@ -0,0 +1 @@ +{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/city.json b/docs/static/tutorial-assets/stories/city.json new file mode 100755 index 0000000..570054c --- /dev/null +++ b/docs/static/tutorial-assets/stories/city.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/closes.json b/docs/static/tutorial-assets/stories/closes.json new file mode 100755 index 0000000..be4ad2c --- /dev/null +++ b/docs/static/tutorial-assets/stories/closes.json @@ -0,0 +1 @@ +{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/comes.json b/docs/static/tutorial-assets/stories/comes.json new file mode 100755 index 0000000..98194f7 --- /dev/null +++ b/docs/static/tutorial-assets/stories/comes.json @@ -0,0 +1 @@ +{"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/confirmed.json b/docs/static/tutorial-assets/stories/confirmed.json new file mode 100755 index 0000000..7845d66 --- /dev/null +++ b/docs/static/tutorial-assets/stories/confirmed.json @@ -0,0 +1 @@ +{"115":{"title":"Life in space confirmed","author":"Nicholas Galilei"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/dead.json b/docs/static/tutorial-assets/stories/dead.json new file mode 100755 index 0000000..f30a244 --- /dev/null +++ b/docs/static/tutorial-assets/stories/dead.json @@ -0,0 +1 @@ +{"132":{"title":"Art is dead","author":"Kat Stropher"},"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/discovered.json b/docs/static/tutorial-assets/stories/discovered.json new file mode 100755 index 0000000..b0fcf5c --- /dev/null +++ b/docs/static/tutorial-assets/stories/discovered.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/exhibit.json b/docs/static/tutorial-assets/stories/exhibit.json new file mode 100755 index 0000000..ab88930 --- /dev/null +++ b/docs/static/tutorial-assets/stories/exhibit.json @@ -0,0 +1 @@ +{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/family.json b/docs/static/tutorial-assets/stories/family.json new file mode 100755 index 0000000..28c5456 --- /dev/null +++ b/docs/static/tutorial-assets/stories/family.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/fancy.json b/docs/static/tutorial-assets/stories/fancy.json new file mode 100755 index 0000000..c97d865 --- /dev/null +++ b/docs/static/tutorial-assets/stories/fancy.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/fifth.json b/docs/static/tutorial-assets/stories/fifth.json new file mode 100755 index 0000000..1859840 --- /dev/null +++ b/docs/static/tutorial-assets/stories/fifth.json @@ -0,0 +1 @@ +{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/for.json b/docs/static/tutorial-assets/stories/for.json new file mode 100755 index 0000000..854e847 --- /dev/null +++ b/docs/static/tutorial-assets/stories/for.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/fortune.json b/docs/static/tutorial-assets/stories/fortune.json new file mode 100755 index 0000000..0a2d105 --- /dev/null +++ b/docs/static/tutorial-assets/stories/fortune.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/friendly.json b/docs/static/tutorial-assets/stories/friendly.json new file mode 100755 index 0000000..ab88930 --- /dev/null +++ b/docs/static/tutorial-assets/stories/friendly.json @@ -0,0 +1 @@ +{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/fun.json b/docs/static/tutorial-assets/stories/fun.json new file mode 100755 index 0000000..50a05a5 --- /dev/null +++ b/docs/static/tutorial-assets/stories/fun.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/grafitti.json b/docs/static/tutorial-assets/stories/grafitti.json new file mode 100755 index 0000000..1f2bb31 --- /dev/null +++ b/docs/static/tutorial-assets/stories/grafitti.json @@ -0,0 +1 @@ +{"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/grand.json b/docs/static/tutorial-assets/stories/grand.json new file mode 100755 index 0000000..43243ae --- /dev/null +++ b/docs/static/tutorial-assets/stories/grand.json @@ -0,0 +1 @@ +{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/have.json b/docs/static/tutorial-assets/stories/have.json new file mode 100755 index 0000000..24ffbbe --- /dev/null +++ b/docs/static/tutorial-assets/stories/have.json @@ -0,0 +1 @@ +{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/i.json b/docs/static/tutorial-assets/stories/i.json new file mode 100755 index 0000000..3a03634 --- /dev/null +++ b/docs/static/tutorial-assets/stories/i.json @@ -0,0 +1 @@ +{"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/ice-cream.json b/docs/static/tutorial-assets/stories/ice-cream.json new file mode 100755 index 0000000..7bb17af --- /dev/null +++ b/docs/static/tutorial-assets/stories/ice-cream.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/in.json b/docs/static/tutorial-assets/stories/in.json new file mode 100755 index 0000000..933d064 --- /dev/null +++ b/docs/static/tutorial-assets/stories/in.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/infestation.json b/docs/static/tutorial-assets/stories/infestation.json new file mode 100755 index 0000000..be4ad2c --- /dev/null +++ b/docs/static/tutorial-assets/stories/infestation.json @@ -0,0 +1 @@ +{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/is.json b/docs/static/tutorial-assets/stories/is.json new file mode 100755 index 0000000..21c214d --- /dev/null +++ b/docs/static/tutorial-assets/stories/is.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/life.json b/docs/static/tutorial-assets/stories/life.json new file mode 100755 index 0000000..355ed8f --- /dev/null +++ b/docs/static/tutorial-assets/stories/life.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/lives.json b/docs/static/tutorial-assets/stories/lives.json new file mode 100755 index 0000000..dfadd5c --- /dev/null +++ b/docs/static/tutorial-assets/stories/lives.json @@ -0,0 +1 @@ +{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/made.json b/docs/static/tutorial-assets/stories/made.json new file mode 100755 index 0000000..6f34f5c --- /dev/null +++ b/docs/static/tutorial-assets/stories/made.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/master.json b/docs/static/tutorial-assets/stories/master.json new file mode 100755 index 0000000..af7e497 --- /dev/null +++ b/docs/static/tutorial-assets/stories/master.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/me.json b/docs/static/tutorial-assets/stories/me.json new file mode 100755 index 0000000..5b21136 --- /dev/null +++ b/docs/static/tutorial-assets/stories/me.json @@ -0,0 +1 @@ +{"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/miniature.json b/docs/static/tutorial-assets/stories/miniature.json new file mode 100755 index 0000000..43243ae --- /dev/null +++ b/docs/static/tutorial-assets/stories/miniature.json @@ -0,0 +1 @@ +{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/monument.json b/docs/static/tutorial-assets/stories/monument.json new file mode 100755 index 0000000..25342a4 --- /dev/null +++ b/docs/static/tutorial-assets/stories/monument.json @@ -0,0 +1 @@ +{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/moved.json b/docs/static/tutorial-assets/stories/moved.json new file mode 100755 index 0000000..640f4bf --- /dev/null +++ b/docs/static/tutorial-assets/stories/moved.json @@ -0,0 +1 @@ +{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/my.json b/docs/static/tutorial-assets/stories/my.json new file mode 100755 index 0000000..dfadd5c --- /dev/null +++ b/docs/static/tutorial-assets/stories/my.json @@ -0,0 +1 @@ +{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/new.json b/docs/static/tutorial-assets/stories/new.json new file mode 100755 index 0000000..afadfec --- /dev/null +++ b/docs/static/tutorial-assets/stories/new.json @@ -0,0 +1 @@ +{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/not.json b/docs/static/tutorial-assets/stories/not.json new file mode 100755 index 0000000..5b21136 --- /dev/null +++ b/docs/static/tutorial-assets/stories/not.json @@ -0,0 +1 @@ +{"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/ocean.json b/docs/static/tutorial-assets/stories/ocean.json new file mode 100755 index 0000000..3d1cbc2 --- /dev/null +++ b/docs/static/tutorial-assets/stories/ocean.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/of.json b/docs/static/tutorial-assets/stories/of.json new file mode 100755 index 0000000..06ffca1 --- /dev/null +++ b/docs/static/tutorial-assets/stories/of.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/oil-drum.json b/docs/static/tutorial-assets/stories/oil-drum.json new file mode 100755 index 0000000..24ffbbe --- /dev/null +++ b/docs/static/tutorial-assets/stories/oil-drum.json @@ -0,0 +1 @@ +{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/on.json b/docs/static/tutorial-assets/stories/on.json new file mode 100755 index 0000000..dfadd5c --- /dev/null +++ b/docs/static/tutorial-assets/stories/on.json @@ -0,0 +1 @@ +{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/out.json b/docs/static/tutorial-assets/stories/out.json new file mode 100755 index 0000000..5283a31 --- /dev/null +++ b/docs/static/tutorial-assets/stories/out.json @@ -0,0 +1 @@ +{"116":{"title":"City running out of parking space","author":"Dan Tannerson"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/paintings.json b/docs/static/tutorial-assets/stories/paintings.json new file mode 100755 index 0000000..1f2bb31 --- /dev/null +++ b/docs/static/tutorial-assets/stories/paintings.json @@ -0,0 +1 @@ +{"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/parking.json b/docs/static/tutorial-assets/stories/parking.json new file mode 100755 index 0000000..73ebb1c --- /dev/null +++ b/docs/static/tutorial-assets/stories/parking.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/parlor.json b/docs/static/tutorial-assets/stories/parlor.json new file mode 100755 index 0000000..be4ad2c --- /dev/null +++ b/docs/static/tutorial-assets/stories/parlor.json @@ -0,0 +1 @@ +{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/prices.json b/docs/static/tutorial-assets/stories/prices.json new file mode 100755 index 0000000..0b31006 --- /dev/null +++ b/docs/static/tutorial-assets/stories/prices.json @@ -0,0 +1 @@ +{"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/real-estate.json b/docs/static/tutorial-assets/stories/real-estate.json new file mode 100755 index 0000000..289314d --- /dev/null +++ b/docs/static/tutorial-assets/stories/real-estate.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/running.json b/docs/static/tutorial-assets/stories/running.json new file mode 100755 index 0000000..5283a31 --- /dev/null +++ b/docs/static/tutorial-assets/stories/running.json @@ -0,0 +1 @@ +{"116":{"title":"City running out of parking space","author":"Dan Tannerson"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/shop.json b/docs/static/tutorial-assets/stories/shop.json new file mode 100755 index 0000000..25342a4 --- /dev/null +++ b/docs/static/tutorial-assets/stories/shop.json @@ -0,0 +1 @@ +{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/sinking.json b/docs/static/tutorial-assets/stories/sinking.json new file mode 100755 index 0000000..8467e0b --- /dev/null +++ b/docs/static/tutorial-assets/stories/sinking.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/soldier.json b/docs/static/tutorial-assets/stories/soldier.json new file mode 100755 index 0000000..28a80fd --- /dev/null +++ b/docs/static/tutorial-assets/stories/soldier.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/space.json b/docs/static/tutorial-assets/stories/space.json new file mode 100755 index 0000000..1be033b --- /dev/null +++ b/docs/static/tutorial-assets/stories/space.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/species.json b/docs/static/tutorial-assets/stories/species.json new file mode 100755 index 0000000..afadfec --- /dev/null +++ b/docs/static/tutorial-assets/stories/species.json @@ -0,0 +1 @@ +{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/spider.json b/docs/static/tutorial-assets/stories/spider.json new file mode 100755 index 0000000..177c34d --- /dev/null +++ b/docs/static/tutorial-assets/stories/spider.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/station.json b/docs/static/tutorial-assets/stories/station.json new file mode 100755 index 0000000..8a8a802 --- /dev/null +++ b/docs/static/tutorial-assets/stories/station.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/still.json b/docs/static/tutorial-assets/stories/still.json new file mode 100755 index 0000000..07c03d7 --- /dev/null +++ b/docs/static/tutorial-assets/stories/still.json @@ -0,0 +1 @@ +{"117":{"title":"Life in the city is still fun","author":"Greg Jenner"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/ten.json b/docs/static/tutorial-assets/stories/ten.json new file mode 100755 index 0000000..24ffbbe --- /dev/null +++ b/docs/static/tutorial-assets/stories/ten.json @@ -0,0 +1 @@ +{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/that.json b/docs/static/tutorial-assets/stories/that.json new file mode 100755 index 0000000..640f4bf --- /dev/null +++ b/docs/static/tutorial-assets/stories/that.json @@ -0,0 +1 @@ +{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/the.json b/docs/static/tutorial-assets/stories/the.json new file mode 100755 index 0000000..9a84fca --- /dev/null +++ b/docs/static/tutorial-assets/stories/the.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/time.json b/docs/static/tutorial-assets/stories/time.json new file mode 100755 index 0000000..1859840 --- /dev/null +++ b/docs/static/tutorial-assets/stories/time.json @@ -0,0 +1 @@ +{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/to.json b/docs/static/tutorial-assets/stories/to.json new file mode 100755 index 0000000..3a17d6d --- /dev/null +++ b/docs/static/tutorial-assets/stories/to.json @@ -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"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/toothpicks.json b/docs/static/tutorial-assets/stories/toothpicks.json new file mode 100755 index 0000000..43243ae --- /dev/null +++ b/docs/static/tutorial-assets/stories/toothpicks.json @@ -0,0 +1 @@ +{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/top.json b/docs/static/tutorial-assets/stories/top.json new file mode 100755 index 0000000..24ffbbe --- /dev/null +++ b/docs/static/tutorial-assets/stories/top.json @@ -0,0 +1 @@ +{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/turned.json b/docs/static/tutorial-assets/stories/turned.json new file mode 100755 index 0000000..3103171 --- /dev/null +++ b/docs/static/tutorial-assets/stories/turned.json @@ -0,0 +1 @@ +{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/under.json b/docs/static/tutorial-assets/stories/under.json new file mode 100755 index 0000000..b17bb9f --- /dev/null +++ b/docs/static/tutorial-assets/stories/under.json @@ -0,0 +1 @@ +{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"},"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/utah.json b/docs/static/tutorial-assets/stories/utah.json new file mode 100755 index 0000000..afadfec --- /dev/null +++ b/docs/static/tutorial-assets/stories/utah.json @@ -0,0 +1 @@ +{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/ways.json b/docs/static/tutorial-assets/stories/ways.json new file mode 100755 index 0000000..24ffbbe --- /dev/null +++ b/docs/static/tutorial-assets/stories/ways.json @@ -0,0 +1 @@ +{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/stories/wheel.json b/docs/static/tutorial-assets/stories/wheel.json new file mode 100755 index 0000000..1859840 --- /dev/null +++ b/docs/static/tutorial-assets/stories/wheel.json @@ -0,0 +1 @@ +{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}} \ No newline at end of file diff --git a/docs/static/tutorial-assets/style.css b/docs/static/tutorial-assets/style.css new file mode 100644 index 0000000..4713844 --- /dev/null +++ b/docs/static/tutorial-assets/style.css @@ -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; +} diff --git a/docs/static/tutorial.md b/docs/static/tutorial.md deleted file mode 100644 index aefa0ab..0000000 --- a/docs/static/tutorial.md +++ /dev/null @@ -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 -
-
- Filter: - ocean - -
-
-
    -
  • -

    The Ocean is Sinking

    -

    Kat Stropher

    -
  • -
  • -

    Ocean life is brutal

    -

    Surphy McBrah

    -
  • -
  • -

    - Family friendly fun at the - ocean exhibit -

    -

    Guy Prosales

    -
  • -
-
-
-

Ocean life is brutal

-

- 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. -

-

Surphy McBrah

-
-
- Auto update: - -
-
-``` - -...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 - - - - - - - -
- - -``` - -> 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 -

- Hello - World! -

-``` - -### 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.