Merge pull request #312 from sidnym-ladrut/i/310/modernize-react-examples

Modernize React Examples
This commit is contained in:
tinnus-napbus 2023-04-02 22:06:52 +12:00 committed by GitHub
commit 5ea088cc0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 618 additions and 534 deletions

View File

@ -10,175 +10,113 @@ 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.
This command will install the Urbit interface package (i.e. `@urbit/http-api`)
and all the other packages used by our React application. When building from
scratch with `create-landscape-app`, this includes a number of useful
development libraries that enable automatic refresh on file edits (i.e. `vite`
and `@vitejs/plugin-react-refresh`) and simple page styling (i.e.
`tailwindcss`). The remainder of this tutorial will focus primarily on how the
Urbit interface package is used to communicate with a live ship from within a
React application.
## Additional tweaks
## Basic app setup
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`:
With all the basics now in place, we can begin work on the app itself. For this
simple demonstration, we'll be working just with the `src/app.jsx` file, which
contains the rendering logic for our React application. Before we look at the
full front-end source for our journal app, let's first review the simpler
default code provided by `create-landscape-app` to cover some Urbit API and
React basics.
```json
"homepage": "/apps/journal/",
### Urbit API setup
First, let's open up `src/app.jsx` and look at the import statements at the top
of this file:
```javascript
import React, { useEffect, useState } from 'react';
import Urbit from '@urbit/http-api';
import { scryCharges } from '@urbit/api';
import { AppTile } from './components/AppTile';
```
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:
The first two of these statements are very common in Urbit React applications;
the first imports the React library and a few of its important functions (to be
covered in a moment) and the second imports the `Urbit` class, which will be
used subsequently to enable browser-to-ship communication.
```js
window.ship = "sampel-palnet";
Next, the code sets up the `Urbit` API object as a global variable, which
allows the browser-to-ship connection to be established *exactly once* when the
page is first being loaded:
```javascript
const api = new Urbit('', '', window.desk);
api.ship = window.ship;
```
`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>
```
## 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`:
```js
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";
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 DayPickerInput from "react-day-picker/DayPickerInput";
import endOfDay from "date-fns/endOfDay";
import startOfDay from "date-fns/startOfDay";
import { BottomScrollListener } from "react-bottom-scroll-listener";
```
Inside the existing `App` class:
```js
class App extends Component {
```
...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`.
```js
state = {
// .....
status: 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:
```js
componentDidMount() {
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();
};
```
The first thing we do is create a new instance of the `Urbit` class we imported
from `@urbit/http-api`, and save it to `window.urbit`. The `Urbit` class
constructor takes three arguments: `url`, `desk` and `code`, of which only `url`
The first statement creates a new instance of the `Urbit` class we imported
from `@urbit/http-api`, and saves it to the `api` variable. The `Urbit` class
constructor takes three arguments: `url`, `code`, and `desk`, of which only `url`
is mandatory.
- `url` is the URL of the ship we want to talk to. Since our React app will be
served by the ship, we can just leave it as an empty `""` string and let
served by the ship, we can just leave it as an empty `''` string and let
`Urbit` use root-relative paths.
- `desk` is only necessary if we want to run threads through Eyre, and since
we're not going to do that, we can exclude it.
- `code` is the web login code for authentication, but since the user will
already have logged in, we can also exclude that.
- `code` is the web login code for authentication. Since the user will already
have logged in, we can also leave it as an empty `''` string.
- `desk` is only necessary if we want to run threads through Eyre. This example
doesn't submit any such requests, but the `desk` is set anyway for
demonstration purposes.
Therefore, we call the class contructor with just the empty `url` string:
The second statement sets the ship name in our `Urbit` instance. Eyre requires
the 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 `api.ship` to this value.
```js
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:
```js
window.urbit.ship = window.ship;
```
Next, we set three callbacks: `onOpen`, `onRetry`, and `onError`. These
callbacks are fired when the state of our channel connection changes:
While not referenced in the `create-landscape-app` default code, the `Urbit`
class has three additional callbacks that can be set: `onOpen`, `onRetry`, and
`onError`. These callbacks are fired when the state of our channel connection
changes:
- `onOpen` is called when a connection is established.
- `onRetry` is called when a channel connection has been interrupted (such as by
@ -188,22 +126,103 @@ callbacks are fired when the state of our channel connection changes:
- `onError` is called with an `Error` message once all retries have failed, or
otherwise when a fatal error occurs.
We'll look at how we handle these cases in the next section. For now, we'll just
set the `status` entry in the state to either `"con"`, `"try"`, or `"err"` as
the case may be. Note that it's not mandatory to set these callbacks, but
leaving connection problems unhandled is usually a bad idea.
We'll look at how we can use these callbacks in the next section. Note that
it's not mandatory to set these callbacks, but leaving connection problems
unhandled is usually a bad idea.
The last thing we do is call:
### React app setup
```js
this.init();
Finally, let's take a quick look at the React rendering logic for our
application. React rendering occurs within components, which are defined either
as classes (e.g. `class A extends Component { /* ... */ }`) or functions (e.g.
`function A() { /* ... */ }`). While recent React versions support both styles,
the latter "modern" style is preferred and used by most Urbit React
applications.
Our code defines a few components, but we'll just focus on the primary
component for this tutorial; this component is defined as a functional
component named `App`:
```javascript
export function App() {
/* ... */
}
```
This function will fetch initial entries and subscribe for updates. We'll look
at it in the next section.
As is common for React components, the first thing we'll define in our `App`
component is its state. In React, modifying a component's state causes it to be
re-rendered, so state variables should be carefully chosen to constitute all
"display-affecting" values. In modern React, component state is defined using
the [`useState()`] hook, which returns a pair of `[stateVariable,
setStateVariableFunction]`. Since our default `create-landscape-app` code just
displays the list of apps installed on a ship, it only needs to store this list
as its state:
```javascript
const [apps, setApps] = useState();
```
With the state established, we now define the code responsible for populating
this state. The canonical way to grab data from an external service/system in
React is to use the [`useEffect()`] hook. This function takes two arguments:
(1) the callback function for loading the external data and (2) a list of all
state variables dependencies, which will cause re-invocations of the first
argument when modified. Our app just needs to load the list of apps on our ship
(called `charges`) once, so its [`useEffect()`] invocation is simple:
```javascript
useEffect(() => {
async function init() {
const charges = (await api.scry(scryCharges)).initial;
setApps(charges);
}
init();
}, []);
```
The last step is to return the HTML that will be used to render our component
in the browser. This HTML must adhere to the syntactic rules of
[JSX](https://en.wikipedia.org/wiki/JSX_(JavaScript)), which allow for greater
flexibility through extensions like embedded JavaScript (contained in curly
brace enclosures). Our component renders each app it found when scrying our
ship as a tile accompanied by its title and description:
```javascript {% mode="collapse" %}
return (
<main className="flex items-center justify-center min-h-screen">
<div className="max-w-md space-y-6 py-20">
<h1 className="text-3xl font-bold">Welcome to hut</h1>
<p>Here&apos;s your urbit&apos;s installed apps:</p>
{apps && (
<ul className="space-y-4">
{Object.entries(apps).map(([desk, app]) => (
<li key={desk} className="flex items-center space-x-3 text-sm leading-tight">
<AppTile {...app} />
<div className="flex-1 text-black">
<p>
<strong>{app.title || desk}</strong>
</p>
{app.info && <p>{app.info}</p>}
</div>
</li>
))}
</ul>
)}
</div>
</main>
);
```
With this brief primer complete, we'll take a closer look at our journal
application's front-end and how it utilizes the Urbit HTTP API in the next
section.
## Resources
- [React Tutorial](https://react.dev/learn/tutorial-tic-tac-toe) - A tutorial
walking through the basics of writing a modern React application.
- [HTTP API Guide](/guides/additional/http-api-guide) - Reference documentation for
`@urbit/http-api`.
@ -214,3 +233,7 @@ at it in the next section.
- [`@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.
[`usestate()`]: https://react.dev/reference/react/useState
[`useeffect()`]: https://react.dev/reference/react/useEffect

View File

@ -3,58 +3,72 @@ title = "7. React app logic"
weight = 8
+++
With the basic things setup, we can now go over the logic of our app. We'll just
focus on functions that are related to ship communications using the `Urbit`
object we previously setup, and ignore UI components and other helper functions.
Now that we've reviewed the basics of setting up an Urbit React app, we can
dive into the more complex logic that drives our [journal app's
front-end](https://github.com/urbit/docs-examples/tree/main/journal-app/ui).
We'll focus on the app's main component `App` (defined in
[`src/app.jsx`](https://github.com/urbit/docs-examples/tree/main/journal-app/ui/src/app.jsx))
and how it leverages functions related to ship communications using the `Urbit`
object. For more information on UI components and other helper functions, see
the [resources section](#resources).
## State
In the previous section we just mentioned the connection `status` field of our
state. Here's the full state of our App:
In the previous section, we introduced how React components use [`useState()`]
to declare state variables within components. The main `App` component in our
journal app contains a number of these statements to manage its many
constituents and sub-components:
```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.
## Initialize
The first thing our app does is call `init()`:
After defining its state, the next thing our `App` component does is define a
function called `init()`, which is one of the first functions called during its
bootstrapping process:
```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
entries; then, if that succeeded, it publishes this update with `setSubEvent()`
and `setLatestUpdate()` 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.
@ -63,13 +77,13 @@ errors later.
![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
before the oldest we currently have. We call this initially, and then each time
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 +107,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 +138,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 +152,121 @@ 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 (achieved by
including the subscription object `subEvent` as a re-invocation trigger in
[`useEffect()`]'s second argument). 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 +289,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 +332,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 +409,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,18 +426,21 @@ 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,
kicks, scry failures, etc differently than just printing an error, it depends on
the needs of your app.
`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.
![search failed screenshot](https://media.urbit.org/guides/core/app-school-full-stack-guide/search-failed.png)
## Resources
- [React Tutorial](https://react.dev/learn/tutorial-tic-tac-toe) - A tutorial
walking through the basics of writing a modern React application.
- [HTTP API Guide](/guides/additional/http-api-guide) - Reference documentation for
`@urbit/http-api`.
@ -403,3 +451,7 @@ 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.
[`usestate()`]: https://react.dev/reference/react/useState
[`useeffect()`]: https://react.dev/reference/react/useEffect

View File

@ -34,20 +34,20 @@ There's a handful of extra files we need in the root of our desk:
We only have one agent to start, so `desk.bill` is very simple:
```
``` {% copy=true %}
:~ %journal
==
```
Likewise, `sys.kelvin` just contains:
```
``` {% copy=true %}
[%zuse 417]
```
The `desk.docket-0` file is slightly more complicated:
```
``` {% copy=true %}
:~
title+'Journal'
info+'Dear diary...'
@ -107,24 +107,22 @@ Once created, we can mount it to the unix filesystem.
In the dojo of a fake ship:
```
> |merge %journal our %webterm
>=
> |mount %journal
>=
``` {% copy=true %}
|new-desk %journal
|mount %journal
```
Now we can browse to it in the unix terminal:
```sh
cd ~/zod/journal
```sh {% copy=true %}
cd /path/to/zod/journal
```
Currently it has the same files as the `%webterm` desk, so we need to delete
those:
```sh
rm -r .
```sh {% copy=true %}
rm -rI /path/to/zod/journal/*
```
Apart from the kernel and standard library, desks need to be totally
@ -134,28 +132,28 @@ For example, since our app contains a number of `.hoon` files, we need the
everything it needs is to copy in the "dev" versions of the `%base` and
`%garden` desks. To do this, we first clone the Urbit git repository:
```sh
```sh {% copy=true %}
git clone https://github.com/urbit/urbit.git urbit-git
```
If we navigate to the `pkg` directory in the cloned repo:
```sh
cd ~/urbit-git/pkg
```sh {% copy=true %}
cd /path/to/urbit-git/pkg
```
...we can combine the `base-dev` and `garden-dev` desks with the included
`symbolic-merge.sh` script:
```sh
```sh {% copy=true %}
./symbolic-merge.sh base-dev journal
./symbolic-merge.sh garden-dev journal
```
Now, we copy the contents of the new `journal` folder into our empty desk:
```sh
cp -rL journal/* ~/zod/journal/
```sh {% copy=true %}
cp -rL journal/* /path/to/zod/journal/
```
Note we've used the `L` flag to resolve symbolic links, because the dev-desks
@ -163,22 +161,25 @@ contain symlinks to files in the actual `arvo` and `garden` folders.
We can copy across all of our own files too:
```sh
cp -r ~/ourfiles/* ~/zod/journal/
```sh {% copy=true %}
cp -r /path/to/ourfiles/* /path/to/zod/journal/
```
Finally, in the dojo, we can commit the whole lot:
```
``` {% copy=true %}
|commit %journal
```
## Glob
The next step is to build our front-end and upload the files to our ship. In the
`journal-ui` folder containing our React app, we can run:
The next step is to build our front-end and upload the files to our ship. If
you haven't yet downloaded the journal front-end source files, you can grab
them from [their repository](https://github.com/urbit/docs-examples). In the
folder containing our React app (`journal-app/ui` relative to the repository
base directory), we can run:
```sh
```sh {% copy=true %}
npm run build
```
@ -186,7 +187,7 @@ This will create a `build` directory containing the compiled front-end files. To
upload it to our ship, we need to first install the `%journal` desk. In the
dojo:
```
``` {% copy=true %}
|install our %journal
```
@ -208,7 +209,7 @@ If we now return to the homescreen of our ship, we'll see our tile displayed, an
The last thing we need to do is publish our app, so other users can install it
from our ship. To do that, we just run the following command in the dojo:
```
``` {% copy=true %}
:treaty|publish %journal
```

View File

@ -28,7 +28,7 @@ has three folders inside:
1. `bare-desk`: just the hoon files created here without any dependencies.
2. `full-desk`: `bare-desk` plus all dependencies. Note some files are
symlinked, so if you're copying them you'll need to do `cp -rL`.
3. `react-frontend`: the React front-end files.
3. `ui`: the React front-end files.
Let's get started.
@ -53,7 +53,7 @@ curl -L https://urbit.org/install/linux-aarch64/latest | tar xzk --transform='s/
#### macOS (`x86_64`)
```shell {% copy=true %}
curl -L https://urbit.org/install/macos-x86_64/latest | tar xzk -s '/.*/urbit/'
curl -L https://urbit.org/install/macos-x86_64/latest | tar xzk -s '/.*/urbit/'
```
#### macOS (`aarch64`)
@ -1117,7 +1117,7 @@ in `hut/mar/hut/do.hoon` and `hut/mar/hut/did.hoon` respectively.
::
++ grow
|%
:: this mark is primarily used inbound from the
:: this mark is primarily used inbound from the
:: front-end, so we only need a simple %noun
:: conversion method here
::
@ -1333,248 +1333,256 @@ in `hut/mar/hut/do.hoon` and `hut/mar/hut/did.hoon` respectively.
Our back-end is complete, so we can now work on our React front-end. We'll just
look at the basic setup process here, but you can get the full React app by
cloning [this repo on Github](https://github.com/urbit/docs-examples) and run
`npm i` in `chat-app/react-frontend`. Additional commentary on the code is in
the [additional commentary](#additional-commentary) section below.
`npm i` in `chat-app/ui`. Additional commentary on the code is in the
[additional commentary](#additional-commentary) section below.
#### Basic setup process
When creating it from scratch, we can first run `create-react-app` like usual:
When creating it from scratch, first make sure you have Node.js installed on
your computer (you can download it from their
[website](https://nodejs.org/en/download)) and then run `create-landscape-app`:
```shell {% copy=true %}
npx create-react-app hut-ui
cd hut-ui
```shell
npx @urbit/create-landscape-app
✔ What should we call your application? … hut
✔ What URL do you use to access Urbit? … http://127.0.0.1:8080
```
To make talking to our ship easy, we'll install the `@urbit/http-api` module:
This will generate a React project in the `hut/ui` directory with all the
basic necessities for Urbit front-end development. Next, run the following
commands to install the project's dependencies:
```
npm i @urbit/http-api
```shell
cd hut/ui
npm i
```
`http-api` handles most of the tricky parts of communicating with our ship for
us, and has a simple set of methods for doing things like pokes, subscriptions,
receiving updates, etc.
The next thing we need to do is edit `package.json`. We'll change the name of
the app, and we'll also add an additional `"homepage"` entry. Front-ends are
serve at `/apps/<name>`, so we need to set that as the root for when we build
it:
```json
"name": "hut",
"homepage": "/apps/hut/",
```
Next, we need to edit `public/index.html` and add a script import to the
`<head>` section. `http-api` needs to know the name of our ship in order to talk
to it, so our ship serves a simple script at `/session.js` that just does
`window.ship = "sampel-palnet";`.
```html
<script src="/session.js"></script>
```
We can now open `src/App.js`, wipe its contents, and start writing our own app.
The first thing is to import the `Urbit` class from `@urbit/http-api`:
We can now open `src/app.jsx`, wipe its contents, and start writing our own
app. The first thing is to import the `Urbit` class from `@urbit/http-api`:
```javascript
import React, {Component} from "react";
import React, {useEffect, useState} from "react";
import Urbit from "@urbit/http-api";
// .....
```
In our App class, we'll create a new `Urbit` instance and tell it our ship name.
We'll also add some connection state callbacks. Our app is simple and will just
display the connection status in the top-right corner.
We'll create an `App` component that will create a new `Urbit` instance on load
to monitor our front-end's connection with our ship. Our app is simple and will
just display the connection status in the top-left corner:
```javascript
constructor(props) {
super(props);
window.urbit = new Urbit("");
window.urbit.ship = window.ship;
// ......
window.urbit.onOpen = () => this.setState({conn: "ok"});
window.urbit.onRetry = () => this.setState({conn: "try"});
window.urbit.onError = () => this.setState({conn: "err"});
// ......
};
export function App() {
const [status, setStatus] = useState("try");
useEffect(() => {
window.urbit = new Urbit("");
window.urbit.ship = window.ship;
window.urbit.onOpen = () => setStatus("con");
window.urbit.onRetry = () => setStatus("try");
window.urbit.onError = () => setStatus("err");
const subscription = window.urbit.subscribe({
app: "hut",
path: "/all",
event: (e) => console.log(e),
});
return () => window.urbit.unsubscribe(subscription);
}, []);
return (<h1>{status}</h1>);
}
```
```javascript
constructor(props) {
super(props);
window.urbit = new Urbit("");
window.urbit.ship = window.ship;
// ......
window.urbit.onOpen = () => this.setState({conn: "ok"});
window.urbit.onRetry = () => this.setState({conn: "try"});
window.urbit.onError = () => this.setState({conn: "err"});
// ......
};
```
After we've finished writing our React app, we can build it and view the
resulting files in the `dist` directory:
After we've finished writing our React app, we can build it:
```shell {% copy=true %}
```shell
npm run build
ls dist
```
#### Additional commentary
There are a fair few functions our front-end uses, so we'll just look at a
handful. The first is `doPoke`, which (as the name suggests) sends a poke to a
ship. It takes the poke in JSON form. It then calls the `poke` method of our
`Urbit` object to perform the poke.
There are a fair few functions in the
[complete front-end source for `%hut`](https://github.com/urbit/docs-examples);
we'll just look at a handful to cover the basics. The first is the `appPoke`
in `src/lib.js`, which (as the name suggests) sends a poke to a ship. It takes
the poke in JSON form and calls the `poke` method of our `Urbit` object to
perform the poke:
```javascript
doPoke = jon => {
window.urbit.poke({
export function appPoke(jon) {
return api.poke({
app: "hut",
mark: "hut-do",
json: jon,
})
};
});
}
```
Here's an example of a `%join`-type `act` in JSON form:
An example of sending a `poke` with a `%join`-type `act` in JSON form can be
found in the `src/components/SelectGid.jsx` source file:
```javascript
joinGid = () => {
const joinSelect = this.state.joinSelect
if (joinSelect === "def") return;
const [host, name] = joinSelect.split("/");
this.doPoke(
{"join": {
"gid" : {"host": host, "name": name},
"who" : this.our
}}
);
this.setState({joinSelect: "def"})
const handleJoin = () => {
if (joinSelect !== "def") {
const [host, name] = joinSelect.split("/");
appPoke({
"join": {
"gid" : {"host": host, "name": name},
"who" : OUR
}
});
}
};
```
Our front-end will subscribe to updates for all groups our `%hut` agent is
currently tracking. To do so, it calls the `subscribe` method of the `Urbit`
object with the `path` to subscribe to and an `event` callback to handle each
update it receives. Our agent publishes all updates on the local-only `/all`
path.
object (aliased to `api` in our example) with the `path` to subscribe to and an
`event` callback to handle each update it receives. Our agent publishes all
updates on the local-only `/all` path. Here's the source in the `src/app.jsx`
file:
```javascript
subscribe = () => {
window.urbit.subscribe({
app: "hut",
path: "/all",
event: this.handleUpdate
});
};
const subscription = api.subscribe({
app: "hut",
path: "/all",
event: setSubEvent,
});
```
Here's the `handleUpdate` function we gave as a callback. The update will be one
of our `hut-upd` types in JSON form, so we just switch on the type and handle it
as appropriate.
Notice that the above call to `subscribe` passes the `setSubEvent` function.
This is part of a common pattern for Urbit React applications wherein a state
variable is used to track new events and cause component re-rendering. The
broad outline for this workflow is as follows:
1. Create a component subscription event variable with:
```javascript
const [subEvent, setSubEvent] = useState();
```
2. Call the `subscribe` function, passing `setSubEvent` as the `event` keyword
argument:
```javascript
urbit.subscribe({ /* ... */, event: setSubEvent });
```
3. Create a subscription handler function that updates when new events are
available with:
```javascript
useEffect(() => {/* handler goes here */}, [subEvent]);
```
The source for the final `useEffect` portion of this workflow (found in the
`src/app.jsx` file) can be found below:
```javascript {% mode="collapse" %}
handleUpdate = upd => {
const {huts, msgJar, joined, currentGid, currentHut} = this.state;
if ("initAll" in upd) {
upd.initAll.huts.forEach(obj =>
huts.set(this.gidToStr(obj.gid), new Set(obj.names))
);
this.setState({
huts: huts,
msgJar: new Map(
upd.initAll.msgJar.map(obj => [this.hutToStr(obj.hut), obj.msgs])
),
joined: new Map(
upd.initAll.joined.map(obj =>
[this.gidToStr(obj.gid), new Set(obj.ppl)]
)
)
})
} else if ("init" in upd) {
upd.init.msgJar.forEach(obj =>
msgJar.set(this.hutToStr(obj.hut), obj.msgs)
);
this.setState({
msgJar: msgJar,
huts: huts.set(
this.gidToStr(upd.init.huts[0].gid),
new Set(upd.init.huts[0].names)
),
joined: joined.set(
this.gidToStr(upd.init.joined[0].gid),
new Set(upd.init.joined[0].ppl)
)
})
} else if ("new" in upd) {
const gidStr = this.gidToStr(upd.new.hut.gid);
const hutStr = this.hutToStr(upd.new.hut);
(huts.has(gidStr))
? huts.get(gidStr).add(upd.new.hut.name)
: huts.set(gidStr, new Set(upd.new.hut.name));
this.setState({
huts: huts,
msgJar: msgJar.set(hutStr, upd.new.msgs)
})
} else if ("post" in upd) {
const hutStr = this.hutToStr(upd.post.hut);
(msgJar.has(hutStr))
? msgJar.get(hutStr).push(upd.post.msg)
: msgJar.set(hutStr, [upd.post.msg]);
this.setState(
{msgJar: msgJar},
() => {
(hutStr === this.state.currentHut)
&& this.scrollToBottom();
useEffect(() => {
const updateFuns = {
"initAll": (update) => {
update.huts.forEach(obj =>
huts.set(gidToStr(obj.gid), new Set(obj.names))
);
setHuts(new Map(huts));
setChatContents(new Map(
update.msgJar.map(o => [hutToStr(o.hut), o.msgs])
));
setChatMembers(new Map(
update.joined.map(o => [gidToStr(o.gid), new Set(o.ppl)])
));
}, "init": (update) => {
setChatContents(new Map(update.msgJar.reduce(
(a, n) => a.set(hutToStr(n.hut), n.msgs)
, chatContents)));
setHuts(new Map(huts.set(
gidToStr(update.huts[0].gid),
new Set(update.huts[0].names)
)));
setChatMembers(new Map(chatMembers.set(
gidToStr(update.joined[0].gid),
new Set(update.joined[0].ppl)
)));
}, "new": (update) => {
const gidStr = gidToStr(update.hut.gid);
const hutStr = hutToStr(update.hut);
if (huts.has(gidStr)) {
huts.get(gidStr).add(update.hut.name);
} else {
huts.set(gidStr, new Set(update.hut.name));
}
)
} else if ("join" in upd) {
const gidStr = this.gidToStr(upd.join.gid);
(joined.has(gidStr))
? joined.get(gidStr).add(upd.join.who)
: joined.set(gidStr, new Set([upd.join.who]));
this.setState({joined: joined})
} else if ("quit" in upd) {
const gidStr = this.gidToStr(upd.quit.gid);
if ("~" + window.ship === upd.quit.who) {
(huts.has(gidStr)) &&
huts.get(gidStr).forEach(name =>
msgJar.delete(gidStr + "/" + name)
setHuts(new Map(huts));
setChatMembers(new Map(chatMembers.set(hutStr, update.msgs)));
}, "post": (update) => {
const newHut = hutToStr(update.hut);
if (chatContents.has(newHut)) {
chatContents.set(newHut, [...chatContents.get(newHut), update.msg]);
} else {
chatContents.set(newHut, [update.msg]);
}
setChatContents(new Map(chatContents));
}, "join": (update) => {
const gidStr = gidToStr(update.gid);
if (chatMembers.has(gidStr)) {
chatMembers.get(gidStr).add(update.who)
} else {
chatMembers.set(gidStr, new Set([update.who]));
}
setChatMembers(new Map(chatMembers));
setJoinSelect("def");
}, "quit": (update) => {
const gidStr = gidToStr(update.gid);
if (update.who === OUR) {
huts.delete(gidStr);
chatMembers.delete(gidStr);
if(huts.has(gidStr)) {
huts.get(gidStr).forEach(name =>
chatContents.delete(gidStr + "/" + name)
);
}
setHuts(new Map(huts));
setChatMembers(new Map(chatMembers));
setChatContents(new Map(chatContents));
setCurrGid((currGid === gidStr) ? null : currGid);
setCurrHut((currHut === null)
? null
: (`${currHut.split("/")[0]}/${currHut.split("/")[1]}` === gidStr)
? null
: currHut
);
huts.delete(gidStr);
joined.delete(gidStr);
this.setState({
msgJar: msgJar,
huts: huts,
joined: joined,
currentGid: (currentGid === gidStr)
? null : currentGid,
currentHut: (currentHut === null) ? null :
(
currentHut.split("/")[0] + "/" + currentHut.split("/")[1]
=== gidStr
)
? null : currentHut,
make: (currentGid === gidStr) ? "" : this.state.make
})
} else {
(joined.has(gidStr)) &&
joined.get(gidStr).delete(upd.quit.who);
this.setState({joined: joined})
}
} else if ("del" in upd) {
const gidStr = this.gidToStr(upd.del.hut.gid);
const hutStr = this.hutToStr(upd.del.hut);
(huts.has(gidStr)) &&
huts.get(gidStr).delete(upd.del.hut.name);
msgJar.delete(hutStr);
this.setState({
huts: huts,
msgJar: msgJar,
currentHut: (currentHut === hutStr) ? null : currentHut
})
setViewSelect("def");
setHutInput((currGid === gidStr) ? "" : hutInput);
} else {
if (chatMembers.has(gidStr)) {
chatMembers.get(gidStr).delete(update.who);
}
setChatMembers(new Map(chatMembers));
}
}, "del": (update) => {
const gidStr = gidToStr(update.hut.gid);
const hutStr = hutToStr(update.hut);
if (huts.has(gidStr)) {
huts.get(gidStr).delete(update.hut.name);
}
chatContents.delete(hutStr);
setHuts(new Map(huts));
setChatContents(new Map(chatContents));
setCurrHut((currHut === hutStr) ? null : currHut);
},
};
const eventTypes = Object.keys(subEvent);
if (eventTypes.length > 0) {
const eventType = eventTypes[0];
updateFuns[eventType](subEvent[eventType]);
}
};
}, [subEvent]);
```
## Desk config
@ -1587,7 +1595,7 @@ this by adding a `sys.kelvin` file to the root of our `hut` directory:
```shell {% copy=true %}
cd hut
echo "[%zuse 417]" > sys.kelvin
echo "[%zuse 415]" > sys.kelvin
```
We also need to specify which agents to start when our desk is installed. We do
@ -1644,7 +1652,7 @@ delete those files and copy in our own instead. In the normal shell, do the
following:
```shell {% copy=true %}
rm -r dev-comet/hut/*
rm -rI dev-comet/hut/*
cp -r hut/* dev-comet/hut/
```