@stapel/search-react 0.9.0 → 0.10.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/CHANGELOG.md +55 -0
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +8 -23
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +24 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -0
- package/dist/default/LocationSummaryLine.js +83 -0
- package/dist/default/LocationSummaryLine.js.map +1 -0
- package/dist/default/SearchPage.d.ts +19 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +6 -6
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/default/geoSheet.d.ts +61 -0
- package/dist/default/geoSheet.d.ts.map +1 -0
- package/dist/default/geoSheet.js +63 -0
- package/dist/default/geoSheet.js.map +1 -0
- package/dist/default/index.d.ts +2 -0
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -0
- package/dist/default/index.js.map +1 -1
- package/dist/headless/useSearchCount.d.ts +44 -0
- package/dist/headless/useSearchCount.d.ts.map +1 -0
- package/dist/headless/useSearchCount.js +121 -0
- package/dist/headless/useSearchCount.js.map +1 -0
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +10 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +11 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +1 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/state/limits.d.ts +21 -0
- package/dist/state/limits.d.ts.map +1 -1
- package/dist/state/limits.js +21 -0
- package/dist/state/limits.js.map +1 -1
- package/llms.txt +3 -2
- package/manifest.json +31 -3
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/default/FilterChips.tsx +17 -56
- package/src/default/LocationSummaryLine.tsx +187 -0
- package/src/default/SearchPage.tsx +33 -3
- package/src/default/geoSheet.tsx +161 -0
- package/src/default/index.ts +3 -0
- package/src/headless/useSearchCount.ts +189 -0
- package/src/i18n/es.ts +1 -0
- package/src/i18n/keys.ts +11 -0
- package/src/i18n/ru.ts +1 -0
- package/src/index.ts +8 -0
- package/src/state/limits.ts +23 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useSearchCount(state)` — how many results a search state WOULD return,
|
|
3
|
+
* without showing any of them.
|
|
4
|
+
*
|
|
5
|
+
* A quick-search panel's button ("Show 128 listings") has to know the total
|
|
6
|
+
* for a state that is not on screen and not in the URL: the person is still
|
|
7
|
+
* composing it. `<SearchResults>` cannot answer that — it reads the committed
|
|
8
|
+
* URL state — and `useAppliedCount` deliberately reads the page already in
|
|
9
|
+
* cache. So this is a read of its own, over a state the caller hands in.
|
|
10
|
+
*
|
|
11
|
+
* ── THE GAP THIS HOOK RIDES OVER, STATED ──────────────────────────────────
|
|
12
|
+
*
|
|
13
|
+
* **stapel-search has no count-only endpoint.** `SearchApi` is `query`,
|
|
14
|
+
* `suggest` and `ranking`; nothing answers "how many" without also assembling
|
|
15
|
+
* a page. So this hook issues the ORDINARY query with the smallest page the
|
|
16
|
+
* endpoint will serve ({@link SEARCH_COUNT_PAGE_SIZE}) and facet counting
|
|
17
|
+
* switched off, and reads `count` out of the envelope it gets back. That is a
|
|
18
|
+
* deliberate choice with a cost: the engine still ranks the candidate set and
|
|
19
|
+
* the backend still serializes one row, so a count is roughly as expensive as
|
|
20
|
+
* a search. It is not a bug to be worked around client-side — a debounce is
|
|
21
|
+
* the mitigation, not a fix.
|
|
22
|
+
*
|
|
23
|
+
* FOLLOW-UP (stapel-search): a `GET /count` verb that answers the envelope's
|
|
24
|
+
* three count fields (`count`, `count_is_lower_bound`, `exact_total`) plus
|
|
25
|
+
* `degraded[]` and nothing else. When it lands, this hook's body changes and
|
|
26
|
+
* its signature does not.
|
|
27
|
+
*
|
|
28
|
+
* ── What it asks, and what it deliberately does not ───────────────────────
|
|
29
|
+
*
|
|
30
|
+
* The state is normalized before it goes out, and each removal is a rule:
|
|
31
|
+
*
|
|
32
|
+
* - `facets: "off"` — a count does not draw a facet panel, and counting
|
|
33
|
+
* facets is the expensive half of the request.
|
|
34
|
+
* - `limit: 1` — the smallest page. Zero is not a page size the endpoint
|
|
35
|
+
* documents, and asking for the default 24 would serialize 24 cards nobody
|
|
36
|
+
* renders.
|
|
37
|
+
* - `anchor` / `direction` dropped — a cursor asks about a PAGE, and the
|
|
38
|
+
* count is about the whole result set. Keeping one would also cache the
|
|
39
|
+
* same total once per page a person walked through.
|
|
40
|
+
* - `sort` dropped — the total does not depend on the order, and keeping it
|
|
41
|
+
* would miss the cache every time somebody changed the sort.
|
|
42
|
+
*
|
|
43
|
+
* Everything else — `q`, `category`, `owner`, filters, ranges, geo, `lang` —
|
|
44
|
+
* is exactly what a real search would carry, because those are what change the
|
|
45
|
+
* answer.
|
|
46
|
+
*
|
|
47
|
+
* ── The count is a LoadState, and the number is not always a number ────────
|
|
48
|
+
*
|
|
49
|
+
* The ready value carries `countKind`: `"exact"` is a total, `"at_least"` is
|
|
50
|
+
* a floor, `"unknown"` is the engine declining to say — and `count: null`
|
|
51
|
+
* under `"unknown"` is never `0`. A skin that renders the floor as a total is
|
|
52
|
+
* the defect `state/degradations.ts` exists to prevent, so the kind travels
|
|
53
|
+
* with the number rather than beside it.
|
|
54
|
+
*/
|
|
55
|
+
import { useEffect, useRef, useState } from "react";
|
|
56
|
+
import { loadStateFromQuery, mapLoad } from "@stapel/core";
|
|
57
|
+
import type { LoadState } from "@stapel/core";
|
|
58
|
+
import { searchQueryParams } from "../api/searchApi.js";
|
|
59
|
+
import type { SearchQueryState } from "../api/types.js";
|
|
60
|
+
import { useSearchQuery } from "../model/queries.js";
|
|
61
|
+
import {
|
|
62
|
+
SEARCH_COUNT_DEBOUNCE_MS,
|
|
63
|
+
SEARCH_COUNT_PAGE_SIZE,
|
|
64
|
+
} from "../state/limits.js";
|
|
65
|
+
import { countKind, parseDegradations } from "../state/degradations.js";
|
|
66
|
+
import type { SearchCountKind } from "../state/degradations.js";
|
|
67
|
+
|
|
68
|
+
/** What a count read answers. */
|
|
69
|
+
export interface SearchCount {
|
|
70
|
+
/** `null` is "the engine cannot say" — a real state, and NOT zero. */
|
|
71
|
+
readonly count: number | null;
|
|
72
|
+
/** Whether that number may be spoken as a total, as a floor, or at all. */
|
|
73
|
+
readonly kind: SearchCountKind;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The state a count is asked about: a doc type plus as much or as little of a
|
|
78
|
+
* search as the caller has composed. Partial on purpose — a panel holding one
|
|
79
|
+
* chosen facet is a legitimate question, and making it build a full
|
|
80
|
+
* {@link SearchQueryState} would mean spelling `filters: {}` and `ranges: {}`
|
|
81
|
+
* at every call site.
|
|
82
|
+
*/
|
|
83
|
+
export type SearchCountState = Pick<SearchQueryState, "type"> &
|
|
84
|
+
Partial<Omit<SearchQueryState, "type">>;
|
|
85
|
+
|
|
86
|
+
export interface UseSearchCountOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Ask at all. Default `true`. `false` holds the hook at `loading` (the
|
|
89
|
+
* fleet's shape for "no answer yet"), so a panel that is still resolving its
|
|
90
|
+
* category does not ask about a state it has not finished building.
|
|
91
|
+
*/
|
|
92
|
+
readonly enabled?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Quiet time before a CHANGED state is asked about (default
|
|
95
|
+
* {@link SEARCH_COUNT_DEBOUNCE_MS}). The first state is asked about
|
|
96
|
+
* immediately — a panel that opens should not wait a quarter second to say
|
|
97
|
+
* its number — and every change after that is coalesced, because a count
|
|
98
|
+
* rides the full query (see this file's header) and a request per keystroke
|
|
99
|
+
* is how a throttled endpoint starts answering 429.
|
|
100
|
+
*
|
|
101
|
+
* `0` disables it, for a caller whose state only changes on a commit.
|
|
102
|
+
*/
|
|
103
|
+
readonly debounceMs?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The wire form of a count question — exported so a test (or a host building
|
|
108
|
+
* its own key) can see exactly what is asked, rather than inferring it.
|
|
109
|
+
*/
|
|
110
|
+
export function countQueryState(state: SearchCountState): SearchQueryState {
|
|
111
|
+
const {
|
|
112
|
+
anchor: _anchor,
|
|
113
|
+
direction: _direction,
|
|
114
|
+
sort: _sort,
|
|
115
|
+
...rest
|
|
116
|
+
} = state;
|
|
117
|
+
return {
|
|
118
|
+
q: "",
|
|
119
|
+
filters: {},
|
|
120
|
+
ranges: {},
|
|
121
|
+
...rest,
|
|
122
|
+
facets: "off",
|
|
123
|
+
limit: SEARCH_COUNT_PAGE_SIZE,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A stable string for "is this the same question". Built from the same wire
|
|
129
|
+
* object the query key is built from, with the keys sorted, so two states
|
|
130
|
+
* assembled in a different order are one question and not two.
|
|
131
|
+
*/
|
|
132
|
+
function questionOf(state: SearchQueryState): string {
|
|
133
|
+
const params = searchQueryParams(state);
|
|
134
|
+
return JSON.stringify(
|
|
135
|
+
Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function useSearchCount(
|
|
140
|
+
state: SearchCountState,
|
|
141
|
+
options: UseSearchCountOptions = {}
|
|
142
|
+
): LoadState<SearchCount> {
|
|
143
|
+
const target = countQueryState(state);
|
|
144
|
+
const question = questionOf(target);
|
|
145
|
+
const debounceMs = options.debounceMs ?? SEARCH_COUNT_DEBOUNCE_MS;
|
|
146
|
+
|
|
147
|
+
// The state the timer will settle ON when it fires — always the newest one,
|
|
148
|
+
// not the one that started the timer. Typing "hond" then "honda" must ask
|
|
149
|
+
// about "honda" once, never about "hond" late.
|
|
150
|
+
const latest = useRef(target);
|
|
151
|
+
latest.current = target;
|
|
152
|
+
|
|
153
|
+
const [settled, setSettled] = useState<{
|
|
154
|
+
readonly question: string;
|
|
155
|
+
readonly state: SearchQueryState;
|
|
156
|
+
}>(() => ({ question, state: target }));
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (settled.question === question) return undefined;
|
|
160
|
+
if (debounceMs <= 0) {
|
|
161
|
+
setSettled({ question, state: latest.current });
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const handle = setTimeout(() => {
|
|
165
|
+
setSettled({ question, state: latest.current });
|
|
166
|
+
}, debounceMs);
|
|
167
|
+
return () => {
|
|
168
|
+
clearTimeout(handle);
|
|
169
|
+
};
|
|
170
|
+
}, [question, settled.question, debounceMs]);
|
|
171
|
+
|
|
172
|
+
// `useSearchQuery` keeps the previous answer on screen while the next one is
|
|
173
|
+
// in flight (`keepPreviousData`), which is what a counted button wants: the
|
|
174
|
+
// number belongs to the last state we actually asked about, and it does not
|
|
175
|
+
// blank between two of them.
|
|
176
|
+
const query = useSearchQuery(settled.state, {
|
|
177
|
+
enabled: options.enabled ?? true,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return mapLoad(loadStateFromQuery(query), (data) => ({
|
|
181
|
+
count: data.count,
|
|
182
|
+
kind: countKind(
|
|
183
|
+
data.count,
|
|
184
|
+
data.count_is_lower_bound,
|
|
185
|
+
data.exact_total,
|
|
186
|
+
parseDegradations(data.degraded)
|
|
187
|
+
),
|
|
188
|
+
}));
|
|
189
|
+
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -109,6 +109,7 @@ export const searchI18nBundleEs: I18nDictionary = {
|
|
|
109
109
|
"search.geo.radius_km": "A menos de {km} km",
|
|
110
110
|
"search.geo.radius_label": "Radio, km",
|
|
111
111
|
"search.geo.clear": "En cualquier lugar",
|
|
112
|
+
"search.geo.everywhere": "Buscando en todas partes",
|
|
112
113
|
"search.geo.box": "Dentro del área mostrada",
|
|
113
114
|
"search.geo.chosen_place": "Un lugar elegido en el mapa",
|
|
114
115
|
|
package/src/i18n/keys.ts
CHANGED
|
@@ -151,6 +151,16 @@ export const SEARCH_I18N_KEYS = {
|
|
|
151
151
|
geoRadiusKm: "search.geo.radius_km",
|
|
152
152
|
geoRadiusLabel: "search.geo.radius_label",
|
|
153
153
|
geoClear: "search.geo.clear",
|
|
154
|
+
/**
|
|
155
|
+
* What `<LocationSummaryLine>` says when NO location is applied.
|
|
156
|
+
*
|
|
157
|
+
* Not `search.geo.clear` reused: that is the label on a BUTTON that widens
|
|
158
|
+
* the search ("Anywhere"), and this is a STATEMENT about where the search is
|
|
159
|
+
* currently looking. The two are the same word in English and diverge the
|
|
160
|
+
* moment a translator treats one as an imperative — which is exactly the
|
|
161
|
+
* class of bug a shared key produces and nobody sees in the source locale.
|
|
162
|
+
*/
|
|
163
|
+
geoEverywhere: "search.geo.everywhere",
|
|
154
164
|
geoBox: "search.geo.box",
|
|
155
165
|
/**
|
|
156
166
|
* What a location constraint is called when nobody has given it a NAME.
|
|
@@ -326,6 +336,7 @@ export const searchI18nBundleEn: Record<string, string> = {
|
|
|
326
336
|
"search.geo.radius_km": "Within {km} km",
|
|
327
337
|
"search.geo.radius_label": "Radius, km",
|
|
328
338
|
"search.geo.clear": "Anywhere",
|
|
339
|
+
"search.geo.everywhere": "Searching everywhere",
|
|
329
340
|
"search.geo.box": "Inside the shown area",
|
|
330
341
|
"search.geo.chosen_place": "A chosen place on the map",
|
|
331
342
|
|
package/src/i18n/ru.ts
CHANGED
|
@@ -136,6 +136,7 @@ export const searchI18nBundleRu: I18nDictionary = {
|
|
|
136
136
|
"search.geo.radius_km": "В радиусе {km} км",
|
|
137
137
|
"search.geo.radius_label": "Радиус, км",
|
|
138
138
|
"search.geo.clear": "Везде",
|
|
139
|
+
"search.geo.everywhere": "Ищем везде",
|
|
139
140
|
"search.geo.box": "В показанной области",
|
|
140
141
|
"search.geo.chosen_place": "Выбранное место на карте",
|
|
141
142
|
|
package/src/index.ts
CHANGED
|
@@ -113,6 +113,8 @@ export type { BuildRangeGroupsInput, RangeGroup } from "./state/ranges.js";
|
|
|
113
113
|
export {
|
|
114
114
|
SEARCH_BOX_DEBOUNCE_MS,
|
|
115
115
|
SEARCH_BOX_SUGGEST_DEBOUNCE_MS,
|
|
116
|
+
SEARCH_COUNT_DEBOUNCE_MS,
|
|
117
|
+
SEARCH_COUNT_PAGE_SIZE,
|
|
116
118
|
SEARCH_DEFAULT_PAGE_SIZE,
|
|
117
119
|
SEARCH_MAX_PAGE_SIZE,
|
|
118
120
|
SEARCH_QUERY_MAX_CHARS,
|
|
@@ -152,6 +154,12 @@ export type { FacetPanelBag } from "./headless/FacetPanel.js";
|
|
|
152
154
|
export { useAppliedSort } from "./headless/useAppliedSort.js";
|
|
153
155
|
export { useSearchBox } from "./headless/useSearchBox.js";
|
|
154
156
|
export type { SearchBoxBag, UseSearchBoxOptions } from "./headless/useSearchBox.js";
|
|
157
|
+
export { countQueryState, useSearchCount } from "./headless/useSearchCount.js";
|
|
158
|
+
export type {
|
|
159
|
+
SearchCount,
|
|
160
|
+
SearchCountState,
|
|
161
|
+
UseSearchCountOptions,
|
|
162
|
+
} from "./headless/useSearchCount.js";
|
|
155
163
|
export { RankingDisclosure } from "./headless/RankingDisclosure.js";
|
|
156
164
|
export type { RankingDisclosureBag } from "./headless/RankingDisclosure.js";
|
|
157
165
|
|
package/src/state/limits.ts
CHANGED
|
@@ -27,6 +27,29 @@ export const SEARCH_MAX_PAGE_SIZE = 100;
|
|
|
27
27
|
/** `services.suggest` clamps `limit` into `1..25`. */
|
|
28
28
|
export const SUGGEST_MAX_LIMIT = 25;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The page a COUNT read asks for.
|
|
32
|
+
*
|
|
33
|
+
* There is no count-only endpoint (`SearchApi` is query/suggest/ranking), so
|
|
34
|
+
* `useSearchCount` rides the ordinary query and takes the total out of the
|
|
35
|
+
* envelope. One row is the smallest page the endpoint documents — `limit=0` is
|
|
36
|
+
* not a page size — and it is the difference between serializing one card
|
|
37
|
+
* nobody renders and serializing the default twenty-four.
|
|
38
|
+
*/
|
|
39
|
+
export const SEARCH_COUNT_PAGE_SIZE = 1;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How long a composing panel has to stop changing before its count is asked
|
|
43
|
+
* for.
|
|
44
|
+
*
|
|
45
|
+
* Shorter than the search commit (350ms): a count follows discrete choices — a
|
|
46
|
+
* select, a chip, a range end — rather than keystrokes, and a button whose
|
|
47
|
+
* number arrives a third of a second after the choice reads as broken. Longer
|
|
48
|
+
* than zero: the read is a full query (see `SEARCH_COUNT_PAGE_SIZE`), so a
|
|
49
|
+
* person walking a select's options must not spend one search per option.
|
|
50
|
+
*/
|
|
51
|
+
export const SEARCH_COUNT_DEBOUNCE_MS = 250;
|
|
52
|
+
|
|
30
53
|
/**
|
|
31
54
|
* The shortest prefix worth asking the index about.
|
|
32
55
|
*
|