@stapel/search-react 0.29.1 → 0.31.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 +76 -0
- package/README.md +56 -0
- package/dist/api/generated/schema.d.ts +11 -1
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +46 -3
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +5 -0
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +63 -1
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +10 -2
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/FilterChips.d.ts.map +1 -1
- package/dist/default/FilterChips.js +2 -0
- package/dist/default/FilterChips.js.map +1 -1
- package/dist/default/RangeFilterRow.d.ts.map +1 -1
- package/dist/default/RangeFilterRow.js +18 -4
- package/dist/default/RangeFilterRow.js.map +1 -1
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +4 -0
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/headless/FacetPanel.d.ts +24 -1
- package/dist/headless/FacetPanel.d.ts.map +1 -1
- package/dist/headless/FacetPanel.js +19 -3
- package/dist/headless/FacetPanel.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +31 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +0 -0
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/state/degradations.d.ts +12 -0
- package/dist/state/degradations.d.ts.map +1 -1
- package/dist/state/degradations.js +12 -0
- package/dist/state/degradations.js.map +1 -1
- package/dist/state/ranges.d.ts +41 -4
- package/dist/state/ranges.d.ts.map +1 -1
- package/dist/state/ranges.js +62 -11
- package/dist/state/ranges.js.map +1 -1
- package/llms.txt +2 -2
- package/manifest.json +8 -2
- package/nav-manifest.json +1 -1
- package/package.json +6 -6
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +11 -1
- package/src/api/types.ts +48 -3
- package/src/default/FacetGroupControl.tsx +64 -1
- package/src/default/FacetPanelPane.tsx +22 -4
- package/src/default/FilterChips.tsx +2 -0
- package/src/default/RangeFilterRow.tsx +17 -2
- package/src/default/SearchPage.tsx +4 -0
- package/src/headless/FacetPanel.tsx +53 -3
- package/src/headless/SearchStateProvider.tsx +86 -1
- package/src/index.ts +6 -0
- package/src/state/degradations.ts +13 -0
- package/src/state/ranges.ts +112 -14
package/src/state/ranges.ts
CHANGED
|
@@ -10,10 +10,20 @@
|
|
|
10
10
|
*
|
|
11
11
|
* A facet answer (`facets: {slug: {value: count}}`) enumerates DISCRETE
|
|
12
12
|
* values; a range is not enumerable and no bucket is ever sent for one. So
|
|
13
|
-
* the rows come from
|
|
13
|
+
* the rows come from three places:
|
|
14
14
|
*
|
|
15
|
+
* - the ANSWER's `facet_meta.ranges` (stapel-search 0.14.7+) — `{slug:
|
|
16
|
+
* {min, max}}` for every axis this page has NUMBERS behind, measured with
|
|
17
|
+
* the range filters removed and uncapped by `MAX_FACET_FIELDS`. When the
|
|
18
|
+
* answer reports it, it is the authority on which attribute axes exist and
|
|
19
|
+
* where their ends are: the schema says an axis COULD be numeric, and this
|
|
20
|
+
* says it IS, on these documents. It reaches axes the schema's own type
|
|
21
|
+
* cannot — a vocabulary-backed `year`, a `floor`, a `doors` are CHOICES in
|
|
22
|
+
* the catalogue and from/to's to a buyer, and both are served.
|
|
15
23
|
* - the CATEGORY SCHEMA — the same `categoryFeatures` slot that gives the
|
|
16
|
-
* checkboxes their labels — filtered to the numeric value types
|
|
24
|
+
* checkboxes their labels — filtered to the numeric value types. It is the
|
|
25
|
+
* FALLBACK, and the only source against a server that reports no `ranges`
|
|
26
|
+
* (older, or an engine that listed `facet_ranges` in `degraded[]`).
|
|
17
27
|
* - the ANSWER's `facet_meta.core_ranges` (stapel-search 0.4.0+), which
|
|
18
28
|
* names the range slugs addressing a COLUMN of the document rather than
|
|
19
29
|
* an attribute. `price` is the shipped one, and it is why this module
|
|
@@ -37,7 +47,11 @@
|
|
|
37
47
|
*/
|
|
38
48
|
import { featureConfig, featureName, featureType } from "@stapel/attributes-react";
|
|
39
49
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
40
|
-
import type {
|
|
50
|
+
import type {
|
|
51
|
+
FacetRangesMap,
|
|
52
|
+
SearchQueryState,
|
|
53
|
+
SearchRange,
|
|
54
|
+
} from "../api/types.js";
|
|
41
55
|
|
|
42
56
|
/**
|
|
43
57
|
* Value types a numeric range row is drawn for (`config.type`, the
|
|
@@ -60,9 +74,19 @@ export interface RangeGroup {
|
|
|
60
74
|
* never promised a number, and re-formatting one would rewrite the link). */
|
|
61
75
|
readonly from: string | undefined;
|
|
62
76
|
readonly to: string | undefined;
|
|
63
|
-
/**
|
|
77
|
+
/** The axis's ends: the ANSWER's measured bounds when it reported any for
|
|
78
|
+
* this slug, the schema's declared ones otherwise. */
|
|
64
79
|
readonly min: number | undefined;
|
|
65
80
|
readonly max: number | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* `true` when {@link min}/{@link max} were MEASURED over this answer
|
|
83
|
+
* (`facet_meta.ranges`) rather than declared by the schema.
|
|
84
|
+
*
|
|
85
|
+
* The difference is what a picker may be drawn from: a catalogue's
|
|
86
|
+
* `1900..2027` is what a year could be, and `2015..2020` is what this page
|
|
87
|
+
* has. Never set on a core axis — see {@link BuildRangeGroupsInput.ranges}.
|
|
88
|
+
*/
|
|
89
|
+
readonly measured: boolean;
|
|
66
90
|
/** Unit suffix the schema declares (`postfix`, or a convertible unit). */
|
|
67
91
|
readonly unit: string | undefined;
|
|
68
92
|
/** `1` for an integer feature — a whole-number input for a whole number. */
|
|
@@ -81,8 +105,10 @@ export interface RangeGroup {
|
|
|
81
105
|
* to pick from — `undefined` for every other axis, including price.
|
|
82
106
|
*
|
|
83
107
|
* A year is not a number a person types, it is one of a hundred-odd values,
|
|
84
|
-
* and the reference classified draws it as two pickers.
|
|
85
|
-
* the
|
|
108
|
+
* and the reference classified draws it as two pickers. {@link min}/{@link
|
|
109
|
+
* max} bound the list — the answer's measured ends when it reported any
|
|
110
|
+
* (`year: 1990..2024` over these documents), the schema's declared ones
|
|
111
|
+
* otherwise (`year: 1900..2027` on a live cars leaf);
|
|
86
112
|
* {@link RANGE_PICKER_MAX_VALUES} is where a picker stops being one and
|
|
87
113
|
* becomes a scroll with a search box in it, at which point two typed fields
|
|
88
114
|
* are the better control. Descending, because the busy end of a bounded
|
|
@@ -124,6 +150,31 @@ export interface BuildRangeGroupsInput {
|
|
|
124
150
|
* can actually serve, and this list is empty against an older one.
|
|
125
151
|
*/
|
|
126
152
|
readonly coreRanges?: readonly string[];
|
|
153
|
+
/**
|
|
154
|
+
* `facet_meta.ranges` — the axes this ANSWER measured, and their ends
|
|
155
|
+
* (stapel-search 0.14.7+).
|
|
156
|
+
*
|
|
157
|
+
* Two jobs, and the second is the one the schema cannot do:
|
|
158
|
+
* - BOUNDS. `min`/`max` come from here when the axis is in it, and from
|
|
159
|
+
* the schema's `config` otherwise. A catalogue's `1900..2027` is what a
|
|
160
|
+
* year COULD be; `2015..2020` is what this page has.
|
|
161
|
+
* - EXISTENCE. An axis the schema types as a CHOICE — a vocabulary-backed
|
|
162
|
+
* `year`, a `floor`, a `doors` — is a from/to to a buyer, and the server
|
|
163
|
+
* now says so by measuring it. Reported axes get a row whatever the
|
|
164
|
+
* schema calls them.
|
|
165
|
+
*
|
|
166
|
+
* `undefined` means the server said nothing: it predates 0.14.7, or its
|
|
167
|
+
* engine has no `ranges` verb and listed `facet_ranges` in `degraded[]`.
|
|
168
|
+
* The schema's bounds are the fallback then — never an empty rail, which
|
|
169
|
+
* would read as "this category has no numbers".
|
|
170
|
+
*
|
|
171
|
+
* A CORE axis is left alone by it. The price field is not clamped to the
|
|
172
|
+
* corpus's current ends: those ends move with every other filter, and an
|
|
173
|
+
* input that refuses the number a person meant to type is worse than an
|
|
174
|
+
* unbounded one. The measured price is still on the envelope for a host
|
|
175
|
+
* that wants to draw a histogram over it.
|
|
176
|
+
*/
|
|
177
|
+
readonly ranges?: FacetRangesMap;
|
|
127
178
|
/** ISO 4217 code for the money axes, when the surface knows one. */
|
|
128
179
|
readonly currency?: string;
|
|
129
180
|
/** Translator for label keys (the schema's `name` is often one). */
|
|
@@ -154,6 +205,34 @@ function translate(t: ((key: string) => string) | undefined, key: string): strin
|
|
|
154
205
|
*/
|
|
155
206
|
export const RANGE_PICKER_MAX_VALUES = 300;
|
|
156
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Is this axis a WHOLE-NUMBER one — the question both the picker and the
|
|
210
|
+
* input's step ask?
|
|
211
|
+
*
|
|
212
|
+
* The schema answers it for a typed feature (`int`, and never `float` or a
|
|
213
|
+
* convertible unit, whose values are 1.4 litres). For an axis the schema
|
|
214
|
+
* types as a CHOICE, or does not describe at all, the ANSWER answers it: the
|
|
215
|
+
* server measured two ends over the real documents, and whole ends over a
|
|
216
|
+
* vocabulary of numeric codes is what a `year` or a `floor` is.
|
|
217
|
+
*/
|
|
218
|
+
function isIntegerAxis(
|
|
219
|
+
feature: FeatureDef | undefined,
|
|
220
|
+
min: number | undefined,
|
|
221
|
+
max: number | undefined,
|
|
222
|
+
measured: boolean
|
|
223
|
+
): boolean {
|
|
224
|
+
const type = feature === undefined ? undefined : featureType(feature);
|
|
225
|
+
if (type === "int") return true;
|
|
226
|
+
if (type === "float" || type === "convertible_unit") return false;
|
|
227
|
+
return (
|
|
228
|
+
measured &&
|
|
229
|
+
min !== undefined &&
|
|
230
|
+
max !== undefined &&
|
|
231
|
+
Number.isInteger(min) &&
|
|
232
|
+
Number.isInteger(max)
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
157
236
|
/**
|
|
158
237
|
* The value list for a bounded integer axis, or `undefined`.
|
|
159
238
|
*
|
|
@@ -163,10 +242,10 @@ export const RANGE_PICKER_MAX_VALUES = 300;
|
|
|
163
242
|
function pickerValues(
|
|
164
243
|
feature: FeatureDef | undefined,
|
|
165
244
|
min: number | undefined,
|
|
166
|
-
max: number | undefined
|
|
245
|
+
max: number | undefined,
|
|
246
|
+
measured: boolean
|
|
167
247
|
): readonly number[] | undefined {
|
|
168
|
-
if (feature
|
|
169
|
-
if (featureType(feature) !== "int") return undefined;
|
|
248
|
+
if (!isIntegerAxis(feature, min, max, measured)) return undefined;
|
|
170
249
|
if (min === undefined || max === undefined) return undefined;
|
|
171
250
|
if (!Number.isInteger(min) || !Number.isInteger(max)) return undefined;
|
|
172
251
|
const span = max - min + 1;
|
|
@@ -198,12 +277,24 @@ export function buildRangeGroups(
|
|
|
198
277
|
for (const feature of input.categoryFeatures ?? []) bySlug.set(feature.slug, feature);
|
|
199
278
|
|
|
200
279
|
const core = new Set(input.coreRanges ?? []);
|
|
280
|
+
const bounds = input.ranges;
|
|
281
|
+
const reported = new Set(Object.keys(bounds ?? {}));
|
|
201
282
|
const slugs: string[] = [...core];
|
|
202
283
|
for (const feature of input.categoryFeatures ?? []) {
|
|
203
284
|
// A core slug shadows a same-named attribute — which is exactly what the
|
|
204
285
|
// server does with it (`index_schema.CORE_RANGE_FIELDS` reserves the
|
|
205
286
|
// slug), so drawing both would put two controls over one filter.
|
|
206
|
-
if (
|
|
287
|
+
if (core.has(feature.slug)) continue;
|
|
288
|
+
// The schema's own numeric types, PLUS anything this answer measured: a
|
|
289
|
+
// vocabulary-backed `year` is a choice in the catalogue and a from/to on
|
|
290
|
+
// the page, and the server measuring it is the fact that settles it.
|
|
291
|
+
// Schema ORDER either way, so an axis does not move when it is measured.
|
|
292
|
+
if (isRangeFeature(feature) || reported.has(feature.slug)) slugs.push(feature.slug);
|
|
293
|
+
}
|
|
294
|
+
// Measured axes the schema never mentioned — a host that hands in no
|
|
295
|
+
// `categoryFeatures` still gets its rows, labelled by slug.
|
|
296
|
+
for (const slug of reported) {
|
|
297
|
+
if (!slugs.includes(slug)) slugs.push(slug);
|
|
207
298
|
}
|
|
208
299
|
for (const slug of Object.keys(input.state.ranges)) {
|
|
209
300
|
if (!slugs.includes(slug)) slugs.push(slug);
|
|
@@ -214,6 +305,12 @@ export function buildRangeGroups(
|
|
|
214
305
|
const feature = isCore ? undefined : bySlug.get(slug);
|
|
215
306
|
const config = feature === undefined ? {} : featureConfig(feature);
|
|
216
307
|
const applied: SearchRange | undefined = input.state.ranges[slug];
|
|
308
|
+
// Measured ends win over declared ones, on an attribute axis only — see
|
|
309
|
+
// `BuildRangeGroupsInput.ranges` for why the price input keeps its own.
|
|
310
|
+
const axis = isCore ? undefined : bounds?.[slug];
|
|
311
|
+
const measured = axis !== undefined;
|
|
312
|
+
const min = measured ? axis.min : num(config["min"]);
|
|
313
|
+
const max = measured ? axis.max : num(config["max"]);
|
|
217
314
|
return {
|
|
218
315
|
slug,
|
|
219
316
|
label: isCore
|
|
@@ -224,16 +321,17 @@ export function buildRangeGroups(
|
|
|
224
321
|
feature,
|
|
225
322
|
from: applied?.from,
|
|
226
323
|
to: applied?.to,
|
|
227
|
-
min
|
|
228
|
-
max
|
|
324
|
+
min,
|
|
325
|
+
max,
|
|
326
|
+
measured,
|
|
229
327
|
// A core money axis carries a CURRENCY, not a unit suffix: "₽" is
|
|
230
328
|
// formatted from the code for the reader's locale, a unit suffix is a literal
|
|
231
329
|
// the category author typed.
|
|
232
330
|
unit: isCore
|
|
233
331
|
? undefined
|
|
234
332
|
: (str(config["postfix"]) ?? str(config["unit_m"]) ?? str(config["unit_i"])),
|
|
235
|
-
step:
|
|
236
|
-
picker: isCore ? undefined : pickerValues(feature,
|
|
333
|
+
step: !isCore && isIntegerAxis(feature, min, max, measured) ? 1 : undefined,
|
|
334
|
+
picker: isCore ? undefined : pickerValues(feature, min, max, measured),
|
|
237
335
|
active: applied !== undefined,
|
|
238
336
|
core: isCore,
|
|
239
337
|
currency: isCore ? str(input.currency) : undefined,
|