@wix/himalaya-cli 0.1464.0 → 0.1466.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/dist/cli.mjs +3 -2
- package/guides/designing-himi-screens.md +58 -3
- package/guides/dynamic-list-t5.descriptor.json +69 -23
- package/guides/dynamic-list.service.ts +60 -15
- package/guides/form-submit-t5.descriptor.json +28 -27
- package/guides/form-submit.service.ts +60 -2
- package/guides/getting-started.md +2 -1
- package/guides/himi-skill.md +2 -1
- package/guides/index.json +1 -0
- package/guides/recipe-dynamic-list.md +57 -22
- package/guides/recipe-form-submit.md +39 -7
- package/guides/recipe-four-state.md +178 -54
- package/guides/recipe-onboarding.md +2 -1
- package/guides/recipe-search.md +89 -12
- package/guides/recipe-voice-journal.md +15 -0
- package/guides/search-list-t5.descriptor.json +85 -24
- package/guides/search-list.service.ts +83 -21
- package/package.json +2 -2
|
@@ -60,9 +60,12 @@ interface Row { // FLAT strings/booleans only — see "Normalize" b
|
|
|
60
60
|
interface State {
|
|
61
61
|
status: "loading" | "ready" | "error";
|
|
62
62
|
items: Row[];
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
errorKind: "" | "offline" | "server" | "signIn" | "permission"; // classifyError(e), never the raw message
|
|
64
|
+
// hide* flags for hiddenPath, derived in shape() — see `himi docs recipe-four-state`
|
|
65
|
+
hideLoading: boolean; notEmpty: boolean;
|
|
66
|
+
hideOfflineError: boolean; hideServerError: boolean;
|
|
67
|
+
hideSignInError: boolean; hidePermissionError: boolean; hideRefreshError: boolean;
|
|
68
|
+
nextCursor: string; failedCursor: string; hideLoadMoreError: boolean; // §5 load-more
|
|
66
69
|
}
|
|
67
70
|
```
|
|
68
71
|
|
|
@@ -143,25 +146,46 @@ nesting to one level: a carousel of carousels is legal and unreadable.
|
|
|
143
146
|
- A binding substitutes only when it is the **entire prop value** — never mid-string
|
|
144
147
|
(`"Total: $item.price"` renders the raw token). Pre-compose strings in the worker.
|
|
145
148
|
|
|
146
|
-
## 4.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
## 4. Loading, error and retry
|
|
150
|
+
|
|
151
|
+
A list loads data, so it ships all four states — the full idiom is
|
|
152
|
+
`himi docs recipe-four-state`. For a list it comes down to this:
|
|
153
|
+
|
|
154
|
+
- **Loading** — a `Skeleton` shaped like the rows (`height` of one row, `count`
|
|
155
|
+
about one screen of them), gated by `hiddenPath: "$service:products.hideLoading"`.
|
|
156
|
+
It shows only while the first load runs with nothing on screen; a pull-to-refresh
|
|
157
|
+
keeps the rows and uses the list's own spinner.
|
|
158
|
+
- **Empty** — an `EmptyState` with a directive action, gated by `notEmpty`.
|
|
159
|
+
- **Error, nothing on screen** — one `EmptyState` per class `classifyError` can
|
|
160
|
+
return (offline, server, sign-in, permission), **no icon**, user-facing copy.
|
|
161
|
+
Offline and server get **Try again** → `tier5.products.retry`; sign-in gets
|
|
162
|
+
**Sign in**; permission gets no action. A list with no sign-in flow maps
|
|
163
|
+
`"signIn"` to `"permission"` — a visitor still gets a 401 or 403 (Bookings
|
|
164
|
+
`searchServices`, above) and a missing node paints a blank screen. The worker's
|
|
165
|
+
`catch` sets `errorKind` from `classifyError(e)` — never `String(e.message)`,
|
|
166
|
+
which shows the user a wire error.
|
|
167
|
+
- **Error, rows on screen** — a pull-to-refresh that fails offline or on the
|
|
168
|
+
server keeps the rows (`shape({ ...s, status: "error", errorKind })`, `items`
|
|
169
|
+
untouched) and shows a `Banner` with **Try again** above them, gated by
|
|
170
|
+
`hideRefreshError`. A sign-in or permission failure clears the rows instead.
|
|
171
|
+
A failed **load-more** has its own retry (§5).
|
|
151
172
|
|
|
152
173
|
```jsonc
|
|
153
|
-
{ "id": "loading",
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{ "id": "error
|
|
174
|
+
{ "id": "loading",
|
|
175
|
+
"body": { "package": "himalaya", "type": "Skeleton",
|
|
176
|
+
"props": { "height": 72, "cornerRadius": 12, "count": 6 } },
|
|
177
|
+
"modifiers": { "hiddenPath": "$service:products.hideLoading" } },
|
|
178
|
+
{ "id": "error.server",
|
|
179
|
+
"body": { "package": "himalaya", "type": "EmptyState",
|
|
180
|
+
"props": { "title": "Unable to load products",
|
|
181
|
+
"subtitle": "There was a technical issue on our end. Please wait and try again later.",
|
|
182
|
+
"action": { "actionId": "tier5.products.retry" }, "actionLabel": "Try again" } },
|
|
183
|
+
"modifiers": { "hiddenPath": "$service:products.hideServerError" } }
|
|
158
184
|
```
|
|
159
185
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
`
|
|
163
|
-
Design guidance for these states: `himi docs designing-himi-screens` §5, and the
|
|
164
|
-
full state-machine idiom: `himi docs recipe-four-state`.
|
|
186
|
+
(Richer screens gate whole sections with the same `hiddenPath`/`hiddenWhen`
|
|
187
|
+
modifiers — see `apps/himi-owner-lite`.) Design guidance for these states:
|
|
188
|
+
`himi docs designing-himi-screens` §5 ("Loading states", "Error states").
|
|
165
189
|
|
|
166
190
|
## 5. Refresh + load-more gestures (RFC 0008)
|
|
167
191
|
|
|
@@ -193,6 +217,16 @@ native decoders drop anything else without a word:
|
|
|
193
217
|
stashed cursor, **append de-duplicated** rows (`seen` by id), roll `nextCursor`
|
|
194
218
|
from the response (`pagingMetadata.cursors.next ?? ""`), and on failure set
|
|
195
219
|
`nextCursor: ""` but **keep the rows** — a broken page-2 must not blank page-1.
|
|
220
|
+
Stash the cursor that failed (`failedCursor`) and show a small "Couldn't load
|
|
221
|
+
more" row with **Try again** at the end of the list (gated by
|
|
222
|
+
`hideLoadMoreError`), whose action restores `nextCursor = failedCursor` and runs
|
|
223
|
+
`loadMore` again. Do not route it through the refresh `Banner`: that **Try
|
|
224
|
+
again** reloads page 1 and throws away the pages already shown.
|
|
225
|
+
- **Except when page 2 loses access.** A `signIn` or `permission` failure on
|
|
226
|
+
`loadMore` is not retryable and the rows already shown are no longer the
|
|
227
|
+
user's to see: clear them and go to the same sign-in / permission
|
|
228
|
+
`EmptyState` a refresh uses (`load()` in `himi docs recipe-four-state`). Keep
|
|
229
|
+
page 1 and offer the cursor retry only for `offline` and `server`.
|
|
196
230
|
- APIs that don't page (or v1 fallbacks without cursors) simply never set
|
|
197
231
|
`nextCursor` — the gesture props are safe to leave in place.
|
|
198
232
|
|
|
@@ -245,10 +279,11 @@ Mechanics and the prod caveat in full: `himi docs tier-5-add-a-screen`.
|
|
|
245
279
|
no longer needed; prefer the component, which stays accessible and themable.
|
|
246
280
|
- **Declare `wix_api`** in `logicBundle.declaredCapabilities` if the worker touches
|
|
247
281
|
the Wix transport — validate warns (errors pre-push), and the native gate enforces it.
|
|
248
|
-
- **`himi render` shows
|
|
249
|
-
`himi render` boots the worker so `$service:` bindings resolve
|
|
250
|
-
|
|
251
|
-
|
|
282
|
+
- **`himi render --no-mocks` shows the loading frame; `himi render --live` shows REAL
|
|
283
|
+
data.** `himi render` boots the worker so `$service:` bindings resolve. When the app
|
|
284
|
+
has recorded data (`dev/net-mocks.json`, CMS seed rows) it runs `load()` against that
|
|
285
|
+
data by default and shows rows; with none, or with `--no-mocks`, it shows the
|
|
286
|
+
*initial* state — your loading skeleton, not rows. To prove real-data binding **locally, with no VPN and no push**, add `--live`:
|
|
252
287
|
it mints an anonymous visitor token from the app's `clientId` and **awaits the worker's
|
|
253
288
|
real fetches** against live Wix, so the render shows actual rows and catches live-only
|
|
254
289
|
binding bugs (a mistyped `$item.<field>` an empty initial array can't disprove). Use
|
|
@@ -35,6 +35,7 @@ the app's `.agents/skills/himalaya/examples/` (written by `himi init` and `himi
|
|
|
35
35
|
],
|
|
36
36
|
"submitButton": { "id": "save", "body": { "package": "himalaya", "type": "Button",
|
|
37
37
|
"props": { "title": "Create post", "style": "primary", "fullWidth": true,
|
|
38
|
+
"disabledPath": "$service:postForm.cannotSave",
|
|
38
39
|
"action": { "actionId": "tier5.postForm.submit" } } } }
|
|
39
40
|
}
|
|
40
41
|
}
|
|
@@ -91,12 +92,16 @@ interface FormState {
|
|
|
91
92
|
status: "idle" | "saving" | "saved" | "error";
|
|
92
93
|
error: string; // "" unless failed → collapseWhenEmpty
|
|
93
94
|
savedMessage: string; // "" unless saved → collapseWhenEmpty
|
|
95
|
+
cannotSave: boolean; // true after a permission failure → disabledPath
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
const svc = defineService<FormState, {
|
|
97
99
|
submit: (s: FormState, values?: Record<string, unknown>) => Promise<FormState>;
|
|
98
100
|
}>("postForm", {
|
|
99
|
-
state: { title: "", body: "", status: "idle", error: "", savedMessage: "" },
|
|
101
|
+
state: { title: "", body: "", status: "idle", error: "", savedMessage: "", cannotSave: false },
|
|
102
|
+
// Outcome keys are per-attempt: a cold boot must not restore a stale error, or a submit
|
|
103
|
+
// disabled by a 403 the user has since been granted past.
|
|
104
|
+
notHydrated: ["status", "error", "savedMessage", "cannotSave"],
|
|
100
105
|
actions: {
|
|
101
106
|
submit: async (s, values) => {
|
|
102
107
|
const title = String(values?.title ?? "").trim();
|
|
@@ -106,17 +111,38 @@ const svc = defineService<FormState, {
|
|
|
106
111
|
await createDraftPost({ title, richContent: /* … */ } as WixDraftPostWrite);
|
|
107
112
|
return { ...s, title, body, status: "saved", error: "", savedMessage: "Draft created." };
|
|
108
113
|
} catch (e) {
|
|
109
|
-
|
|
114
|
+
// Keep what the user typed (echo it back into the prefill), and say what
|
|
115
|
+
// happened in their words — never e.message.
|
|
116
|
+
const kind = classifyError(e); // from himi docs recipe-four-state
|
|
117
|
+
return { ...s, title, body, status: "error", error: SAVE_FAILED[kind], savedMessage: "",
|
|
118
|
+
cannotSave: kind === "permission" }; // bound to the submit's disabledPath
|
|
110
119
|
}
|
|
111
120
|
},
|
|
112
121
|
},
|
|
113
122
|
});
|
|
114
123
|
export { svc };
|
|
124
|
+
|
|
125
|
+
// One sentence per class of `classifyError` (himi docs recipe-four-state).
|
|
126
|
+
const SAVE_FAILED: Record<string, string> = {
|
|
127
|
+
offline: "You're offline, so nothing was saved. Try again when you reconnect.",
|
|
128
|
+
server: "Couldn't save your changes. Try again.",
|
|
129
|
+
signIn: "Your session has ended. Sign in, then save again.",
|
|
130
|
+
permission: "You don't have permission to save this.",
|
|
131
|
+
};
|
|
115
132
|
```
|
|
116
133
|
|
|
117
|
-
- **
|
|
118
|
-
`
|
|
119
|
-
|
|
134
|
+
- **Field rules stay on the fields.** Required and format checks belong in the
|
|
135
|
+
`validation` block above: the renderer holds the submit and shows each message
|
|
136
|
+
beside its field. The worker's `if (!title)` is only a guard; a cross-field or
|
|
137
|
+
server answer about ONE field goes to that field through `text.errorPath`.
|
|
138
|
+
- **A failed save is an action error.** It shows near the submit button (the
|
|
139
|
+
`err` line below), in user-facing words picked by class, and it **keeps what the
|
|
140
|
+
user typed** — the catch returns `title`/`body` into the prefill so nothing is
|
|
141
|
+
lost. The submit button is the retry: there is no separate **Try again**.
|
|
142
|
+
A **sign-in** failure says to sign in and save again (the submit works once the
|
|
143
|
+
user is signed in). A **permission** failure says so and sets `cannotSave`,
|
|
144
|
+
which the submit button binds through `disabledPath`, so it offers no retry
|
|
145
|
+
that would fail the same way.
|
|
120
146
|
- The write goes through a `@himalaya/wix-<domain>` client (declare `wix_api`!).
|
|
121
147
|
**Wrap the entity envelope the API expects** (`{ draftPost: {...} }` handled by
|
|
122
148
|
the SDK; raw `wixApi.call` writes must wrap it themselves) — an unwrapped body
|
|
@@ -125,7 +151,9 @@ export { svc };
|
|
|
125
151
|
|
|
126
152
|
## 3. Feedback: outcome strings + a gated follow-up action
|
|
127
153
|
|
|
128
|
-
|
|
154
|
+
Directly below the `Form` — its submit button is the form's last element — the
|
|
155
|
+
standard outcome row, each line rendering only in its state, next to the action it
|
|
156
|
+
reports on. Bind the submit's `disabledPath` to `cannotSave`:
|
|
129
157
|
|
|
130
158
|
```jsonc
|
|
131
159
|
{ "id": "err", "…Text": { "text": "$service:postForm.error", "color": "danger",
|
|
@@ -163,12 +191,16 @@ shape (heading swap, `savedScreenId`, back-navigation).
|
|
|
163
191
|
"$service:<svc>.busy"` — `disabledPath` wants a bool, not the `status` string.
|
|
164
192
|
It only ever ADDS a reason to be disabled: a bound `false` never re-enables a
|
|
165
193
|
submit the form's own validation is holding.
|
|
194
|
+
- **An edit form loads data first.** Its `load` is a read like any other: a
|
|
195
|
+
`Skeleton` shaped like the fields while it runs, and a classified error state
|
|
196
|
+
with **Try again** if it fails (`himi docs recipe-four-state`). A create form
|
|
197
|
+
with nothing to prefill renders at once and needs no loading state.
|
|
166
198
|
- **`himi render` shows the initial (empty) form** — prefill bindings resolve to
|
|
167
199
|
initial state; a real submit needs the pushed preview or a device drive.
|
|
168
200
|
|
|
169
201
|
## See also
|
|
170
202
|
|
|
171
|
-
- `himi docs recipe-four-state` — the
|
|
203
|
+
- `himi docs recipe-four-state` — the loading, classified error and retry idiom this recipe leans on.
|
|
172
204
|
- `himi docs recipe-dynamic-list` — the list the created entity lands in.
|
|
173
205
|
- `himi components Form` / `FormField` / `TextField` / `Toggle` / `Picker`.
|
|
174
206
|
- `.agents/skills/himalaya/examples/form-submit.*` (written into the app by `himi init` / `himi agent-init`) — this recipe, runnable.
|
|
@@ -1,52 +1,117 @@
|
|
|
1
1
|
# Recipe: four screen states (loading / empty / error / ready)
|
|
2
2
|
|
|
3
|
+
**The rule every recipe follows:** a screen that loads or writes data shows a
|
|
4
|
+
**loading state**, a **classified error state**, and a **recovery action where the
|
|
5
|
+
user can fix the failure** — retry for an offline or a server failure, and for a failed
|
|
6
|
+
write; Sign in when the user is signed out; nothing for a permission failure, an empty result
|
|
7
|
+
or a field error, where the same request gives the same answer. This recipe is the shared
|
|
8
|
+
idiom; the other recipes link here for it. Design guidance for the states:
|
|
9
|
+
`himi docs designing-himi-screens` §5 ("Loading states", "Error states").
|
|
10
|
+
|
|
3
11
|
Every data-backed screen has four states, and Himi's idiom renders all of them
|
|
4
|
-
from **one flat layout** — no conditional trees, no separate loading screen.
|
|
5
|
-
|
|
6
|
-
the
|
|
7
|
-
|
|
8
|
-
bound to a worker boolean. The empty state is always an `EmptyState` node, gated
|
|
9
|
-
the same way.
|
|
12
|
+
from **one flat layout** — no conditional trees, no separate loading screen. Each
|
|
13
|
+
state is its own node, gated with the `hiddenPath` modifier bound to a worker
|
|
14
|
+
boolean, and the worker derives every flag from one `status` field. The empty state
|
|
15
|
+
and each error class are `EmptyState` nodes, gated the same way.
|
|
10
16
|
|
|
11
17
|
This is the corpus norm: of the in-repo worker bundles, ~3 in 4 model loading,
|
|
12
18
|
more than half model error, and the reference apps (`apps/himi-owner-lite`,
|
|
13
|
-
`apps/demo-app`, and Himi Blog) all ship this
|
|
19
|
+
`apps/demo-app`, and Himi Blog) all ship this shape. The
|
|
14
20
|
`examples/dynamic-list.*` and `examples/form-submit.*` pairs both demonstrate it.
|
|
15
21
|
|
|
16
|
-
## 1. One status field, derived
|
|
22
|
+
## 1. One status field, a classified error, derived flags
|
|
17
23
|
|
|
18
24
|
```ts
|
|
25
|
+
type ErrorKind = "" | "offline" | "server" | "signIn" | "permission";
|
|
26
|
+
|
|
19
27
|
interface State {
|
|
20
28
|
status: "loading" | "ready" | "error";
|
|
21
29
|
items: Row[];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
errorKind: ErrorKind; // why the last load failed; "" unless status is "error"
|
|
31
|
+
// hiddenPath hides a node when its flag is TRUE — name flags in the hide direction.
|
|
32
|
+
hideLoading: boolean; // the skeleton: shown only while loading with nothing on screen
|
|
33
|
+
notEmpty: boolean; // the EmptyState: shown only when ready with zero rows
|
|
34
|
+
hideOfflineError: boolean; // one EmptyState per error class …
|
|
35
|
+
hideServerError: boolean;
|
|
36
|
+
hideSignInError: boolean;
|
|
37
|
+
hidePermissionError: boolean;
|
|
38
|
+
hideRefreshError: boolean; // … and a Banner when a refresh fails over rows we kept
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
function shape(s: Partial<State>): State {
|
|
29
42
|
const status = s.status ?? "ready";
|
|
30
43
|
const items = s.items ?? [];
|
|
44
|
+
const errorKind: ErrorKind = status === "error" ? (s.errorKind || "server") : "";
|
|
45
|
+
const blank = items.length === 0; // nothing on screen to keep
|
|
31
46
|
return {
|
|
32
|
-
status, items,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
status, items, errorKind,
|
|
48
|
+
hideLoading: !(status === "loading" && blank),
|
|
49
|
+
notEmpty: !(status === "ready" && blank),
|
|
50
|
+
hideOfflineError: !(blank && errorKind === "offline"),
|
|
51
|
+
hideServerError: !(blank && errorKind === "server"),
|
|
52
|
+
hideSignInError: !(blank && errorKind === "signIn"),
|
|
53
|
+
hidePermissionError: !(blank && errorKind === "permission"),
|
|
54
|
+
// Only a failure a retry can fix gets the banner over kept rows.
|
|
55
|
+
hideRefreshError: !(!blank && (errorKind === "offline" || errorKind === "server")),
|
|
37
56
|
};
|
|
38
57
|
}
|
|
58
|
+
|
|
59
|
+
// The HTTP status rides in the message: `HTTP 503: …` on a device, `Wix 503 for …`
|
|
60
|
+
// under `himi test` / `himi render`.
|
|
61
|
+
const STATUS = /\b(?:HTTP|Wix) (\d{3})\b/;
|
|
62
|
+
// No answer at all — what a dropped connection says on every platform.
|
|
63
|
+
const TRANSPORT = /NSURLErrorDomain|(?:UnknownHost|Connect|SocketTimeout|NoRouteToHost|SSLHandshake)Exception|Failed to connect|Unable to resolve host|Connection (?:refused|reset)|Network is unreachable|Software caused connection abort|ECONNRESET|ETIMEDOUT|EPIPE|Broken pipe|stream was reset|unexpected end of stream|timed out|^timeout$|Failed to fetch|NetworkError|^(?:TypeError: )?Load failed$|net::ERR_|^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i;
|
|
64
|
+
// Android reports an unknown host as the bare hostname (`users.wix.com`) — hence the last form.
|
|
65
|
+
// No session on the device: the native bridge refuses before any request leaves.
|
|
66
|
+
// A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
|
|
67
|
+
// Android `site account is not the bound site …`.
|
|
68
|
+
const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site/i;
|
|
69
|
+
|
|
70
|
+
function classifyError(e: unknown): ErrorKind {
|
|
71
|
+
const msg = String((e as Error)?.message ?? e ?? "");
|
|
72
|
+
const code = String((e as { code?: unknown })?.code ?? "");
|
|
73
|
+
const status = Number(STATUS.exec(msg)?.[1] ?? 0);
|
|
74
|
+
if (status === 401) return "signIn"; // signed out (or the session expired)
|
|
75
|
+
if (status === 403) return "permission"; // signed in, without access
|
|
76
|
+
if (SIGNED_OUT.test(msg)) return "signIn";
|
|
77
|
+
if (code === "offline_write_blocked") return "offline"; // a write refused before it left
|
|
78
|
+
// A web call that never reached Wix arrives as
|
|
79
|
+
// `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
|
|
80
|
+
if (status && !msg.includes("webwix_direct_failed")) return "server";
|
|
81
|
+
if (TRANSPORT.test(msg)) return "offline";
|
|
82
|
+
return "server"; // anything else — a bug, a malformed row — is not the user's connection
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function load(s: State): Promise<State> {
|
|
86
|
+
try {
|
|
87
|
+
const items = (await fetchRows()).map(normalize);
|
|
88
|
+
return shape({ status: "ready", items });
|
|
89
|
+
} catch (e) {
|
|
90
|
+
const errorKind = classifyError(e);
|
|
91
|
+
// Keep the rows: a refresh that fails must not blank a screen that had content —
|
|
92
|
+
// unless access is gone, when the rows must not stay on screen either.
|
|
93
|
+
const lostAccess = errorKind === "signIn" || errorKind === "permission";
|
|
94
|
+
return shape({ ...s, items: lostAccess ? [] : s.items, status: "error", errorKind });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
39
97
|
```
|
|
40
98
|
|
|
41
|
-
Route **every** state change through `shape()` — set `status`
|
|
42
|
-
recompute the
|
|
43
|
-
end up loading-and-errored at once.
|
|
99
|
+
Route **every** state change through `shape()` — set `status`, `errorKind` and the
|
|
100
|
+
raw data, and let it recompute the flags. Hand-maintaining them per action is how
|
|
101
|
+
screens end up loading-and-errored at once.
|
|
102
|
+
|
|
103
|
+
**Never show the raw error.** `e.message` is for your logs; the user sees the copy
|
|
104
|
+
of the class it falls in. The classifier above is deliberately small: add a class
|
|
105
|
+
only when the screen shows the user something different for it.
|
|
44
106
|
|
|
45
|
-
## 2. The descriptor: one node per state
|
|
107
|
+
## 2. The descriptor: one node per state
|
|
46
108
|
|
|
47
109
|
```jsonc
|
|
48
|
-
{ "id": "loading",
|
|
49
|
-
|
|
110
|
+
{ "id": "loading",
|
|
111
|
+
"body": { "package": "himalaya", "type": "Skeleton",
|
|
112
|
+
"props": { "height": 72, "cornerRadius": 12, "count": 4 } }, // shaped like the rows
|
|
113
|
+
"modifiers": { "hiddenPath": "$service:products.hideLoading" } },
|
|
114
|
+
|
|
50
115
|
{ "id": "empty",
|
|
51
116
|
"body": { "package": "himalaya", "type": "EmptyState",
|
|
52
117
|
"props": { "icon": "bag", "title": "No products in this store yet",
|
|
@@ -54,62 +119,121 @@ end up loading-and-errored at once.
|
|
|
54
119
|
"action": { "actionId": "tier5.products.create" },
|
|
55
120
|
"actionLabel": "Add your first product" } },
|
|
56
121
|
"modifiers": { "hiddenPath": "$service:products.notEmpty" } },
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
122
|
+
|
|
123
|
+
// Errors: an EmptyState per class, with NO icon, user-facing copy, and an action
|
|
124
|
+
// only when the user can recover.
|
|
125
|
+
{ "id": "error.offline",
|
|
126
|
+
"body": { "package": "himalaya", "type": "EmptyState",
|
|
127
|
+
"props": { "title": "Reconnect to the internet",
|
|
128
|
+
"subtitle": "Connect to the internet, then try again.",
|
|
129
|
+
"action": { "actionId": "tier5.products.retry" },
|
|
130
|
+
"actionLabel": "Try again" } },
|
|
131
|
+
"modifiers": { "hiddenPath": "$service:products.hideOfflineError" } },
|
|
132
|
+
{ "id": "error.server",
|
|
133
|
+
"body": { "package": "himalaya", "type": "EmptyState",
|
|
134
|
+
"props": { "title": "Unable to load products",
|
|
135
|
+
"subtitle": "There was a technical issue on our end. Please wait and try again later.",
|
|
136
|
+
"action": { "actionId": "tier5.products.retry" },
|
|
137
|
+
"actionLabel": "Try again" } },
|
|
138
|
+
"modifiers": { "hiddenPath": "$service:products.hideServerError" } },
|
|
139
|
+
{ "id": "error.signIn",
|
|
140
|
+
"body": { "package": "himalaya", "type": "EmptyState",
|
|
141
|
+
"props": { "title": "Sign in to view products",
|
|
142
|
+
"subtitle": "Your session has ended.",
|
|
143
|
+
"action": { "actionId": "tier5.products.signIn" }, // a worker action that dispatches himi.wix.signInDirect
|
|
144
|
+
"actionLabel": "Sign in" } },
|
|
145
|
+
"modifiers": { "hiddenPath": "$service:products.hideSignInError" } },
|
|
146
|
+
{ "id": "error.permission", // no action: retrying gives the same answer
|
|
147
|
+
"body": { "package": "himalaya", "type": "EmptyState",
|
|
148
|
+
"props": { "title": "You need permission to view this screen",
|
|
149
|
+
"subtitle": "Ask the owner of this business for access." } },
|
|
150
|
+
"modifiers": { "hiddenPath": "$service:products.hidePermissionError" } },
|
|
151
|
+
|
|
152
|
+
// A refresh that failed over rows we kept: say so above the rows, keep the rows.
|
|
153
|
+
{ "id": "error.refresh",
|
|
154
|
+
"body": { "package": "himalaya", "type": "Banner",
|
|
155
|
+
"props": { "severity": "warning", "title": "Couldn't refresh",
|
|
156
|
+
"message": "Showing what was loaded before.",
|
|
157
|
+
"action": { "actionId": "tier5.products.retry" },
|
|
158
|
+
"actionLabel": "Try again" } },
|
|
159
|
+
"modifiers": { "hiddenPath": "$service:products.hideRefreshError" } }
|
|
64
160
|
```
|
|
65
161
|
|
|
66
|
-
- `collapseWhenEmpty` on a `Text` renders **nothing at all** (no blank line) when
|
|
67
|
-
the bound string is `""` — the whole state machine is "which string is non-empty".
|
|
68
162
|
- `hiddenPath` / `hiddenWhen` hide a node when the bound boolean is **true** —
|
|
69
|
-
name worker booleans in the *hide* direction (`
|
|
163
|
+
name worker booleans in the *hide* direction (`notEmpty`, `hideServerError`) so
|
|
70
164
|
the binding reads without negation.
|
|
71
165
|
- Ready state is simply the data itself (the list rows, the detail fields) — it
|
|
72
166
|
needs no dedicated marker.
|
|
73
|
-
- **
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
`
|
|
167
|
+
- **Sign in is a worker action.** A renderer cannot dispatch `himi.wix.*`, so the
|
|
168
|
+
sign-in `EmptyState` calls `tier5.products.signIn`, which the worker defines:
|
|
169
|
+
it dispatches `himi.wix.signInDirect` and then reloads
|
|
170
|
+
(`himi docs wix-login-integration`).
|
|
171
|
+
- **Loading is a `Skeleton` shaped like the loaded content** when you know the
|
|
172
|
+
layout — the same rows at about the same size. `StatefulSection` or
|
|
173
|
+
`ActivityIndicator` are fine where they fit better. A plain `"Loading…"` `Text`
|
|
174
|
+
bound to a worker string (`collapseWhenEmpty: true`) is the minimum, not the goal.
|
|
175
|
+
- **Use `EmptyState` for every empty state and every screen-level error.** It
|
|
176
|
+
draws one standard layout on every platform with the app's tokens. Only `title`
|
|
177
|
+
is required. `actionLabel` is required when you set `action`: there is no
|
|
178
|
+
default label, and `himi validate` reports a missing one. For an error, leave
|
|
179
|
+
`icon` out.
|
|
77
180
|
- Write the `EmptyState` title and subtitle as literal copy or `$t:` keys. Today,
|
|
78
|
-
iOS and Android do not resolve a `$service:` binding in those props
|
|
79
|
-
|
|
181
|
+
iOS and Android do not resolve a `$service:` binding in those props — which is
|
|
182
|
+
why each error class is its own node instead of one node with bound text.
|
|
183
|
+
- **A node for every class `classifyError` can return** — otherwise that failure
|
|
184
|
+
paints a blank screen. A screen with no sign-in flow still gets a 401 when a
|
|
185
|
+
session ends; map `"signIn"` to `"permission"` in its classifier rather than drop
|
|
186
|
+
the node.
|
|
187
|
+
- **When a planned `ErrorState` component ships,** swap the error `EmptyState`
|
|
188
|
+
nodes for it. The classes, the copy and the recovery rules stay as they are.
|
|
80
189
|
|
|
81
190
|
## 3. Which state when
|
|
82
191
|
|
|
83
|
-
| State
|
|
84
|
-
|
|
85
|
-
| loading | initial state
|
|
86
|
-
| error
|
|
87
|
-
|
|
|
88
|
-
|
|
|
192
|
+
| State | Set where | User affordance |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| loading | initial state (`status: "loading"`) | content-shaped skeleton; NO retry |
|
|
195
|
+
| error, nothing on screen | `catch` in the first load | the class's `EmptyState`: user-facing copy + a recovery action where one exists |
|
|
196
|
+
| error, rows on screen | `catch` in a refresh (offline or server) | rows stay; a `Banner` with **Try again** above them |
|
|
197
|
+
| sign-in or permission | `catch` in any load | rows go; the class's `EmptyState` (Sign in, or no action) |
|
|
198
|
+
| empty | derived: ready + 0 rows | say what *would* fill it, and how |
|
|
199
|
+
| ready | successful fetch | the content |
|
|
89
200
|
|
|
90
201
|
- **Transient write feedback** (saved/deleted/published toasts) is the same idiom
|
|
91
202
|
one more time: an `actionMessage`/`savedMessage` string, non-empty only right
|
|
92
|
-
after the write (see `himi docs recipe-form-submit`).
|
|
203
|
+
after the write (see `himi docs recipe-form-submit`). A **failed write** keeps
|
|
204
|
+
what the user entered and shows its error next to the submit button, which is
|
|
205
|
+
the retry.
|
|
93
206
|
- **Empty ≠ error.** A 200 with zero rows is empty; only a failed call is error.
|
|
94
207
|
Write empty copy that names the next step ("Add your first product") — see
|
|
95
208
|
`himi docs designing-himi-screens` §5 for the design guidance.
|
|
96
|
-
- **Retry re-runs `load()
|
|
97
|
-
|
|
209
|
+
- **Retry re-runs `load()`.** An action returns one state, so the error stays up
|
|
210
|
+
until the new result replaces it — rows, empty, or a fresh error. Do not clear
|
|
211
|
+
the rows to "show progress"; that is how a second failure blanks the screen.
|
|
98
212
|
|
|
99
213
|
## Gotchas
|
|
100
214
|
|
|
101
215
|
- **Initialize `status: "loading"` and kick `svc.load()` at the bottom of the
|
|
102
216
|
worker file** — a screen that boots `ready`+empty flashes the empty state before
|
|
103
217
|
data arrives.
|
|
104
|
-
- **
|
|
105
|
-
`
|
|
106
|
-
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
218
|
+
- **Keep failed-load rows.** On a *refresh* failure, keep the old rows and show
|
|
219
|
+
the `Banner` — never blank to a dead screen (set `status` and `errorKind`, keep
|
|
220
|
+
`items`). `load-more` is the same: a broken page 2 must not blank page 1 — and
|
|
221
|
+
its retry is its own (see `himi docs recipe-dynamic-list`), because the refresh
|
|
222
|
+
banner's **Try again** reloads page 1.
|
|
223
|
+
- **Never leave a bound display string `undefined`** — `collapseWhenEmpty`
|
|
224
|
+
collapses `""`; an undefined binding renders the literal path. `shape()` prevents
|
|
225
|
+
this.
|
|
226
|
+
- **`himi render` shows the loaded screen when the app has recorded data**
|
|
227
|
+
(`dev/net-mocks.json`, CMS seed rows). `himi render <screenId> --no-mocks` shows
|
|
228
|
+
the initial frame — the loading state — and
|
|
229
|
+
`--state '{"products":{"status":"error","errorKind":"offline","hideOfflineError":false,…}}'`
|
|
230
|
+
forces an error class without the network. A `dev/net-mocks.json` rule that
|
|
231
|
+
answers 401, 403 or 500 previews the sign-in, permission and server classes
|
|
232
|
+
through the real `classifyError`.
|
|
110
233
|
|
|
111
234
|
## See also
|
|
112
235
|
|
|
113
236
|
- `himi docs recipe-dynamic-list` — the list recipe this state machine wraps.
|
|
114
237
|
- `himi docs recipe-form-submit` — the write-outcome variant (saving/saved/error).
|
|
238
|
+
- `himi docs recipe-search` — the same states around a query.
|
|
115
239
|
- `himi docs designing-himi-screens` §5 — copywriting + design for the states.
|
|
@@ -57,7 +57,8 @@ const svc = defineService<State, {
|
|
|
57
57
|
|
|
58
58
|
- **Best-effort by design**: wrap every storage touch in `try/catch`. A missing
|
|
59
59
|
or failing store means the card shows again next launch — a mild repeat,
|
|
60
|
-
never an error state.
|
|
60
|
+
never an error state. The card reads a local flag and loads no remote data,
|
|
61
|
+
so it needs no loading state, no error state and no retry.
|
|
61
62
|
- Namespace the key (`<app>.<what>`) — the store is app-scoped but shared
|
|
62
63
|
across the app's workers.
|
|
63
64
|
|