@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.
@@ -6,8 +6,9 @@
6
6
  // the current query, and let the SearchField's debounced onChange action swap
7
7
  // what the single bound path holds. The descriptor never rebinds anything.
8
8
  //
9
- // Builds on the dynamic-list recipe (worker owns the array, four-state display
10
- // strings) — see examples/dynamic-list.* for that base. Declares "wix_api".
9
+ // Builds on the dynamic-list recipe (worker owns the array, one hiddenPath flag per
10
+ // state, classified errors) — see examples/dynamic-list.* for that base. A retry
11
+ // keeps the user's query. Declares "wix_api".
11
12
 
12
13
  import { defineService } from "@wix/himalaya/state";
13
14
  import { queryPosts, type WixPost } from "@wix/himalaya/wix-blog";
@@ -20,15 +21,24 @@ interface PostRow {
20
21
  categoryIds: string[];
21
22
  }
22
23
 
24
+ // A public blog has no sign-in flow, so a 401 reads as "permission" here.
25
+ type ErrorKind = "" | "offline" | "server" | "permission";
26
+
23
27
  interface PostsState {
24
28
  status: "loading" | "ready" | "error";
25
29
  all: PostRow[]; // the full fetched set — NOT bound by the descriptor
26
30
  items: PostRow[]; // ← itemsPath binds here; ALWAYS derived from all + query
27
31
  query: string;
28
- loadingText: string;
29
- emptyText: string; // distinguishes "no data" from "no matches"
30
- error: string;
31
- notErrored: boolean;
32
+ errorKind: ErrorKind;
33
+ // hiddenPath flags (hide when TRUE) — see `himi docs recipe-four-state`
34
+ hideLoading: boolean;
35
+ hideNoPosts: boolean; // EmptyState: the blog has no posts at all
36
+ hideNoMatches: boolean; // EmptyState: posts exist, none match the query (not an error)
37
+ notEmpty: boolean; // false while either EmptyState shows
38
+ hideOfflineError: boolean;
39
+ hideServerError: boolean;
40
+ hidePermissionError: boolean;
41
+ hideRefreshError: boolean;
32
42
  }
33
43
 
34
44
  function normalize(raw: WixPost): PostRow {
@@ -54,38 +64,78 @@ function shape(s: Partial<PostsState>): PostsState {
54
64
  const all = s.all ?? [];
55
65
  const query = s.query ?? "";
56
66
  const items = s.items ?? visible(all, query);
67
+ const errorKind: ErrorKind = status === "error" ? (s.errorKind || "server") : "";
68
+ const blank = all.length === 0; // nothing loaded to keep
57
69
  return {
58
70
  status,
59
71
  all,
60
72
  items,
61
73
  query,
62
- loadingText: status === "loading" ? "Loading posts…" : "",
63
- emptyText:
64
- status === "ready" && items.length === 0
65
- ? query
66
- ? `No matches for “${query}”.`
67
- : "No posts yet."
68
- : "",
69
- error: s.error ?? "",
70
- notErrored: status !== "error",
74
+ errorKind,
75
+ // Two EmptyStates, not one bound Text: iOS/Android do not bind EmptyState text.
76
+ hideNoPosts: !(status === "ready" && blank),
77
+ // Over rows kept after a failed refresh too: no matches is still empty, not an error.
78
+ hideNoMatches: !(!blank && items.length === 0),
79
+ notEmpty: !((status === "ready" && blank) || (!blank && items.length === 0)),
80
+ hideLoading: !(status === "loading" && blank),
81
+ hideOfflineError: !(blank && errorKind === "offline"),
82
+ hideServerError: !(blank && errorKind === "server"),
83
+ hidePermissionError: !(blank && errorKind === "permission"),
84
+ hideRefreshError: !(!blank && (errorKind === "offline" || errorKind === "server")),
71
85
  };
72
86
  }
73
87
 
88
+ // The HTTP status rides in the message: `HTTP 503: …` on a device, `Wix 503 for …`
89
+ // under `himi test` / `himi render`. Only a transport failure is "offline".
90
+ const STATUS = /\b(?:HTTP|Wix) (\d{3})\b/;
91
+ // No answer at all — what a dropped connection says on every platform.
92
+ 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;
93
+ // Android reports an unknown host as the bare hostname (`users.wix.com`) — hence the last form.
94
+ // No session on the device: the native bridge refuses before any request leaves.
95
+ // A pinned site with no member session: iOS `siteNotPermitted(requested: "account", …)`,
96
+ // Android `site account is not the bound site …`; the web host `HTTP 501: {"error":"webwix_owner_no_session"}`.
97
+ const SIGNED_OUT = /wixApi not enabled|session unavailable|PRINCIPAL_CHANGED|not signed in|siteNotPermitted\(requested: "account"|site account is not the bound site|webwix_owner_no_session/i;
98
+
99
+ // The same classifier as `himi docs recipe-four-state`, except that a public screen with no sign-in flow shows the permission state for a sign-in failure.
100
+ function classifyError(e: unknown): ErrorKind {
101
+ const msg = String((e as Error)?.message ?? e ?? "");
102
+ const code = String((e as { code?: unknown })?.code ?? "");
103
+ const status = Number(STATUS.exec(msg)?.[1] ?? 0);
104
+ if (status === 401) return "permission";
105
+ if (status === 403) return "permission";
106
+ if (SIGNED_OUT.test(msg)) return "permission";
107
+ if (code === "offline_write_blocked") return "offline"; // a write refused before it left
108
+ // A web call that never reached Wix arrives as
109
+ // `HTTP 502: {"error":"webwix_direct_failed","detail":"Failed to fetch"}` — judge its detail.
110
+ if (status && !msg.includes("webwix_direct_failed")) return "server";
111
+ if (TRANSPORT.test(msg)) return "offline";
112
+ return "server"; // a bug or a malformed row is not the user's connection
113
+ }
114
+
74
115
  // The SearchField onChange arg shape varies by surface — read it defensively.
75
116
  function queryFrom(arg: unknown): string {
76
117
  const a = arg as { value?: string; text?: string; query?: string } | undefined;
77
118
  return String(a?.value ?? a?.text ?? a?.query ?? "");
78
119
  }
79
120
 
80
- async function loadList(): Promise<PostsState> {
121
+ // Actions are not serialized: the user can type while load() is in flight, and the
122
+ // action that finishes last writes the whole state. So the latest query lives outside
123
+ // the state, and the fetch applies it when it returns — a retry or a slow first load
124
+ // never throws away what the user typed.
125
+ let currentQuery = "";
126
+
127
+ async function loadList(s: Partial<PostsState>): Promise<PostsState> {
81
128
  try {
82
129
  const res = await queryPosts({ query: { paging: { limit: 100 } } });
83
130
  const all = ((res as { posts?: WixPost[] }).posts ?? [])
84
131
  .map(normalize)
85
132
  .filter((r) => r.id);
86
- return shape({ status: "ready", all, query: "" });
133
+ return shape({ status: "ready", all, query: currentQuery });
87
134
  } catch (e) {
88
- return shape({ status: "error", all: [], error: String((e as Error)?.message ?? e) });
135
+ const errorKind = classifyError(e);
136
+ // Keep what was loaded on a refresh failure; clear it when access is gone.
137
+ const all = errorKind === "permission" ? [] : s.all ?? [];
138
+ return shape({ status: "error", all, query: currentQuery, errorKind });
89
139
  }
90
140
  }
91
141
 
@@ -97,17 +147,29 @@ const svc = defineService<PostsState, {
97
147
  }>("posts", {
98
148
  state: shape({ status: "loading" }),
99
149
  actions: {
100
- load: async () => loadList(),
101
- retry: async () => loadList(),
150
+ // `load` opens the screen, and the uncontrolled SearchField opens empty: start the
151
+ // query over too. A query typed while this fetch is in flight still wins.
152
+ load: async (s) => { currentQuery = ""; return loadList(s); },
153
+ retry: async (s) => loadList(s),
102
154
  // In-worker filter: swap what the bound `items` path holds. For datasets past
103
155
  // a few hundred rows, make this re-query the API instead (same descriptor).
104
156
  search: (s, arg) => {
105
157
  const query = queryFrom(arg);
158
+ currentQuery = query;
106
159
  return shape({ ...s, query, items: visible(s.all, query) });
107
160
  },
108
- clear: (s) => shape({ ...s, query: "", items: s.all }),
161
+ clear: (s) => { currentQuery = ""; return shape({ ...s, query: "", items: s.all }); },
109
162
  },
110
163
  });
111
164
 
165
+ // The SearchField is uncontrolled and shows empty on a cold boot, but the runtime restores the
166
+ // persisted snapshot: last run's `query`, and the `items` and empty flags derived from it. Re-derive
167
+ // them in place for an empty query before anything paints, or the list stays filtered by text the
168
+ // field does not show until the first fetch settles.
169
+ const booted = (svc as unknown as { getState?: () => Record<string, unknown> | undefined }).getState?.();
170
+ if (booted && typeof booted === "object") {
171
+ Object.assign(booted, shape({ ...(booted as Partial<PostsState>), query: "", items: undefined }));
172
+ }
173
+
112
174
  svc.load(); // fetch once at boot; searching never re-fetches
113
175
  export { svc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1465.0",
3
+ "version": "0.1467.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -71,5 +71,5 @@
71
71
  "artifactId": "himalaya-cli"
72
72
  }
73
73
  },
74
- "falconPackageHash": "19e0653508dd0a35a2413578ca22c6452471d39f3797b2cd827fb652"
74
+ "falconPackageHash": "7882aee1b35bad5d711c75125239b98cc49d21ee40dff33a6fda8b6b"
75
75
  }