@wix/himalaya-cli 0.1465.0 → 0.1467.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 +24 -7
- 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/qa-builds.md +78 -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
|
@@ -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
|
|
package/guides/recipe-search.md
CHANGED
|
@@ -64,9 +64,9 @@ not say why there are no rows.
|
|
|
64
64
|
| Initial, no query | Search prompt plus useful recent queries, categories, or suggestions. Avoid presenting this as a failed search. |
|
|
65
65
|
| Loading | First load: a skeleton or progress state. Refresh: keep current results visible and show a compact progress indicator when possible. |
|
|
66
66
|
| Results | Matching rows, result count, and the available sort/filter controls. |
|
|
67
|
-
| No matches |
|
|
67
|
+
| No matches | An `EmptyState` explaining that the current query or filters found nothing; offer a way to adjust them, and Clear only where the field is controlled (the native slot) — the inline `SearchField` is uncontrolled, so Clear there empties the query but not the field. |
|
|
68
68
|
| Empty catalog | Explain that the catalog itself has no items yet, independent of the query; provide a useful browse/back action. |
|
|
69
|
-
| Error |
|
|
69
|
+
| Error | Classified as in `himi docs recipe-four-state`: an icon-less `EmptyState` per class with user-facing copy, Try again only for offline and server, Sign in for sign-in, no action for permission. Retain the query and controls, and keep it distinct from no matches. |
|
|
70
70
|
|
|
71
71
|
### Native slot and old-client fallback
|
|
72
72
|
|
|
@@ -170,7 +170,12 @@ interface PostsState {
|
|
|
170
170
|
all: PostRow[]; // the full fetched set — NOT bound by the descriptor
|
|
171
171
|
items: PostRow[]; // ← itemsPath binds here; always derived from all + query
|
|
172
172
|
query: string;
|
|
173
|
-
|
|
173
|
+
hideNoPosts: boolean; // the EmptyState for "loaded, and there is nothing at all"
|
|
174
|
+
hideNoMatches: boolean; // the EmptyState for "there are posts, none match the query"
|
|
175
|
+
errorKind: "" | "offline" | "server" | "signIn" | "permission"; // classifyError(e)
|
|
176
|
+
hideLoading: boolean; hideOfflineError: boolean; hideServerError: boolean;
|
|
177
|
+
hideSignInError: boolean; hidePermissionError: boolean;
|
|
178
|
+
hideRefreshError: boolean; // hiddenPath flags, as in recipe-four-state
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
function visible(all: PostRow[], query: string): PostRow[] {
|
|
@@ -186,31 +191,103 @@ function queryFrom(arg: unknown): string {
|
|
|
186
191
|
return String(a?.value ?? a?.text ?? a?.query ?? "");
|
|
187
192
|
}
|
|
188
193
|
|
|
194
|
+
// Actions are not serialized: the user can type while load() is in flight, and the
|
|
195
|
+
// action that finishes last writes the whole state. So the latest query lives outside
|
|
196
|
+
// the state, and every fetch applies it when it returns.
|
|
197
|
+
let currentQuery = "";
|
|
198
|
+
|
|
199
|
+
// The SearchField is uncontrolled: it opens empty, so the query must start over too.
|
|
200
|
+
// `load` (opening the screen) resets it; a query typed while that fetch is in flight
|
|
201
|
+
// still wins. `retry` keeps it.
|
|
202
|
+
load: async (s) => { currentQuery = ""; return loadList(s); },
|
|
203
|
+
|
|
204
|
+
// After defineService: a cold boot restores the persisted `query` AND the `items` and
|
|
205
|
+
// empty flags derived from it. Re-derive them for an empty query before anything paints.
|
|
206
|
+
const booted = (svc as unknown as { getState?: () => Record<string, unknown> | undefined }).getState?.();
|
|
207
|
+
if (booted && typeof booted === "object") {
|
|
208
|
+
Object.assign(booted, shape({ ...(booted as Partial<PostsState>), query: "", items: undefined }));
|
|
209
|
+
}
|
|
210
|
+
|
|
189
211
|
// actions:
|
|
190
212
|
search: (s, arg) => {
|
|
191
213
|
const query = queryFrom(arg);
|
|
214
|
+
currentQuery = query;
|
|
192
215
|
const items = visible(s.all, query);
|
|
193
|
-
return shape({ ...s, query, items }); // shape() recomputes
|
|
216
|
+
return shape({ ...s, query, items }); // shape() recomputes the empty flags from all+items+query
|
|
194
217
|
},
|
|
195
|
-
clear: (s) => shape({ ...s, query: "", items: s.all }),
|
|
218
|
+
clear: (s) => { currentQuery = ""; return shape({ ...s, query: "", items: s.all }); },
|
|
196
219
|
```
|
|
197
220
|
|
|
198
|
-
- `load()` fetches once, fills `all`, and sets `items = visible(all,
|
|
221
|
+
- `load()` fetches once, fills `all`, and sets `items = visible(all, currentQuery)`.
|
|
222
|
+
On a `signIn` or `permission` failure it clears **`all` as well as `items`** —
|
|
223
|
+
`search`, `clear` and `retry` rebuild rows from `all`, so rows left there come
|
|
224
|
+
back on the next keystroke.
|
|
199
225
|
- The shipped Blog v3 `WixPost` type includes `categoryIds` and both Wix-image and
|
|
200
226
|
embed-thumbnail media projections. Normalize them without a local cast:
|
|
201
227
|
`coverUrl: post.media?.wixMedia?.image?.url ?? post.media?.embedMedia?.thumbnail?.url ?? ""`
|
|
202
228
|
and `categoryIds: post.categoryIds ?? []`.
|
|
203
|
-
-
|
|
204
|
-
|
|
205
|
-
`
|
|
229
|
+
- **Two empty states, both `EmptyState`**: "no data at all" and "no matches" are
|
|
230
|
+
different screens. Search's `shape()` takes four-state's flags with one change:
|
|
231
|
+
`blank` is `all.length === 0`, not `items.length === 0` — `items` is the filtered
|
|
232
|
+
view, so a query with no matches over rows kept after a failed refresh must not
|
|
233
|
+
read as "nothing loaded" and show the full-screen error. In `shape()`, wherever
|
|
234
|
+
items change, recompute
|
|
235
|
+
`hideNoPosts = !(status === "ready" && all.length === 0)` and
|
|
236
|
+
`hideNoMatches = !(all.length > 0 && items.length === 0)` — no `status` check,
|
|
237
|
+
because the user can still search the rows kept after a failed refresh. Their
|
|
238
|
+
copy is static — an `EmptyState` cannot bind its text on native — and the
|
|
239
|
+
no-matches one has no action: the `SearchField` is uncontrolled, so a "Clear
|
|
240
|
+
search" button would empty the worker's query while the field still shows it:
|
|
241
|
+
|
|
242
|
+
```jsonc
|
|
243
|
+
{ "id": "empty.noPosts",
|
|
244
|
+
"body": { "package": "himalaya", "type": "EmptyState",
|
|
245
|
+
"props": { "icon": "inbox", "title": "No posts yet",
|
|
246
|
+
"subtitle": "New posts will show up here." } },
|
|
247
|
+
"modifiers": { "hiddenPath": "$service:posts.hideNoPosts" } },
|
|
248
|
+
{ "id": "empty.noMatches",
|
|
249
|
+
"body": { "package": "himalaya", "type": "EmptyState",
|
|
250
|
+
"props": { "icon": "search", "title": "No posts match your search",
|
|
251
|
+
"subtitle": "Try a different word." } },
|
|
252
|
+
"modifiers": { "hiddenPath": "$service:posts.hideNoMatches" } }
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## 3. Loading, error and retry
|
|
256
|
+
|
|
257
|
+
A searchable list loads data, so it carries the four states of
|
|
258
|
+
`himi docs recipe-four-state`:
|
|
259
|
+
|
|
260
|
+
- **Loading** — a `Skeleton` shaped like the result rows, gated by `hideLoading`,
|
|
261
|
+
while the first `load()` runs. The search field is usable at once.
|
|
262
|
+
- **Error, nothing loaded** — one `EmptyState` per class `classifyError` can
|
|
263
|
+
return, **no icon**, user-facing copy; offline and server get **Try again** →
|
|
264
|
+
`tier5.posts.retry`, sign-in gets **Sign in**, permission gets no action. The
|
|
265
|
+
`catch` sets `errorKind` from `classifyError(e)`; it never shows `e.message`.
|
|
266
|
+
- **Retry keeps the query.** `retry` re-runs `load()` and then re-applies
|
|
267
|
+
`currentQuery` — not `s.query`, which is the query from when the retry
|
|
268
|
+
STARTED — so what the user typed during the fetch is not lost.
|
|
269
|
+
- **No matches is not an error.** A successful search with zero results shows the
|
|
270
|
+
no-matches `EmptyState` ("No posts match your search"), with no action and no retry.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
retry: async (s) => {
|
|
274
|
+
const next = await load(s); // classified on failure
|
|
275
|
+
// Lost access clears the rows; any other failure keeps them for the Banner.
|
|
276
|
+
const lostAccess = next.errorKind === "signIn" || next.errorKind === "permission";
|
|
277
|
+
const all = lostAccess ? [] : next.all;
|
|
278
|
+
return shape({ ...next, all, query: currentQuery, items: visible(all, currentQuery) });
|
|
279
|
+
},
|
|
280
|
+
```
|
|
206
281
|
|
|
207
|
-
##
|
|
282
|
+
## 4. Server-side search (large datasets)
|
|
208
283
|
|
|
209
284
|
In-worker filtering is right up to a few hundred rows. Past that, make `search`
|
|
210
285
|
re-query the API with the term (most Wix domain query endpoints take a filter,
|
|
211
286
|
e.g. blog `queryPosts` — check `himi sdk wix-<domain>`), keep `status: "loading"`
|
|
212
287
|
during the round-trip, and debounce higher (300–400ms). The descriptor does not
|
|
213
|
-
change — only the action's body.
|
|
288
|
+
change — only the action's body. A failed server-side search **keeps the query and
|
|
289
|
+
the results already on screen** and shows the refresh `Banner` with **Try again**,
|
|
290
|
+
which runs the same query again.
|
|
214
291
|
|
|
215
292
|
## Gotchas
|
|
216
293
|
|
|
@@ -231,6 +308,6 @@ change — only the action's body.
|
|
|
231
308
|
## See also
|
|
232
309
|
|
|
233
310
|
- `himi docs recipe-dynamic-list` — the list this recipe filters.
|
|
234
|
-
- `himi docs recipe-four-state` — the
|
|
311
|
+
- `himi docs recipe-four-state` — the loading, classified error and retry idiom.
|
|
235
312
|
- `himi components SearchField` / `FilterChips` / `Tabs`.
|
|
236
313
|
- `.agents/skills/himalaya/examples/search-list.*` (written into the app by `himi init` / `himi agent-init`) — this recipe, runnable.
|
|
@@ -36,6 +36,21 @@ export async function finishVoiceJournal(sessionId: string) {
|
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
**States.** Recording reads no remote data, so the journal screen needs no network
|
|
40
|
+
loading or error states — but it does have states the user must see:
|
|
41
|
+
|
|
42
|
+
- **Unavailable** (`status: "unavailable"` — no microphone, permission denied, or a
|
|
43
|
+
host without capture): an icon-less `EmptyState` that says recording is not
|
|
44
|
+
available and gives the next step the platform offers (for example, how to allow
|
|
45
|
+
the microphone). No **Try again**: the same call fails the same way until the
|
|
46
|
+
user changes something.
|
|
47
|
+
- **Processing** (transcription, diarization): a progress or a placeholder line on
|
|
48
|
+
the entry, never a blocking spinner; a processing failure keeps the audio and
|
|
49
|
+
shows the entry without a transcript.
|
|
50
|
+
- **Saved entries list**: reading `local-db` is local and quick, so it needs no
|
|
51
|
+
skeleton; an empty journal is an `EmptyState` with a "Record your first entry"
|
|
52
|
+
action.
|
|
53
|
+
|
|
39
54
|
The SDUI layer can use the existing list, text, button, progress, audio/media, and state-binding
|
|
40
55
|
components for recording, transcript detail, search, deferred processing, summaries, categories,
|
|
41
56
|
delete, and export. Generate summaries and structured categories with `@himalaya/on-device-ai`
|
|
@@ -33,54 +33,115 @@
|
|
|
33
33
|
"id": "loading",
|
|
34
34
|
"body": {
|
|
35
35
|
"package": "himalaya",
|
|
36
|
-
"type": "
|
|
36
|
+
"type": "Skeleton",
|
|
37
37
|
"props": {
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"collapseWhenEmpty": true
|
|
38
|
+
"height": 56,
|
|
39
|
+
"cornerRadius": 12,
|
|
40
|
+
"count": 5
|
|
42
41
|
}
|
|
42
|
+
},
|
|
43
|
+
"modifiers": {
|
|
44
|
+
"hiddenPath": "$service:posts.hideLoading"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "empty.no_posts",
|
|
49
|
+
"body": {
|
|
50
|
+
"package": "himalaya",
|
|
51
|
+
"type": "EmptyState",
|
|
52
|
+
"props": {
|
|
53
|
+
"icon": "doc",
|
|
54
|
+
"title": "No posts yet",
|
|
55
|
+
"subtitle": "Posts you publish show up here."
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"modifiers": {
|
|
59
|
+
"hiddenPath": "$service:posts.hideNoPosts"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "empty.no_matches",
|
|
64
|
+
"body": {
|
|
65
|
+
"package": "himalaya",
|
|
66
|
+
"type": "EmptyState",
|
|
67
|
+
"props": {
|
|
68
|
+
"icon": "search",
|
|
69
|
+
"title": "No posts match your search",
|
|
70
|
+
"subtitle": "Try a different word."
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"modifiers": {
|
|
74
|
+
"hiddenPath": "$service:posts.hideNoMatches"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "error.offline",
|
|
79
|
+
"body": {
|
|
80
|
+
"package": "himalaya",
|
|
81
|
+
"type": "EmptyState",
|
|
82
|
+
"props": {
|
|
83
|
+
"title": "Reconnect to the internet",
|
|
84
|
+
"subtitle": "Connect to the internet, then try again.",
|
|
85
|
+
"action": {
|
|
86
|
+
"actionId": "tier5.posts.retry"
|
|
87
|
+
},
|
|
88
|
+
"actionLabel": "Try again"
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"modifiers": {
|
|
92
|
+
"hiddenPath": "$service:posts.hideOfflineError"
|
|
43
93
|
}
|
|
44
94
|
},
|
|
45
95
|
{
|
|
46
|
-
"id": "
|
|
96
|
+
"id": "error.server",
|
|
47
97
|
"body": {
|
|
48
98
|
"package": "himalaya",
|
|
49
|
-
"type": "
|
|
99
|
+
"type": "EmptyState",
|
|
50
100
|
"props": {
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
101
|
+
"title": "Unable to load posts",
|
|
102
|
+
"subtitle": "There was a technical issue on our end. Please wait and try again later.",
|
|
103
|
+
"action": {
|
|
104
|
+
"actionId": "tier5.posts.retry"
|
|
105
|
+
},
|
|
106
|
+
"actionLabel": "Try again"
|
|
55
107
|
}
|
|
108
|
+
},
|
|
109
|
+
"modifiers": {
|
|
110
|
+
"hiddenPath": "$service:posts.hideServerError"
|
|
56
111
|
}
|
|
57
112
|
},
|
|
58
113
|
{
|
|
59
|
-
"id": "error",
|
|
114
|
+
"id": "error.permission",
|
|
60
115
|
"body": {
|
|
61
116
|
"package": "himalaya",
|
|
62
|
-
"type": "
|
|
117
|
+
"type": "EmptyState",
|
|
63
118
|
"props": {
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"color": "danger",
|
|
67
|
-
"collapseWhenEmpty": true
|
|
119
|
+
"title": "You need permission to view this screen",
|
|
120
|
+
"subtitle": "Ask the owner of this blog for access."
|
|
68
121
|
}
|
|
122
|
+
},
|
|
123
|
+
"modifiers": {
|
|
124
|
+
"hiddenPath": "$service:posts.hidePermissionError"
|
|
69
125
|
}
|
|
70
126
|
},
|
|
71
127
|
{
|
|
72
|
-
"id": "
|
|
128
|
+
"id": "error.refresh",
|
|
73
129
|
"body": {
|
|
74
130
|
"package": "himalaya",
|
|
75
|
-
"type": "
|
|
131
|
+
"type": "Banner",
|
|
76
132
|
"props": {
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"action": {
|
|
133
|
+
"severity": "warning",
|
|
134
|
+
"title": "Couldn't refresh",
|
|
135
|
+
"message": "Showing what was loaded before.",
|
|
136
|
+
"action": {
|
|
137
|
+
"actionId": "tier5.posts.retry"
|
|
138
|
+
},
|
|
139
|
+
"actionLabel": "Try again"
|
|
81
140
|
}
|
|
82
141
|
},
|
|
83
|
-
"modifiers": {
|
|
142
|
+
"modifiers": {
|
|
143
|
+
"hiddenPath": "$service:posts.hideRefreshError"
|
|
144
|
+
}
|
|
84
145
|
},
|
|
85
146
|
{
|
|
86
147
|
"id": "posts_list",
|