Support desk
Data loading in the router, with a server you can switch off.
A ticket list and a page per ticket, with a pretend server behind them. Every request is written to the API log under the page, so you can see what the router asks for and when. The switch next to the log turns the server off, to see what the pages do when a loader fails.
Preview
Things to try
- Prefetch. Hover a ticket title. The API log shows the request before you click, and the click does not send a second one.
- A pending page. Open a ticket. The address changes at once, the ticket page shows a loading state and the navbar and log stay where they are.
- No pending page. Change the filter on the list. The old list stays on screen until the new one is here, and the loading bar runs meanwhile.
- An error page. Switch the server off, then open a ticket. The error page shows the message. Switch it on again and press Retry.
- Revalidate. Close a ticket. The page sends the change, then loads the ticket again, and the status updates without a navigation.
- Alias and redirect. The address
#/inboxshows the list and keeps its own address.#/redirects to#/tickets. - Tab title. It changes with the page and with the number of tickets, and goes back to the default when you leave.
A failed load is also logged in the browser console as
router:loader-failed.
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Support desk - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.prod.js"></script>
</head>
<body>
<div id="app"></div>
<script>
const {
Component, Signal, application, html, router, Link, Outlet, Show, For,
useHead, useQuery, useRouter, useRouteData, useIsNavigating,
} = Signy;
// A pretend server. Every call is written to the API log, so you can see
// what the router asks for and when.
const serverDown = new Signal(false);
const apiLog = new Signal([]);
let logId = 0;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(text, ms, work) {
apiLog.set((list) => [{ id: ++logId, time: new Date().toLocaleTimeString(), text }, ...list].slice(0, 8));
await wait(ms);
if (serverDown.peek()) throw new Error("503 The server is down");
return work();
}
const tickets = [
{ id: 1, title: "Printer is on fire", requester: "Ada", status: "open", note: "It says PC LOAD LETTER. There are also flames." },
{ id: 2, title: "Cannot log in", requester: "Grace", status: "open", note: "The password is correct. The keyboard is the problem." },
{ id: 3, title: "Coffee machine only speaks French", requester: "Linus", status: "closed", note: "Solved with a croissant." },
{ id: 4, title: "Reply all storm", requester: "Margaret", status: "open", note: "4,000 people want to be removed from the list." },
{ id: 5, title: "A raccoon in the server room", requester: "Alan", status: "open", note: "It is sitting on the router. It seems comfortable." },
];
const api = {
list: (status) =>
call(`GET /tickets?status=${status}`, 500, () =>
tickets.filter((t) => status === "all" || t.status === status).map((t) => ({ ...t })),
),
get: (id) =>
call(`GET /tickets/${id}`, 700, () => {
const ticket = tickets.find((t) => t.id === Number(id));
if (!ticket) throw new Error(`Ticket ${id} does not exist`);
return { ...ticket };
}),
close: (id) =>
call(`POST /tickets/${id}/close`, 600, () => {
tickets.find((t) => t.id === Number(id)).status = "closed";
}),
};
// The tab title, shown at the bottom so you can watch useHead() work
const tabTitle = new Signal(document.title);
new MutationObserver(() => (tabTitle.value = document.title)).observe(document.querySelector("title"), {
childList: true,
characterData: true,
subtree: true,
});
const STATUSES = ["open", "closed", "all"];
// No pending page: the old page stays until the list is here. The loader
// runs again for every filter, because the query is part of the address.
class TicketList extends Component {
setup() {
this.tickets = useRouteData(this);
this.nav = useRouter(this);
this.query = useQuery(this);
useHead(() => ({ title: `Tickets (${(this.tickets.value ?? []).length})` }));
}
render() {
const status = () => this.query.value.status || "open";
return html`
<h1>Tickets</h1>
<div class="cluster" role="group" aria-label="Filter by status">
${STATUSES.map((name) => html`
<button class=${() => (status() === name ? "small" : "small outlined")}
aria-pressed=${() => String(status() === name)}
onClick=${() => this.nav.push({ path: "/tickets", query: { status: name } })}>${name}</button>
`)}
</div>
${Show({
when: () => (this.tickets.value ?? []).length > 0,
fallback: html`<p>No tickets here. Enjoy the quiet.</p>`,
children: html`
<div class="table-responsive">
<table>
<thead><tr><th scope="col">#</th><th scope="col">Ticket</th><th scope="col">From</th><th scope="col">Status</th></tr></thead>
<tbody>
${For({
each: () => this.tickets.value ?? [],
keyBy: "id",
children: (ticket) => html`
<tr>
<td>${ticket.id}</td>
<td><${Link} to=${"/tickets/" + ticket.id} prefetch>${ticket.title}</${Link}></td>
<td>${ticket.requester}</td>
<td>${ticket.status}</td>
</tr>
`,
})}
</tbody>
</table>
</div>
`,
})}
<p><small>Hover a ticket and watch the API log. The request starts before you click.</small></p>
`;
}
}
// Shown while the ticket loads. The navbar and the log stay where they are.
class TicketLoading extends Component {
setup() {
useHead({ title: `Loading ticket ${this.props.params.id}` });
}
render() {
return html`
<h1>Ticket ${this.props.params.id}</h1>
<p aria-busy="true">Fetching the ticket</p>
`;
}
}
class TicketPage extends Component {
busy = new Signal(false);
failure = new Signal("");
setup() {
this.ticket = useRouteData(this);
this.nav = useRouter(this);
useHead(() => ({ title: `#${this.ticket.value?.id} ${this.ticket.value?.title}` }));
}
// Change the data on the server, then run the loader again. The page
// keeps the old ticket until the new one is here.
async close() {
this.busy.value = true;
this.failure.value = "";
try {
await api.close(this.ticket.value.id);
await this.nav.revalidate();
} catch (error) {
this.failure.value = error.message;
} finally {
this.busy.value = false;
}
}
render() {
return html`
<h1>${() => this.ticket.value?.title}</h1>
<p>From ${() => this.ticket.value?.requester}. Status: <strong>${() => this.ticket.value?.status}</strong>.</p>
<p>${() => this.ticket.value?.note}</p>
${Show({
when: () => this.failure.value,
children: (message) => html`<p class="alert danger" role="alert">${message}</p>`,
})}
<div class="cluster">
${Show({
when: () => this.ticket.value?.status === "open",
children: html`
<button aria-busy=${() => String(this.busy.value)} disabled=${() => this.busy.value}
onClick=${() => this.close()}>Close ticket</button>
`,
})}
<${Link} to="/tickets" class="secondary">Back to tickets</${Link}>
</div>
`;
}
}
// For both routes: the loader threw. retry() runs it again, and the
// ticket route shows its pending page while it does.
class LoadFailed extends Component {
setup() {
useHead({ title: "Something went wrong" });
}
render() {
return html`
<h1>Something went wrong</h1>
<p class="alert danger" role="alert">${() => this.props.error.message}</p>
<div class="cluster">
<button onClick=${() => this.props.retry()}>Retry</button>
<${Link} to="/tickets" class="secondary">Back to tickets</${Link}>
</div>
`;
}
}
class NotFound extends Component {
setup() {
useHead({ title: "Not found" });
}
render() {
return html`
<h1>Not found</h1>
<p>There is no such page. <${Link} to="/tickets">Back to the tickets</${Link}>.</p>
`;
}
}
class Layout extends Component {
setup() {
useHead({ titleTemplate: "%s | Support desk" });
this.navigating = useIsNavigating(this);
}
render() {
return html`
${Show({ when: () => this.navigating.value, children: html`<div class="loading-bar" role="progressbar" aria-label="Loading"></div>` })}
<header class="navbar">
<nav class="container" aria-label="Main">
<ul><li><${Link} to="/tickets"><strong>Support desk</strong></${Link}></li></ul>
<ul>
<li><${Link} to="/tickets">Tickets</${Link}></li>
<li><${Link} to="/inbox">Old inbox address</${Link}></li>
</ul>
</nav>
</header>
<main class="container"><${Outlet} /></main>
<footer class="container">
<details open>
<summary>Behind the scenes</summary>
<label><input type="checkbox" role="switch" bind:checked=${serverDown} /> Pretend the server is down</label>
<p>Tab title: <code>${() => tabTitle.value}</code></p>
<p>API log, newest first:</p>
<ul>
${For({
each: () => apiLog.value,
keyBy: "id",
children: (entry) => html`<li><code>${entry.time}</code> ${entry.text}</li>`,
})}
</ul>
</details>
</footer>
`;
}
}
const appRouter = router({
mode: "hash",
routes: [
// The front door sends you to the list
{ path: "/", redirect: "/tickets" },
// An old bookmark: /inbox shows the same page, and the address keeps /inbox
{
path: "/tickets",
alias: "/inbox",
component: TicketList,
error: LoadFailed,
loader: ({ query }) => api.list(query.status || "open"),
},
{
path: "/tickets/:id",
component: TicketPage,
pending: TicketLoading,
error: LoadFailed,
loader: ({ params }) => api.get(params.id),
},
{ path: "*", component: NotFound },
],
});
application(Layout).use(appRouter).mount("#app");
</script>
</body>
</html>
What it uses
- Route loaders and
useRouteData() - Pending and error pages on a route, with
retry() router.revalidate()after a change- Prefetch on
Link - Route
redirectandalias useHead()for the title, with a template in the layoutbind:checkedfor the server switch- useQuery() for the filter and
useIsNavigating()for the loading bar