@wix/himalaya-cli 0.1613.0 → 0.1615.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/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/ui/src/index.d.ts +53 -13
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +3 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/ui.mjs +31 -5
- package/dist/cli.mjs +1638 -1330
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +4 -0
- package/guides/component-reference.json +1 -1
- package/guides/component-reference.md +3 -0
- package/guides/designing-himi-screens.md +4 -1
- package/guides/hhp.md +2 -2
- package/guides/sdk-reference.json +28 -4
- package/package.json +2 -2
- package/template-icons/salon-spa.png +0 -0
- package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/beauty-salon/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/beauty-salon/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/beauty-salon/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/beauty-salon/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/branded-lite/dev/index.ts +192 -0
- package/templates/branded-lite/l10n/en.json +1 -1
- package/templates/branded-lite/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/branded-lite/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/branded-lite/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/branded-lite/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/branded-lite/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/branded-lite/tier5-src/himi-branded-lite-galleries/index.ts +104 -12
- package/templates/branded-lite/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/community-club/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/community-club/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/community-club/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/events-meetups/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/events-meetups/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/events-meetups/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/gym-fitness/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/gym-fitness/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/gym-fitness/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/gym-fitness/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/nonprofit-cause/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/nonprofit-cause/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/nonprofit-cause/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/podcast-creator/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/podcast-creator/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/podcast-creator/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/portfolio-creative/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/portfolio-creative/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/portfolio-creative/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/restaurant/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/restaurant/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/restaurant/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/retail-store/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/retail-store/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/retail-store/tier5-src/himi-branded-lite-home/index.ts +178 -12
- package/templates/salon-spa/README.md +53 -0
- package/templates/salon-spa/config.ts +272 -0
- package/templates/salon-spa/dev/index.ts +19893 -0
- package/templates/salon-spa/himalaya.content.json +49 -0
- package/templates/salon-spa/himalaya.template.json +14 -0
- package/templates/salon-spa/icon.png +0 -0
- package/templates/salon-spa/platforms.json +1 -0
- package/templates/salon-spa/tier5-src/_shared/app-locale.ts +61 -0
- package/templates/salon-spa/tier5-src/_shared/booking-location.ts +147 -0
- package/templates/salon-spa/tier5-src/_shared/booking-session.ts +222 -0
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +278 -0
- package/templates/salon-spa/tier5-src/_shared/branded-load-state.ts +109 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-copy.ts +40 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +48 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-message-model.ts +177 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-model.ts +56 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +581 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-transport.ts +148 -0
- package/templates/salon-spa/tier5-src/_shared/collection-contract.ts +230 -0
- package/templates/salon-spa/tier5-src/_shared/collection-query.ts +193 -0
- package/templates/salon-spa/tier5-src/_shared/collection-service.ts +400 -0
- package/templates/salon-spa/tier5-src/_shared/detail-presence.ts +51 -0
- package/templates/salon-spa/tier5-src/_shared/empty-flags.ts +33 -0
- package/templates/salon-spa/tier5-src/_shared/engine-intl.ts +508 -0
- package/templates/salon-spa/tier5-src/_shared/himi-account.ts +164 -0
- package/templates/salon-spa/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/salon-spa/tier5-src/_shared/himi-bookings.ts +318 -0
- package/templates/salon-spa/tier5-src/_shared/himi-copy.ts +89 -0
- package/templates/salon-spa/tier5-src/_shared/himi-store.ts +7 -0
- package/templates/salon-spa/tier5-src/_shared/himi-text.ts +24 -0
- package/templates/salon-spa/tier5-src/_shared/host-events.ts +66 -0
- package/templates/salon-spa/tier5-src/_shared/hosted-checkout.ts +70 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-form.ts +106 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance-purchase.ts +211 -0
- package/templates/salon-spa/tier5-src/_shared/iap-attendance.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/iap-worker.ts +125 -0
- package/templates/salon-spa/tier5-src/_shared/local-preferences.ts +38 -0
- package/templates/salon-spa/tier5-src/_shared/member-access.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-account-routes.ts +52 -0
- package/templates/salon-spa/tier5-src/_shared/member-bookings.ts +94 -0
- package/templates/salon-spa/tier5-src/_shared/offset-page.ts +10 -0
- package/templates/salon-spa/tier5-src/_shared/online-actions.ts +76 -0
- package/templates/salon-spa/tier5-src/_shared/owner-load-error.ts +348 -0
- package/templates/salon-spa/tier5-src/_shared/payment-setup.ts +6 -0
- package/templates/salon-spa/tier5-src/_shared/rich-body.ts +78 -0
- package/templates/salon-spa/tier5-src/_shared/rich-content-link.ts +34 -0
- package/templates/salon-spa/tier5-src/_shared/weekly-goal.ts +107 -0
- package/templates/salon-spa/tier5-src/_shared/wix-service-price.ts +146 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +669 -0
- package/templates/salon-spa/tier5-src/himi-booking-account/index.ts +241 -0
- package/templates/salon-spa/tier5-src/himi-booking-bookings/index.ts +466 -0
- package/templates/salon-spa/tier5-src/himi-booking-saved/index.ts +103 -0
- package/templates/salon-spa/tier5-src/himi-booking-service/index.ts +1170 -0
- package/templates/salon-spa/tier5-src/himi-booking-services/index.ts +791 -0
- package/templates/salon-spa/tier5-src/himi-booking-time/index.ts +1712 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-agenda/index.ts +950 -0
- package/templates/salon-spa/tier5-src/himi-branded-lite-galleries/index.ts +282 -0
- package/templates/salon-spa/tier5-src/himi-salon-home/index.ts +544 -0
- package/templates/salon-spa/tier5-src/himi-store-account/index.ts +925 -0
- package/templates/salon-spa/tokens.json +341 -0
- package/templates/yoga-studio/dev/index.ts +192 -0
- package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +5 -2
- package/templates/yoga-studio/tier5-src/_shared/himi-account.ts +31 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-booking-saved.ts +88 -0
- package/templates/yoga-studio/tier5-src/himi-booking-service/index.ts +50 -3
- package/templates/yoga-studio/tier5-src/himi-booking-services/index.ts +70 -3
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +40 -8
- package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +7 -2
- package/templates/yoga-studio/tier5-src/himi-branded-lite-home/index.ts +178 -12
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { defineService as register, type ServiceDefinition } from '@himalaya/state';
|
|
2
|
+
import { COLLECTIONS, COLLECTION_CHROME_MIN_ITEMS, initialCollection, normalizeCollection, projectCollection, collectionId, collectionActionsLabel, type CollectionRow } from './collection-contract.js';
|
|
3
|
+
import { localPreferences, type PreferenceRuntime } from './local-preferences.js';
|
|
4
|
+
import { copyParam, stashCopy } from './himi-copy.js';
|
|
5
|
+
import { createCollectionQuery, type CollectionQuerySource, type CollectionQueryState } from './collection-query.js';
|
|
6
|
+
|
|
7
|
+
export interface CollectionSaving {
|
|
8
|
+
read(): string[];
|
|
9
|
+
write(ids: string[], rows: Record<string, unknown>[]): void;
|
|
10
|
+
}
|
|
11
|
+
type State = Record<string, unknown>;
|
|
12
|
+
interface BackendCollection {
|
|
13
|
+
restorePagesOnLoad?: boolean;
|
|
14
|
+
/** Sees the load action's argument (the screen's onAppear params) BEFORE the query runs. A returned
|
|
15
|
+
* `{ filter, sort }` resets the criteria for that load (a screen that always opens on one view). */
|
|
16
|
+
onLoad?(arg: unknown): CollectionCriteriaReset | void;
|
|
17
|
+
controller: ReturnType<typeof createCollectionQuery<CollectionRow>>;
|
|
18
|
+
apply(state: State, page: CollectionQueryState<CollectionRow>): State;
|
|
19
|
+
}
|
|
20
|
+
/** A filter and a sort set together (`collectionCriteria`, `onLoad`). Values the collection's contract does
|
|
21
|
+
* not list are ignored. */
|
|
22
|
+
export interface CollectionCriteriaReset { filter?: string; sort?: string; query?: string }
|
|
23
|
+
type Reducer = (s: State, ...args: unknown[]) => State | undefined | Promise<State | undefined>;
|
|
24
|
+
const object = (x: unknown): State => x && typeof x === 'object' && !Array.isArray(x) ? x as State : {};
|
|
25
|
+
const str = (arg: unknown, key: string): string => typeof arg === 'string' ? arg : String(object(arg)[key] ?? '');
|
|
26
|
+
|
|
27
|
+
/** The toolbar count line — "12 dishes", or "3 of 12 dishes" while a filter narrows the list.
|
|
28
|
+
*
|
|
29
|
+
* It used to read "12 / 12 loaded" and "2 / 2 results" ([branded-lite-qa] #3002): the numbers
|
|
30
|
+
* described the implementation (a client-side filter over a fetched page) rather than what the
|
|
31
|
+
* customer is looking at, and the slash-and-suffix wrapped onto two lines on web. When the
|
|
32
|
+
* backend total is unknown the count shown is simply what is shown; the "Load more" button is
|
|
33
|
+
* what says there is more. */
|
|
34
|
+
export function collectionSummary(o: { shown: number; loaded: number; total?: number; backend?: boolean; noun?: [string, string] }): string {
|
|
35
|
+
const [one, many] = o.noun ?? [copyParam('collection.itemOne', 'item'), copyParam('collection.itemMany', 'items')];
|
|
36
|
+
const whole = o.backend ? (typeof o.total === 'number' ? o.total : o.shown) : o.loaded;
|
|
37
|
+
const noun = (whole === 1 ? one : many);
|
|
38
|
+
return o.shown < whole ? `${o.shown} ${copyParam('collection.of', 'of')} ${whole} ${noun}` : `${o.shown} ${o.shown === 1 ? one : many}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Opt-in decorator. Domain reducers remain authoritative; collection preferences
|
|
42
|
+
* survive reducers which replace their entire state, including async refreshes. */
|
|
43
|
+
export function decorateCollection(name: string, def: { state: State; actions?: Record<string, Reducer> }, saving?: CollectionSaving, backend?: BackendCollection) {
|
|
44
|
+
const config = COLLECTIONS[name];
|
|
45
|
+
if (!config) return def;
|
|
46
|
+
const runtime = (globalThis as { himalaya?: PreferenceRuntime }).himalaya;
|
|
47
|
+
let instanceId = '';
|
|
48
|
+
if (config.instanceInput) {
|
|
49
|
+
let instance = runtime?.input?.[config.instanceInput];
|
|
50
|
+
try { if (typeof instance === 'string') instance = JSON.parse(instance); } catch { instance = undefined; }
|
|
51
|
+
const id = object(instance).id;
|
|
52
|
+
if (typeof id === 'string' && id.length > 0 && id.length <= 256) instanceId = id;
|
|
53
|
+
}
|
|
54
|
+
// Dynamic collections must never read the shared service-level key. Without
|
|
55
|
+
// a configured instance identity they remain session-only.
|
|
56
|
+
const persisted = localPreferences(`collection.${name}${config.instanceInput ? `.${encodeURIComponent(instanceId)}` : ''}`,
|
|
57
|
+
value => normalizeCollection(config, value), config.instanceInput && !instanceId ? {} : runtime);
|
|
58
|
+
let prefs = persisted.read();
|
|
59
|
+
let lastUndo: string[] | undefined;
|
|
60
|
+
let generation = 0;
|
|
61
|
+
let previewId = '';
|
|
62
|
+
let filtersPresented = false;
|
|
63
|
+
let viewName = '';
|
|
64
|
+
let viewMessage = '';
|
|
65
|
+
let saveMessage = '';
|
|
66
|
+
const syncSaved = () => {
|
|
67
|
+
if (!saving) return;
|
|
68
|
+
try {
|
|
69
|
+
const ids = saving.read();
|
|
70
|
+
if (JSON.stringify(ids) !== JSON.stringify(prefs.savedIds)) lastUndo = undefined;
|
|
71
|
+
prefs.savedIds = ids;
|
|
72
|
+
} catch { prefs.savedIds = []; }
|
|
73
|
+
};
|
|
74
|
+
/** Navigation can open a collection in a useful, reviewable context. Keep
|
|
75
|
+
* this input additive and constrained to the same criteria the collection UI
|
|
76
|
+
* can select; arbitrary route values must never become backend filters. */
|
|
77
|
+
/** Labels of the worker-listed filter choices (`filterValuesFrom`), minus the "All" row. */
|
|
78
|
+
const listedFilters = (state: State): string[] => config.filterValuesFrom && Array.isArray(state[config.filterValuesFrom])
|
|
79
|
+
? (state[config.filterValuesFrom] as unknown[]).flatMap(row => {
|
|
80
|
+
const r = row && typeof row === 'object' ? row as Record<string, unknown> : {};
|
|
81
|
+
return typeof r.label === 'string' && r.id !== '' ? [r.label] : [];
|
|
82
|
+
})
|
|
83
|
+
: [];
|
|
84
|
+
let pendingFilter = '';
|
|
85
|
+
const applyNavigationCriteria = (arg: unknown, state: State): boolean => {
|
|
86
|
+
const input = object(arg);
|
|
87
|
+
let changed = false;
|
|
88
|
+
if (Object.prototype.hasOwnProperty.call(input, 'collectionQuery')) {
|
|
89
|
+
prefs.query = String(input.collectionQuery ?? '').slice(0, 256);
|
|
90
|
+
changed = true;
|
|
91
|
+
}
|
|
92
|
+
if (Object.prototype.hasOwnProperty.call(input, 'collectionSort')) {
|
|
93
|
+
const sort = String(input.collectionSort ?? '');
|
|
94
|
+
if (sort === 'default' || sort === 'az' || sort === 'za') { prefs.sort = sort; changed = true; }
|
|
95
|
+
}
|
|
96
|
+
if (Object.prototype.hasOwnProperty.call(input, 'collectionFilter')) {
|
|
97
|
+
const filter = String(input.collectionFilter ?? '');
|
|
98
|
+
const configured = config.filterValues ?? [];
|
|
99
|
+
const rows = Array.isArray(state[config.rows]) ? state[config.rows] as CollectionRow[] : [];
|
|
100
|
+
const derived = config.filter ? rows.map(row => String(row[config.filter!] ?? '')).filter(Boolean) : [];
|
|
101
|
+
const known = listedFilters(state);
|
|
102
|
+
const allowed = filter === 'all' || filter === 'saved' ||
|
|
103
|
+
(filter.startsWith('value:') && [...configured, ...derived, ...known].includes(filter.slice(6)));
|
|
104
|
+
if (allowed) { prefs.filter = filter; changed = true; }
|
|
105
|
+
// A worker-listed choice (e.g. a blog category) may not have loaded yet at boot:
|
|
106
|
+
// hold it and settle it against the list once the list arrives, instead of
|
|
107
|
+
// discarding a valid deep link before load().
|
|
108
|
+
else if (filter.startsWith('value:') && config.filterValuesFrom && known.length === 0) pendingFilter = filter;
|
|
109
|
+
}
|
|
110
|
+
if (changed) {
|
|
111
|
+
prefs.selectedIds = [];
|
|
112
|
+
prefs.selecting = false;
|
|
113
|
+
persisted.write(prefs);
|
|
114
|
+
}
|
|
115
|
+
return changed;
|
|
116
|
+
};
|
|
117
|
+
// A worker's module-level `svc.load()` has no action argument. Navigation
|
|
118
|
+
// params are injected into `himalaya.input` before module evaluation, so
|
|
119
|
+
// apply the same constrained criteria here before that boot request reads
|
|
120
|
+
// `prefs`. The load wrapper below still handles hosts that pass criteria as
|
|
121
|
+
// an explicit action argument.
|
|
122
|
+
applyNavigationCriteria(runtime?.input, def.state);
|
|
123
|
+
const derive = (s: State): State => {
|
|
124
|
+
syncSaved();
|
|
125
|
+
const rows = Array.isArray(s[config.rows]) ? (s[config.rows] as CollectionRow[]).filter(x => x && typeof x === 'object') : [];
|
|
126
|
+
const known = listedFilters(s);
|
|
127
|
+
if (pendingFilter && known.length) {
|
|
128
|
+
if (known.includes(pendingFilter.slice(6))) { prefs.filter = pendingFilter; persisted.write(prefs); }
|
|
129
|
+
pendingFilter = '';
|
|
130
|
+
}
|
|
131
|
+
const projected = projectCollection(rows, config, prefs, !!backend && config.backendQuery === true, known);
|
|
132
|
+
projected.items.forEach(row => {
|
|
133
|
+
row.collectionSelectLabel = row.collectionSelected ? copyParam('collection.selected', 'Selected') : copyParam('collection.select', 'Select');
|
|
134
|
+
// the save toggle names the action it WILL take for this row. The
|
|
135
|
+
// old static "Save / unsave" made the owner work out the row's state.
|
|
136
|
+
row.collectionSaveLabel = row.collectionSaved ? copyParam('collection.unsave', 'Remove from saved') : copyParam('collection.save', 'Save');
|
|
137
|
+
row.collectionActionsLabel = collectionActionsLabel(String(row.collectionTitle ?? ''),
|
|
138
|
+
copyParam('collection.itemActionsFor', 'Actions for {title}'), copyParam('collection.itemActions', 'Item actions'));
|
|
139
|
+
});
|
|
140
|
+
if (previewId && !rows.some(row => collectionId(row) === previewId)) previewId = '';
|
|
141
|
+
const filterChoices = [{ id: 'all', label: copyParam('collection.all', 'All') }, { id: 'saved', label: copyParam('collection.saved', 'Saved') },
|
|
142
|
+
...projected.filters.map(label => ({ id: `value:${label}`, label: copyParam(`collection.filter.${label}`, label) }))];
|
|
143
|
+
const loading = s.status === 'loading' || s.isLoading === true;
|
|
144
|
+
// Older domain reducers expose `errored` rather than `isErrored`; backend
|
|
145
|
+
// adapters can also intentionally keep their display status "ready" while
|
|
146
|
+
// the query controller carries a recoverable failure. Treat each shape as
|
|
147
|
+
// one collection state so the shared UI never renders a blank catalog.
|
|
148
|
+
const backendState = backend?.controller.getState();
|
|
149
|
+
const provisional = backendState?.provisional === true;
|
|
150
|
+
const errored = s.status === 'error' || s.isErrored === true || s.errored === true || backendState?.status === 'error';
|
|
151
|
+
const domainError = typeof s.error === 'string' ? s.error.trim() : '';
|
|
152
|
+
const queryError = typeof backendState?.error === 'string' ? backendState.error.trim() : '';
|
|
153
|
+
// Some domains carry their failure sentence in `emptyText` (portfolio, podcasts);
|
|
154
|
+
// the screen folds that node into this block, so keep the domain's own words.
|
|
155
|
+
const domainEmptyError = errored && typeof s.emptyText === 'string' ? s.emptyText.trim() : '';
|
|
156
|
+
const errorMessage = queryError || domainError || domainEmptyError || copyParam('collection.loadError', 'This collection could not load. Try again.');
|
|
157
|
+
const canRetry = !!backend || typeof def.actions?.refresh === 'function' || typeof def.actions?.load === 'function' || typeof def.actions?.collectionRetry === 'function';
|
|
158
|
+
// Wix refused the visitor: retrying cannot help, signing in can.
|
|
159
|
+
const signInRequired = errored && s.signInRequired === true;
|
|
160
|
+
// "the source has nothing" and "your filter matched nothing" are
|
|
161
|
+
// different states. Only the second may offer "Clear filters", and the list chrome
|
|
162
|
+
// (view, refine, chips, counter, search) means nothing over a source with no rows.
|
|
163
|
+
// Loaded-row collections hold the whole source in `rows`, so zero rows IS an empty
|
|
164
|
+
// source. A backend query returns only matches, so zero rows is source-empty only
|
|
165
|
+
// when nothing narrowed it.
|
|
166
|
+
// Anything the customer (or the domain) narrowed the result by. While narrowed, zero rows
|
|
167
|
+
// is "your filter matched nothing" and the controls that got them here must stay.
|
|
168
|
+
// The DOMAIN's own filter (config.narrowedBy, e.g. blog category) narrowed the rows: the
|
|
169
|
+
// domain owns that empty state and its clear action. The shared "Clear filters" block
|
|
170
|
+
// cannot reset a domain filter, so it never shows for one.
|
|
171
|
+
const domainNarrowed = !!config.narrowedBy && !!s[config.narrowedBy];
|
|
172
|
+
// A default view is what the screen opens on; anything else is the member's own
|
|
173
|
+
// narrowing, which keeps the chrome (and the Clear that undoes it) on screen.
|
|
174
|
+
// "All" and the collection's own default (e.g. Upcoming) are both un-narrowed: Clear
|
|
175
|
+
// resets to "All", so showing it on either would offer a reset that changes nothing.
|
|
176
|
+
const narrowed = !!prefs.query.trim() || (prefs.filter !== 'all' && prefs.filter !== initialCollection(config).filter) || domainNarrowed;
|
|
177
|
+
const sourceEmpty = !loading && !errored && rows.length === 0 &&
|
|
178
|
+
(!(backend && config.backendQuery) || !narrowed) && !domainNarrowed;
|
|
179
|
+
const emptySourceText = (typeof s.emptyText === 'string' && s.emptyText.trim()) || copyParam('collection.emptySource', 'Nothing here yet');
|
|
180
|
+
// Chrome is noise over a small un-narrowed list (fewer than COLLECTION_CHROME_MIN_ITEMS
|
|
181
|
+
// records, no further page), over an empty source, and before the FIRST load or an
|
|
182
|
+
// un-narrowed failure. A re-query while typing keeps it (and the search field's focus).
|
|
183
|
+
const hideChrome = (!narrowed && !s.nextCursor && projected.loaded < COLLECTION_CHROME_MIN_ITEMS)
|
|
184
|
+
|| sourceEmpty || ((loading || errored) && rows.length === 0 && !narrowed);
|
|
185
|
+
// Kept with the state, so a cold boot restored from disk shows its last page and revalidates behind it.
|
|
186
|
+
const collectionShown = backend?.controller.shownPage();
|
|
187
|
+
return { ...s, ...(backend ? { collectionShown: collectionShown ?? null } : {}), collection: { ...prefs, ...projected,
|
|
188
|
+
hideChrome, noActiveCriteria: !narrowed,
|
|
189
|
+
viewName, viewMessage, saveMessage, noSavedViews: prefs.savedViews.length === 0,
|
|
190
|
+
cursor: s.pageError === true || s.loadingMore === true ? '' : s.nextCursor,
|
|
191
|
+
noMore: !s.nextCursor || s.pageError === true || s.loadingMore === true,
|
|
192
|
+
noPageError: s.pageError !== true,
|
|
193
|
+
// Rows read from the device ahead of the live read are shown as they are: "saved offline" is what stays on
|
|
194
|
+
// screen when the live read failed, not a first visit that is still loading.
|
|
195
|
+
noOffline: s.fromCache !== true || provisional,
|
|
196
|
+
noError: !errored,
|
|
197
|
+
noRetry: !canRetry || signInRequired,
|
|
198
|
+
noSignIn: !signInRequired,
|
|
199
|
+
errorTitle: signInRequired ? copyParam('collection.signInTitle', 'Sign in to see this') : copyParam('collection.loadErrorTitle', 'This collection could not load'),
|
|
200
|
+
errorMessage,
|
|
201
|
+
// A skeleton only with nothing to show: a search re-running over the rows on screen keeps them.
|
|
202
|
+
noLoading: !loading || rows.length > 0,
|
|
203
|
+
// De-dup against a domain that paints its OWN empty block (screen-kit emptyBlock is
|
|
204
|
+
// gated on the domain's top-level `noEmpty`): only one empty state ever shows.
|
|
205
|
+
noEmptySource: !sourceEmpty || s.noEmpty === false,
|
|
206
|
+
emptySourceText,
|
|
207
|
+
hideList: prefs.view !== 'list', hideGrid: prefs.view !== 'grid',
|
|
208
|
+
hideComfortable: prefs.density !== 'comfortable', hideCompact: prefs.density !== 'compact',
|
|
209
|
+
selectingLabel: prefs.selecting ? copyParam('collection.done', 'Done') : copyParam('collection.select', 'Select'),
|
|
210
|
+
noSelection: !prefs.selecting, noUndo: !lastUndo,
|
|
211
|
+
cannotSaveSelected: !prefs.selectedIds.length || prefs.selectedIds.some(id => !rows.some(row => collectionId(row) === id)),
|
|
212
|
+
filtersPresented, previewPresented: !!previewId, preview: projected.items.filter(row => row.collectionId === previewId),
|
|
213
|
+
persistenceLabel: persisted.available ? copyParam('collection.onDevice', 'Saved on this device') : copyParam('collection.sessionOnly', 'Choices last for this session'),
|
|
214
|
+
noEmpty: loading || errored || sourceEmpty || domainNarrowed || projected.items.length > 0,
|
|
215
|
+
summary: backend && config.backendQuery && s.fromCache && !provisional
|
|
216
|
+
? copyParam('collection.cached', 'Saved offline · may be outdated')
|
|
217
|
+
: collectionSummary({ shown: projected.items.length, loaded: projected.loaded, backend: !!(backend && config.backendQuery),
|
|
218
|
+
total: typeof s.total === 'number' ? s.total : undefined, noun: config.countNoun
|
|
219
|
+
? [copyParam(`collection.${name}.countOne`, config.countNoun[0]), copyParam(`collection.${name}.countMany`, config.countNoun[1])] : undefined }),
|
|
220
|
+
selectedSummary: `${prefs.selectedIds.length} ${copyParam('collection.selected', 'selected')}`,
|
|
221
|
+
filterChoices,
|
|
222
|
+
chips: config.filterValues ? filterChoices.filter(choice => ['all', 'saved', prefs.filter].includes(choice.id)) : filterChoices,
|
|
223
|
+
} };
|
|
224
|
+
};
|
|
225
|
+
const actions: Record<string, Reducer> = {};
|
|
226
|
+
for (const [key, action] of Object.entries(def.actions ?? {})) {
|
|
227
|
+
actions[key] = (s, ...args) => {
|
|
228
|
+
stashCopy(args[0]);
|
|
229
|
+
const actionState = key === 'load' && applyNavigationCriteria(args[0], s) ? derive(s) : s;
|
|
230
|
+
// A new data request invalidates older requests, while presentation actions
|
|
231
|
+
// are merged at completion rather than clobbered by a captured state.
|
|
232
|
+
const isQuery = /^(load|refresh|retry|search|clearSearch)$/.test(key);
|
|
233
|
+
const previous = generation;
|
|
234
|
+
const revision = isQuery ? ++generation : generation;
|
|
235
|
+
const result = action(actionState, ...args);
|
|
236
|
+
const finish = (next: State | undefined) => {
|
|
237
|
+
// Some workers deduplicate onAppear against their boot request and return
|
|
238
|
+
// the identical state. A no-op must not supersede the request it joined.
|
|
239
|
+
if (next === actionState) {
|
|
240
|
+
if (revision !== generation) return undefined;
|
|
241
|
+
if (isQuery) generation = previous;
|
|
242
|
+
return derive(next);
|
|
243
|
+
}
|
|
244
|
+
return next === undefined || revision !== generation ? undefined : derive(next);
|
|
245
|
+
};
|
|
246
|
+
return result && typeof (result as Promise<unknown>).then === 'function'
|
|
247
|
+
? (result as Promise<State | undefined>).then(finish) : finish(result as State | undefined);
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const change = (update: (arg: unknown, s: State) => void): Reducer => (s, arg) => {
|
|
251
|
+
syncSaved();
|
|
252
|
+
const criteriaBefore = criteriaKey();
|
|
253
|
+
const before = [...prefs.savedIds], undoBefore = lastUndo;
|
|
254
|
+
update(arg, s);
|
|
255
|
+
if (saving && JSON.stringify(before) !== JSON.stringify(prefs.savedIds)) {
|
|
256
|
+
try {
|
|
257
|
+
saving.write(prefs.savedIds, Array.isArray(s[config.rows]) ? s[config.rows] as CollectionRow[] : []);
|
|
258
|
+
saveMessage = '';
|
|
259
|
+
} catch {
|
|
260
|
+
prefs.savedIds = before; lastUndo = undoBefore;
|
|
261
|
+
saveMessage = copyParam('collection.saveError', 'Could not save this change. Try again.');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
persisted.write(prefs);
|
|
265
|
+
if (backend && config.backendQuery && criteriaBefore !== criteriaKey()) return backend.controller.query(criteria()).then(() => undefined);
|
|
266
|
+
return derive(s);
|
|
267
|
+
};
|
|
268
|
+
/** Filter and sort in ONE criteria change, so a tab that sets both runs one query, not two. */
|
|
269
|
+
const setCriteria = (arg: unknown): void => {
|
|
270
|
+
const input = object(arg);
|
|
271
|
+
const sort = String(input.sort ?? '');
|
|
272
|
+
if (sort === 'default' || sort === 'az' || sort === 'za') prefs.sort = sort;
|
|
273
|
+
const filter = String(input.filter ?? '');
|
|
274
|
+
if (filter === 'all' || (filter.startsWith('value:') && (config.filterValues ?? []).includes(filter.slice(6)))) prefs.filter = filter;
|
|
275
|
+
// A screen with no search field clears a query an older screen persisted, or it narrows every view for good.
|
|
276
|
+
if (typeof input.query === 'string') prefs.query = input.query.slice(0, 256);
|
|
277
|
+
};
|
|
278
|
+
const criteria = () => ({ query: prefs.query, sort: prefs.sort, filter: prefs.filter, savedIds: prefs.filter === 'saved' ? prefs.savedIds : [] });
|
|
279
|
+
const criteriaKey = () => JSON.stringify(criteria());
|
|
280
|
+
actions.collectionDismissFilters = change(() => { filtersPresented = false; });
|
|
281
|
+
actions.collectionFilters = change(() => { filtersPresented = !filtersPresented; });
|
|
282
|
+
actions.collectionPreview = change(arg => { previewId = str(arg, 'id'); });
|
|
283
|
+
actions.collectionDismissPreview = change(() => { previewId = ''; });
|
|
284
|
+
actions.collectionView = change(arg => { const value = str(arg, 'selectedId'); if (value === 'list' || value === 'grid') prefs.view = value; });
|
|
285
|
+
actions.collectionDensity = change(arg => { const value = str(arg, 'selectedId'); if (value === 'compact' || value === 'comfortable') prefs.density = value; });
|
|
286
|
+
actions.collectionSort = change(arg => { const value = str(arg, 'selectedId'); if (value === 'default' || value === 'az' || value === 'za') prefs.sort = value; });
|
|
287
|
+
actions.collectionFilter = change(arg => { const values = object(arg).selectedIds; prefs.filter = Array.isArray(values) && typeof values[0] === 'string' ? values[0] : 'all'; });
|
|
288
|
+
actions.collectionSearch = change(arg => { prefs.query = str(arg, 'value').slice(0, 256); });
|
|
289
|
+
actions.collectionCriteria = change(arg => setCriteria(arg));
|
|
290
|
+
actions.collectionViewName = change(arg => { viewName = str(arg, 'value').slice(0, 40); viewMessage = ''; });
|
|
291
|
+
actions.collectionRememberView = change(() => {
|
|
292
|
+
const name = viewName.trim();
|
|
293
|
+
if (!name) { viewMessage = copyParam('collection.nameRequired', 'Give this view a name'); return; }
|
|
294
|
+
const existing = prefs.savedViews.findIndex(v => v.name.toLocaleLowerCase() === name.toLocaleLowerCase());
|
|
295
|
+
if (existing < 0 && prefs.savedViews.length >= 8) { viewMessage = copyParam('collection.viewLimit', 'Remove a saved view to add another (maximum 8)'); return; }
|
|
296
|
+
const { view, density, sort, query, filter } = prefs;
|
|
297
|
+
const entry = { id: `view-${existing < 0 ? prefs.savedViews.length : existing}`, name, view, density, sort, query, filter };
|
|
298
|
+
if (existing < 0) prefs.savedViews.push(entry); else prefs.savedViews[existing] = entry;
|
|
299
|
+
viewName = ''; viewMessage = copyParam('collection.viewSaved', 'View saved');
|
|
300
|
+
});
|
|
301
|
+
actions.collectionApplyView = change(arg => {
|
|
302
|
+
const view = prefs.savedViews.find(v => v.id === str(arg, 'id'));
|
|
303
|
+
if (!view) return;
|
|
304
|
+
const { query, filter, sort, density, view: mode } = view;
|
|
305
|
+
prefs = { ...prefs, query, filter, sort, density, view: mode, selectedIds: [], selecting: false };
|
|
306
|
+
filtersPresented = false; previewId = '';
|
|
307
|
+
});
|
|
308
|
+
actions.collectionRemoveView = change(arg => {
|
|
309
|
+
prefs.savedViews = prefs.savedViews.filter(v => v.id !== str(arg, 'id')).map((v, i) => ({ ...v, id: `view-${i}` }));
|
|
310
|
+
});
|
|
311
|
+
actions.collectionClear = change(() => { prefs.query = ''; prefs.filter = 'all'; prefs.sort = 'default'; });
|
|
312
|
+
actions.collectionSelecting = change(() => { prefs.selecting = !prefs.selecting; if (!prefs.selecting) prefs.selectedIds = []; });
|
|
313
|
+
const known = (s: State, id: string) => !!id && Array.isArray(s[config.rows]) && (s[config.rows] as CollectionRow[]).some(row => collectionId(row) === id);
|
|
314
|
+
actions.collectionSelect = change((arg, s) => { const id = str(arg, 'id'); if (known(s, id)) prefs.selectedIds = prefs.selectedIds.includes(id) ? prefs.selectedIds.filter(x => x !== id) : [...prefs.selectedIds, id]; });
|
|
315
|
+
actions.collectionSave = change((arg, s) => { const id = str(arg, 'id'); if (!known(s, id)) return; lastUndo = [...prefs.savedIds]; prefs.savedIds = prefs.savedIds.includes(id) ? prefs.savedIds.filter(x => x !== id) : [...prefs.savedIds, id]; });
|
|
316
|
+
actions.collectionSaveSelected = change((_arg, s) => { if (!prefs.selectedIds.length || prefs.selectedIds.some(id => !known(s, id))) return; lastUndo = [...prefs.savedIds]; prefs.savedIds = [...new Set([...prefs.savedIds, ...prefs.selectedIds.filter(id => known(s, id))])]; prefs.selecting = false; prefs.selectedIds = []; });
|
|
317
|
+
actions.collectionUndo = change(() => { if (lastUndo) prefs.savedIds = lastUndo; lastUndo = undefined; });
|
|
318
|
+
if (backend) {
|
|
319
|
+
actions.collectionDataChanged = s => derive(backend.apply(s, backend.controller.getState()));
|
|
320
|
+
actions.load = (_s, arg) => {
|
|
321
|
+
stashCopy(arg); syncSaved(); applyNavigationCriteria(arg, _s);
|
|
322
|
+
const reset = backend.onLoad?.(arg);
|
|
323
|
+
if (reset) { setCriteria(reset); persisted.write(prefs); }
|
|
324
|
+
// A load of what the screen already shows is the screen coming back: it revalidates behind its rows.
|
|
325
|
+
return backend.controller.query(criteria(), backend.restorePagesOnLoad, true).then(() => undefined);
|
|
326
|
+
};
|
|
327
|
+
actions.refresh = (_s, arg) => {
|
|
328
|
+
stashCopy(arg); syncSaved();
|
|
329
|
+
return backend.controller.query(criteria()).then(() => undefined);
|
|
330
|
+
};
|
|
331
|
+
actions.loadMore = () => backend.controller.loadMore().then(() => undefined);
|
|
332
|
+
actions.retry = actions.collectionRetry = () => backend.controller.retry().then(() => undefined);
|
|
333
|
+
actions.search = actions.collectionSearch;
|
|
334
|
+
actions.clearSearch = actions.collectionClear;
|
|
335
|
+
}
|
|
336
|
+
// Non-paged collections retain their domain-owned retry behavior. The shared
|
|
337
|
+
// error surface only points at an existing load/refresh reducer; it never
|
|
338
|
+
// invents a fetch or claims that a retry happened.
|
|
339
|
+
if (!actions.collectionRetry && !backend) {
|
|
340
|
+
const retry = actions.refresh ?? actions.load;
|
|
341
|
+
if (retry) actions.collectionRetry = retry;
|
|
342
|
+
}
|
|
343
|
+
return { state: derive(def.state), actions };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Domain-owned page source with shared query controls. Publishing uses a normal
|
|
347
|
+
* reducer and reads the private controller snapshot, never an SDUI payload. */
|
|
348
|
+
export function defineBackendCollection<S extends object, Row extends object>(name: string, options: {
|
|
349
|
+
register?: typeof register;
|
|
350
|
+
/** Explicit mutation preflight; unrelated collection services keep their current reducers. */
|
|
351
|
+
onlineWrites?: boolean;
|
|
352
|
+
/** Where the offline refusal is written (default `actionMessage`) — the field the screen shows next to the action. */
|
|
353
|
+
onlineWriteMessageFields?: readonly string[];
|
|
354
|
+
/** Revalidate already opened pages on navigation return; explicit refresh resets. */
|
|
355
|
+
restorePagesOnLoad?: boolean;
|
|
356
|
+
/** The load action's argument (onAppear params beyond `copy`), before the query runs — for a
|
|
357
|
+
* domain setting that rides the descriptor, e.g. My Bookings' `weeklyGoal`. */
|
|
358
|
+
onLoad?(arg: unknown): CollectionCriteriaReset | void;
|
|
359
|
+
state: S;
|
|
360
|
+
source: CollectionQuerySource<Row>;
|
|
361
|
+
apply(state: S, page: CollectionQueryState<Row>): S;
|
|
362
|
+
saving?: CollectionSaving;
|
|
363
|
+
actions?: Record<string, (state: S, ...args: unknown[]) => S | undefined | Promise<S | undefined>>;
|
|
364
|
+
}) {
|
|
365
|
+
let publish: (() => unknown) | undefined;
|
|
366
|
+
const controller = createCollectionQuery(options.source, () => { publish?.(); });
|
|
367
|
+
const definition = decorateCollection(name, { state: options.state as State, actions: options.actions as Record<string, Reducer> | undefined }, options.saving, {
|
|
368
|
+
restorePagesOnLoad: options.restorePagesOnLoad,
|
|
369
|
+
onLoad: options.onLoad,
|
|
370
|
+
controller: controller as unknown as BackendCollection['controller'],
|
|
371
|
+
apply: (state, page) => options.apply(state as S, page as unknown as CollectionQueryState<Row>) as State,
|
|
372
|
+
});
|
|
373
|
+
// Compatibility starters retain their existing mutation behavior; accept the
|
|
374
|
+
// current authoring contract without enabling the offline-write rollout.
|
|
375
|
+
const handle = (options.register ?? register)(name, definition);
|
|
376
|
+
// Registration restored the state a cold boot saved: the page it showed is this run's, before the first load. Only
|
|
377
|
+
// when the service's own rows came back with it: a bundle that keeps them out of the restore (its `notHydrated`)
|
|
378
|
+
// starts its first load as before, never from rows it chose not to restore. Rows that came back empty are the
|
|
379
|
+
// list's own only when the page it showed was empty too; a page with rows means they were kept out.
|
|
380
|
+
const restored = (handle as { getState?: () => State }).getState?.();
|
|
381
|
+
const rowsKey = COLLECTIONS[name]?.rows;
|
|
382
|
+
const restoredRows = rowsKey ? restored?.[rowsKey] : undefined;
|
|
383
|
+
const shownItems = (restored?.collectionShown as { state?: { items?: unknown } } | undefined)?.state?.items;
|
|
384
|
+
const shownEmpty = Array.isArray(shownItems) && shownItems.length === 0;
|
|
385
|
+
if (Array.isArray(restoredRows) && (restoredRows.length > 0 || shownEmpty)) controller.seed(restored?.collectionShown);
|
|
386
|
+
publish = () => handle.collectionDataChanged();
|
|
387
|
+
return handle;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Keep the domain service's existing TypeScript API. Extra collection verbs are
|
|
391
|
+
* dispatched through SDUI and do not widen the native capability surface. */
|
|
392
|
+
export const defineService: typeof register = ((name: string, def: ServiceDefinition<unknown>) =>
|
|
393
|
+
register(name, { ...def, ...decorateCollection(name, def as unknown as { state: State; actions?: Record<string, Reducer> }) } as ServiceDefinition<State>)) as typeof register;
|
|
394
|
+
|
|
395
|
+
/** Domains can supply their existing saved-items store without putting domain
|
|
396
|
+
* data or mutations in the shared collection layer. */
|
|
397
|
+
export function withCollectionSaving(saving: CollectionSaving): typeof register {
|
|
398
|
+
return ((name: string, def: ServiceDefinition<unknown>) =>
|
|
399
|
+
register(name, decorateCollection(name, def as unknown as { state: State; actions?: Record<string, Reducer> }, saving))) as typeof register;
|
|
400
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Whether a detail screen's entity is there — the worker half of the shared not-found
|
|
2
|
+
// state (the `detailNotFound` descriptor builder).
|
|
3
|
+
//
|
|
4
|
+
// A detail screen's descriptor asks two questions, both as `hiddenWhen` flags:
|
|
5
|
+
// • `noEntity` → hide the entity's content AND its actions (Like, Follow, Send…)
|
|
6
|
+
// • `hideNotFound` → hide the not-found state
|
|
7
|
+
// Four answers exist, and only one of them shows the not-found state:
|
|
8
|
+
// loading — a loading line (`hideLoading` false); no content, no actions, no verdict
|
|
9
|
+
// found — content + actions shown
|
|
10
|
+
// missing — not-found shown, content + actions hidden
|
|
11
|
+
// failed — a load-error line (`hideLoadError` false); no content, no actions
|
|
12
|
+
// A load that FAILS for any reason other than "not found" is `failed`, never `missing`:
|
|
13
|
+
// an offline device must not tell the user their invoice was deleted.
|
|
14
|
+
|
|
15
|
+
export type Presence = "loading" | "found" | "missing" | "failed";
|
|
16
|
+
|
|
17
|
+
export interface PresenceFlags {
|
|
18
|
+
found: boolean;
|
|
19
|
+
notFound: boolean;
|
|
20
|
+
noEntity: boolean;
|
|
21
|
+
hideNotFound: boolean;
|
|
22
|
+
hideLoading: boolean;
|
|
23
|
+
hideLoadError: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function presence(p: Presence): PresenceFlags {
|
|
27
|
+
return {
|
|
28
|
+
found: p === "found", notFound: p === "missing", noEntity: p !== "found", hideNotFound: p !== "missing",
|
|
29
|
+
hideLoading: p !== "loading", hideLoadError: p !== "failed",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The entity id the host baked into this detail screen (`himi.open` params). */
|
|
34
|
+
export function detailInputId(): string {
|
|
35
|
+
const v = (globalThis as { himalaya?: { input?: { id?: unknown } } }).himalaya?.input?.id;
|
|
36
|
+
return typeof v === "string" ? v.trim() : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when a Wix read failed because the record does not exist: the SDK rejects
|
|
40
|
+
* with "HTTP <code>: <body>", so an HTTP 404, or Wix's `NOT_FOUND` error code. A bare
|
|
41
|
+
* "not found" in a message is NOT enough — "capability wix_api not found" or "Method
|
|
42
|
+
* not found" is a broken integration, and must not tell the user the record is gone. */
|
|
43
|
+
export function isNotFoundError(error: unknown): boolean {
|
|
44
|
+
const msg = String((error as { message?: unknown } | null)?.message ?? error ?? "");
|
|
45
|
+
return /\bHTTP\s*404\b/.test(msg) || /\bNOT_FOUND\b/.test(msg);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `missing` for a not-found failure, `failed` (claim nothing) otherwise. */
|
|
49
|
+
export function presenceAfterError(error: unknown): PresenceFlags {
|
|
50
|
+
return presence(isNotFoundError(error) ? "missing" : "failed");
|
|
51
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Empty-collection flags for descriptors.
|
|
2
|
+
//
|
|
3
|
+
// A bound Grid/LazyList with zero rows still renders a zero-height box, and a stack
|
|
4
|
+
// allocates its `spacing` between CHILDREN, not between painted children — so every
|
|
5
|
+
// empty list left a full gap of blank on the screen (the measure pass's `dead-gap`, on
|
|
6
|
+
// iOS and Android as much as on web). `hiddenWhen` hides on a TRUE scalar only, and an
|
|
7
|
+
// empty array is not one, so a descriptor cannot gate a list on its own rows.
|
|
8
|
+
//
|
|
9
|
+
// `withEmptyFlags(def, { rows: "noRows" })` derives `noRows = rows is empty` into the
|
|
10
|
+
// initial state and after EVERY action (sync or async), so a builder can write
|
|
11
|
+
// `hiddenWhen(grid(...), "$service:svc.noRows")` without each reducer remembering to.
|
|
12
|
+
type State = Record<string, unknown>;
|
|
13
|
+
type Action = (s: State, ...args: unknown[]) => unknown;
|
|
14
|
+
|
|
15
|
+
export function withEmptyFlags<D extends { state: object; actions?: object }>(def: D, flags: Record<string, string>): D {
|
|
16
|
+
const derive = <T>(s: T): T => {
|
|
17
|
+
if (!s || typeof s !== "object") return s;
|
|
18
|
+
const out = { ...(s as State) };
|
|
19
|
+
for (const [rows, flag] of Object.entries(flags)) {
|
|
20
|
+
const v = out[rows];
|
|
21
|
+
out[flag] = !Array.isArray(v) || v.length === 0;
|
|
22
|
+
}
|
|
23
|
+
return out as T;
|
|
24
|
+
};
|
|
25
|
+
const actions: Record<string, Action> = {};
|
|
26
|
+
for (const [name, action] of Object.entries((def.actions ?? {}) as Record<string, Action>)) {
|
|
27
|
+
actions[name] = (s, ...args) => {
|
|
28
|
+
const r = action(s, ...args);
|
|
29
|
+
return r && typeof (r as Promise<unknown>).then === "function" ? (r as Promise<unknown>).then(derive) : derive(r);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { ...def, state: derive(def.state), actions } as D;
|
|
33
|
+
}
|