@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.
Files changed (195) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/MODULE.md +203 -0
  3. package/README.md +175 -0
  4. package/dist/api/generated/schema.d.ts +375 -0
  5. package/dist/api/generated/schema.d.ts.map +1 -0
  6. package/dist/api/generated/schema.js +2 -0
  7. package/dist/api/generated/schema.js.map +1 -0
  8. package/dist/api/searchApi.d.ts +77 -0
  9. package/dist/api/searchApi.d.ts.map +1 -0
  10. package/dist/api/searchApi.js +79 -0
  11. package/dist/api/searchApi.js.map +1 -0
  12. package/dist/api/types.d.ts +131 -0
  13. package/dist/api/types.d.ts.map +1 -0
  14. package/dist/api/types.js +20 -0
  15. package/dist/api/types.js.map +1 -0
  16. package/dist/default/DegradationNotice.d.ts +19 -0
  17. package/dist/default/DegradationNotice.d.ts.map +1 -0
  18. package/dist/default/DegradationNotice.js +14 -0
  19. package/dist/default/DegradationNotice.js.map +1 -0
  20. package/dist/default/ErrorAlert.d.ts +9 -0
  21. package/dist/default/ErrorAlert.d.ts.map +1 -0
  22. package/dist/default/ErrorAlert.js +27 -0
  23. package/dist/default/ErrorAlert.js.map +1 -0
  24. package/dist/default/FacetPanelPane.d.ts +26 -0
  25. package/dist/default/FacetPanelPane.d.ts.map +1 -0
  26. package/dist/default/FacetPanelPane.js +32 -0
  27. package/dist/default/FacetPanelPane.js.map +1 -0
  28. package/dist/default/RankingDisclosurePane.d.ts +17 -0
  29. package/dist/default/RankingDisclosurePane.d.ts.map +1 -0
  30. package/dist/default/RankingDisclosurePane.js +23 -0
  31. package/dist/default/RankingDisclosurePane.js.map +1 -0
  32. package/dist/default/SearchPage.d.ts +34 -0
  33. package/dist/default/SearchPage.d.ts.map +1 -0
  34. package/dist/default/SearchPage.js +16 -0
  35. package/dist/default/SearchPage.js.map +1 -0
  36. package/dist/default/SearchResultCard.d.ts +34 -0
  37. package/dist/default/SearchResultCard.d.ts.map +1 -0
  38. package/dist/default/SearchResultCard.js +38 -0
  39. package/dist/default/SearchResultCard.js.map +1 -0
  40. package/dist/default/SearchResultsPane.d.ts +32 -0
  41. package/dist/default/SearchResultsPane.d.ts.map +1 -0
  42. package/dist/default/SearchResultsPane.js +46 -0
  43. package/dist/default/SearchResultsPane.js.map +1 -0
  44. package/dist/default/SortSelect.d.ts +22 -0
  45. package/dist/default/SortSelect.d.ts.map +1 -0
  46. package/dist/default/SortSelect.js +39 -0
  47. package/dist/default/SortSelect.js.map +1 -0
  48. package/dist/default/UrlIssueNotice.d.ts +12 -0
  49. package/dist/default/UrlIssueNotice.d.ts.map +1 -0
  50. package/dist/default/UrlIssueNotice.js +13 -0
  51. package/dist/default/UrlIssueNotice.js.map +1 -0
  52. package/dist/default/index.d.ts +40 -0
  53. package/dist/default/index.d.ts.map +1 -0
  54. package/dist/default/index.js +34 -0
  55. package/dist/default/index.js.map +1 -0
  56. package/dist/default/theme.d.ts +24 -0
  57. package/dist/default/theme.d.ts.map +1 -0
  58. package/dist/default/theme.js +39 -0
  59. package/dist/default/theme.js.map +1 -0
  60. package/dist/default/types.d.ts +12 -0
  61. package/dist/default/types.d.ts.map +1 -0
  62. package/dist/default/types.js +2 -0
  63. package/dist/default/types.js.map +1 -0
  64. package/dist/headless/FacetPanel.d.ts +61 -0
  65. package/dist/headless/FacetPanel.d.ts.map +1 -0
  66. package/dist/headless/FacetPanel.js +57 -0
  67. package/dist/headless/FacetPanel.js.map +1 -0
  68. package/dist/headless/RankingDisclosure.d.ts +39 -0
  69. package/dist/headless/RankingDisclosure.d.ts.map +1 -0
  70. package/dist/headless/RankingDisclosure.js +28 -0
  71. package/dist/headless/RankingDisclosure.js.map +1 -0
  72. package/dist/headless/SearchProvider.d.ts +19 -0
  73. package/dist/headless/SearchProvider.d.ts.map +1 -0
  74. package/dist/headless/SearchProvider.js +15 -0
  75. package/dist/headless/SearchProvider.js.map +1 -0
  76. package/dist/headless/SearchResults.d.ts +68 -0
  77. package/dist/headless/SearchResults.d.ts.map +1 -0
  78. package/dist/headless/SearchResults.js +70 -0
  79. package/dist/headless/SearchResults.js.map +1 -0
  80. package/dist/headless/SearchStateProvider.d.ts +69 -0
  81. package/dist/headless/SearchStateProvider.d.ts.map +1 -0
  82. package/dist/headless/SearchStateProvider.js +71 -0
  83. package/dist/headless/SearchStateProvider.js.map +1 -0
  84. package/dist/i18n/errorsMap.d.ts +27 -0
  85. package/dist/i18n/errorsMap.d.ts.map +1 -0
  86. package/dist/i18n/errorsMap.js +37 -0
  87. package/dist/i18n/errorsMap.js.map +1 -0
  88. package/dist/i18n/es.d.ts +13 -0
  89. package/dist/i18n/es.d.ts.map +1 -0
  90. package/dist/i18n/es.js +85 -0
  91. package/dist/i18n/es.js.map +1 -0
  92. package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
  93. package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
  94. package/dist/i18n/generated/errors.es.gen.js +63 -0
  95. package/dist/i18n/generated/errors.es.gen.js.map +1 -0
  96. package/dist/i18n/generated/errors.gen.d.ts +359 -0
  97. package/dist/i18n/generated/errors.gen.d.ts.map +1 -0
  98. package/dist/i18n/generated/errors.gen.js +183 -0
  99. package/dist/i18n/generated/errors.gen.js.map +1 -0
  100. package/dist/i18n/generated/errors.ru.gen.d.ts +9 -0
  101. package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -0
  102. package/dist/i18n/generated/errors.ru.gen.js +63 -0
  103. package/dist/i18n/generated/errors.ru.gen.js.map +1 -0
  104. package/dist/i18n/keys.d.ts +107 -0
  105. package/dist/i18n/keys.d.ts.map +1 -0
  106. package/dist/i18n/keys.js +185 -0
  107. package/dist/i18n/keys.js.map +1 -0
  108. package/dist/i18n/ru.d.ts +29 -0
  109. package/dist/i18n/ru.d.ts.map +1 -0
  110. package/dist/i18n/ru.js +101 -0
  111. package/dist/i18n/ru.js.map +1 -0
  112. package/dist/index.d.ts +72 -0
  113. package/dist/index.d.ts.map +1 -0
  114. package/dist/index.js +66 -0
  115. package/dist/index.js.map +1 -0
  116. package/dist/model/context.d.ts +11 -0
  117. package/dist/model/context.d.ts.map +1 -0
  118. package/dist/model/context.js +16 -0
  119. package/dist/model/context.js.map +1 -0
  120. package/dist/model/queries.d.ts +48 -0
  121. package/dist/model/queries.d.ts.map +1 -0
  122. package/dist/model/queries.js +65 -0
  123. package/dist/model/queries.js.map +1 -0
  124. package/dist/model/queryKeys.d.ts +25 -0
  125. package/dist/model/queryKeys.d.ts.map +1 -0
  126. package/dist/model/queryKeys.js +22 -0
  127. package/dist/model/queryKeys.js.map +1 -0
  128. package/dist/model/runtime.d.ts +25 -0
  129. package/dist/model/runtime.d.ts.map +1 -0
  130. package/dist/model/runtime.js +6 -0
  131. package/dist/model/runtime.js.map +1 -0
  132. package/dist/nav/manifest.d.ts +21 -0
  133. package/dist/nav/manifest.d.ts.map +1 -0
  134. package/dist/nav/manifest.js +34 -0
  135. package/dist/nav/manifest.js.map +1 -0
  136. package/dist/router/index.d.ts +10 -0
  137. package/dist/router/index.d.ts.map +1 -0
  138. package/dist/router/index.js +34 -0
  139. package/dist/router/index.js.map +1 -0
  140. package/dist/state/degradations.d.ts +30 -0
  141. package/dist/state/degradations.d.ts.map +1 -0
  142. package/dist/state/degradations.js +52 -0
  143. package/dist/state/degradations.js.map +1 -0
  144. package/dist/state/facets.d.ts +68 -0
  145. package/dist/state/facets.d.ts.map +1 -0
  146. package/dist/state/facets.js +171 -0
  147. package/dist/state/facets.js.map +1 -0
  148. package/dist/state/urlState.d.ts +143 -0
  149. package/dist/state/urlState.d.ts.map +1 -0
  150. package/dist/state/urlState.js +319 -0
  151. package/dist/state/urlState.js.map +1 -0
  152. package/llms.txt +87 -0
  153. package/manifest.json +652 -0
  154. package/nav-manifest.json +44 -0
  155. package/package.json +133 -0
  156. package/src/analytics/generated/events.json +7 -0
  157. package/src/api/generated/schema.ts +377 -0
  158. package/src/api/searchApi.ts +169 -0
  159. package/src/api/types.ts +160 -0
  160. package/src/default/DegradationNotice.tsx +50 -0
  161. package/src/default/ErrorAlert.tsx +46 -0
  162. package/src/default/FacetPanelPane.tsx +174 -0
  163. package/src/default/RankingDisclosurePane.tsx +116 -0
  164. package/src/default/SearchPage.tsx +87 -0
  165. package/src/default/SearchResultCard.tsx +96 -0
  166. package/src/default/SearchResultsPane.tsx +173 -0
  167. package/src/default/SortSelect.tsx +77 -0
  168. package/src/default/UrlIssueNotice.tsx +37 -0
  169. package/src/default/index.ts +48 -0
  170. package/src/default/theme.tsx +66 -0
  171. package/src/default/types.ts +12 -0
  172. package/src/headless/FacetPanel.tsx +113 -0
  173. package/src/headless/RankingDisclosure.tsx +58 -0
  174. package/src/headless/SearchProvider.tsx +20 -0
  175. package/src/headless/SearchResults.tsx +149 -0
  176. package/src/headless/SearchStateProvider.tsx +175 -0
  177. package/src/i18n/errorsMap.ts +50 -0
  178. package/src/i18n/es.ts +110 -0
  179. package/src/i18n/generated/errors.es.gen.ts +69 -0
  180. package/src/i18n/generated/errors.gen.ts +214 -0
  181. package/src/i18n/generated/errors.json +423 -0
  182. package/src/i18n/generated/errors.ru.gen.ts +69 -0
  183. package/src/i18n/keys.ts +220 -0
  184. package/src/i18n/ru.ts +126 -0
  185. package/src/index.ts +154 -0
  186. package/src/model/context.tsx +28 -0
  187. package/src/model/queries.ts +79 -0
  188. package/src/model/queryKeys.ts +48 -0
  189. package/src/model/runtime.ts +33 -0
  190. package/src/nav/manifest.ts +53 -0
  191. package/src/router/index.ts +38 -0
  192. package/src/state/degradations.ts +75 -0
  193. package/src/state/facets.ts +219 -0
  194. package/src/state/urlState.ts +458 -0
  195. package/tsconfig.json +26 -0
@@ -0,0 +1,79 @@
1
+ import { keepPreviousData, useQuery } from "@tanstack/react-query";
2
+ import type { UseQueryResult } from "@tanstack/react-query";
3
+ import type { StapelApiError } from "@stapel/core";
4
+ import { searchQueryParams } from "../api/searchApi.js";
5
+ import type {
6
+ RankingResponse,
7
+ SearchQueryState,
8
+ SearchResponse,
9
+ } from "../api/types.js";
10
+ import { useSearchApi } from "./context.js";
11
+ import { searchQueryKeys } from "./queryKeys.js";
12
+
13
+ /**
14
+ * Read hooks over the search API (frontend-standard §2). Keys are namespaced
15
+ * (see `searchQueryKeys`).
16
+ *
17
+ * NOT SESSION-GATED, and that is the documented exception rather than an
18
+ * omission. Sibling pairs gate every read on `useActiveSessionReady` because
19
+ * their endpoints need a principal; these three are `AllowAny`, and core's own
20
+ * doc comment carves out exactly this case ("or be unconditionally safe
21
+ * pre-session, e.g. a public GET"). Gating them would make a storefront's
22
+ * catalogue wait for a login bootstrap that a visitor who will never sign in
23
+ * has no stake in — a blank shop front for the length of a token refresh.
24
+ */
25
+
26
+ /**
27
+ * One keyset page of results for a search state.
28
+ *
29
+ * `placeholderData: keepPreviousData`, and it is not a nicety. Facets are
30
+ * DRILL-DOWN: the whole point of the panel is that choosing a value leaves its
31
+ * siblings with the counts you would get by switching to them. A panel that
32
+ * blanks to a spinner between every click cannot show that — the person sees
33
+ * the numbers vanish and reappear, which reads exactly like the naive facets
34
+ * this contract avoids. The previous answer therefore stays on screen while
35
+ * the next one is in flight, with `isFetching` telling a skin to dim it.
36
+ *
37
+ * The load discipline is intact: the FIRST load has nothing to keep and is
38
+ * `loading`, and a failure still lands as `failed` rather than leaving stale
39
+ * rows pretending to be current.
40
+ *
41
+ * `retry: false`: the refusals here are VERDICTS about the request, not blips
42
+ * — an unknown sort, a malformed range, a cursor past `MAX_RESULT_WINDOW`.
43
+ * Retrying a 400 three times only delays the moment the page can say which
44
+ * one it was. A real outage answers 503 and is retried by the person, through
45
+ * the bag's `refetch` (the spec's §7.4 negative leg: "we could not ask" plus a
46
+ * retry, never "nothing found").
47
+ */
48
+ export function useSearchQuery(
49
+ state: SearchQueryState,
50
+ options?: { readonly enabled?: boolean }
51
+ ): UseQueryResult<SearchResponse, StapelApiError> {
52
+ const api = useSearchApi();
53
+ const params = searchQueryParams(state);
54
+ return useQuery({
55
+ queryKey: searchQueryKeys.query(params),
56
+ queryFn: ({ signal }) => api.query(state, { signal }),
57
+ enabled: (options?.enabled ?? true) && state.type.length > 0,
58
+ placeholderData: keepPreviousData,
59
+ retry: false,
60
+ });
61
+ }
62
+
63
+ /**
64
+ * The P2B Art. 5 ranking disclosure.
65
+ *
66
+ * `staleTime: Infinity`: the scorer registry changes when somebody deploys,
67
+ * not while a person reads the page it explains.
68
+ */
69
+ export function useRankingDisclosure(
70
+ type?: string
71
+ ): UseQueryResult<RankingResponse, StapelApiError> {
72
+ const api = useSearchApi();
73
+ return useQuery({
74
+ queryKey: searchQueryKeys.ranking(type),
75
+ queryFn: ({ signal }) => api.ranking(type, { signal }),
76
+ staleTime: Number.POSITIVE_INFINITY,
77
+ retry: false,
78
+ });
79
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Namespaced TanStack Query keys (frontend-standard §2 — namespaced keys).
3
+ * Everything under the `"search"` root so a host can invalidate the whole
4
+ * module or match a single read. Explicit tuple return types satisfy
5
+ * `--isolatedDeclarations`.
6
+ *
7
+ * THE KEY IS THE REQUEST. `query()` is keyed on the very object
8
+ * `searchQueryParams()` hands the client, not on a hand-picked subset — so a
9
+ * parameter that changes the URL and the request but not the key (the stale
10
+ * page bug: new filter, cached rows) cannot be written. TanStack hashes keys
11
+ * with sorted-key JSON, so two equal states hash equal regardless of the order
12
+ * the members were built in, and one cursor's page stays cached under its own
13
+ * key — which is what makes "back" instant.
14
+ */
15
+
16
+ /** The wire query object a search read is keyed on. */
17
+ export type SearchQueryKeyParams = Readonly<
18
+ Record<
19
+ string,
20
+ | string
21
+ | number
22
+ | boolean
23
+ | undefined
24
+ | readonly (string | number | boolean)[]
25
+ >
26
+ >;
27
+
28
+ const ROOT = "search" as const;
29
+
30
+ export const searchQueryKeys: {
31
+ readonly all: readonly ["search"];
32
+ query(
33
+ params: SearchQueryKeyParams
34
+ ): readonly ["search", "query", SearchQueryKeyParams];
35
+ /** The P2B disclosure. `type` is optional on the endpoint; normalized to
36
+ * `null` so an absent type and an explicit `undefined` cannot cache twice. */
37
+ ranking(type?: string): readonly ["search", "ranking", string | null];
38
+ suggest(
39
+ type: string,
40
+ q: string,
41
+ limit?: number
42
+ ): readonly ["search", "suggest", string, string, number | null];
43
+ } = {
44
+ all: [ROOT],
45
+ query: (params) => [ROOT, "query", params],
46
+ ranking: (type) => [ROOT, "ranking", type ?? null],
47
+ suggest: (type, q, limit) => [ROOT, "suggest", type, q, limit ?? null],
48
+ };
@@ -0,0 +1,33 @@
1
+ import { createModuleRuntime } from "@stapel/core";
2
+ import type { CreateModuleRuntimeOptions, ModuleRuntime } from "@stapel/core";
3
+ import { createSearchApi } from "../api/searchApi.js";
4
+ import type { SearchApi } from "../api/searchApi.js";
5
+
6
+ /**
7
+ * The wired search runtime — core's `ModuleRuntime` bound to this pair's API
8
+ * (slim wave §21/S2). The returned `client` is what the host injects into
9
+ * core's `StapelConfigProvider` (as the default or the `"search"` module
10
+ * client), preserving the client-injection fork seam (frontend-standard §7.2).
11
+ *
12
+ * ANONYMOUS BY DESIGN. Every endpoint this pair calls is `AllowAny`, so a
13
+ * storefront's catalogue, category and search pages need nothing but:
14
+ *
15
+ * ```tsx
16
+ * const runtime = createSearchRuntime({ baseUrl: "/search/api/v1/" });
17
+ * <SearchProvider runtime={runtime}>…</SearchProvider>
18
+ * ```
19
+ *
20
+ * No session, no workspace id, no auth client. When a session DOES exist the
21
+ * host's auth runtime supplies the token on the shared client and the same
22
+ * calls carry it — this pair neither requires nor waits for one, which is why
23
+ * its read hooks are deliberately not gated on `useActiveSessionReady`.
24
+ */
25
+ export type SearchRuntime = ModuleRuntime<SearchApi>;
26
+
27
+ export type CreateSearchRuntimeOptions = CreateModuleRuntimeOptions;
28
+
29
+ export function createSearchRuntime(
30
+ options: CreateSearchRuntimeOptions
31
+ ): SearchRuntime {
32
+ return createModuleRuntime((client) => createSearchApi(client), options);
33
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * This pair's contribution to the scripted-fullstack nav contract
3
+ * (`@stapel/core`'s `NavEntry`/`PackageNavManifest`). `scripts/gen-nav-
4
+ * manifest.mjs` reads `navEntries` below, stamps `package`/`version` from THIS
5
+ * package's own `package.json`, and emits
6
+ * `packages/search-react/nav-manifest.json` plus this package's slice of the
7
+ * root aggregate.
8
+ *
9
+ * Both entries declare `surface: "public"` EXPLICITLY. The derivation
10
+ * `requiresAuth ? "member" : "public"` would land on the same answer here, but
11
+ * the explicit declaration is what a public container can rely on: an entry
12
+ * that later gains `requiresAuth` for an unrelated reason must not silently
13
+ * fall out of the anonymous tree (`core/src/nav.ts`, `navEntrySurface`).
14
+ *
15
+ * `menuVisibleDefault: false` on both: the results page is a navigation TARGET
16
+ * reached from the header's search box, and the ranking disclosure is a
17
+ * footer link. Neither is a menu item — the same treatment `auth.login` gets.
18
+ */
19
+ import type { NavEntry } from "@stapel/core";
20
+
21
+ export const navEntries: readonly NavEntry[] = [
22
+ {
23
+ id: "search.results",
24
+ labelKey: "search.results.title",
25
+ icon: "SearchOutlined",
26
+ route: { path: "/s" },
27
+ component: { export: "SearchPage", subpath: "default" },
28
+ placement: { level: "top" },
29
+ menuVisibleDefault: false,
30
+ requiresAuth: false,
31
+ surface: "public",
32
+ order: 10,
33
+ },
34
+ {
35
+ // The P2B Art. 5 disclosure as a PAGE, not a link to raw JSON. The
36
+ // regulation asks for a plain-language description of the main ranking
37
+ // parameters; `GET /search/api/v1/ranking` is the data behind it, and
38
+ // handing a visitor that JSON would satisfy nobody's reading of it. The
39
+ // route is `/ranking-disclosure`, not `/ranking`, so the SPA path cannot
40
+ // be mistaken — by a person or by `stapel/no-string-paths` — for the API
41
+ // operation of the same name.
42
+ id: "search.ranking",
43
+ labelKey: "search.ranking.title",
44
+ icon: "OrderedListOutlined",
45
+ route: { path: "/ranking-disclosure" },
46
+ component: { export: "RankingDisclosurePane", subpath: "default" },
47
+ placement: { level: "top" },
48
+ menuVisibleDefault: false,
49
+ requiresAuth: false,
50
+ surface: "public",
51
+ order: 11,
52
+ },
53
+ ];
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `@stapel/search-react/router` — the react-router v7 binding for the URL
3
+ * state, and nothing else.
4
+ *
5
+ * A SEPARATE entry point on purpose. URL state is a router concern, and the
6
+ * pair does not own the host's router: a Next.js app, a hash router, or a
7
+ * server render that reads `new URL(request.url).searchParams` all satisfy
8
+ * `SearchParamsAdapter` without this file. Keeping the binding here means the
9
+ * main entry pulls no router at all (size-limit proves it), and a host that
10
+ * uses react-router gets the call it cannot make wrong:
11
+ *
12
+ * ```tsx
13
+ * <SearchStateProvider adapter={useRouterSearchParams()} defaultType="listing">
14
+ * ```
15
+ */
16
+ import { useMemo } from "react";
17
+ import { useSearchParams } from "react-router";
18
+ import type { SearchParamsAdapter } from "../headless/SearchStateProvider.js";
19
+
20
+ /**
21
+ * react-router's `useSearchParams()` as a {@link SearchParamsAdapter}.
22
+ *
23
+ * `replace` is forwarded rather than dropped: a facet click PUSHES, so Back
24
+ * removes exactly that filter (the spec's §4.2 acceptance), while typing in
25
+ * the search box replaces so one history entry per keystroke never happens.
26
+ */
27
+ export function useRouterSearchParams(): SearchParamsAdapter {
28
+ const [params, setParams] = useSearchParams();
29
+ return useMemo(
30
+ () => ({
31
+ params,
32
+ setParams: (next: URLSearchParams, options?: { readonly replace?: boolean }) => {
33
+ setParams(next, { replace: options?.replace ?? false });
34
+ },
35
+ }),
36
+ [params, setParams]
37
+ );
38
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `degraded[]` — the backend declaring, per query, what the configured engine
3
+ * could not do. The UI's job is to say it, not to smooth it over: a result
4
+ * page that quietly drops "typo tolerance is off" is a page that looks like a
5
+ * complete answer and is not one.
6
+ *
7
+ * The literals are produced in `stapel-search/services.py::_degradations` plus
8
+ * whatever the backend and the facet counter contribute; the concatenation is
9
+ * NOT de-duplicated upstream, so `"typo_tolerance"` can genuinely arrive
10
+ * twice. {@link parseDegradations} de-duplicates by the raw literal.
11
+ *
12
+ * An unrecognised literal is kept, not dropped — the honest failure mode for
13
+ * "the backend degraded in a way this build predates" is a generic sentence
14
+ * with the literal beside it, not silence.
15
+ */
16
+ import type { SearchDegradation, SearchDegradationKind } from "../api/types.js";
17
+
18
+ const SCORER_PREFIX = "scorer:";
19
+
20
+ const KNOWN: Readonly<Record<string, SearchDegradationKind>> = {
21
+ typo_tolerance: "typo_tolerance",
22
+ phrase_synonyms: "phrase_synonyms",
23
+ exact_total: "exact_total",
24
+ exact_facet_counts: "exact_facet_counts",
25
+ category_rollup: "category_rollup",
26
+ };
27
+
28
+ /** i18n key per kind (`search.degraded.*`). */
29
+ export function degradationMessageKey(kind: SearchDegradationKind): string {
30
+ return `search.degraded.${kind}`;
31
+ }
32
+
33
+ /** Parse and de-duplicate the envelope's `degraded[]`. */
34
+ export function parseDegradations(
35
+ degraded: readonly string[] | undefined
36
+ ): readonly SearchDegradation[] {
37
+ if (degraded === undefined) return [];
38
+ const seen = new Set<string>();
39
+ const out: SearchDegradation[] = [];
40
+ for (const raw of degraded) {
41
+ if (seen.has(raw)) continue;
42
+ seen.add(raw);
43
+ if (raw.startsWith(SCORER_PREFIX)) {
44
+ out.push({
45
+ kind: "scorer",
46
+ raw,
47
+ scorer: raw.slice(SCORER_PREFIX.length),
48
+ messageKey: degradationMessageKey("scorer"),
49
+ });
50
+ continue;
51
+ }
52
+ const kind = KNOWN[raw];
53
+ out.push({
54
+ kind: kind ?? "unknown",
55
+ raw,
56
+ messageKey: degradationMessageKey(kind ?? "unknown"),
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Does this set of degradations mean the RESULT COUNT is an estimate?
64
+ *
65
+ * Two independent signals say so and a skin must not have to know which:
66
+ * the envelope's own `exact_total: false`, and the `"exact_total"`
67
+ * degradation. They agree in practice; treating either as decisive is what
68
+ * keeps "≈1 200" from ever being rendered as "1 200".
69
+ */
70
+ export function countIsEstimate(
71
+ exactTotal: boolean,
72
+ degradations: readonly SearchDegradation[]
73
+ ): boolean {
74
+ return !exactTotal || degradations.some((d) => d.kind === "exact_total");
75
+ }
@@ -0,0 +1,219 @@
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
+ import type { FeatureDef } from "@stapel/attributes-react";
25
+ import type { FacetMeta, SearchQueryState } from "../api/types.js";
26
+
27
+ /** One value of one facet. */
28
+ export interface FacetOption {
29
+ readonly value: string;
30
+ /**
31
+ * How many candidates carry this value with the slug's own filter removed —
32
+ * or `null` when the slug was not counted (skipped, or facets off).
33
+ */
34
+ readonly count: number | null;
35
+ /** Resolved through the category schema; the raw value when there is none. */
36
+ readonly label: string;
37
+ readonly selected: boolean;
38
+ }
39
+
40
+ /** One facet slug, with its options. */
41
+ export interface FacetGroup {
42
+ readonly slug: string;
43
+ /** The feature's display name (translated when it is a key), else the slug. */
44
+ readonly label: string;
45
+ /** The category-schema entry behind the slug, when the host supplied one. */
46
+ readonly feature: FeatureDef | undefined;
47
+ /** `false` when the server skipped this slug — counts are `null`. */
48
+ readonly counted: boolean;
49
+ readonly options: readonly FacetOption[];
50
+ /** The values currently chosen for this slug (URL state, not the response). */
51
+ readonly selected: readonly string[];
52
+ }
53
+
54
+ export interface BuildFacetGroupsInput {
55
+ /** The envelope's `facets`: `{slug: {value: count}}`. */
56
+ readonly facets: Readonly<Record<string, Readonly<Record<string, number>>>>;
57
+ readonly meta: FacetMeta;
58
+ /** Current URL state — the source of what is selected. */
59
+ readonly state: SearchQueryState;
60
+ /** The category's feature schema, for labels and option order. */
61
+ readonly categoryFeatures?: readonly FeatureDef[];
62
+ /** Translator for label keys. */
63
+ readonly t?: (key: string) => string;
64
+ /** BCP-47 tag, forwarded to `formatFeatureValue` for `date` options. */
65
+ readonly locale?: string;
66
+ }
67
+
68
+ function translate(t: ((key: string) => string) | undefined, key: string): string {
69
+ if (t === undefined) return key;
70
+ const resolved = t(key);
71
+ return resolved.length > 0 ? resolved : key;
72
+ }
73
+
74
+ /**
75
+ * The declared option ORDER for a closed set, if the schema declares one.
76
+ *
77
+ * Closed sets arrive zero-filled from the server (`fill_zero_options`), and a
78
+ * closed set is a list somebody authored: showing it in count order reshuffles
79
+ * a size chart on every click. Open sets have no authored order, so they fall
80
+ * back to count-descending.
81
+ */
82
+ function declaredOptionValues(feature: FeatureDef | undefined): readonly string[] {
83
+ if (feature === undefined) return [];
84
+ const raw = featureConfig(feature)["options"];
85
+ if (!Array.isArray(raw)) return [];
86
+ const out: string[] = [];
87
+ for (const option of raw) {
88
+ if (option !== null && typeof option === "object") {
89
+ const value = (option as { value?: unknown }).value;
90
+ if (value !== undefined && value !== null) out.push(String(value));
91
+ } else if (typeof option === "string") {
92
+ out.push(option);
93
+ }
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * A facet value as a person reads it, through
100
+ * `@stapel/attributes-react`'s `formatFeatureValue` — the same formatter the
101
+ * card and the spec table use, so a value cannot read one way in the filter
102
+ * and another way in the result.
103
+ *
104
+ * The wire carries facet values as STRINGS (they are index terms), so the raw
105
+ * value is coerced back into the shape the type's formatter expects before
106
+ * being handed over. `select` is a list even for one value
107
+ * (`types/select/dto.py`), `date` is a Unix timestamp integer, numbers are
108
+ * numbers. A type the formatter cannot read gives back the raw value — never
109
+ * an empty label, which would leave an unnameable checkbox on screen.
110
+ */
111
+ export function facetOptionLabel(
112
+ feature: FeatureDef | undefined,
113
+ value: string,
114
+ options?: { readonly t?: (key: string) => string; readonly locale?: string }
115
+ ): string {
116
+ if (feature === undefined) return value;
117
+ const type = featureType(feature);
118
+ if (type === undefined) return value;
119
+
120
+ let coerced: unknown = value;
121
+ switch (type) {
122
+ case "select":
123
+ case "hierarchical_select":
124
+ coerced = [value];
125
+ break;
126
+ case "int":
127
+ case "float":
128
+ case "date": {
129
+ const parsed = Number(value);
130
+ coerced = Number.isFinite(parsed) ? parsed : value;
131
+ break;
132
+ }
133
+ case "bool":
134
+ coerced = value === "true" || value === "1";
135
+ break;
136
+ default:
137
+ coerced = value;
138
+ }
139
+
140
+ const formatted = formatFeatureValue(
141
+ feature,
142
+ { type, value: coerced },
143
+ {
144
+ ...(options?.t !== undefined ? { t: options.t } : {}),
145
+ ...(options?.locale !== undefined ? { locale: options.locale } : {}),
146
+ }
147
+ );
148
+ return formatted !== undefined && formatted.length > 0 ? formatted : value;
149
+ }
150
+
151
+ /**
152
+ * Fold the response's counts, the URL's selections and the category schema
153
+ * into the panel's groups.
154
+ *
155
+ * Groups appear for every counted slug, every skipped slug, and every slug the
156
+ * person has filtered on — the last one matters: a filter whose slug fell out
157
+ * of the plan must stay visible, or it becomes a constraint with no control to
158
+ * remove it.
159
+ */
160
+ export function buildFacetGroups(input: BuildFacetGroupsInput): readonly FacetGroup[] {
161
+ const bySlug = new Map<string, FeatureDef>();
162
+ for (const feature of input.categoryFeatures ?? []) bySlug.set(feature.slug, feature);
163
+
164
+ const skipped = new Set(input.meta.skipped);
165
+ const slugs: string[] = [];
166
+ const seen = new Set<string>();
167
+ for (const slug of [
168
+ ...Object.keys(input.facets),
169
+ ...input.meta.skipped,
170
+ ...Object.keys(input.state.filters),
171
+ ]) {
172
+ if (seen.has(slug)) continue;
173
+ seen.add(slug);
174
+ slugs.push(slug);
175
+ }
176
+
177
+ return slugs.map((slug) => {
178
+ const feature = bySlug.get(slug);
179
+ const counts = input.facets[slug] ?? {};
180
+ const counted = !skipped.has(slug) && slug in input.facets;
181
+ const selected = input.state.filters[slug] ?? [];
182
+
183
+ const declared = declaredOptionValues(feature);
184
+ const values: string[] = [];
185
+ const push = (value: string): void => {
186
+ if (!values.includes(value)) values.push(value);
187
+ };
188
+ // Declared order first (closed sets), then whatever else the counter
189
+ // returned, then anything selected that neither of them mentioned.
190
+ for (const value of declared) if (value in counts) push(value);
191
+ const remaining = Object.keys(counts)
192
+ .filter((value) => !declared.includes(value))
193
+ .sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0) || a.localeCompare(b));
194
+ for (const value of remaining) push(value);
195
+ for (const value of selected) push(value);
196
+
197
+ const labelOptions = {
198
+ ...(input.t !== undefined ? { t: input.t } : {}),
199
+ ...(input.locale !== undefined ? { locale: input.locale } : {}),
200
+ };
201
+
202
+ return {
203
+ slug,
204
+ label:
205
+ feature === undefined
206
+ ? slug
207
+ : translate(input.t, featureName(feature)),
208
+ feature,
209
+ counted,
210
+ selected,
211
+ options: values.map((value) => ({
212
+ value,
213
+ count: counted ? (counts[value] ?? 0) : null,
214
+ label: facetOptionLabel(feature, value, labelOptions),
215
+ selected: selected.includes(value),
216
+ })),
217
+ };
218
+ });
219
+ }