@stapel/search-react 0.25.0 → 0.26.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 +18 -0
- package/MODULE.md +21 -9
- package/README.md +71 -13
- package/dist/api/generated/schema.d.ts +18 -3
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/types.d.ts +39 -4
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/FacetGroupControl.d.ts +20 -13
- package/dist/default/FacetGroupControl.d.ts.map +1 -1
- package/dist/default/FacetGroupControl.js +29 -21
- package/dist/default/FacetGroupControl.js.map +1 -1
- package/dist/default/FacetPanelPane.d.ts +15 -4
- package/dist/default/FacetPanelPane.d.ts.map +1 -1
- package/dist/default/FacetPanelPane.js +10 -5
- package/dist/default/FacetPanelPane.js.map +1 -1
- package/dist/default/SearchPage.d.ts +16 -0
- package/dist/default/SearchPage.d.ts.map +1 -1
- package/dist/default/SearchPage.js +7 -3
- package/dist/default/SearchPage.js.map +1 -1
- package/dist/headless/SearchStateProvider.d.ts +12 -2
- package/dist/headless/SearchStateProvider.d.ts.map +1 -1
- package/dist/headless/SearchStateProvider.js +58 -6
- package/dist/headless/SearchStateProvider.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +9 -1
- package/dist/model/queries.js.map +1 -1
- package/dist/state/facets.d.ts +62 -11
- package/dist/state/facets.d.ts.map +1 -1
- package/dist/state/facets.js +141 -29
- package/dist/state/facets.js.map +1 -1
- package/dist/state/urlState.d.ts +77 -2
- package/dist/state/urlState.d.ts.map +1 -1
- package/dist/state/urlState.js +80 -6
- package/dist/state/urlState.js.map +1 -1
- package/llms.txt +3 -2
- package/manifest.json +18 -1
- package/nav-manifest.json +1 -1
- package/package.json +8 -8
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +18 -3
- package/src/api/types.ts +42 -4
- package/src/default/FacetGroupControl.tsx +27 -23
- package/src/default/FacetPanelPane.tsx +30 -8
- package/src/default/SearchPage.tsx +25 -1
- package/src/headless/SearchStateProvider.tsx +94 -4
- package/src/i18n/generated/errors.es.gen.ts +1 -0
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -0
- package/src/index.ts +13 -1
- package/src/model/queries.ts +11 -1
- package/src/state/facets.ts +173 -30
- package/src/state/urlState.ts +137 -5
package/src/api/types.ts
CHANGED
|
@@ -24,11 +24,17 @@ export type Schemas = components["schemas"];
|
|
|
24
24
|
* slug either; ABSENT is a server too old to send one, and both read the same
|
|
25
25
|
* way here (fall through to the schema).
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* WHY IT IS NOT THE GENERATED SHAPE: stapel-search 0.14.5's `schema.json`
|
|
28
|
+
* declares this field REQUIRED, but the contract this pair announces is
|
|
29
|
+
* `>=0.14 <0.15`, and a 0.14.0 server inside that range sends no `label` at
|
|
30
|
+
* all. The generated member is therefore `Omit`ted and re-declared here as
|
|
31
|
+
* optional-and-nullable — a type must not promise a field a server the pair
|
|
32
|
+
* says it supports does not send.
|
|
30
33
|
*/
|
|
31
|
-
export type FacetLabels =
|
|
34
|
+
export type FacetLabels = Omit<
|
|
35
|
+
Schemas["FacetLabels"],
|
|
36
|
+
"label" | "label_translatable" | "url_key"
|
|
37
|
+
> & {
|
|
32
38
|
readonly label?: string | null;
|
|
33
39
|
/**
|
|
34
40
|
* Whether {@link label} is a translation KEY rather than a caption, the way
|
|
@@ -43,6 +49,38 @@ export type FacetLabels = Schemas["FacetLabels"] & {
|
|
|
43
49
|
* fixture captured from the wire is not a type error.
|
|
44
50
|
*/
|
|
45
51
|
readonly label_translatable?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* What this group is called IN THE ADDRESS: `f.<url_key>`, `r.<url_key>`.
|
|
54
|
+
*
|
|
55
|
+
* The slug minus the type suffix an importer mints (`_select`,
|
|
56
|
+
* `_ref_select`, `_int`, `_bool`, `_string`) where dropping it stays
|
|
57
|
+
* unambiguous among the features of the category in scope, and the slug
|
|
58
|
+
* itself otherwise — and always when the query names no category
|
|
59
|
+
* (stapel-search 0.14.4+, `facets.url_keys`). Derived per request, never
|
|
60
|
+
* stored: the slug remains the feature's identity and both forms are
|
|
61
|
+
* accepted inside the scope, which is what lets a client write the short
|
|
62
|
+
* one without a migration behind it.
|
|
63
|
+
*
|
|
64
|
+
* WHY IT IS NOT THE GENERATED SHAPE: 0.14.5 declares it REQUIRED, and the
|
|
65
|
+
* announced contract is `>=0.14 <0.15` — a 0.14.0 server in that range
|
|
66
|
+
* states no `url_key`, and the codec's whole point is that such a link
|
|
67
|
+
* still works. `Omit`ted from the generated member and re-declared
|
|
68
|
+
* optional here. Measured on a live answer 2026-09-04:
|
|
69
|
+
* `make_ref_select` → `make`, `fuel_type_ref_select` → `fuel_type`,
|
|
70
|
+
* `model` → `model`.
|
|
71
|
+
*/
|
|
72
|
+
readonly url_key?: string | null;
|
|
73
|
+
/**
|
|
74
|
+
* The vocabulary this group's values are drawn from, when the server names
|
|
75
|
+
* one.
|
|
76
|
+
*
|
|
77
|
+
* The one fact that decides whether an axis is a DICTIONARY rather than a
|
|
78
|
+
* list, and the client's second-best source for it: the first is the
|
|
79
|
+
* category schema's own `optionsRef`, which a host that threaded no schema
|
|
80
|
+
* does not have. Absent on every server that does not state it — absence
|
|
81
|
+
* is not "inline", it is "unsaid", and the schema is asked next.
|
|
82
|
+
*/
|
|
83
|
+
readonly vocabulary?: string | null;
|
|
46
84
|
};
|
|
47
85
|
|
|
48
86
|
/** `facet_labels` as a whole: `{slug: {label, translatable, values}}`. */
|
|
@@ -67,12 +67,9 @@ import type { CSSProperties, ReactElement } from "react";
|
|
|
67
67
|
import { Button, Checkbox, Flex, Input, Typography } from "antd";
|
|
68
68
|
import { useT } from "@stapel/core";
|
|
69
69
|
import { controls, cssVar, radii, spacing } from "@stapel/tokens";
|
|
70
|
-
import {
|
|
71
|
-
VOCABULARY_BACKED_TYPES,
|
|
72
|
-
featureConfig,
|
|
73
|
-
featureType,
|
|
74
|
-
} from "@stapel/attributes-react";
|
|
70
|
+
import { featureConfig, featureType } from "@stapel/attributes-react";
|
|
75
71
|
import type { FeatureDef } from "@stapel/attributes-react";
|
|
72
|
+
import { facetGroupIsVocabularyBacked } from "../state/facets.js";
|
|
76
73
|
import type { FacetGroup, FacetOption } from "../state/facets.js";
|
|
77
74
|
import { translitPrefixMatch } from "../state/translit.js";
|
|
78
75
|
import { SEARCH_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -147,14 +144,21 @@ function singleChoice(feature: FeatureDef | undefined): boolean {
|
|
|
147
144
|
}
|
|
148
145
|
|
|
149
146
|
/**
|
|
150
|
-
* Is this group a DICTIONARY —
|
|
147
|
+
* Is this group a DICTIONARY — an axis whose values live in a vocabulary?
|
|
151
148
|
*
|
|
152
|
-
* Two ways to be one,
|
|
153
|
-
* case is the one where it is empty:
|
|
149
|
+
* Two ways to be one, and only the second one counts anything:
|
|
154
150
|
*
|
|
155
|
-
* - the
|
|
156
|
-
*
|
|
157
|
-
* table to draw
|
|
151
|
+
* - the axis is VOCABULARY-BACKED (`facetGroupIsVocabularyBacked`): the def
|
|
152
|
+
* types it `ref_select`/`ref_hierarchical_select`, so its config is a
|
|
153
|
+
* POINTER and there was never an option table to draw — or there is no def
|
|
154
|
+
* and the answer named the vocabulary itself. **However many buckets came
|
|
155
|
+
* back.** This threshold used to be counted against the answer's evidence,
|
|
156
|
+
* and on a stand holding three cars the make axis has three buckets, drew
|
|
157
|
+
* three checkboxes, and hid the other four hundred makes behind nothing at
|
|
158
|
+
* all: the founder's "what if there are hundreds of options" is a question about the DICTIONARY,
|
|
159
|
+
* and the dictionary is large whether or not this leaf has stock. The
|
|
160
|
+
* field is also the only control that can search values the answer never
|
|
161
|
+
* enumerated, which is exactly the case a thin stand produces;
|
|
158
162
|
* - there is NO def at all and the answer came back with more values than a
|
|
159
163
|
* fold. At a live classified's cars branch the storefront passed an empty
|
|
160
164
|
* feature list, so the 418 makes arrived as an unnamed, untyped group of
|
|
@@ -162,20 +166,20 @@ function singleChoice(feature: FeatureDef | undefined): boolean {
|
|
|
162
166
|
* answers that, and refusing to draw one because the schema is missing
|
|
163
167
|
* punishes the buyer for the wiring.
|
|
164
168
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
+
* An INLINE option set is never one, whatever its length: a `select` carries
|
|
170
|
+
* its own table, that table is the whole of the axis, and a small one
|
|
171
|
+
* (≤ {@link FACET_DICTIONARY_THRESHOLD} options) is a list a person reads at
|
|
172
|
+
* a glance rather than types into.
|
|
169
173
|
*/
|
|
170
174
|
export function isDictionaryFacet(group: FacetGroup): boolean {
|
|
175
|
+
if (facetGroupIsVocabularyBacked(group)) return true;
|
|
176
|
+
// A typed def that is not vocabulary-backed carries its options inline:
|
|
177
|
+
// checkboxes or pills, however many there are.
|
|
178
|
+
if (group.feature !== undefined) return false;
|
|
171
179
|
const buckets = group.options.filter(
|
|
172
180
|
(option) => option.count !== null && option.count > 0
|
|
173
181
|
).length;
|
|
174
|
-
|
|
175
|
-
const feature = group.feature;
|
|
176
|
-
if (feature === undefined) return true;
|
|
177
|
-
const type = featureType(feature);
|
|
178
|
-
return type !== undefined && VOCABULARY_BACKED_TYPES.includes(type);
|
|
182
|
+
return buckets > FACET_DICTIONARY_THRESHOLD;
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
/**
|
|
@@ -189,9 +193,9 @@ export function isDictionaryFacet(group: FacetGroup): boolean {
|
|
|
189
193
|
* `maxSelected: 1` over a 418-value vocabulary, so "pick one" won and the
|
|
190
194
|
* control it produced was four hundred pills in a 280px rail — a wall
|
|
191
195
|
* with a different border radius. Above the fold the shape a person needs
|
|
192
|
-
* is a search box, whether or not they may tick two
|
|
193
|
-
*
|
|
194
|
-
*
|
|
196
|
+
* is a search box, whether or not they may tick two. A single-choice axis
|
|
197
|
+
* with an INLINE option table still means pills, because a dictionary is
|
|
198
|
+
* now about where the values live and not about how many came back;
|
|
195
199
|
* - and a dictionary is a dictionary before it is a checkbox list, because
|
|
196
200
|
* the checkbox list is the shape it was drawn as when nobody could pick a
|
|
197
201
|
* make.
|
|
@@ -260,17 +260,28 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
260
260
|
*/
|
|
261
261
|
readonly skippedNotice?: boolean;
|
|
262
262
|
/**
|
|
263
|
-
* Draw the
|
|
264
|
-
*
|
|
265
|
-
* heading row — one control, not two) beside it. Default
|
|
263
|
+
* Draw the footer inside the panel: the live result count as the bar's
|
|
264
|
+
* strong text, and the clear-all control (which then moves out of the
|
|
265
|
+
* heading row — one control, not two) beside it. Default: no bar.
|
|
266
266
|
*
|
|
267
267
|
* `<SearchPage>` turns it on for the desktop RAIL only. Desktop filters
|
|
268
268
|
* apply instantly, so the bar is FEEDBACK plus the way out, not an apply
|
|
269
269
|
* button — which is exactly why the phone sheet must not get it: the sheet
|
|
270
270
|
* already closes through its own "Show N results" footer, and a second
|
|
271
271
|
* count-bearing bar above that one would be the same sentence twice.
|
|
272
|
+
*
|
|
273
|
+
* ── Where it sits, and why that is a choice ───────────────────────────────
|
|
274
|
+
*
|
|
275
|
+
* `"sticky"` (and `true`, which is what it has always meant) pins the bar to
|
|
276
|
+
* the bottom of the panel's own scroll port. That is right in a SHEET, whose
|
|
277
|
+
* port is the sheet and whose bar is the way out of it. In the desktop
|
|
278
|
+
* COLUMN the rail scrolls with the page, so an opaque bar pinned to the
|
|
279
|
+
* bottom of the viewport parks itself over the last two facet groups and
|
|
280
|
+
* they cannot be reached at all — a storefront was reaching for `!important`
|
|
281
|
+
* to lift it off. `"static"` puts the bar after the groups, where it stops
|
|
282
|
+
* covering anything, and the column layout of `<SearchPage>` passes it.
|
|
272
283
|
*/
|
|
273
|
-
readonly footerBar?: boolean;
|
|
284
|
+
readonly footerBar?: boolean | "sticky" | "static";
|
|
274
285
|
/**
|
|
275
286
|
* The partition control, drawn at the TOP of the panel — above the price,
|
|
276
287
|
* above every facet.
|
|
@@ -319,6 +330,9 @@ export interface FacetPanelPaneProps extends ThemeModeProp {
|
|
|
319
330
|
function RailFooterBar(props: {
|
|
320
331
|
readonly activeFilters: number;
|
|
321
332
|
readonly clearAll: () => void;
|
|
333
|
+
/** `"sticky"` pins it to the scroll port's floor; `"static"` lets it sit
|
|
334
|
+
* after the last group. See {@link FacetPanelPaneProps.footerBar}. */
|
|
335
|
+
readonly position: "sticky" | "static";
|
|
322
336
|
}): ReactElement | null {
|
|
323
337
|
const t = useT();
|
|
324
338
|
const tPlural = useTPlural();
|
|
@@ -340,9 +354,9 @@ function RailFooterBar(props: {
|
|
|
340
354
|
return (
|
|
341
355
|
<div
|
|
342
356
|
data-testid="facets-footer-bar"
|
|
357
|
+
data-position={props.position}
|
|
343
358
|
style={{
|
|
344
|
-
position: "sticky",
|
|
345
|
-
bottom: 0,
|
|
359
|
+
...(props.position === "sticky" ? { position: "sticky", bottom: 0 } : {}),
|
|
346
360
|
// Opaque, or the options scrolling under the bar read THROUGH it.
|
|
347
361
|
background: token.colorBgContainer,
|
|
348
362
|
borderBlockStart: `1px solid ${token.colorSplit}`,
|
|
@@ -513,6 +527,13 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
513
527
|
// panel's own search box: the URL is the search, and how much of the rail a
|
|
514
528
|
// person has unfolded is not part of it.
|
|
515
529
|
const [tailOpen, setTailOpen] = useState(false);
|
|
530
|
+
// `true` is the shape the prop shipped with and keeps meaning: pinned.
|
|
531
|
+
const footerBar: "sticky" | "static" | "none" =
|
|
532
|
+
props.footerBar === true
|
|
533
|
+
? "sticky"
|
|
534
|
+
: props.footerBar === false || props.footerBar === undefined
|
|
535
|
+
? "none"
|
|
536
|
+
: props.footerBar;
|
|
516
537
|
|
|
517
538
|
return (
|
|
518
539
|
<SkinTheme {...(props.mode !== undefined ? { mode: props.mode } : {})}>
|
|
@@ -577,7 +598,7 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
577
598
|
{/* With the footer bar on, clear-all lives THERE — beside the
|
|
578
599
|
count it acts on — and drawing it here too would be two
|
|
579
600
|
identical exits one panel apart. */}
|
|
580
|
-
{bag.activeFilters > 0 &&
|
|
601
|
+
{bag.activeFilters > 0 && footerBar === "none" && (
|
|
581
602
|
<Button
|
|
582
603
|
style={FACET_CLEAR}
|
|
583
604
|
onClick={bag.clearAll}
|
|
@@ -845,10 +866,11 @@ export function FacetPanelPane(props: FacetPanelPaneProps): ReactElement {
|
|
|
845
866
|
</>
|
|
846
867
|
)}
|
|
847
868
|
|
|
848
|
-
{
|
|
869
|
+
{footerBar !== "none" && (
|
|
849
870
|
<RailFooterBar
|
|
850
871
|
activeFilters={bag.activeFilters}
|
|
851
872
|
clearAll={bag.clearAll}
|
|
873
|
+
position={footerBar}
|
|
852
874
|
/>
|
|
853
875
|
)}
|
|
854
876
|
</Flex>
|
|
@@ -261,6 +261,22 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
261
261
|
/** Facet slugs pinned above every other group — see
|
|
262
262
|
* {@link FacetPanelPaneProps.pinnedFacets}. */
|
|
263
263
|
readonly pinnedFacets?: readonly string[];
|
|
264
|
+
/**
|
|
265
|
+
* How a DICTIONARY group is drawn — see
|
|
266
|
+
* {@link FacetPanelPaneProps.dictionaryMode}.
|
|
267
|
+
*
|
|
268
|
+
* Defaulted PER LAYOUT rather than left to the panel's own default, because
|
|
269
|
+
* the two frames want opposite shapes and only this component knows which
|
|
270
|
+
* one it is drawing: the desktop rail gets `"field"` (a select-style «Any»
|
|
271
|
+
* that opens the searchable list — a 418-value vocabulary held open in a
|
|
272
|
+
* 280px column is the whole column), the phone sheet gets `"inline"`
|
|
273
|
+
* (the sheet is already the disclosure). Set it to override both.
|
|
274
|
+
*
|
|
275
|
+
* It was unreachable through this component until now: the panel had the
|
|
276
|
+
* prop, `<SearchPage>` forwarded nothing, and a storefront that mounts the
|
|
277
|
+
* page rather than the pane could not get the field at all.
|
|
278
|
+
*/
|
|
279
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
264
280
|
/** Print the engine's list of uncounted facet slugs in the filter panel.
|
|
265
281
|
* Default `false` — see {@link FacetPanelPaneProps.skippedNotice}. */
|
|
266
282
|
readonly skippedNotice?: boolean;
|
|
@@ -422,6 +438,7 @@ export interface SearchPageProps extends ThemeModeProp, ParseSearchStateOptions
|
|
|
422
438
|
|
|
423
439
|
interface SearchPageBodyProps {
|
|
424
440
|
readonly renderCard?: SearchCardRenderer;
|
|
441
|
+
readonly dictionaryMode?: "field" | "inline";
|
|
425
442
|
readonly categoryFeatures?: readonly FeatureDef[];
|
|
426
443
|
readonly renderEmptyExits?: () => ReactNode;
|
|
427
444
|
readonly locale?: string;
|
|
@@ -545,7 +562,12 @@ function SearchPageBody(props: SearchPageBodyProps): ReactElement {
|
|
|
545
562
|
result count scrolled out of sight above the fold. */}
|
|
546
563
|
{filtersEmpty ? null : (
|
|
547
564
|
<FacetPanelPane
|
|
548
|
-
{...(layout === "sheet"
|
|
565
|
+
{...(layout === "sheet"
|
|
566
|
+
? { heading: null }
|
|
567
|
+
: // STATIC, not sticky: the rail scrolls with the page, and a bar
|
|
568
|
+
// pinned to the port's floor sat on top of the last groups.
|
|
569
|
+
{ footerBar: "static" as const })}
|
|
570
|
+
dictionaryMode={props.dictionaryMode ?? (layout === "sheet" ? "inline" : "field")}
|
|
549
571
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
550
572
|
{...(props.renderEmptyExits !== undefined
|
|
551
573
|
? { renderEmptyExits: props.renderEmptyExits }
|
|
@@ -805,6 +827,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
805
827
|
onViewChange,
|
|
806
828
|
resultsAction,
|
|
807
829
|
resultsHeadingLevel,
|
|
830
|
+
dictionaryMode,
|
|
808
831
|
mode,
|
|
809
832
|
...parseOptions
|
|
810
833
|
} = props;
|
|
@@ -814,6 +837,7 @@ export function SearchPage(props: SearchPageProps): ReactElement {
|
|
|
814
837
|
<SearchStateProvider adapter={adapter} geoOffer={geoOffer} {...parseOptions}>
|
|
815
838
|
<SearchPageBody
|
|
816
839
|
{...(renderCard !== undefined ? { renderCard } : {})}
|
|
840
|
+
{...(dictionaryMode !== undefined ? { dictionaryMode } : {})}
|
|
817
841
|
{...(categoryFeatures !== undefined ? { categoryFeatures } : {})}
|
|
818
842
|
{...(locale !== undefined ? { locale } : {})}
|
|
819
843
|
{...(resolveFacetLabels !== undefined ? { resolveFacetLabels } : {})}
|
|
@@ -2,17 +2,22 @@ import {
|
|
|
2
2
|
createContext,
|
|
3
3
|
useCallback,
|
|
4
4
|
useContext,
|
|
5
|
+
useEffect,
|
|
5
6
|
useMemo,
|
|
7
|
+
useState,
|
|
6
8
|
} from "react";
|
|
7
9
|
import type { ReactElement, ReactNode } from "react";
|
|
8
10
|
import type {
|
|
11
|
+
FacetLabelsMap,
|
|
9
12
|
SearchGeo,
|
|
10
13
|
SearchQueryState,
|
|
11
14
|
SearchRange,
|
|
12
15
|
} from "../api/types.js";
|
|
13
16
|
import {
|
|
17
|
+
EMPTY_FACET_KEYS,
|
|
14
18
|
activeFilterCount,
|
|
15
19
|
clearFilters,
|
|
20
|
+
facetKeyMapFromLabels,
|
|
16
21
|
parseSearchState,
|
|
17
22
|
patchSearchState,
|
|
18
23
|
setFilterValues,
|
|
@@ -21,6 +26,7 @@ import {
|
|
|
21
26
|
writeSearchState,
|
|
22
27
|
} from "../state/urlState.js";
|
|
23
28
|
import type {
|
|
29
|
+
FacetKeyMap,
|
|
24
30
|
ParseSearchStateOptions,
|
|
25
31
|
SearchStateIssue,
|
|
26
32
|
SearchStatePatch,
|
|
@@ -132,6 +138,56 @@ function sameCenter(
|
|
|
132
138
|
|
|
133
139
|
const StateContext = createContext<SearchStateBag | null>(null);
|
|
134
140
|
|
|
141
|
+
/**
|
|
142
|
+
* WHERE THE SHORT KEYS COME FROM.
|
|
143
|
+
*
|
|
144
|
+
* `f.make` is a fact of the ANSWER (`facet_labels[slug].url_key`, resolved by
|
|
145
|
+
* the server inside the queried category's scope), and the codec that writes
|
|
146
|
+
* the address runs above the query that produces it. So the map is published
|
|
147
|
+
* upwards: whoever holds an answer hands it to this provider, which re-parses
|
|
148
|
+
* the URL with it and writes every subsequent address through it.
|
|
149
|
+
*
|
|
150
|
+
* Late by construction and correct at every moment in between: before the
|
|
151
|
+
* first answer the state holds whatever key the link carried, the request
|
|
152
|
+
* carries the same key, and the server resolves both forms. Nothing waits and
|
|
153
|
+
* nothing is rewritten behind the reader.
|
|
154
|
+
*/
|
|
155
|
+
interface FacetKeyRegistry {
|
|
156
|
+
readonly keys: FacetKeyMap;
|
|
157
|
+
publish(next: FacetKeyMap): void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const FacetKeysContext = createContext<FacetKeyRegistry | null>(null);
|
|
161
|
+
|
|
162
|
+
/** Two maps are the same map when they write the same keys — the read side is
|
|
163
|
+
* derived from the write side, so comparing one compares both. */
|
|
164
|
+
function sameKeys(a: FacetKeyMap, b: FacetKeyMap): boolean {
|
|
165
|
+
const left = Object.keys(a.write);
|
|
166
|
+
const right = Object.keys(b.write);
|
|
167
|
+
if (left.length !== right.length) return false;
|
|
168
|
+
return left.every((slug) => a.write[slug] === b.write[slug]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Publish an answer's short keys to the state provider above.
|
|
173
|
+
*
|
|
174
|
+
* A no-op outside `<SearchStateProvider>` and a no-op on a server that sends
|
|
175
|
+
* no `url_key`: both leave the address spelled in slugs, which is what it was
|
|
176
|
+
* spelled in before this existed.
|
|
177
|
+
*/
|
|
178
|
+
export function usePublishFacetKeys(labels: FacetLabelsMap | undefined): void {
|
|
179
|
+
const registry = useContext(FacetKeysContext);
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (registry === null || labels === undefined) return;
|
|
182
|
+
registry.publish(facetKeyMapFromLabels(labels));
|
|
183
|
+
}, [registry, labels]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The short-key map this search is currently writing its address with. */
|
|
187
|
+
export function useFacetKeys(): FacetKeyMap {
|
|
188
|
+
return useContext(FacetKeysContext)?.keys ?? EMPTY_FACET_KEYS;
|
|
189
|
+
}
|
|
190
|
+
|
|
135
191
|
export interface SearchStateProviderProps extends ParseSearchStateOptions {
|
|
136
192
|
readonly adapter: SearchParamsAdapter;
|
|
137
193
|
/**
|
|
@@ -193,9 +249,27 @@ export function SearchStateProvider(
|
|
|
193
249
|
|
|
194
250
|
const search = params.toString();
|
|
195
251
|
|
|
252
|
+
// The answer's short keys (`f.make`), published from below — see
|
|
253
|
+
// `FacetKeysContext`. Held here because this is where both directions of
|
|
254
|
+
// the codec run.
|
|
255
|
+
const [facetKeys, setFacetKeys] = useState<FacetKeyMap>(EMPTY_FACET_KEYS);
|
|
256
|
+
const registry = useMemo<FacetKeyRegistry>(
|
|
257
|
+
() => ({
|
|
258
|
+
keys: facetKeys,
|
|
259
|
+
publish: (next) => {
|
|
260
|
+
// Idempotent on purpose: this is called from an effect under a query
|
|
261
|
+
// whose data identity is stable, and a setState that always produced
|
|
262
|
+
// a new object would re-render the whole page per answer.
|
|
263
|
+
setFacetKeys((was) => (sameKeys(was, next) ? was : next));
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
[facetKeys]
|
|
267
|
+
);
|
|
268
|
+
|
|
196
269
|
const parsed = useMemo(
|
|
197
270
|
() =>
|
|
198
271
|
parseSearchState(new URLSearchParams(search), {
|
|
272
|
+
facetKeys,
|
|
199
273
|
defaultType,
|
|
200
274
|
...(defaultQ !== undefined ? { defaultQ } : {}),
|
|
201
275
|
...(defaultSort !== undefined ? { defaultSort } : {}),
|
|
@@ -203,14 +277,26 @@ export function SearchStateProvider(
|
|
|
203
277
|
...(defaultCategory !== undefined ? { defaultCategory } : {}),
|
|
204
278
|
...(defaultLang !== undefined ? { defaultLang } : {}),
|
|
205
279
|
}),
|
|
206
|
-
[
|
|
280
|
+
[
|
|
281
|
+
search,
|
|
282
|
+
facetKeys,
|
|
283
|
+
defaultType,
|
|
284
|
+
defaultQ,
|
|
285
|
+
defaultSort,
|
|
286
|
+
defaultLimit,
|
|
287
|
+
defaultCategory,
|
|
288
|
+
defaultLang,
|
|
289
|
+
]
|
|
207
290
|
);
|
|
208
291
|
|
|
209
292
|
const commit = useCallback(
|
|
210
293
|
(next: SearchQueryState, options?: { readonly replace?: boolean }): void => {
|
|
211
|
-
setParams(
|
|
294
|
+
setParams(
|
|
295
|
+
writeSearchState(next, new URLSearchParams(search), facetKeys),
|
|
296
|
+
options
|
|
297
|
+
);
|
|
212
298
|
},
|
|
213
|
-
[setParams, search]
|
|
299
|
+
[setParams, search, facetKeys]
|
|
214
300
|
);
|
|
215
301
|
|
|
216
302
|
|
|
@@ -280,7 +366,11 @@ export function SearchStateProvider(
|
|
|
280
366
|
}, [parsed, commit, geoOffer]);
|
|
281
367
|
|
|
282
368
|
|
|
283
|
-
return
|
|
369
|
+
return (
|
|
370
|
+
<FacetKeysContext.Provider value={registry}>
|
|
371
|
+
<StateContext.Provider value={bag}>{children}</StateContext.Provider>
|
|
372
|
+
</FacetKeysContext.Provider>
|
|
373
|
+
);
|
|
284
374
|
}
|
|
285
375
|
|
|
286
376
|
/**
|
|
@@ -35,6 +35,7 @@ export const searchErrorBundleEs: Record<SearchErrorCode, string> = {
|
|
|
35
35
|
"error.400.search_sort_needs_center": "Ordenar por distancia requiere lat y lon",
|
|
36
36
|
"error.400.search_too_many_facets": "Demasiados filtros de faceta (límite {limit})",
|
|
37
37
|
"error.400.search_too_many_ranges": "Demasiados filtros de rango (límite {limit})",
|
|
38
|
+
"error.400.search_unknown_category": "No existe la categoría «{category}». `category` admite el id del nodo o su ruta raíz/hoja.",
|
|
38
39
|
"error.400.search_unknown_doc_type": "Tipo de búsqueda desconocido «{doc_type}»",
|
|
39
40
|
"error.400.search_unknown_sort": "Orden desconocido «{sort}»",
|
|
40
41
|
"error.400.search_window_exceeded": "Esta página de resultados supera el límite de {window}. Acote la búsqueda en lugar de avanzar más páginas.",
|
|
@@ -58,6 +58,7 @@ export const SEARCH_ERRORS = {
|
|
|
58
58
|
"error.400.search_sort_needs_center": { status: 400, params: [], remediation: "fix_input", en: "Sorting by distance needs lat and lon" },
|
|
59
59
|
"error.400.search_too_many_facets": { status: 400, params: ["limit"], remediation: "fix_input", en: "Too many facet filters (limit {limit})" },
|
|
60
60
|
"error.400.search_too_many_ranges": { status: 400, params: ["limit"], remediation: "fix_input", en: "Too many range filters (limit {limit})" },
|
|
61
|
+
"error.400.search_unknown_category": { status: 400, params: ["category"], remediation: "fix_input", en: "No category '{category}' exists. `category` takes the node id or its root/leaf path." },
|
|
61
62
|
"error.400.search_unknown_doc_type": { status: 400, params: ["doc_type"], remediation: "fix_input", en: "Unknown search type '{doc_type}'" },
|
|
62
63
|
"error.400.search_unknown_sort": { status: 400, params: ["sort"], remediation: "fix_input", en: "Unknown sort '{sort}'" },
|
|
63
64
|
"error.400.search_window_exceeded": { status: 400, params: ["window"], remediation: "fix_input", en: "This result page is beyond the maximum window of {window}. Narrow the search instead of paging deeper." },
|
|
@@ -118,6 +119,7 @@ export const SEARCH_ERROR_CODES: readonly SearchErrorCode[] = [
|
|
|
118
119
|
"error.400.search_sort_needs_center",
|
|
119
120
|
"error.400.search_too_many_facets",
|
|
120
121
|
"error.400.search_too_many_ranges",
|
|
122
|
+
"error.400.search_unknown_category",
|
|
121
123
|
"error.400.search_unknown_doc_type",
|
|
122
124
|
"error.400.search_unknown_sort",
|
|
123
125
|
"error.400.search_window_exceeded",
|
|
@@ -180,6 +182,7 @@ export const searchErrorBundleEn: Record<SearchErrorCode, string> = {
|
|
|
180
182
|
"error.400.search_sort_needs_center": "Sorting by distance needs lat and lon",
|
|
181
183
|
"error.400.search_too_many_facets": "Too many facet filters (limit {limit})",
|
|
182
184
|
"error.400.search_too_many_ranges": "Too many range filters (limit {limit})",
|
|
185
|
+
"error.400.search_unknown_category": "No category '{category}' exists. `category` takes the node id or its root/leaf path.",
|
|
183
186
|
"error.400.search_unknown_doc_type": "Unknown search type '{doc_type}'",
|
|
184
187
|
"error.400.search_unknown_sort": "Unknown sort '{sort}'",
|
|
185
188
|
"error.400.search_window_exceeded": "This result page is beyond the maximum window of {window}. Narrow the search instead of paging deeper.",
|
|
@@ -195,6 +195,15 @@
|
|
|
195
195
|
"remediation": "fix_input",
|
|
196
196
|
"en": "Too many range filters (limit {limit})"
|
|
197
197
|
},
|
|
198
|
+
{
|
|
199
|
+
"code": "error.400.search_unknown_category",
|
|
200
|
+
"status": 400,
|
|
201
|
+
"params": [
|
|
202
|
+
"category"
|
|
203
|
+
],
|
|
204
|
+
"remediation": "fix_input",
|
|
205
|
+
"en": "No category '{category}' exists. `category` takes the node id or its root/leaf path."
|
|
206
|
+
},
|
|
198
207
|
{
|
|
199
208
|
"code": "error.400.search_unknown_doc_type",
|
|
200
209
|
"status": 400,
|
|
@@ -35,6 +35,7 @@ export const searchErrorBundleRu: Record<SearchErrorCode, string> = {
|
|
|
35
35
|
"error.400.search_sort_needs_center": "Сортировка по расстоянию требует координат (lat и lon)",
|
|
36
36
|
"error.400.search_too_many_facets": "Слишком много фильтров-фасетов (предел {limit})",
|
|
37
37
|
"error.400.search_too_many_ranges": "Слишком много диапазонных фильтров (предел {limit})",
|
|
38
|
+
"error.400.search_unknown_category": "Категории «{category}» не существует. В `category` передаётся id узла или путь корень/лист.",
|
|
38
39
|
"error.400.search_unknown_doc_type": "Неизвестный тип поиска «{doc_type}»",
|
|
39
40
|
"error.400.search_unknown_sort": "Неизвестная сортировка «{sort}»",
|
|
40
41
|
"error.400.search_window_exceeded": "Эта страница выдачи выходит за предел в {window} результатов. Уточните запрос вместо перелистывания вглубь.",
|
package/src/index.ts
CHANGED
|
@@ -77,11 +77,16 @@ export type {
|
|
|
77
77
|
|
|
78
78
|
// ── state (pure: no React, no router) ────────────────────────────────────────
|
|
79
79
|
export {
|
|
80
|
+
EMPTY_FACET_KEYS,
|
|
80
81
|
FILTER_PREFIX,
|
|
81
82
|
RANGE_PREFIX,
|
|
82
83
|
SEARCH_PARAM,
|
|
83
84
|
activeFilterCount,
|
|
85
|
+
buildFacetKeyMap,
|
|
84
86
|
clearFilters,
|
|
87
|
+
facetKeyForSlug,
|
|
88
|
+
facetKeyMapFromLabels,
|
|
89
|
+
facetSlugForKey,
|
|
85
90
|
ownsParam,
|
|
86
91
|
parseSearchState,
|
|
87
92
|
patchSearchState,
|
|
@@ -91,6 +96,7 @@ export {
|
|
|
91
96
|
writeSearchState,
|
|
92
97
|
} from "./state/urlState.js";
|
|
93
98
|
export type {
|
|
99
|
+
FacetKeyMap,
|
|
94
100
|
ParseSearchStateOptions,
|
|
95
101
|
ParsedSearchState,
|
|
96
102
|
SearchStateIssue,
|
|
@@ -118,6 +124,7 @@ export {
|
|
|
118
124
|
buildFacetGroups,
|
|
119
125
|
facetGroupHasEvidence,
|
|
120
126
|
facetGroupIsDrawable,
|
|
127
|
+
facetGroupIsVocabularyBacked,
|
|
121
128
|
facetOptionLabel,
|
|
122
129
|
isFacetableFeature,
|
|
123
130
|
orderFacetGroupsBySchema,
|
|
@@ -177,7 +184,12 @@ export { useRankingDisclosure, useSearchQuery, useSuggest } from "./model/querie
|
|
|
177
184
|
|
|
178
185
|
// ── headless (renderless components) ─────────────────────────────────────────
|
|
179
186
|
export { SearchProvider } from "./headless/SearchProvider.js";
|
|
180
|
-
export {
|
|
187
|
+
export {
|
|
188
|
+
SearchStateProvider,
|
|
189
|
+
useFacetKeys,
|
|
190
|
+
usePublishFacetKeys,
|
|
191
|
+
useSearchState,
|
|
192
|
+
} from "./headless/SearchStateProvider.js";
|
|
181
193
|
export type {
|
|
182
194
|
SearchParamsAdapter,
|
|
183
195
|
SearchStateBag,
|
package/src/model/queries.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
SuggestAnswer,
|
|
10
10
|
} from "../api/types.js";
|
|
11
11
|
import { SUGGEST_MAX_LIMIT, SUGGEST_MIN_CHARS } from "../state/limits.js";
|
|
12
|
+
import { usePublishFacetKeys } from "../headless/SearchStateProvider.js";
|
|
12
13
|
import { useSearchApi } from "./context.js";
|
|
13
14
|
import { searchQueryKeys } from "./queryKeys.js";
|
|
14
15
|
|
|
@@ -53,13 +54,22 @@ export function useSearchQuery(
|
|
|
53
54
|
): UseQueryResult<SearchResponse, StapelApiError> {
|
|
54
55
|
const api = useSearchApi();
|
|
55
56
|
const params = searchQueryParams(state);
|
|
56
|
-
|
|
57
|
+
const query = useQuery<SearchResponse, StapelApiError>({
|
|
57
58
|
queryKey: searchQueryKeys.query(params),
|
|
58
59
|
queryFn: ({ signal }) => api.query(state, { signal }),
|
|
59
60
|
enabled: (options?.enabled ?? true) && state.type.length > 0,
|
|
60
61
|
placeholderData: keepPreviousData,
|
|
61
62
|
retry: false,
|
|
62
63
|
});
|
|
64
|
+
// The answer states what each axis is called IN THE ADDRESS
|
|
65
|
+
// (`facet_labels[slug].url_key`); the codec that writes the address sits
|
|
66
|
+
// above this hook, so the map is handed up. Every surface reads through
|
|
67
|
+
// this one hook, so there is exactly one place the address learns its own
|
|
68
|
+
// spelling — and outside a `<SearchStateProvider>` it is a no-op.
|
|
69
|
+
usePublishFacetKeys(
|
|
70
|
+
query.data === undefined ? undefined : query.data.facet_labels
|
|
71
|
+
);
|
|
72
|
+
return query;
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
/**
|