anbaric 1.37.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/patterns/first-app.md +59 -10
- package/docs/patterns/ux-practices.md +30 -0
- package/package.json +5 -5
|
@@ -22,7 +22,10 @@ rejected`. The review step waits for a human.
|
|
|
22
22
|
|
|
23
23
|
```ts
|
|
24
24
|
// src/machine.ts
|
|
25
|
-
import {Action, Await, Code, PropertyDefinition, State, StateMachine, Terminal, Transition} from "anbaric";
|
|
25
|
+
import {Action, Await, Code, JobPersistenceFactory, PropertyDefinition, State, StateMachine, Terminal, Transition} from "anbaric";
|
|
26
|
+
|
|
27
|
+
// Shared with the web layer so pages can read a job's state without a reload.
|
|
28
|
+
const persistence = JobPersistenceFactory.instance();
|
|
26
29
|
|
|
27
30
|
const amount = new PropertyDefinition("amount");
|
|
28
31
|
amount.required = true;
|
|
@@ -56,9 +59,9 @@ const expenses = new StateMachine("expenses", [
|
|
|
56
59
|
]),
|
|
57
60
|
new Terminal("paid", Terminal.Outcome.SUCCESS),
|
|
58
61
|
new Terminal("rejected", Terminal.Outcome.FAILURE),
|
|
59
|
-
], "submitted", [amount, approved, paid]);
|
|
62
|
+
], "submitted", [amount, approved, paid], persistence);
|
|
60
63
|
|
|
61
|
-
export {expenses};
|
|
64
|
+
export {expenses, persistence};
|
|
62
65
|
```
|
|
63
66
|
|
|
64
67
|
Notice the shape: the `submitted` state parks on `review` until a manager sets
|
|
@@ -68,16 +71,53 @@ state pays automatically and ends at `paid`.
|
|
|
68
71
|
## 3. Serve a tiny UI
|
|
69
72
|
|
|
70
73
|
The review page reads the job id from the query string and resolves the `Await`
|
|
71
|
-
by updating the job.
|
|
74
|
+
by updating the job. Updating a job only *queues* the change, so the page
|
|
75
|
+
acknowledges the click, then **polls the job and updates in place** until it
|
|
76
|
+
settles - it never reloads to find out what happened. (Any HTML works; here's
|
|
77
|
+
the shape.)
|
|
72
78
|
|
|
73
79
|
```ts
|
|
74
80
|
// src/main.ts
|
|
75
81
|
import {createServer} from "node:http";
|
|
76
|
-
import {Human} from "anbaric";
|
|
77
|
-
import {expenses} from "./machine.js";
|
|
82
|
+
import {Human, serializeJob, SystemActor} from "anbaric";
|
|
83
|
+
import {expenses, persistence} from "./machine.js";
|
|
84
|
+
|
|
85
|
+
const reviewPage = (jobId : string) => `<!doctype html>
|
|
86
|
+
<p id="status">Loading…</p>
|
|
87
|
+
<button id="approve">Approve</button>
|
|
88
|
+
<script>
|
|
89
|
+
const jobId = ${JSON.stringify(jobId)};
|
|
90
|
+
const status = document.getElementById("status");
|
|
91
|
+
const approve = document.getElementById("approve");
|
|
92
|
+
let submitted = false;
|
|
93
|
+
let polls = 0;
|
|
94
|
+
// Done when the job ended, or is parked waiting for this person's decision.
|
|
95
|
+
const settled = (job) => job.status !== "ACTIVE" || (job.waitingFor && ! submitted);
|
|
96
|
+
|
|
97
|
+
const refresh = async () => {
|
|
98
|
+
const job = await (await fetch("status?job=" + jobId)).json(); // relative link!
|
|
99
|
+
status.textContent = job.status === "FAILED" ? "Failed - see the audit trail" : "State: " + job.state;
|
|
100
|
+
approve.disabled = job.state !== "submitted";
|
|
101
|
+
if (settled(job)) return;
|
|
102
|
+
if (polls++ < 60) setTimeout(refresh, 1000); // at most once a second
|
|
103
|
+
else status.textContent += " - still working, check back shortly";
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
approve.onclick = async () => {
|
|
107
|
+
approve.disabled = true;
|
|
108
|
+
submitted = true;
|
|
109
|
+
polls = 0;
|
|
110
|
+
status.textContent = "Approval submitted - processing…";
|
|
111
|
+
await fetch("review?job=" + jobId, { method: "POST" });
|
|
112
|
+
refresh();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
refresh();
|
|
116
|
+
</script>`;
|
|
78
117
|
|
|
79
118
|
createServer(async (req, res) => {
|
|
80
119
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
120
|
+
const jobId = url.searchParams.get("job") ?? "";
|
|
81
121
|
|
|
82
122
|
if (url.pathname === "/submit") {
|
|
83
123
|
const job = await expenses.startJob(new Map([["amount", 42]]));
|
|
@@ -85,19 +125,28 @@ createServer(async (req, res) => {
|
|
|
85
125
|
return;
|
|
86
126
|
}
|
|
87
127
|
|
|
128
|
+
if (url.pathname === "/status") {
|
|
129
|
+
const job = await persistence.retrieve(jobId, SystemActor.actor);
|
|
130
|
+
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(serializeJob(job)));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
88
134
|
if (url.pathname === "/review" && req.method === "POST") {
|
|
89
|
-
const jobId = url.searchParams.get("job")!;
|
|
90
135
|
const actor = await Human.fromSession(req); // the logged-in manager
|
|
91
136
|
await expenses.updateJob(jobId, new Map([["approved", true]]), actor);
|
|
92
|
-
res.writeHead(
|
|
137
|
+
res.writeHead(202).end(); // queued - the page polls for the outcome
|
|
93
138
|
return;
|
|
94
139
|
}
|
|
95
140
|
|
|
96
|
-
res.writeHead(200, { "content-type": "text/html" })
|
|
97
|
-
.end(`<form method="post" action="review?job=${url.searchParams.get("job") ?? ""}">…</form>`);
|
|
141
|
+
res.writeHead(200, { "content-type": "text/html" }).end(reviewPage(jobId));
|
|
98
142
|
}).listen(Number(process.env.PORT ?? 3000));
|
|
99
143
|
```
|
|
100
144
|
|
|
145
|
+
Only fall back to a plain form `POST` with a redirect back to the page when the
|
|
146
|
+
client can't run script; then the page shows the state as of the reload, and the
|
|
147
|
+
person has to refresh to see it move. See
|
|
148
|
+
[UX practices](ux-practices.md) for the rules on polling and feedback.
|
|
149
|
+
|
|
101
150
|
> Locally there's no signed session, so `Human.fromSession` will throw — for a
|
|
102
151
|
> local run, substitute `new Human("dev", "manager")`. Deployed, the real session
|
|
103
152
|
> is resolved. See [Serving a web UI](../features/serving-a-web-ui.md).
|
|
@@ -26,6 +26,12 @@ sent twice.
|
|
|
26
26
|
After acknowledging, watch the job so the page reflects reality rather than a
|
|
27
27
|
guess. Poll it and re-render as the state changes:
|
|
28
28
|
|
|
29
|
+
- **Update the page in place - don't reload it.** Expose the job's state as JSON
|
|
30
|
+
(read it from the machine's persistence and `serializeJob` it) and `fetch` that
|
|
31
|
+
from the page, updating the DOM as it changes. A full page refresh - a form
|
|
32
|
+
`POST` answered with a redirect, a meta refresh, `location.reload()` - is the
|
|
33
|
+
fallback for a client that cannot run script, not the default: it shows the
|
|
34
|
+
state as of the reload and leaves the person refreshing by hand to see it move.
|
|
29
35
|
- **Poll at most once per second.** Anything faster adds load without telling
|
|
30
36
|
the user anything new; a job that transitions immediately is still only
|
|
31
37
|
observable per processing pass.
|
|
@@ -46,6 +52,30 @@ A job whose action threw is `Job.Status.FAILED`, with the reason in its audit
|
|
|
46
52
|
trail. Show that the work stopped and why. Silently leaving the last-known state
|
|
47
53
|
on screen turns a failure into a mystery.
|
|
48
54
|
|
|
55
|
+
## Say it was built with Anbaric - quietly
|
|
56
|
+
|
|
57
|
+
Every app built on Anbaric carries a small **"Built with Anbaric"** line, at the
|
|
58
|
+
foot of the page or the bottom of the nav. It's an attribution, not a feature:
|
|
59
|
+
small type, a muted tint, the ident (its Asriel Ink is near enough black) at
|
|
60
|
+
text height, and nothing that draws the eye away from the app's own UI.
|
|
61
|
+
|
|
62
|
+
With the design system, it's one component:
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import { BuiltWithAnbaric } from '@anbaric/design-system/components/BuiltWithAnbaric'
|
|
66
|
+
|
|
67
|
+
<footer><BuiltWithAnbaric /></footer>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Without it, the same thing by hand - the ident lives at
|
|
71
|
+
`shared/assets/anbaric-ident.svg` in the design system:
|
|
72
|
+
|
|
73
|
+
```html
|
|
74
|
+
<a href="https://anbaric.ai" style="display:inline-flex;align-items:center;gap:.4rem;font-size:.75rem;opacity:.7;text-decoration:none;color:inherit">
|
|
75
|
+
<img src="anbaric-ident.svg" alt="" width="12" height="12"> Built with Anbaric
|
|
76
|
+
</a>
|
|
77
|
+
```
|
|
78
|
+
|
|
49
79
|
## Styling (optional)
|
|
50
80
|
|
|
51
81
|
If the user hasn't asked for a particular look, you may use the **Anbaric design
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.38.0",
|
|
4
4
|
"description": "Everything needed to write an Anbaric app: state machines, jobs, document and secret stores, local in-memory implementations and the Anbaric Cloud clients",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"prepublishOnly": "npm run build"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"anbaric-impl-cloud": "^1.
|
|
28
|
-
"anbaric-data-store": "^1.
|
|
29
|
-
"anbaric-state-machine": "^1.
|
|
30
|
-
"anbaric-tsapi": "^1.
|
|
27
|
+
"anbaric-impl-cloud": "^1.38.0",
|
|
28
|
+
"anbaric-data-store": "^1.38.0",
|
|
29
|
+
"anbaric-state-machine": "^1.38.0",
|
|
30
|
+
"anbaric-tsapi": "^1.38.0"
|
|
31
31
|
}
|
|
32
32
|
}
|