@stapel/search-react 0.1.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 +1 -0
- package/MODULE.md +203 -0
- package/README.md +175 -0
- package/dist/api/generated/schema.d.ts +375 -0
- package/dist/api/generated/schema.d.ts.map +1 -0
- package/dist/api/generated/schema.js +2 -0
- package/dist/api/generated/schema.js.map +1 -0
- package/dist/api/searchApi.d.ts +77 -0
- package/dist/api/searchApi.d.ts.map +1 -0
- package/dist/api/searchApi.js +79 -0
- package/dist/api/searchApi.js.map +1 -0
- package/dist/api/types.d.ts +131 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +20 -0
- package/dist/api/types.js.map +1 -0
- package/dist/default/DegradationNotice.d.ts +19 -0
- package/dist/default/DegradationNotice.d.ts.map +1 -0
- package/dist/default/DegradationNotice.js +14 -0
- package/dist/default/DegradationNotice.js.map +1 -0
- package/dist/default/ErrorAlert.d.ts +9 -0
- package/dist/default/ErrorAlert.d.ts.map +1 -0
- package/dist/default/ErrorAlert.js +27 -0
- package/dist/default/ErrorAlert.js.map +1 -0
- package/dist/default/FacetPanelPane.d.ts +26 -0
- package/dist/default/FacetPanelPane.d.ts.map +1 -0
- package/dist/default/FacetPanelPane.js +32 -0
- package/dist/default/FacetPanelPane.js.map +1 -0
- package/dist/default/RankingDisclosurePane.d.ts +17 -0
- package/dist/default/RankingDisclosurePane.d.ts.map +1 -0
- package/dist/default/RankingDisclosurePane.js +23 -0
- package/dist/default/RankingDisclosurePane.js.map +1 -0
- package/dist/default/SearchPage.d.ts +34 -0
- package/dist/default/SearchPage.d.ts.map +1 -0
- package/dist/default/SearchPage.js +16 -0
- package/dist/default/SearchPage.js.map +1 -0
- package/dist/default/SearchResultCard.d.ts +34 -0
- package/dist/default/SearchResultCard.d.ts.map +1 -0
- package/dist/default/SearchResultCard.js +38 -0
- package/dist/default/SearchResultCard.js.map +1 -0
- package/dist/default/SearchResultsPane.d.ts +32 -0
- package/dist/default/SearchResultsPane.d.ts.map +1 -0
- package/dist/default/SearchResultsPane.js +46 -0
- package/dist/default/SearchResultsPane.js.map +1 -0
- package/dist/default/SortSelect.d.ts +22 -0
- package/dist/default/SortSelect.d.ts.map +1 -0
- package/dist/default/SortSelect.js +39 -0
- package/dist/default/SortSelect.js.map +1 -0
- package/dist/default/UrlIssueNotice.d.ts +12 -0
- package/dist/default/UrlIssueNotice.d.ts.map +1 -0
- package/dist/default/UrlIssueNotice.js +13 -0
- package/dist/default/UrlIssueNotice.js.map +1 -0
- package/dist/default/index.d.ts +40 -0
- package/dist/default/index.d.ts.map +1 -0
- package/dist/default/index.js +34 -0
- package/dist/default/index.js.map +1 -0
- package/dist/default/theme.d.ts +24 -0
- package/dist/default/theme.d.ts.map +1 -0
- package/dist/default/theme.js +39 -0
- package/dist/default/theme.js.map +1 -0
- package/dist/default/types.d.ts +12 -0
- package/dist/default/types.d.ts.map +1 -0
- package/dist/default/types.js +2 -0
- package/dist/default/types.js.map +1 -0
- package/dist/headless/FacetPanel.d.ts +61 -0
- package/dist/headless/FacetPanel.d.ts.map +1 -0
- package/dist/headless/FacetPanel.js +57 -0
- package/dist/headless/FacetPanel.js.map +1 -0
- package/dist/headless/RankingDisclosure.d.ts +39 -0
- package/dist/headless/RankingDisclosure.d.ts.map +1 -0
- package/dist/headless/RankingDisclosure.js +28 -0
- package/dist/headless/RankingDisclosure.js.map +1 -0
- package/dist/headless/SearchProvider.d.ts +19 -0
- package/dist/headless/SearchProvider.d.ts.map +1 -0
- package/dist/headless/SearchProvider.js +15 -0
- package/dist/headless/SearchProvider.js.map +1 -0
- package/dist/headless/SearchResults.d.ts +68 -0
- package/dist/headless/SearchResults.d.ts.map +1 -0
- package/dist/headless/SearchResults.js +70 -0
- package/dist/headless/SearchResults.js.map +1 -0
- package/dist/headless/SearchStateProvider.d.ts +69 -0
- package/dist/headless/SearchStateProvider.d.ts.map +1 -0
- package/dist/headless/SearchStateProvider.js +71 -0
- package/dist/headless/SearchStateProvider.js.map +1 -0
- package/dist/i18n/errorsMap.d.ts +27 -0
- package/dist/i18n/errorsMap.d.ts.map +1 -0
- package/dist/i18n/errorsMap.js +37 -0
- package/dist/i18n/errorsMap.js.map +1 -0
- package/dist/i18n/es.d.ts +13 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +85 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +63 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +359 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.gen.js +183 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.ru.gen.js +63 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
- package/dist/i18n/keys.d.ts +107 -0
- package/dist/i18n/keys.d.ts.map +1 -0
- package/dist/i18n/keys.js +185 -0
- package/dist/i18n/keys.js.map +1 -0
- package/dist/i18n/ru.d.ts +29 -0
- package/dist/i18n/ru.d.ts.map +1 -0
- package/dist/i18n/ru.js +101 -0
- package/dist/i18n/ru.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +66 -0
- package/dist/index.js.map +1 -0
- package/dist/model/context.d.ts +11 -0
- package/dist/model/context.d.ts.map +1 -0
- package/dist/model/context.js +16 -0
- package/dist/model/context.js.map +1 -0
- package/dist/model/queries.d.ts +48 -0
- package/dist/model/queries.d.ts.map +1 -0
- package/dist/model/queries.js +65 -0
- package/dist/model/queries.js.map +1 -0
- package/dist/model/queryKeys.d.ts +25 -0
- package/dist/model/queryKeys.d.ts.map +1 -0
- package/dist/model/queryKeys.js +22 -0
- package/dist/model/queryKeys.js.map +1 -0
- package/dist/model/runtime.d.ts +25 -0
- package/dist/model/runtime.d.ts.map +1 -0
- package/dist/model/runtime.js +6 -0
- package/dist/model/runtime.js.map +1 -0
- package/dist/nav/manifest.d.ts +21 -0
- package/dist/nav/manifest.d.ts.map +1 -0
- package/dist/nav/manifest.js +34 -0
- package/dist/nav/manifest.js.map +1 -0
- package/dist/router/index.d.ts +10 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +34 -0
- package/dist/router/index.js.map +1 -0
- package/dist/state/degradations.d.ts +30 -0
- package/dist/state/degradations.d.ts.map +1 -0
- package/dist/state/degradations.js +52 -0
- package/dist/state/degradations.js.map +1 -0
- package/dist/state/facets.d.ts +68 -0
- package/dist/state/facets.d.ts.map +1 -0
- package/dist/state/facets.js +171 -0
- package/dist/state/facets.js.map +1 -0
- package/dist/state/urlState.d.ts +143 -0
- package/dist/state/urlState.d.ts.map +1 -0
- package/dist/state/urlState.js +319 -0
- package/dist/state/urlState.js.map +1 -0
- package/llms.txt +87 -0
- package/manifest.json +652 -0
- package/nav-manifest.json +44 -0
- package/package.json +133 -0
- package/src/analytics/generated/events.json +7 -0
- package/src/api/generated/schema.ts +377 -0
- package/src/api/searchApi.ts +169 -0
- package/src/api/types.ts +160 -0
- package/src/default/DegradationNotice.tsx +50 -0
- package/src/default/ErrorAlert.tsx +46 -0
- package/src/default/FacetPanelPane.tsx +174 -0
- package/src/default/RankingDisclosurePane.tsx +116 -0
- package/src/default/SearchPage.tsx +87 -0
- package/src/default/SearchResultCard.tsx +96 -0
- package/src/default/SearchResultsPane.tsx +173 -0
- package/src/default/SortSelect.tsx +77 -0
- package/src/default/UrlIssueNotice.tsx +37 -0
- package/src/default/index.ts +48 -0
- package/src/default/theme.tsx +66 -0
- package/src/default/types.ts +12 -0
- package/src/headless/FacetPanel.tsx +113 -0
- package/src/headless/RankingDisclosure.tsx +58 -0
- package/src/headless/SearchProvider.tsx +20 -0
- package/src/headless/SearchResults.tsx +149 -0
- package/src/headless/SearchStateProvider.tsx +175 -0
- package/src/i18n/errorsMap.ts +50 -0
- package/src/i18n/es.ts +110 -0
- package/src/i18n/generated/errors.es.gen.ts +69 -0
- package/src/i18n/generated/errors.gen.ts +214 -0
- package/src/i18n/generated/errors.json +423 -0
- package/src/i18n/generated/errors.ru.gen.ts +69 -0
- package/src/i18n/keys.ts +220 -0
- package/src/i18n/ru.ts +126 -0
- package/src/index.ts +154 -0
- package/src/model/context.tsx +28 -0
- package/src/model/queries.ts +79 -0
- package/src/model/queryKeys.ts +48 -0
- package/src/model/runtime.ts +33 -0
- package/src/nav/manifest.ts +53 -0
- package/src/router/index.ts +38 -0
- package/src/state/degradations.ts +75 -0
- package/src/state/facets.ts +219 -0
- package/src/state/urlState.ts +458 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The facet panel's data model — and the two things about stapel-search's
|
|
3
|
+
* facets a panel is obliged to show.
|
|
4
|
+
*
|
|
5
|
+
* 1. **They are DRILL-DOWN.** Every facet is counted over the candidate set
|
|
6
|
+
* with ITS OWN filter removed (`stapel-search/facets.py`). So picking
|
|
7
|
+
* "Bosch" does not zero its neighbours: the other brands keep the counts
|
|
8
|
+
* they would have if you swapped to them. A panel that greys the siblings
|
|
9
|
+
* out has silently converted a drill-down facet into a naive one, and the
|
|
10
|
+
* e2e leg in the spec (§7.2) exists to catch exactly that.
|
|
11
|
+
* 2. **The server does not send option LABELS** (`facets.py` returns
|
|
12
|
+
* `{value: count}` and nothing else). The labels are in the category's
|
|
13
|
+
* feature schema, which is why {@link buildFacetGroups} takes
|
|
14
|
+
* `categoryFeatures` — the second slot-seam of the pair, filled by the
|
|
15
|
+
* container from `categories-react` (spec §6.2 item 2). Without it the
|
|
16
|
+
* panel still works and shows raw values; it does not invent labels.
|
|
17
|
+
*
|
|
18
|
+
* A slug the server SKIPPED (`facet_meta.skipped`, dropped at
|
|
19
|
+
* `MAX_FACET_FIELDS`) is not counted at all. Its options carry `count: null`,
|
|
20
|
+
* never `0` — "we did not count this" and "there are none" are different
|
|
21
|
+
* sentences and the honest one has to survive to the screen.
|
|
22
|
+
*/
|
|
23
|
+
import { featureConfig, featureName, featureType, formatFeatureValue } from "@stapel/attributes-react";
|
|
24
|
+
function translate(t, key) {
|
|
25
|
+
if (t === undefined)
|
|
26
|
+
return key;
|
|
27
|
+
const resolved = t(key);
|
|
28
|
+
return resolved.length > 0 ? resolved : key;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The declared option ORDER for a closed set, if the schema declares one.
|
|
32
|
+
*
|
|
33
|
+
* Closed sets arrive zero-filled from the server (`fill_zero_options`), and a
|
|
34
|
+
* closed set is a list somebody authored: showing it in count order reshuffles
|
|
35
|
+
* a size chart on every click. Open sets have no authored order, so they fall
|
|
36
|
+
* back to count-descending.
|
|
37
|
+
*/
|
|
38
|
+
function declaredOptionValues(feature) {
|
|
39
|
+
if (feature === undefined)
|
|
40
|
+
return [];
|
|
41
|
+
const raw = featureConfig(feature)["options"];
|
|
42
|
+
if (!Array.isArray(raw))
|
|
43
|
+
return [];
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const option of raw) {
|
|
46
|
+
if (option !== null && typeof option === "object") {
|
|
47
|
+
const value = option.value;
|
|
48
|
+
if (value !== undefined && value !== null)
|
|
49
|
+
out.push(String(value));
|
|
50
|
+
}
|
|
51
|
+
else if (typeof option === "string") {
|
|
52
|
+
out.push(option);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A facet value as a person reads it, through
|
|
59
|
+
* `@stapel/attributes-react`'s `formatFeatureValue` — the same formatter the
|
|
60
|
+
* card and the spec table use, so a value cannot read one way in the filter
|
|
61
|
+
* and another way in the result.
|
|
62
|
+
*
|
|
63
|
+
* The wire carries facet values as STRINGS (they are index terms), so the raw
|
|
64
|
+
* value is coerced back into the shape the type's formatter expects before
|
|
65
|
+
* being handed over. `select` is a list even for one value
|
|
66
|
+
* (`types/select/dto.py`), `date` is a Unix timestamp integer, numbers are
|
|
67
|
+
* numbers. A type the formatter cannot read gives back the raw value — never
|
|
68
|
+
* an empty label, which would leave an unnameable checkbox on screen.
|
|
69
|
+
*/
|
|
70
|
+
export function facetOptionLabel(feature, value, options) {
|
|
71
|
+
if (feature === undefined)
|
|
72
|
+
return value;
|
|
73
|
+
const type = featureType(feature);
|
|
74
|
+
if (type === undefined)
|
|
75
|
+
return value;
|
|
76
|
+
let coerced = value;
|
|
77
|
+
switch (type) {
|
|
78
|
+
case "select":
|
|
79
|
+
case "hierarchical_select":
|
|
80
|
+
coerced = [value];
|
|
81
|
+
break;
|
|
82
|
+
case "int":
|
|
83
|
+
case "float":
|
|
84
|
+
case "date": {
|
|
85
|
+
const parsed = Number(value);
|
|
86
|
+
coerced = Number.isFinite(parsed) ? parsed : value;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "bool":
|
|
90
|
+
coerced = value === "true" || value === "1";
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
coerced = value;
|
|
94
|
+
}
|
|
95
|
+
const formatted = formatFeatureValue(feature, { type, value: coerced }, {
|
|
96
|
+
...(options?.t !== undefined ? { t: options.t } : {}),
|
|
97
|
+
...(options?.locale !== undefined ? { locale: options.locale } : {}),
|
|
98
|
+
});
|
|
99
|
+
return formatted !== undefined && formatted.length > 0 ? formatted : value;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fold the response's counts, the URL's selections and the category schema
|
|
103
|
+
* into the panel's groups.
|
|
104
|
+
*
|
|
105
|
+
* Groups appear for every counted slug, every skipped slug, and every slug the
|
|
106
|
+
* person has filtered on — the last one matters: a filter whose slug fell out
|
|
107
|
+
* of the plan must stay visible, or it becomes a constraint with no control to
|
|
108
|
+
* remove it.
|
|
109
|
+
*/
|
|
110
|
+
export function buildFacetGroups(input) {
|
|
111
|
+
const bySlug = new Map();
|
|
112
|
+
for (const feature of input.categoryFeatures ?? [])
|
|
113
|
+
bySlug.set(feature.slug, feature);
|
|
114
|
+
const skipped = new Set(input.meta.skipped);
|
|
115
|
+
const slugs = [];
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
for (const slug of [
|
|
118
|
+
...Object.keys(input.facets),
|
|
119
|
+
...input.meta.skipped,
|
|
120
|
+
...Object.keys(input.state.filters),
|
|
121
|
+
]) {
|
|
122
|
+
if (seen.has(slug))
|
|
123
|
+
continue;
|
|
124
|
+
seen.add(slug);
|
|
125
|
+
slugs.push(slug);
|
|
126
|
+
}
|
|
127
|
+
return slugs.map((slug) => {
|
|
128
|
+
const feature = bySlug.get(slug);
|
|
129
|
+
const counts = input.facets[slug] ?? {};
|
|
130
|
+
const counted = !skipped.has(slug) && slug in input.facets;
|
|
131
|
+
const selected = input.state.filters[slug] ?? [];
|
|
132
|
+
const declared = declaredOptionValues(feature);
|
|
133
|
+
const values = [];
|
|
134
|
+
const push = (value) => {
|
|
135
|
+
if (!values.includes(value))
|
|
136
|
+
values.push(value);
|
|
137
|
+
};
|
|
138
|
+
// Declared order first (closed sets), then whatever else the counter
|
|
139
|
+
// returned, then anything selected that neither of them mentioned.
|
|
140
|
+
for (const value of declared)
|
|
141
|
+
if (value in counts)
|
|
142
|
+
push(value);
|
|
143
|
+
const remaining = Object.keys(counts)
|
|
144
|
+
.filter((value) => !declared.includes(value))
|
|
145
|
+
.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0) || a.localeCompare(b));
|
|
146
|
+
for (const value of remaining)
|
|
147
|
+
push(value);
|
|
148
|
+
for (const value of selected)
|
|
149
|
+
push(value);
|
|
150
|
+
const labelOptions = {
|
|
151
|
+
...(input.t !== undefined ? { t: input.t } : {}),
|
|
152
|
+
...(input.locale !== undefined ? { locale: input.locale } : {}),
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
slug,
|
|
156
|
+
label: feature === undefined
|
|
157
|
+
? slug
|
|
158
|
+
: translate(input.t, featureName(feature)),
|
|
159
|
+
feature,
|
|
160
|
+
counted,
|
|
161
|
+
selected,
|
|
162
|
+
options: values.map((value) => ({
|
|
163
|
+
value,
|
|
164
|
+
count: counted ? (counts[value] ?? 0) : null,
|
|
165
|
+
label: facetOptionLabel(feature, value, labelOptions),
|
|
166
|
+
selected: selected.includes(value),
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=facets.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"facets.js","sourceRoot":"","sources":["../../src/state/facets.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE URL IS THE STATE.
|
|
3
|
+
*
|
|
4
|
+
* Every parameter of a search — text, category, facet filters, ranges, geo,
|
|
5
|
+
* sort, page size and the keyset cursor — lives in the query string, and no
|
|
6
|
+
* component keeps a second copy. The acceptance test the spec sets (§4.2) is
|
|
7
|
+
* behavioural, not structural: copy the address into another tab and get the
|
|
8
|
+
* same results; press Back and lose exactly the last filter; reload and lose
|
|
9
|
+
* nothing. All three follow from there being one source.
|
|
10
|
+
*
|
|
11
|
+
* The parameter NAMES here are the backend's own (`type`, `q`, `f.<slug>`,
|
|
12
|
+
* `r.<slug>`, `lat`/`lon`/`radius_km`, `bbox`, `sort`, `facets`, `anchor`,
|
|
13
|
+
* `direction`, `limit`). That is deliberate: a browser URL is then the API
|
|
14
|
+
* query string, so a support ticket that pastes a link is a request anyone can
|
|
15
|
+
* replay with curl, and there is no translation table to drift.
|
|
16
|
+
*
|
|
17
|
+
* This module is PURE — no React, no router, no fetch. The React binding is
|
|
18
|
+
* `headless/SearchStateProvider`; the react-router binding is the `./router`
|
|
19
|
+
* subpath. Keeping the codec here is what makes the round trip testable in
|
|
20
|
+
* both directions without a DOM.
|
|
21
|
+
*/
|
|
22
|
+
import type { SearchQueryState, SearchRange } from "../api/types.js";
|
|
23
|
+
/** Wire parameter names, in one place so a rename is one edit. */
|
|
24
|
+
export declare const SEARCH_PARAM: {
|
|
25
|
+
readonly type: "type";
|
|
26
|
+
readonly q: "q";
|
|
27
|
+
readonly lang: "lang";
|
|
28
|
+
readonly category: "category";
|
|
29
|
+
readonly owner: "owner";
|
|
30
|
+
readonly sort: "sort";
|
|
31
|
+
readonly facets: "facets";
|
|
32
|
+
readonly anchor: "anchor";
|
|
33
|
+
readonly direction: "direction";
|
|
34
|
+
readonly limit: "limit";
|
|
35
|
+
readonly lat: "lat";
|
|
36
|
+
readonly lon: "lon";
|
|
37
|
+
readonly radiusKm: "radius_km";
|
|
38
|
+
readonly bbox: "bbox";
|
|
39
|
+
};
|
|
40
|
+
/** Prefix of a facet filter parameter (`f.brand=bosch`). */
|
|
41
|
+
export declare const FILTER_PREFIX = "f.";
|
|
42
|
+
/** Prefix of a range filter parameter (`r.price=100..500`). */
|
|
43
|
+
export declare const RANGE_PREFIX = "r.";
|
|
44
|
+
/**
|
|
45
|
+
* Something in the URL this codec could not make sense of.
|
|
46
|
+
*
|
|
47
|
+
* Reported rather than silently dropped, for the same reason a facet's
|
|
48
|
+
* `skipped` list is reported: a link whose location filter lost half of
|
|
49
|
+
* itself should say so, not quietly widen the search and let the person
|
|
50
|
+
* believe they are looking at what they shared. Each issue carries an i18n
|
|
51
|
+
* key so a skin can render a sentence.
|
|
52
|
+
*/
|
|
53
|
+
export interface SearchStateIssue {
|
|
54
|
+
/** The offending parameter name, as it appeared in the URL. */
|
|
55
|
+
readonly param: string;
|
|
56
|
+
readonly code: SearchStateIssueCode;
|
|
57
|
+
/** i18n key for the sentence (`search.url.issue.*`). */
|
|
58
|
+
readonly messageKey: string;
|
|
59
|
+
}
|
|
60
|
+
export type SearchStateIssueCode =
|
|
61
|
+
/** A numeric parameter was not a number. */
|
|
62
|
+
"not_a_number"
|
|
63
|
+
/** `lat` without `lon`, or the other way round. */
|
|
64
|
+
| "geo_incomplete"
|
|
65
|
+
/** `bbox` was not four numbers. */
|
|
66
|
+
| "bbox_malformed"
|
|
67
|
+
/** `r.<slug>` was not `from..to`. */
|
|
68
|
+
| "range_malformed";
|
|
69
|
+
/** What {@link parseSearchState} answers: the state, plus what it could not read. */
|
|
70
|
+
export interface ParsedSearchState {
|
|
71
|
+
readonly state: SearchQueryState;
|
|
72
|
+
readonly issues: readonly SearchStateIssue[];
|
|
73
|
+
}
|
|
74
|
+
export interface ParseSearchStateOptions {
|
|
75
|
+
/**
|
|
76
|
+
* The doc type to search when the URL carries none. Required in practice —
|
|
77
|
+
* `type` is the endpoint's only mandatory parameter — and supplied by the
|
|
78
|
+
* host, because which types exist is a deployment fact
|
|
79
|
+
* (`stapel-search`'s registry), not something this package can know.
|
|
80
|
+
*/
|
|
81
|
+
readonly defaultType: string;
|
|
82
|
+
/** Applied when the URL carries no `q`. Defaults to `""`. */
|
|
83
|
+
readonly defaultQ?: string;
|
|
84
|
+
/** Applied when the URL carries no `sort`; omitted lets the server choose. */
|
|
85
|
+
readonly defaultSort?: string;
|
|
86
|
+
/** Applied when the URL carries no `limit`. */
|
|
87
|
+
readonly defaultLimit?: number;
|
|
88
|
+
/** Applied when the URL carries no `category` (e.g. a category page). */
|
|
89
|
+
readonly defaultCategory?: string;
|
|
90
|
+
/** Applied when the URL carries no `lang`. */
|
|
91
|
+
readonly defaultLang?: string;
|
|
92
|
+
}
|
|
93
|
+
/** URL → state. Never throws; anything unreadable lands in `issues`. */
|
|
94
|
+
export declare function parseSearchState(params: URLSearchParams, options: ParseSearchStateOptions): ParsedSearchState;
|
|
95
|
+
/**
|
|
96
|
+
* State → URL.
|
|
97
|
+
*
|
|
98
|
+
* `base` is the CURRENT query string: every parameter this codec does not own
|
|
99
|
+
* is copied through untouched, so a host's own `?ref=`/`utm_*` survives a
|
|
100
|
+
* facet click. Every parameter it DOES own is rewritten from the state, so a
|
|
101
|
+
* removed filter actually leaves the URL instead of lingering as a stale key.
|
|
102
|
+
*/
|
|
103
|
+
export declare function writeSearchState(state: SearchQueryState, base?: URLSearchParams): URLSearchParams;
|
|
104
|
+
/** Is this URL parameter owned by the search codec? */
|
|
105
|
+
export declare function ownsParam(key: string): boolean;
|
|
106
|
+
/** A patch over {@link SearchQueryState}; `null` clears an optional member. */
|
|
107
|
+
export type SearchStatePatch = {
|
|
108
|
+
readonly [K in keyof SearchQueryState]?: SearchQueryState[K] | null;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Apply a patch, and — unless the patch itself moves the page — DROP the
|
|
112
|
+
* keyset cursor.
|
|
113
|
+
*
|
|
114
|
+
* This is the single most load-bearing line in the module. `anchor` encodes a
|
|
115
|
+
* position inside ONE ordered candidate set (`{v, k, o}` over the active sort);
|
|
116
|
+
* carry it across a filter or sort change and the server either refuses it or,
|
|
117
|
+
* worse, honours it against a different set and answers page 4 of something
|
|
118
|
+
* the person never asked for. Every mutator in the provider goes through here,
|
|
119
|
+
* so "changing a filter returns you to the first page" is a property of the
|
|
120
|
+
* state machine rather than a rule each call site must remember.
|
|
121
|
+
*/
|
|
122
|
+
export declare function patchSearchState(state: SearchQueryState, patch: SearchStatePatch): SearchQueryState;
|
|
123
|
+
/** Toggle one value of one facet slug, OR-style. Returns a new state with the
|
|
124
|
+
* cursor dropped (see {@link patchSearchState}). */
|
|
125
|
+
export declare function toggleFilterValue(state: SearchQueryState, slug: string, value: string): SearchQueryState;
|
|
126
|
+
/** Replace one slug's chosen values; an empty list removes the filter. */
|
|
127
|
+
export declare function setFilterValues(state: SearchQueryState, slug: string, values: readonly string[]): SearchQueryState;
|
|
128
|
+
/** Set or clear one `r.<slug>` range. */
|
|
129
|
+
export declare function setRangeValue(state: SearchQueryState, slug: string, range: SearchRange | null): SearchQueryState;
|
|
130
|
+
/**
|
|
131
|
+
* Drop every filter, range and geo constraint, keeping what identifies the
|
|
132
|
+
* search itself: the doc type, the text, the language, the category the
|
|
133
|
+
* person navigated into, and the page size.
|
|
134
|
+
*
|
|
135
|
+
* Category is KEPT on purpose: on `/c/:slug` it is the page, not a filter, and
|
|
136
|
+
* a "clear filters" button that teleported the visitor to the root catalogue
|
|
137
|
+
* would be removing something they never set.
|
|
138
|
+
*/
|
|
139
|
+
export declare function clearFilters(state: SearchQueryState): SearchQueryState;
|
|
140
|
+
/** How many constraints the person has actually applied (facet values +
|
|
141
|
+
* ranges + geo) — what a "clear all (N)" control counts. */
|
|
142
|
+
export declare function activeFilterCount(state: SearchQueryState): number;
|
|
143
|
+
//# sourceMappingURL=urlState.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"urlState.d.ts","sourceRoot":"","sources":["../../src/state/urlState.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/** Wire parameter names, in one place so a rename is one edit. */
|
|
2
|
+
export const SEARCH_PARAM = {
|
|
3
|
+
type: "type",
|
|
4
|
+
q: "q",
|
|
5
|
+
lang: "lang",
|
|
6
|
+
category: "category",
|
|
7
|
+
owner: "owner",
|
|
8
|
+
sort: "sort",
|
|
9
|
+
facets: "facets",
|
|
10
|
+
anchor: "anchor",
|
|
11
|
+
direction: "direction",
|
|
12
|
+
limit: "limit",
|
|
13
|
+
lat: "lat",
|
|
14
|
+
lon: "lon",
|
|
15
|
+
radiusKm: "radius_km",
|
|
16
|
+
bbox: "bbox",
|
|
17
|
+
};
|
|
18
|
+
/** Prefix of a facet filter parameter (`f.brand=bosch`). */
|
|
19
|
+
export const FILTER_PREFIX = "f.";
|
|
20
|
+
/** Prefix of a range filter parameter (`r.price=100..500`). */
|
|
21
|
+
export const RANGE_PREFIX = "r.";
|
|
22
|
+
const ISSUE_KEY = {
|
|
23
|
+
not_a_number: "search.url.issue.not_a_number",
|
|
24
|
+
geo_incomplete: "search.url.issue.geo_incomplete",
|
|
25
|
+
bbox_malformed: "search.url.issue.bbox_malformed",
|
|
26
|
+
range_malformed: "search.url.issue.range_malformed",
|
|
27
|
+
};
|
|
28
|
+
function num(raw, param, issues) {
|
|
29
|
+
const value = Number(raw);
|
|
30
|
+
if (raw.trim().length === 0 || !Number.isFinite(value)) {
|
|
31
|
+
issues.push({ param, code: "not_a_number", messageKey: ISSUE_KEY.not_a_number });
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function parseGeo(params, issues) {
|
|
37
|
+
const bbox = params.get(SEARCH_PARAM.bbox);
|
|
38
|
+
if (bbox !== null) {
|
|
39
|
+
const parts = bbox.split(",");
|
|
40
|
+
if (parts.length !== 4) {
|
|
41
|
+
issues.push({
|
|
42
|
+
param: SEARCH_PARAM.bbox,
|
|
43
|
+
code: "bbox_malformed",
|
|
44
|
+
messageKey: ISSUE_KEY.bbox_malformed,
|
|
45
|
+
});
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const values = parts.map((p) => Number(p));
|
|
49
|
+
if (values.some((v) => !Number.isFinite(v))) {
|
|
50
|
+
issues.push({
|
|
51
|
+
param: SEARCH_PARAM.bbox,
|
|
52
|
+
code: "bbox_malformed",
|
|
53
|
+
messageKey: ISSUE_KEY.bbox_malformed,
|
|
54
|
+
});
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
// `minLon > maxLon` is LEGAL — it means the box crosses the antimeridian
|
|
58
|
+
// (`stapel-search/query.py`). Normalizing it here would silently turn a
|
|
59
|
+
// Pacific search into the rest of the world.
|
|
60
|
+
return {
|
|
61
|
+
kind: "bbox",
|
|
62
|
+
minLat: values[0],
|
|
63
|
+
minLon: values[1],
|
|
64
|
+
maxLat: values[2],
|
|
65
|
+
maxLon: values[3],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const rawLat = params.get(SEARCH_PARAM.lat);
|
|
69
|
+
const rawLon = params.get(SEARCH_PARAM.lon);
|
|
70
|
+
if (rawLat === null && rawLon === null)
|
|
71
|
+
return undefined;
|
|
72
|
+
if (rawLat === null || rawLon === null) {
|
|
73
|
+
issues.push({
|
|
74
|
+
param: rawLat === null ? SEARCH_PARAM.lat : SEARCH_PARAM.lon,
|
|
75
|
+
code: "geo_incomplete",
|
|
76
|
+
messageKey: ISSUE_KEY.geo_incomplete,
|
|
77
|
+
});
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const lat = num(rawLat, SEARCH_PARAM.lat, issues);
|
|
81
|
+
const lon = num(rawLon, SEARCH_PARAM.lon, issues);
|
|
82
|
+
if (lat === undefined || lon === undefined)
|
|
83
|
+
return undefined;
|
|
84
|
+
const rawRadius = params.get(SEARCH_PARAM.radiusKm);
|
|
85
|
+
const radiusKm = rawRadius === null ? undefined : num(rawRadius, SEARCH_PARAM.radiusKm, issues);
|
|
86
|
+
return {
|
|
87
|
+
kind: "center",
|
|
88
|
+
lat,
|
|
89
|
+
lon,
|
|
90
|
+
...(radiusKm !== undefined ? { radiusKm } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function parseFacetSelection(raw) {
|
|
94
|
+
if (raw === null)
|
|
95
|
+
return undefined;
|
|
96
|
+
const lowered = raw.trim().toLowerCase();
|
|
97
|
+
if (lowered === "on" || lowered === "off")
|
|
98
|
+
return lowered;
|
|
99
|
+
const slugs = raw
|
|
100
|
+
.split(",")
|
|
101
|
+
.map((s) => s.trim())
|
|
102
|
+
.filter((s) => s.length > 0);
|
|
103
|
+
return slugs.length > 0 ? slugs : undefined;
|
|
104
|
+
}
|
|
105
|
+
/** URL → state. Never throws; anything unreadable lands in `issues`. */
|
|
106
|
+
export function parseSearchState(params, options) {
|
|
107
|
+
const issues = [];
|
|
108
|
+
const filters = {};
|
|
109
|
+
const ranges = {};
|
|
110
|
+
for (const key of new Set(params.keys())) {
|
|
111
|
+
if (key.startsWith(FILTER_PREFIX) && key.length > FILTER_PREFIX.length) {
|
|
112
|
+
// `getAll` is the whole point: a repeated key is OR within the slug.
|
|
113
|
+
const values = params.getAll(key).filter((v) => v.length > 0);
|
|
114
|
+
if (values.length > 0)
|
|
115
|
+
filters[key.slice(FILTER_PREFIX.length)] = values;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (key.startsWith(RANGE_PREFIX) && key.length > RANGE_PREFIX.length) {
|
|
119
|
+
const raw = params.get(key) ?? "";
|
|
120
|
+
const separator = raw.indexOf("..");
|
|
121
|
+
if (separator < 0) {
|
|
122
|
+
issues.push({
|
|
123
|
+
param: key,
|
|
124
|
+
code: "range_malformed",
|
|
125
|
+
messageKey: ISSUE_KEY.range_malformed,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const from = raw.slice(0, separator);
|
|
130
|
+
const to = raw.slice(separator + 2);
|
|
131
|
+
if (from.length === 0 && to.length === 0)
|
|
132
|
+
continue;
|
|
133
|
+
ranges[key.slice(RANGE_PREFIX.length)] = {
|
|
134
|
+
...(from.length > 0 ? { from } : {}),
|
|
135
|
+
...(to.length > 0 ? { to } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const rawLimit = params.get(SEARCH_PARAM.limit);
|
|
140
|
+
const limit = rawLimit === null
|
|
141
|
+
? options.defaultLimit
|
|
142
|
+
: num(rawLimit, SEARCH_PARAM.limit, issues);
|
|
143
|
+
const rawDirection = params.get(SEARCH_PARAM.direction);
|
|
144
|
+
// The server coerces anything unrecognised to "next" without complaining
|
|
145
|
+
// (`query.py`), so the codec mirrors that instead of inventing a refusal
|
|
146
|
+
// the backend does not have.
|
|
147
|
+
const direction = rawDirection === null ? undefined : rawDirection === "prev" ? "prev" : "next";
|
|
148
|
+
const state = {
|
|
149
|
+
type: params.get(SEARCH_PARAM.type) ?? options.defaultType,
|
|
150
|
+
q: params.get(SEARCH_PARAM.q) ?? options.defaultQ ?? "",
|
|
151
|
+
filters,
|
|
152
|
+
ranges,
|
|
153
|
+
...optional(SEARCH_PARAM.lang, params.get(SEARCH_PARAM.lang) ?? options.defaultLang),
|
|
154
|
+
...optional(SEARCH_PARAM.category, params.get(SEARCH_PARAM.category) ?? options.defaultCategory),
|
|
155
|
+
...optional(SEARCH_PARAM.owner, params.get(SEARCH_PARAM.owner) ?? undefined),
|
|
156
|
+
...optional("geo", parseGeo(params, issues)),
|
|
157
|
+
...optional(SEARCH_PARAM.sort, params.get(SEARCH_PARAM.sort) ?? options.defaultSort),
|
|
158
|
+
...optional("facets", parseFacetSelection(params.get(SEARCH_PARAM.facets))),
|
|
159
|
+
...optional(SEARCH_PARAM.anchor, params.get(SEARCH_PARAM.anchor) ?? undefined),
|
|
160
|
+
...optional("direction", direction),
|
|
161
|
+
...optional("limit", limit),
|
|
162
|
+
};
|
|
163
|
+
return { state, issues };
|
|
164
|
+
}
|
|
165
|
+
/** `exactOptionalPropertyTypes` makes `{k: undefined}` and "absent" different
|
|
166
|
+
* types; this spreads to nothing when the value is absent. */
|
|
167
|
+
function optional(key, value) {
|
|
168
|
+
return value === undefined ? {} : { [key]: value };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* State → URL.
|
|
172
|
+
*
|
|
173
|
+
* `base` is the CURRENT query string: every parameter this codec does not own
|
|
174
|
+
* is copied through untouched, so a host's own `?ref=`/`utm_*` survives a
|
|
175
|
+
* facet click. Every parameter it DOES own is rewritten from the state, so a
|
|
176
|
+
* removed filter actually leaves the URL instead of lingering as a stale key.
|
|
177
|
+
*/
|
|
178
|
+
export function writeSearchState(state, base) {
|
|
179
|
+
const next = new URLSearchParams();
|
|
180
|
+
if (base !== undefined) {
|
|
181
|
+
for (const key of new Set(base.keys())) {
|
|
182
|
+
if (ownsParam(key))
|
|
183
|
+
continue;
|
|
184
|
+
for (const value of base.getAll(key))
|
|
185
|
+
next.append(key, value);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
next.set(SEARCH_PARAM.type, state.type);
|
|
189
|
+
if (state.q.length > 0)
|
|
190
|
+
next.set(SEARCH_PARAM.q, state.q);
|
|
191
|
+
if (state.lang !== undefined)
|
|
192
|
+
next.set(SEARCH_PARAM.lang, state.lang);
|
|
193
|
+
if (state.category !== undefined)
|
|
194
|
+
next.set(SEARCH_PARAM.category, state.category);
|
|
195
|
+
if (state.owner !== undefined)
|
|
196
|
+
next.set(SEARCH_PARAM.owner, state.owner);
|
|
197
|
+
for (const slug of Object.keys(state.filters).sort()) {
|
|
198
|
+
for (const value of state.filters[slug] ?? []) {
|
|
199
|
+
next.append(`${FILTER_PREFIX}${slug}`, value);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const slug of Object.keys(state.ranges).sort()) {
|
|
203
|
+
const range = state.ranges[slug];
|
|
204
|
+
if (range === undefined)
|
|
205
|
+
continue;
|
|
206
|
+
if (range.from === undefined && range.to === undefined)
|
|
207
|
+
continue;
|
|
208
|
+
next.set(`${RANGE_PREFIX}${slug}`, `${range.from ?? ""}..${range.to ?? ""}`);
|
|
209
|
+
}
|
|
210
|
+
if (state.geo !== undefined) {
|
|
211
|
+
if (state.geo.kind === "bbox") {
|
|
212
|
+
const { minLat, minLon, maxLat, maxLon } = state.geo;
|
|
213
|
+
next.set(SEARCH_PARAM.bbox, `${minLat},${minLon},${maxLat},${maxLon}`);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
next.set(SEARCH_PARAM.lat, String(state.geo.lat));
|
|
217
|
+
next.set(SEARCH_PARAM.lon, String(state.geo.lon));
|
|
218
|
+
if (state.geo.radiusKm !== undefined) {
|
|
219
|
+
next.set(SEARCH_PARAM.radiusKm, String(state.geo.radiusKm));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (state.sort !== undefined)
|
|
224
|
+
next.set(SEARCH_PARAM.sort, state.sort);
|
|
225
|
+
if (state.facets !== undefined) {
|
|
226
|
+
next.set(SEARCH_PARAM.facets, Array.isArray(state.facets) ? state.facets.join(",") : state.facets);
|
|
227
|
+
}
|
|
228
|
+
if (state.anchor !== undefined)
|
|
229
|
+
next.set(SEARCH_PARAM.anchor, state.anchor);
|
|
230
|
+
if (state.direction !== undefined)
|
|
231
|
+
next.set(SEARCH_PARAM.direction, state.direction);
|
|
232
|
+
if (state.limit !== undefined)
|
|
233
|
+
next.set(SEARCH_PARAM.limit, String(state.limit));
|
|
234
|
+
return next;
|
|
235
|
+
}
|
|
236
|
+
/** Is this URL parameter owned by the search codec? */
|
|
237
|
+
export function ownsParam(key) {
|
|
238
|
+
if (key.startsWith(FILTER_PREFIX) || key.startsWith(RANGE_PREFIX))
|
|
239
|
+
return true;
|
|
240
|
+
return Object.values(SEARCH_PARAM).includes(key);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Apply a patch, and — unless the patch itself moves the page — DROP the
|
|
244
|
+
* keyset cursor.
|
|
245
|
+
*
|
|
246
|
+
* This is the single most load-bearing line in the module. `anchor` encodes a
|
|
247
|
+
* position inside ONE ordered candidate set (`{v, k, o}` over the active sort);
|
|
248
|
+
* carry it across a filter or sort change and the server either refuses it or,
|
|
249
|
+
* worse, honours it against a different set and answers page 4 of something
|
|
250
|
+
* the person never asked for. Every mutator in the provider goes through here,
|
|
251
|
+
* so "changing a filter returns you to the first page" is a property of the
|
|
252
|
+
* state machine rather than a rule each call site must remember.
|
|
253
|
+
*/
|
|
254
|
+
export function patchSearchState(state, patch) {
|
|
255
|
+
const movesPage = "anchor" in patch || "direction" in patch;
|
|
256
|
+
const merged = { ...state, ...patch };
|
|
257
|
+
if (!movesPage) {
|
|
258
|
+
merged["anchor"] = undefined;
|
|
259
|
+
merged["direction"] = undefined;
|
|
260
|
+
}
|
|
261
|
+
// `type` and `q` are never absent: `q` has the empty string for "no text",
|
|
262
|
+
// and a state without `type` could not be requested at all.
|
|
263
|
+
if (typeof merged["q"] !== "string")
|
|
264
|
+
merged["q"] = "";
|
|
265
|
+
if (typeof merged["type"] !== "string")
|
|
266
|
+
merged["type"] = state.type;
|
|
267
|
+
// `null` in a patch CLEARS a member, and `exactOptionalPropertyTypes` makes
|
|
268
|
+
// "absent" and "present but undefined" different types — so the cleared
|
|
269
|
+
// members are filtered out rather than left as `undefined` holes.
|
|
270
|
+
return Object.fromEntries(Object.entries(merged).filter(([, value]) => value !== null && value !== undefined));
|
|
271
|
+
}
|
|
272
|
+
/** Toggle one value of one facet slug, OR-style. Returns a new state with the
|
|
273
|
+
* cursor dropped (see {@link patchSearchState}). */
|
|
274
|
+
export function toggleFilterValue(state, slug, value) {
|
|
275
|
+
const current = state.filters[slug] ?? [];
|
|
276
|
+
const next = current.includes(value)
|
|
277
|
+
? current.filter((v) => v !== value)
|
|
278
|
+
: [...current, value];
|
|
279
|
+
return setFilterValues(state, slug, next);
|
|
280
|
+
}
|
|
281
|
+
/** Replace one slug's chosen values; an empty list removes the filter. */
|
|
282
|
+
export function setFilterValues(state, slug, values) {
|
|
283
|
+
const filters = Object.fromEntries(Object.entries(state.filters).filter(([key]) => key !== slug));
|
|
284
|
+
if (values.length > 0)
|
|
285
|
+
filters[slug] = values;
|
|
286
|
+
return patchSearchState(state, { filters });
|
|
287
|
+
}
|
|
288
|
+
/** Set or clear one `r.<slug>` range. */
|
|
289
|
+
export function setRangeValue(state, slug, range) {
|
|
290
|
+
const ranges = Object.fromEntries(Object.entries(state.ranges).filter(([key]) => key !== slug));
|
|
291
|
+
if (range !== null && (range.from !== undefined || range.to !== undefined)) {
|
|
292
|
+
ranges[slug] = range;
|
|
293
|
+
}
|
|
294
|
+
return patchSearchState(state, { ranges });
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Drop every filter, range and geo constraint, keeping what identifies the
|
|
298
|
+
* search itself: the doc type, the text, the language, the category the
|
|
299
|
+
* person navigated into, and the page size.
|
|
300
|
+
*
|
|
301
|
+
* Category is KEPT on purpose: on `/c/:slug` it is the page, not a filter, and
|
|
302
|
+
* a "clear filters" button that teleported the visitor to the root catalogue
|
|
303
|
+
* would be removing something they never set.
|
|
304
|
+
*/
|
|
305
|
+
export function clearFilters(state) {
|
|
306
|
+
return patchSearchState(state, { filters: {}, ranges: {}, geo: null });
|
|
307
|
+
}
|
|
308
|
+
/** How many constraints the person has actually applied (facet values +
|
|
309
|
+
* ranges + geo) — what a "clear all (N)" control counts. */
|
|
310
|
+
export function activeFilterCount(state) {
|
|
311
|
+
let count = 0;
|
|
312
|
+
for (const values of Object.values(state.filters))
|
|
313
|
+
count += values.length;
|
|
314
|
+
count += Object.keys(state.ranges).length;
|
|
315
|
+
if (state.geo !== undefined)
|
|
316
|
+
count += 1;
|
|
317
|
+
return count;
|
|
318
|
+
}
|
|
319
|
+
//# sourceMappingURL=urlState.js.map
|