In the next few sections, you'll start adding a login page. With more than a single page, our system requires routing. There are two routing options:
- server-side
- client-side
With server-side routing, you create one app per page. Each page:
- can evolve independently
- can use a different version of the framework
- can even use a different framework
- can use only HTML/CSS when JS is not needed
- can use natural code splitting without any tools
You integrate pages with hypermedia: links and forms. To read more about this architecture, go to the Self-Contained Systems website.
With client-side routing, you manage page transitions in JS, and you need a client-side router. Most JS routers:
- hijack links and forms to prevent the default browser behavior
- call
history.pushState
to update URL bar - listen to
popstate
events to make the back and forward buttons work
Unfortunately, if you're only doing client-side routing, you end up with long loading and parsing of your JS files. Loading code for all routes is a waste of user's bandwidth and time. You can use code-splitting to load and parse only the code for the current page. Unfortunately, it's not free. First, you have to sprinkle your whole application with loading of async chunks. Second, you need to setup a complicated build tool for the older browsers.
We will focus on server-side routing in this chapter. We will cover client-side routing in the next one.
In this section, you'll add a global layout function with shared navigation.
First write our target signature for the layout decorator in src/App.js:
app({
view: layout(view),
});
layout
should wrap the original view
function and return a new function with a state parameter.
Create src/Layout.js with a function matching this specification.
export const layout = (view) => (state) => html`…`;
Fill in the gaps:
import html from "hyperlit";
const nav = html`
<nav>
<a href="/" class="href">Posts</a>
${" "}
<a href="/login" class="href">Login</a>
</nav>
`;
export const layout = (view) => (state) => html`
<div>
<header>
${nav}
<h4>hyperposts</h4>
</header>
<main>
${view(state)}
</main>
</div>
`;
layout
provides a common header with two navigation links.
Then it delegates the main content rendering to the view
function.
Since we're moving h4
header to the common layout component, remove it from the Posts.js.
Import the layout in src/App.js and test your app:
import { layout } from "./Layout.js";
To explore server-side routing, create a second HTML page login.html.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HyperPosts</title>
<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" type="text/css">
<script type="module" src="src/Login.js"></script>
</head>
<body>
<main>
<div id="app"></div>
</main>
</body>
</html>
This is almost the same HTML you wrote for the main page. The only difference is a JS file name.
Create src/Login.js:
import { app } from "hyperapp";
import html from "hyperlit";
import { WriteToStorage } from "hyperapp-fx/src/index.js";
import { layout } from "./Layout.js";
import {withTargetValue} from "./lib/DomEvents.js";
const state = {
username: "",
};
const ChangeLogin = (state, username) => [
{ ...state, username },
WriteToStorage({ key: "hyperposts", value: username }),
];
const view = (state) => html`
<form method="get" action="/">
<input type="text" oninput=${withTargetValue(ChangeLogin)} value=${state.username} />
${" "}
<button>Login</button>
</form>
`;
app({
init: [state],
view: layout(view),
node: document.getElementById("app"),
});
This is another Hyperapp application. In this book, we use the same Hyperapp version for login and posts. However, having two separate pages allows us to evolve the framework versions independently. You don't have to upgrade all your pages at once.
The code for the login page should look familiar by now.
The only new thing is the WriteToStorage
effect from the hyperapp-fx library.
As the user types her username, we save it to the localStorage.
WriteToStorage({key: "hyperposts", value: login})
basically translates to localStorage.setItem("hyperposts", JSON.stringify(login))
.
When the user clicks the Login button, a regular HTML form should take her to the main page. Navigation is handled by the browser talking to the HTTP server. No JS is involved. It turns out browsers are really good at handling hypermedia. You only have to make sure that JS doesn't hijack the form submission.
Read the login username and set it on the posts page. When the user submits a new post, use her actual username instead of "anonymous".
To make it easier here's a starter code for reading from storage:
import { ReadFromStorage } from "hyperapp-fx/src/index.js";
const ReadLogin = ReadFromStorage({key: "hyperposts", action: ({value}) => ...})
Solution
import { ReadFromStorage } from "hyperapp-fx/src/index.js";
export const state = {
...
username: "anonymous",
};
export const AddPost = (state, id) => {
...
const newPost = {
id,
username: state.username,
body: state.currentPostText,
};
...
};
const SetUsername = (state, { value }) =>
value ? { ...state, username: value } : state;
const ReadUsername = ReadFromStorage({
key: "hyperposts",
action: SetUsername,
});
export const init = [state, LoadLatestPosts, ReadUsername];
Write an integration test for a logged-in user posting with her username.
Hints:
- use
randomMessage
,sendMessage
, andwaitForMessage
helpers functions you already have in App.test.js - remove logged-in user from
localStorage
before each test - simulate logged-in user with this code
localStorage.setItem("hyperposts", JSON.stringify("kate"));
Solution
beforeEach(function () {
document.body.innerHTML = '<div id="app"></div>';
localStorage.removeItem("hyperposts");
});
it("Add a post as logged in user", async () => {
const randomUser = "user" + Date.now();
localStorage.setItem("hyperposts", JSON.stringify(randomUser));
stop = App();
await waitFor(() => {
assert.strictEqual(getAllByTestId(container(), "item").length, 10);
});
await sendMessage("logged in user message");
await waitFor(() => {
assert.strictEqual(getAllByTestId(container(), "item").length, 11);
assert.ok(getByText(container(), "@" + randomUser));
});
});
In this exercise, you'll see the benefits of having a single application state. Display current username in the application header as shown in the following figure:
Solution
Layout.js
export const layout = (view) => (state) => html`
<div>
<header>
${nav}
<h4>@${state.username} hyperposts</h4>
</header>
<main>
${view(state)}
</main>
</div>
`;
The single application state approach also shines when you need to persist your entire state to localStorage or some remote API.