app-school-ii: revise to use 'create-landscape-app' and new %journal source snippets; add notes on modern react

This commit is contained in:
Sidnym Ladrut 2023-03-15 01:27:48 +00:00
parent 26b52f692b
commit fe1b346493
2 changed files with 273 additions and 257 deletions

View File

@ -10,143 +10,116 @@ React app front-end.
Node.js must be installed, and can be downloaded from their
[website](https://nodejs.org/en/download). With that installed, we'll have the
`npm` package manager available. The first thing we'll do is globally install
the `create-react-app` package with the following command:
`npm` package manager available and its utility binaries like `npx` to help
set up our project. The first thing we'll do is create a project using the
[`create-landscape-app`](https://www.npmjs.com/package/@urbit/create-landscape-app)
template with the following command:
```sh
npm install -g create-react-app
```
Once installed, we can use it to create a new `journal-ui` directory and setup a
new React app in it with the following command:
```sh
create-react-app journal-ui
npx @urbit/create-landscape-app
✔ What should we call your application? … journal
✔ What URL do you use to access Urbit? … http://127.0.0.1:8080
```
We can then open our new directory:
```sh
cd journal-ui
```sh {% copy=true %}
cd journal/ui
```
Its contents should look something like this:
```
journal-ui
├── node_modules
ui
├── index.html
├── package.json
├── package-lock.json
├── public
├── README.md
├── postcss.config.js
├── tailwind.config.js
├── vite.config.js
└── src
```
## Install `http-api`
## Install dependencies
Inside our React app directory, let's install the `@urbit/http-api` NPM package:
Inside our React app directory, let's install the NPM packages used by
our project:
```sh
npm i @urbit/http-api
```sh {% copy=true %}
npm i
```
We also install a handful of other packages for the UI components
(`bootstrap@5.1.3 react-bootstrap@2.2.0 react-textarea-autosize@8.3.3
date-fns@2.28.0 react-bottom-scroll-listener@5.0.0 react-day-picker@7.4.10`),
but that's not important to our purposes here.
## Additional tweaks
Our front-end will be served directly from the ship by the `%docket` app, where
a user will open it by clicking on its homescreen tile. Docket serves such
front-ends with a base URL path of `/apps/[desk]/`, so in our case it will be
`/apps/journal`. In order for our app to be built with correct resource paths,
we must add the following line to `package.json`:
```json
"homepage": "/apps/journal/",
```
Our app also needs to know the name of the ship it's being served from in order
to talk with it. The `%docket` agent serves a small file for this purpose at
`[host]/session.js`. This file is very simple and just contains:
```js
window.ship = "sampel-palnet";
```
`sampel-palnet` will of course be replaced by the actual name of the ship. We
include this script by adding the following line to the `<head>` section of
`public/index.html`:
```
<script src="/session.js"></script>
```
This command will install the Urbit interface package (i.e. `@urbit/http-api`)
and a handful of other packages for the UI components (e.g. `react-bootstrap`,
`react-bottom-scroll-listener`, `react-day-picker`). The remainder of this
tutorial will focus primarily on how the former is used to communicate with a
live ship from within a React application.
## Basic API setup
With everything now setup, we can begin work on the app itself. In this case
we'll just edit the existing `App.js` file in the `/src` directory. The first thing is to import the `Urbit` class from `@urbit/http-api`:
With everything now set up, we can begin work on the app itself. In this case
we'll just edit the `src/app.jsx` file. The first thing is to clear the content
of the file and then add the following import statements for the React
framework and the `Urbit` class:
```js
```javascript {% copy=true %}
import React, { useState, useEffect } from "react";
import Urbit from "@urbit/http-api";
```
We also need to import a few other things, mostly relating to UI components (but
these aren't important for our purposes here):
```js
import React, { Component } from "react";
```javascript {% copy=true %}
import "bootstrap/dist/css/bootstrap.min.css";
import "react-day-picker/lib/style.css";
import TextareaAutosize from "react-textarea-autosize";
import Button from "react-bootstrap/Button";
import Card from "react-bootstrap/Card";
import Stack from "react-bootstrap/Stack";
import Tab from "react-bootstrap/Tab";
import Tabs from "react-bootstrap/Tabs";
import ToastContainer from "react-bootstrap/ToastContainer";
import Toast from "react-bootstrap/Toast";
import Spinner from "react-bootstrap/Spinner";
import CloseButton from "react-bootstrap/CloseButton";
import Modal from "react-bootstrap/Modal";
import {
Modal, Card, Stack, Tab, Tabs, Toast, ToastContainer,
Button, Spinner, CloseButton,
} from "react-bootstrap";
import DayPickerInput from "react-day-picker/DayPickerInput";
import endOfDay from "date-fns/endOfDay";
import startOfDay from "date-fns/startOfDay";
import { startOfDay, endOfDay } from "date-fns";
import { BottomScrollListener } from "react-bottom-scroll-listener";
```
Inside the existing `App` class:
Now we'll begin defining our components. For the purposes of this tutorial,
we'll focus on the primary `App` component, which is defined as follows:
```js
class App extends Component {
```javascript {% copy=true %}
export default function App() {
/* remainder of the source goes here */
}
```
...we'll clear out the existing demo code and start adding ours. The first thing
is to define our app's state. We'll look at most of the state entries in the
next section. For now, we'll just consider `status`.
The first thing we'll define in our `App` component is its state. In modern
React, component state is defined using the
[`useState()`](https://beta.reactjs.org/reference/react/useState) hook, which
returns a pair of `[stateVariable, setStateVariableFunction]`. For now, we'll
just consider the `status` state variable:
```js
state = {
// .....
status: null,
// .....
};
```javascript {% copy=true %}
const [status, setStatus] = useState(null);
```
Next, we'll setup the `Urbit` API object in `componentDidMount`. We could do
this outside the `App` class since we're adding it to `window`, but we'll do it
this way so it's all in one place:
Next, we'll set up the `Urbit` API object in a
[`useEffect()`](https://beta.reactjs.org/reference/react/useEffect) call, which
allows the connection to be established *exactly once* after the initial
content of the page is rendered. Since the connection itself is independent of
the component state, we could do this outside of the `App` component; however,
in this case, we choose to put it in a component `useEffect()` so all the setup
code is together:
```js
componentDidMount() {
```javascript {% copy=true %}
useEffect(() => {
window.urbit = new Urbit("");
window.urbit.ship = window.ship;
window.urbit.onOpen = () => this.setState({status: "con"});
window.urbit.onRetry = () => this.setState({status: "try"});
window.urbit.onError = (err) => this.setState({status: "err"});
this.init();
};
window.urbit.onOpen = () => setStatus("con");
window.urbit.onRetry = () => setStatus("try");
window.urbit.onError = () => setStatus("err");
init();
}, []);
```
The first thing we do is create a new instance of the `Urbit` class we imported
@ -164,16 +137,17 @@ is mandatory.
Therefore, we call the class contructor with just the empty `url` string:
```js
```javascript
window.urbit = new Urbit("");
```
Next, we need to set the ship name in our `Urbit` instance. Eyre requires the
ship name be specified in all requests, so if we don't set it, Eyre will reject
all the messages we send. We previously included `session.js` which sets
`window.ship` to the ship name, so we just set `window.urbit.ship` as that:
ship name be specified in all requests; if we don't set it, Eyre will reject
all the messages we send. Fortunately, `create-landscape-app` handles this
detail by automatically initializing the active ship's name to the variable
`window.ship`, so we just set `window.urbit.ship` to this value:
```js
```javascript
window.urbit.ship = window.ship;
```
@ -195,8 +169,8 @@ leaving connection problems unhandled is usually a bad idea.
The last thing we do is call:
```js
this.init();
```javascript
init();
```
This function will fetch initial entries and subscribe for updates. We'll look

View File

@ -12,23 +12,27 @@ object we previously setup, and ignore UI components and other helper functions.
In the previous section we just mentioned the connection `status` field of our
state. Here's the full state of our App:
```js {% copy=true %}
state = {
entries: [], // list of journal entries for display
drafts: {}, // edits which haven't been submitted yet
newDraft: {}, // new entry which hasn't been submitted yet
results: [], // search results
searchStart: null, // search query start date
searchEnd: null, // search query end date
resultStart: null, // search results start date
resultEnd: null, // search results end date
searchTime: null, // time of last search
latestUpdate: null, // most recent update we've received
entryToDelete: null, // deletion target for confirmation modal
status: null, // connection status (con, try, err)
errorCount: 0, // number of errors so far
errors: new Map(), // list of error messages for display
};
```javascript
// Control/Meta State //
const [subEvent, setSubEvent] = useState({});
const [latestUpdate, setLatestUpdate] = useState(null);
const [status, setStatus] = useState(null);
const [errorCount, setErrorCount] = useState(0);
const [errors, setErrors] = useState(new Map());
// Journal State //
const [entries, setEntries] = useState([]);
const [drafts, setDrafts] = useState({});
const [newDraft, setNewDraft] = useState({});
const [entryToDelete, setEntryToDelete] = useState(null);
// Search State //
const [results, setResults] = useState([]);
const [searchMeta, setSearchMeta] = useState({
time: null,
start: null,
end: null,
});
```
We'll see how these are used subsequently.
@ -37,39 +41,40 @@ We'll see how these are used subsequently.
The first thing our app does is call `init()`:
```js
init = () => {
this.getEntries().then(
```javascript
const init = () => {
getEntries().then(
(result) => {
this.handleUpdate(result);
this.setState({ latestUpdate: result.time });
this.subscribe();
setSubEvent(result);
setLatestUpdate(result.time);
subscribe();
},
(err) => {
this.setErrorMsg("Connection failed");
this.setState({ status: "err" });
addError("Connection failed");
setStatus("err");
}
);
};
```
This function just calls `getEntries()` to retrieve the initial list of journal
entries then, if that succeeded, it calls `subscribe()` to subscribe for new
updates. If the initial entry retrieval failed, we set the connection `status`
and save an error message in the `errors` map. We'll look at what we do with
errors later.
entries; then, if that succeeded, it publishes this update with `setSubEvent()`
and `setLatestUpdate()` invocations and then calls `subscribe()` to subscribe
for new updates. If the initial entry retrieval failed, we set the connection
`status` and save an error message in the `errors` map. We'll look at what we
do with errors later.
## Getting entries
![entries screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/entries.png)
The `getEntries` function scries our `%journal` agent for up to 10 entries
The `getEntries()` function scries our `%journal` agent for up to 10 entries
before the oldest we currently have. We call this initially, and then each time
the user scrolls to the bottom of the list.
```js
getEntries = async () => {
const { entries: e } = this.state;
```javascript
const getEntries = async () => {
const e = entries;
const before = e.length === 0 ? Date.now() : e[e.length - 1].id;
const max = 10;
const path = `/entries/before/${before}/${max}`;
@ -93,27 +98,27 @@ direct GET requests allow other marks too.
The `Urbit.scry` method returns a Promise which will contain an HTTP error
message if the scry failed. We handle it with a `.then` expression back in the
function that called it, either [`init()`](#initialize) or `moreEntries()`. If
the Promise is successfuly, the results are passed to the
[`handleUpdate`](#updates) function which appends the new entries to the
existing ones in state.
the Promise is successfully evaluated, the results are passed to the
[`setSubEvent()`](#updates) function, which appends the new entries to the
existing ones via a [`useEffect()`] hook (more on this [below](#updates)).
## Subscription
A subscription to the `/updates` path of our `%journal` agent is opened with our
`subscribe()` function:
```js
subscribe = () => {
```javascript
const subscribe = () => {
try {
window.urbit.subscribe({
app: "journal",
path: "/updates",
event: this.handleUpdate,
err: () => this.setErrorMsg("Subscription rejected"),
quit: () => this.setErrorMsg("Kicked from subscription"),
event: setSubEvent,
err: () => addError("Subscription rejected"),
quit: () => addError("Kicked from subscription"),
});
} catch {
this.setErrorMsg("Subscription failed");
addError("Subscription failed");
}
};
```
@ -124,7 +129,8 @@ object:
- `app` - the target agent.
- `path` - the `%watch` path we're subscribing to.
- `event` - a function to handle each fact the agent sends out. We call our
`handleUpdate` function, which we'll describe below.
`setSubEvent()` function to set off a cascade to update the interface;
this process is described [below](#updates).
- `err` - a function to call if the subscription request is rejected (nacked).
We just display an error in this case.
- `quit` - a function to call if we get kicked from the subscription. We also
@ -137,86 +143,119 @@ keep track of these IDs in your app's state.
## Updates
This `handleUpdate` function handles all updates we receive. It's called
whenever an event comes in for our subscription, and it's also called with the
results of [`getEntries`](#getting-entries) and [`getUpdates`](#error-handling)
(described later).
The architecture for updating a React interface based on incoming facts from an
`Urbit` subscription tends to follow a common pattern constituted of three
major parts:
It's a bit complex, but basically it just checks whether the JSON object is
`add`, `edit`, `delete`, or `entries`, and then updates the state appropriately.
The object it's receiving is just the `$update` structure converted to JSON by
the mark conversion functions we wrote previously.
1. A [`useState()`] call that creates an update object field as part of the
main component's state:
```javascript
const [subEvent, setSubEvent] = useState({});
```
2. An `Urbit.subscribe` call that passes the update object's setter function as
its `event` field:
```javascript
window.urbit.subscribe({/* ... */, event: setSubEvent});
```
3. A [`useEffect()`] invocation that triggers off of the update object, which
contains the logic for handling subscription updates:
```javascript
useEffect(() => {/* ... */}, [subEvent]);
```
```js
handleUpdate = (upd) => {
const { entries, drafts, results, latestUpdate } = this.state;
if (upd.time !== latestUpdate) {
if ("entries" in upd) {
this.setState({ entries: entries.concat(upd.entries) });
} else if ("add" in upd) {
const { time, add } = upd;
const eInd = this.spot(add.id, entries);
const rInd = this.spot(add.id, results);
const toE =
entries.length === 0 || add.id > entries[entries.length - 1].id;
const toR = this.inSearch(add.id, time);
The key piece of this architecture is the [`useEffect()`] trigger, which is
called whenever an event comes in on the subscription wire. In our application,
this hook is also triggered by calls to [`getEntries()`](#getting-entries) and
[`getUpdates()`](#error-handling), which will be described in greater detail
later.
The trigger code is a bit complex, but in broad brushstrokes it just checks the
header of the incoming JSON object (i.e. one of `add`, `edit`, `delete`, or
`entries`) and then updates the state appropriately. The object it's receiving
is just the `$update` structure converted to JSON by the mark conversion
functions we wrote previously.
```javascript {% mode="collapse" %}
useEffect(() => {
const getDataIndex = (id, data) => {
let low = 0;
let high = data.length;
while (low < high) {
let mid = (low + high) >>> 1;
if (data[mid].id > id) low = mid + 1;
else high = mid;
}
return low;
};
const isInSearch = (id, time) => (
searchMeta.time !== null &&
time >= searchMeta.time &&
searchMeta.start.getTime() <= id &&
searchMeta.end.getTime() >= id
);
if (subEvent.time !== latestUpdate) {
if ("entries" in subEvent) {
setEntries(entries.concat(subEvent.entries));
} else if ("add" in subEvent) {
const { time, add } = subEvent;
const eInd = getDataIndex(add.id, entries);
const rInd = getDataIndex(add.id, results);
const toE = entries.length === 0 || add.id > entries[entries.length - 1].id;
const toR = isInSearch(add.id, time);
toE && entries.splice(eInd, 0, add);
toR && results.splice(rInd, 0, add);
this.setState({
...(toE && { entries: entries }),
...(toR && { results: results }),
latestUpdate: time,
});
} else if ("edit" in upd) {
const { time, edit } = upd;
toE && setEntries([...entries]);
toR && setResults([...results]);
setLatestUpdate(time);
} else if ("edit" in subEvent) {
const { time, edit } = subEvent;
const eInd = entries.findIndex((e) => e.id === edit.id);
const rInd = results.findIndex((e) => e.id === edit.id);
const toE = eInd !== -1;
const toR = rInd !== -1 && this.inSearch(edit.id, time);
const toR = rInd !== -1 && isInSearch(edit.id, time);
if (toE) entries[eInd] = edit;
if (toR) results[rInd] = edit;
(toE || toR) && delete drafts[edit.id];
this.setState({
...(toE && { entries: entries }),
...(toR && { results: results }),
...((toE || toR) && { drafts: drafts }),
latestUpdate: time,
});
} else if ("del" in upd) {
const { time, del } = upd;
toE && setEntries([...entries]);
toR && setResults([...results]);
(toE || toR) && setDrafts({...drafts});
setLatestUpdate(time);
} else if ("del" in subEvent) {
const { time, del } = subEvent;
const eInd = entries.findIndex((e) => e.id === del.id);
const rInd = results.findIndex((e) => e.id === del.id);
const toE = eInd !== -1;
const toR = this.inSearch(del.id, time) && rInd !== -1;
const toR = isInSearch(del.id, time) && rInd !== -1;
toE && entries.splice(eInd, 1);
toR && results.splice(rInd, 1);
(toE || toR) && delete drafts[del.id];
this.setState({
...(toE && { entries: entries }),
...(toR && { results: results }),
...((toE || toR) && { drafts: drafts }),
latestUpdate: time,
});
toE && setEntries([...entries]);
toR && setResults([...results]);
(toE || toR) && setDrafts({...drafts});
setLatestUpdate(time);
}
}
};
}, [subEvent]);
```
## Add, edit, delete
![add screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/add.png)
When a user writes a new journal entry and hits submit, the `submitNew` function
is called. It uses the `Urbit.poke` method to poke our `%journal` agent.
When a user writes a new journal entry and hits submit, the `createEntry()`
function is called. It uses the `Urbit.poke` method to poke our `%journal`
agent.
```js
submitNew = (id, txt) => {
```javascript
const createEntry = (id, txt) => {
window.urbit.poke({
app: "journal",
mark: "journal-action",
json: { add: { id: id, txt: txt } },
onSuccess: () => this.setState({ newDraft: {} }),
onError: () => this.setErrorMsg("New entry rejected"),
onSuccess: () => setDraft({}),
onError: () => setError("New entry rejected"),
});
};
```
@ -239,35 +278,38 @@ The `Urbit.poke` method takes five arguments:
`onSuccess` and `onError` are optional, but it's usually desirable to handle
these cases.
The `delete` and `submitEdit` functions are similar to `submitNew`, but for the
`%del` and `%edit` actions rather than `%add`:
The `deleteEntry()` and `editEntry()` functions are similar to `createEntry()`,
but for the `%del` and `%edit` actions rather than `%add`:
![edit screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/edit.png)
```js
submitEdit = (id, txt) => {
if (txt !== null) {
```javascript
const editEntry = (id, txt) => {
if (txt === null) {
delete drafts[id];
setDrafts({...drafts});
} else {
window.urbit.poke({
app: "journal",
mark: "journal-action",
json: { edit: { id: id, txt: txt } },
onError: () => this.setErrorMsg("Edit rejected"),
onError: () => setError("Edit rejected"),
});
} else this.cancelEdit(id);
}
};
```
![delete screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/delete.png)
```js
delete = (id) => {
```javascript
const deleteEntry = (id) => {
window.urbit.poke({
app: "journal",
mark: "journal-action",
json: {"del": {"id": id}},
onError: ()=>this.setErrorMsg("Deletion rejected")
})
this.setState({rmModalShow: false, entryToDelete: null})
json: { del: { id: id } },
onError: () => setError("Deletion rejected"),
});
setDeleteId(null);
};
```
@ -279,73 +321,66 @@ our agent.
![search screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/search.png)
When searching for entries between two dates, the `getSearch` function is
When searching for entries between two dates, the `searchEntries()` function is
called, which uses the `Urbit.scry` method to scry for the results in a similar
fashion to [`getEntries`](#getting-entries), but using the
`/x/entries/between/[start]/[end]` endpoint.
```js
getSearch = async () => {
const { searchStart: ss, searchEnd: se, latestUpdate: lu } = this.state;
if (lu !== null && ss !== null && se !== null) {
let start = ss.getTime();
let end = se.getTime();
if (start < 0) start = 0;
if (end < 0) end = 0;
const path = `/entries/between/${start}/${end}`;
window.urbit
.scry({
app: "journal",
path: path,
})
.then(
(result) => {
this.setState({
searchTime: result.time,
searchStart: null,
searchEnd: null,
resultStart: ss,
resultEnd: se,
results: result.entries,
});
},
(err) => {
this.setErrorMsg("Search failed");
}
);
} else {
lu !== null && this.setErrorMsg("Searh failed");
}
```javascript
const searchEntries = async () => {
const start = Math.max(inputStart.getTime(), 0);
const end = Math.max(inputEnd.getTime(), 0);
window.urbit.scry({
app: "journal",
path: `/entries/between/${start}/${end}`,
}).then(
(result) => {
setInputStart(null);
setInputEnd(null);
setResults(result.entries);
setSearchMeta({
time: result.time,
start: inputStart,
end: inputEnd
});
},
(err) => {
setError("Search failed");
}
);
};
```
## Error handling
When the channel connection is interrupted, the `Urbit` object will begin trying to reconnect. On each attempt, it sets the connection `status` to `"try"`, as we specified for the `onRetry` callback. When this is set, a "reconnecting" message is displayed at the bottom of the screen:
When the channel connection is interrupted, the `Urbit` object will begin
trying to reconnect. On each attempt, it sets the connection `status` to
`"try"`, as we specified for the `onRetry` callback. When this is set, a
"reconnecting" message is displayed at the bottom of the screen:
![reconnecting screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/reconnecting.png)
If all three reconnection attempts fail, the `onError` callback is fired and we replace the "reconnecting" message with a "reconnect" button:
If all three reconnection attempts fail, the `onError` callback is fired and we
replace the "reconnecting" message with a "reconnect" button:
![reconnect screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/reconnect.png)
When clicked, the following function is called:
```js
reconnect = () => {
```javascript
const reconnect = () => {
window.urbit.reset();
const latest = this.state.latestUpdate;
if (latest === null) {
this.init();
if (latestUpdate === null) {
init();
} else {
this.getUpdates().then(
getUpdates().then(
(result) => {
result.logs.map((e) => this.handleUpdate(e));
this.subscribe();
result.logs.map(setSubEvent);
subscribe();
},
(err) => {
this.setErrorMsg("Connection failed");
this.setState({ status: "err" });
addError("Connection failed");
setStatus("err");
}
);
}
@ -363,10 +398,9 @@ Since we've reset the channel, we don't know if we've missed any updates. Rather
than having to refresh our whole state, we can use the `getUpdates()` function
to get any missing update:
```js
getUpdates = async () => {
const { latestUpdate: latest } = this.state;
const since = latest === null ? Date.now() : latest;
```javascript
const getUpdates = async () => {
const since = latestUpdate === null ? Date.now() : latestUpdate;
const path = `/updates/since/${since}`;
return window.urbit.scry({
app: "journal",
@ -381,11 +415,11 @@ recent than `latestUpdate`, which is always set to the last logged action we
received. The `getUpdates` function returns a Promise to the `reconnect`
function above which called it. The `reconnect` function handles it in a `.then`
expression, where the success case passes each update retrieved to the
[`handleUpdate`](#updates) function, updating our state.
[`setSubEvent()`](#updates) function, updating our state.
Lastly, as well as handling channel connection errors, we also handle errors
such as poke nacks or failed scries by printing error messages added to the
`error` map by the `setErrorMsg` function. You could of course handle nacks,
`error` map by the `setErrorMsg()` function. You could of course handle nacks,
kicks, scry failures, etc differently than just printing an error, it depends on
the needs of your app.
@ -403,3 +437,11 @@ the needs of your app.
- [`@urbit/http-api` source
code](https://github.com/urbit/urbit/tree/master/pkg/npm/http-api) - The
source code for the `@urbit/http-api` NPM package.
- [Modern React
Tutorial](https://beta.reactjs.org/learn/tutorial-tic-tac-toe) - A tutorial
walking through the basics of writing a modern React application.
[`usestate()`]: https://beta.reactjs.org/reference/react/useState
[`useeffect()`]: https://beta.reactjs.org/reference/react/useEffect