mirror of
https://github.com/urbit/developers.urbit.org.git
synced 2024-09-11 13:55:59 +03:00
app-school-ii: revise to use 'create-landscape-app' and new %journal source snippets; add notes on modern react
This commit is contained in:
parent
26b52f692b
commit
fe1b346493
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user