@stapel/search-react 0.20.0 → 0.21.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 +88 -0
- package/dist/api/generated/schema.d.ts +96 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +47 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +27 -1
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/LocationSummaryLine.d.ts +3 -0
- package/dist/default/LocationSummaryLine.d.ts.map +1 -1
- package/dist/default/LocationSummaryLine.js +55 -8
- package/dist/default/LocationSummaryLine.js.map +1 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +5 -0
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +29 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +14 -0
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +15 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +38 -3
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +6 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +42 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +49 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +8 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +9 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +13 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/facets.d.ts +3 -0
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +48 -1
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +26 -1
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +43 -17
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +12 -2
- package/nav-manifest.json +1 -1
- package/package.json +4 -4
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +96 -4
- package/src/api/types.ts +51 -5
- package/src/default/FacetPanelPane.tsx +36 -1
- package/src/default/LocationSummaryLine.tsx +114 -53
- package/src/default/SearchPage.tsx +5 -0
- package/src/headless/FacetPanel.tsx +49 -1
- package/src/headless/SearchStateProvider.tsx +59 -3
- package/src/i18n/es.ts +10 -0
- package/src/i18n/keys.ts +53 -0
- package/src/i18n/ru.ts +14 -0
- package/src/index.ts +3 -0
- package/src/state/degradations.ts +14 -0
- package/src/state/facets.ts +47 -1
- package/src/state/urlState.ts +72 -13
|
@@ -17,12 +17,26 @@ import type { SearchDegradation, SearchDegradationKind } from "../api/types.js";
|
|
|
17
17
|
|
|
18
18
|
const SCORER_PREFIX = "scorer:";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* "This answer's facet plan could not be drawn from evidence."
|
|
22
|
+
*
|
|
23
|
+
* Named rather than inlined because the FACET PANEL reads the raw literal
|
|
24
|
+
* off the envelope: whether "this search offers no filters" is a true
|
|
25
|
+
* sentence is a question about THIS answer, not about the banner, and the
|
|
26
|
+
* panel must not have to parse the whole list to ask it.
|
|
27
|
+
*/
|
|
28
|
+
export const FACET_PLAN_EVIDENCE = "facet_plan_evidence";
|
|
29
|
+
|
|
20
30
|
const KNOWN: Readonly<Record<string, SearchDegradationKind>> = {
|
|
21
31
|
typo_tolerance: "typo_tolerance",
|
|
22
32
|
phrase_synonyms: "phrase_synonyms",
|
|
23
33
|
exact_total: "exact_total",
|
|
24
34
|
exact_facet_counts: "exact_facet_counts",
|
|
25
35
|
category_rollup: "category_rollup",
|
|
36
|
+
// Reader-facing by omission from OPERATOR_KINDS below, and deliberately:
|
|
37
|
+
// it changes what an empty filter panel MEANS, which is the one thing on
|
|
38
|
+
// the page a buyer can act on (widen, or filter by category instead).
|
|
39
|
+
[FACET_PLAN_EVIDENCE]: "facet_plan_evidence",
|
|
26
40
|
};
|
|
27
41
|
|
|
28
42
|
/** i18n key per kind (`search.degraded.*`). */
|
package/src/state/facets.ts
CHANGED
|
@@ -362,6 +362,9 @@ export function facetOptionLabel(
|
|
|
362
362
|
* a chip in the row. The APPLIED-filter clause outranks the type rule: a
|
|
363
363
|
* constraint the URL carries always gets its control back, whatever the schema
|
|
364
364
|
* now says about it, or a person is left holding a filter they cannot clear.
|
|
365
|
+
*
|
|
366
|
+
* A COUNTED group with zero coverage goes the same way, and for the same
|
|
367
|
+
* reason — see {@link keepsAnAxisOpen}.
|
|
365
368
|
*/
|
|
366
369
|
export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGroup[] {
|
|
367
370
|
const bySlug = new Map<string, FeatureDef>();
|
|
@@ -462,5 +465,48 @@ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGr
|
|
|
462
465
|
selected: selected.includes(value),
|
|
463
466
|
})),
|
|
464
467
|
};
|
|
465
|
-
});
|
|
468
|
+
}).filter(keepsAnAxisOpen);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Is this group an axis a person can actually move along?
|
|
473
|
+
*
|
|
474
|
+
* The coverage floor stops at the queried category's own schema, on the
|
|
475
|
+
* server, on purpose: `FACET_MIN_COVERAGE` governs only the slugs an
|
|
476
|
+
* evidence plan BORROWED from sibling leaves, because "a closed option set
|
|
477
|
+
* answering with its zeros is a shipped decision". That is right about an
|
|
478
|
+
* option and wrong about a GROUP. A size chart showing `XL — 0` beside
|
|
479
|
+
* `M — 12` is telling the truth about a shape the reader wants to see whole;
|
|
480
|
+
* a group whose every option is 0 is not a shape, it is three checkboxes that
|
|
481
|
+
* are each guaranteed to return nothing, and it costs a heading in the rail
|
|
482
|
+
* and a chip on a 390px row to say so.
|
|
483
|
+
*
|
|
484
|
+
* Measured on the deployed phones leaf: `sim_config`, `device_history` and
|
|
485
|
+
* `set` are authored `select` features that no listing in the leaf fills.
|
|
486
|
+
* The server's `fill_zero_options` creates the slug and zero-fills every
|
|
487
|
+
* authored option, so all three arrive counted, complete and dead, and the
|
|
488
|
+
* withholding loop never looks at them because an authored plan has no
|
|
489
|
+
* `evidence`. Nothing on the wire marks them: the client has to sum the
|
|
490
|
+
* buckets itself, which is exactly what {@link facetCoverage} already does
|
|
491
|
+
* for two other surfaces.
|
|
492
|
+
*
|
|
493
|
+
* Three things this must NOT drop, which is why the predicate is this narrow:
|
|
494
|
+
*
|
|
495
|
+
* - an UNCOUNTED group (`counted: false`). Its options carry `count: null`,
|
|
496
|
+
* so it sums to zero for the opposite reason — nobody looked. "We did not
|
|
497
|
+
* count this" and "there are none" are different sentences, and dropping
|
|
498
|
+
* on the first is the regression the `MAX_FACET_FIELDS` branch below
|
|
499
|
+
* exists to prevent (a live cars leaf: 26 facetable features declared, 12
|
|
500
|
+
* counted, and `/query` accepts `f.<slug>` for all 26).
|
|
501
|
+
* - a group the reader has ALREADY filtered on, whatever its counts say —
|
|
502
|
+
* the same clause that outranks the type rule. Withholding that group
|
|
503
|
+
* leaves a constraint applied with no control to undo it.
|
|
504
|
+
* - a group with any non-zero option. A zero option beside a live one is
|
|
505
|
+
* drill-down working as designed: it reports what swapping to that value
|
|
506
|
+
* would get you, and the answer being "nothing" is information.
|
|
507
|
+
*/
|
|
508
|
+
function keepsAnAxisOpen(group: FacetGroup): boolean {
|
|
509
|
+
if (!group.counted) return true;
|
|
510
|
+
if (group.selected.length > 0) return true;
|
|
511
|
+
return facetCoverage(group) > 0;
|
|
466
512
|
}
|
package/src/state/urlState.ts
CHANGED
|
@@ -74,19 +74,45 @@ export type SearchStateIssueCode =
|
|
|
74
74
|
/** `bbox` was not four numbers. */
|
|
75
75
|
| "bbox_malformed"
|
|
76
76
|
/** `r.<slug>` was not `from..to`. */
|
|
77
|
-
| "range_malformed"
|
|
77
|
+
| "range_malformed"
|
|
78
|
+
/**
|
|
79
|
+
* `radius_km` with nothing to measure it FROM — no `lat`/`lon`, no `bbox`.
|
|
80
|
+
*
|
|
81
|
+
* Not malformed: `radius_km=300` is a perfectly good number, and the search
|
|
82
|
+
* that ran is the honest one (a radius with no centre narrows nothing, so
|
|
83
|
+
* the count is the full count). What was wrong was the SILENCE. The URL said
|
|
84
|
+
* 300km, the results were unnarrowed, and the location control went on
|
|
85
|
+
* advertising its own default — measured on a live leaf as "300 in the
|
|
86
|
+
* address bar, 25 on the screen" with no way for a person to tell which one
|
|
87
|
+
* the page had used. Neither number is dropped and neither is rewritten;
|
|
88
|
+
* the disagreement is simply stated.
|
|
89
|
+
*/
|
|
90
|
+
| "radius_without_place";
|
|
78
91
|
|
|
79
92
|
const ISSUE_KEY: Readonly<Record<SearchStateIssueCode, string>> = {
|
|
80
93
|
not_a_number: "search.url.issue.not_a_number",
|
|
81
94
|
geo_incomplete: "search.url.issue.geo_incomplete",
|
|
82
95
|
bbox_malformed: "search.url.issue.bbox_malformed",
|
|
83
96
|
range_malformed: "search.url.issue.range_malformed",
|
|
97
|
+
radius_without_place: "search.url.issue.radius_without_place",
|
|
84
98
|
};
|
|
85
99
|
|
|
86
100
|
/** What {@link parseSearchState} answers: the state, plus what it could not read. */
|
|
87
101
|
export interface ParsedSearchState {
|
|
88
102
|
readonly state: SearchQueryState;
|
|
89
103
|
readonly issues: readonly SearchStateIssue[];
|
|
104
|
+
/**
|
|
105
|
+
* A `radius_km` the URL carries with no place to measure it from.
|
|
106
|
+
*
|
|
107
|
+
* NOT part of the state — a radius with no centre is not a filter and
|
|
108
|
+
* nothing is sent to the server — but it is a number the person typed, and
|
|
109
|
+
* dropping it on the floor is how the control and the address ended up
|
|
110
|
+
* disagreeing: `?radius_km=300`, a button offering "within 25 km", and a
|
|
111
|
+
* press that silently rewrote 300 to 25. Whoever draws the offer reads this
|
|
112
|
+
* so that what the button SAYS, what pressing it DOES, and what the link
|
|
113
|
+
* ASKED FOR are one number.
|
|
114
|
+
*/
|
|
115
|
+
readonly orphanRadiusKm?: number;
|
|
90
116
|
}
|
|
91
117
|
|
|
92
118
|
export interface ParseSearchStateOptions {
|
|
@@ -122,10 +148,16 @@ function num(
|
|
|
122
148
|
return value;
|
|
123
149
|
}
|
|
124
150
|
|
|
151
|
+
/** Where `parseGeo` leaves a radius it could not attach to anything. */
|
|
152
|
+
interface GeoParse {
|
|
153
|
+
readonly geo: SearchGeo | undefined;
|
|
154
|
+
readonly orphanRadiusKm?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
125
157
|
function parseGeo(
|
|
126
158
|
params: URLSearchParams,
|
|
127
159
|
issues: SearchStateIssue[]
|
|
128
|
-
):
|
|
160
|
+
): GeoParse {
|
|
129
161
|
const bbox = params.get(SEARCH_PARAM.bbox);
|
|
130
162
|
if (bbox !== null) {
|
|
131
163
|
const parts = bbox.split(",");
|
|
@@ -135,7 +167,7 @@ function parseGeo(
|
|
|
135
167
|
code: "bbox_malformed",
|
|
136
168
|
messageKey: ISSUE_KEY.bbox_malformed,
|
|
137
169
|
});
|
|
138
|
-
return undefined;
|
|
170
|
+
return { geo: undefined };
|
|
139
171
|
}
|
|
140
172
|
const values = parts.map((p) => Number(p));
|
|
141
173
|
if (values.some((v) => !Number.isFinite(v))) {
|
|
@@ -144,43 +176,64 @@ function parseGeo(
|
|
|
144
176
|
code: "bbox_malformed",
|
|
145
177
|
messageKey: ISSUE_KEY.bbox_malformed,
|
|
146
178
|
});
|
|
147
|
-
return undefined;
|
|
179
|
+
return { geo: undefined };
|
|
148
180
|
}
|
|
149
181
|
// `minLon > maxLon` is LEGAL — it means the box crosses the antimeridian
|
|
150
182
|
// (`stapel-search/query.py`). Normalizing it here would silently turn a
|
|
151
183
|
// Pacific search into the rest of the world.
|
|
152
184
|
return {
|
|
185
|
+
geo: {
|
|
153
186
|
kind: "bbox",
|
|
154
187
|
minLat: values[0] as number,
|
|
155
188
|
minLon: values[1] as number,
|
|
156
189
|
maxLat: values[2] as number,
|
|
157
190
|
maxLon: values[3] as number,
|
|
191
|
+
},
|
|
158
192
|
};
|
|
159
193
|
}
|
|
160
194
|
|
|
161
195
|
const rawLat = params.get(SEARCH_PARAM.lat);
|
|
162
196
|
const rawLon = params.get(SEARCH_PARAM.lon);
|
|
163
|
-
if (rawLat === null && rawLon === null)
|
|
197
|
+
if (rawLat === null && rawLon === null) {
|
|
198
|
+
// A radius parked in the URL with no point to measure it from. See
|
|
199
|
+
// `radius_without_place`: the search below is correct, the screen was the
|
|
200
|
+
// liar.
|
|
201
|
+
const orphan = params.get(SEARCH_PARAM.radiusKm);
|
|
202
|
+
if (orphan !== null) {
|
|
203
|
+
issues.push({
|
|
204
|
+
param: SEARCH_PARAM.radiusKm,
|
|
205
|
+
code: "radius_without_place",
|
|
206
|
+
messageKey: ISSUE_KEY.radius_without_place,
|
|
207
|
+
});
|
|
208
|
+
const km = Number(orphan);
|
|
209
|
+
if (Number.isFinite(km) && km > 0) {
|
|
210
|
+
return { geo: undefined, orphanRadiusKm: km };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { geo: undefined };
|
|
214
|
+
}
|
|
164
215
|
if (rawLat === null || rawLon === null) {
|
|
165
216
|
issues.push({
|
|
166
217
|
param: rawLat === null ? SEARCH_PARAM.lat : SEARCH_PARAM.lon,
|
|
167
218
|
code: "geo_incomplete",
|
|
168
219
|
messageKey: ISSUE_KEY.geo_incomplete,
|
|
169
220
|
});
|
|
170
|
-
return undefined;
|
|
221
|
+
return { geo: undefined };
|
|
171
222
|
}
|
|
172
223
|
const lat = num(rawLat, SEARCH_PARAM.lat, issues);
|
|
173
224
|
const lon = num(rawLon, SEARCH_PARAM.lon, issues);
|
|
174
|
-
if (lat === undefined || lon === undefined) return undefined;
|
|
225
|
+
if (lat === undefined || lon === undefined) return { geo: undefined };
|
|
175
226
|
|
|
176
227
|
const rawRadius = params.get(SEARCH_PARAM.radiusKm);
|
|
177
228
|
const radiusKm =
|
|
178
229
|
rawRadius === null ? undefined : num(rawRadius, SEARCH_PARAM.radiusKm, issues);
|
|
179
230
|
return {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
231
|
+
geo: {
|
|
232
|
+
kind: "center",
|
|
233
|
+
lat,
|
|
234
|
+
lon,
|
|
235
|
+
...(radiusKm !== undefined ? { radiusKm } : {}),
|
|
236
|
+
},
|
|
184
237
|
};
|
|
185
238
|
}
|
|
186
239
|
|
|
@@ -246,6 +299,8 @@ export function parseSearchState(
|
|
|
246
299
|
const direction: "next" | "prev" | undefined =
|
|
247
300
|
rawDirection === null ? undefined : rawDirection === "prev" ? "prev" : "next";
|
|
248
301
|
|
|
302
|
+
const geoParse = parseGeo(params, issues);
|
|
303
|
+
|
|
249
304
|
const state: SearchQueryState = {
|
|
250
305
|
type: params.get(SEARCH_PARAM.type) ?? options.defaultType,
|
|
251
306
|
q: params.get(SEARCH_PARAM.q) ?? options.defaultQ ?? "",
|
|
@@ -257,7 +312,7 @@ export function parseSearchState(
|
|
|
257
312
|
params.get(SEARCH_PARAM.category) ?? options.defaultCategory
|
|
258
313
|
),
|
|
259
314
|
...optional(SEARCH_PARAM.owner, params.get(SEARCH_PARAM.owner) ?? undefined),
|
|
260
|
-
...optional("geo",
|
|
315
|
+
...optional("geo", geoParse.geo),
|
|
261
316
|
...optional(
|
|
262
317
|
SEARCH_PARAM.sort,
|
|
263
318
|
params.get(SEARCH_PARAM.sort) ?? options.defaultSort
|
|
@@ -268,7 +323,11 @@ export function parseSearchState(
|
|
|
268
323
|
...optional("limit", limit),
|
|
269
324
|
};
|
|
270
325
|
|
|
271
|
-
return {
|
|
326
|
+
return {
|
|
327
|
+
state,
|
|
328
|
+
issues,
|
|
329
|
+
...optional("orphanRadiusKm", geoParse.orphanRadiusKm),
|
|
330
|
+
};
|
|
272
331
|
}
|
|
273
332
|
|
|
274
333
|
/** `exactOptionalPropertyTypes` makes `{k: undefined}` and "absent" different
|