@salesforce/ui-bundle-template-feature-react-search 11.3.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 (169) hide show
  1. package/LICENSE.txt +82 -0
  2. package/README.md +692 -0
  3. package/dist/.forceignore +15 -0
  4. package/dist/.husky/pre-commit +4 -0
  5. package/dist/.prettierignore +11 -0
  6. package/dist/.prettierrc +17 -0
  7. package/dist/CHANGELOG.md +3499 -0
  8. package/dist/README.md +28 -0
  9. package/dist/config/project-scratch-def.json +13 -0
  10. package/dist/eslint.config.js +7 -0
  11. package/dist/force-app/main/default/uiBundles/feature-react-search/.forceignore +15 -0
  12. package/dist/force-app/main/default/uiBundles/feature-react-search/.graphqlrc.yml +2 -0
  13. package/dist/force-app/main/default/uiBundles/feature-react-search/.prettierignore +9 -0
  14. package/dist/force-app/main/default/uiBundles/feature-react-search/.prettierrc +11 -0
  15. package/dist/force-app/main/default/uiBundles/feature-react-search/CHANGELOG.md +10 -0
  16. package/dist/force-app/main/default/uiBundles/feature-react-search/README.md +75 -0
  17. package/dist/force-app/main/default/uiBundles/feature-react-search/codegen.yml +95 -0
  18. package/dist/force-app/main/default/uiBundles/feature-react-search/components.json +18 -0
  19. package/dist/force-app/main/default/uiBundles/feature-react-search/e2e/app.spec.ts +17 -0
  20. package/dist/force-app/main/default/uiBundles/feature-react-search/eslint.config.js +169 -0
  21. package/dist/force-app/main/default/uiBundles/feature-react-search/feature-react-search.uibundle-meta.xml +7 -0
  22. package/dist/force-app/main/default/uiBundles/feature-react-search/index.html +12 -0
  23. package/dist/force-app/main/default/uiBundles/feature-react-search/package.json +76 -0
  24. package/dist/force-app/main/default/uiBundles/feature-react-search/playwright.config.ts +24 -0
  25. package/dist/force-app/main/default/uiBundles/feature-react-search/scripts/get-graphql-schema.mjs +71 -0
  26. package/dist/force-app/main/default/uiBundles/feature-react-search/scripts/rewrite-e2e-assets.mjs +23 -0
  27. package/dist/force-app/main/default/uiBundles/feature-react-search/src/api/graphqlClient.ts +44 -0
  28. package/dist/force-app/main/default/uiBundles/feature-react-search/src/app.tsx +17 -0
  29. package/dist/force-app/main/default/uiBundles/feature-react-search/src/appLayout.tsx +83 -0
  30. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/book.svg +3 -0
  31. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/copy.svg +4 -0
  32. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/rocket.svg +3 -0
  33. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/icons/star.svg +3 -0
  34. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/codey-1.png +0 -0
  35. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/codey-2.png +0 -0
  36. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/codey-3.png +0 -0
  37. package/dist/force-app/main/default/uiBundles/feature-react-search/src/assets/images/vibe-codey.svg +194 -0
  38. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/alerts/status-alert.tsx +52 -0
  39. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/layouts/card-layout.tsx +29 -0
  40. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/alert.tsx +76 -0
  41. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/avatar.tsx +109 -0
  42. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/badge.tsx +48 -0
  43. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/breadcrumb.tsx +109 -0
  44. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/button.tsx +67 -0
  45. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/calendar.tsx +232 -0
  46. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/card.tsx +103 -0
  47. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/checkbox.tsx +32 -0
  48. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/collapsible.tsx +33 -0
  49. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/datePicker.tsx +127 -0
  50. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/dialog.tsx +162 -0
  51. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/dropdown-menu.tsx +257 -0
  52. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/field.tsx +237 -0
  53. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/index.ts +109 -0
  54. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/input.tsx +19 -0
  55. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/label.tsx +22 -0
  56. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/pagination.tsx +132 -0
  57. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/popover.tsx +89 -0
  58. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/select.tsx +193 -0
  59. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/separator.tsx +26 -0
  60. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/skeleton.tsx +14 -0
  61. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/sonner.tsx +20 -0
  62. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/spinner.tsx +16 -0
  63. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/table.tsx +114 -0
  64. package/dist/force-app/main/default/uiBundles/feature-react-search/src/components/ui/tabs.tsx +88 -0
  65. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/distinctValuesService.ts +84 -0
  66. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/searchService.ts +71 -0
  67. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +160 -0
  68. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +77 -0
  69. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +167 -0
  70. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +115 -0
  71. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/ScopeSelector.tsx +55 -0
  72. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SearchBar.tsx +55 -0
  73. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SortControl.tsx +62 -0
  74. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/ActiveFilters.tsx +61 -0
  75. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +122 -0
  76. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/FilterContext.tsx +70 -0
  77. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
  78. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +50 -0
  79. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
  80. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +47 -0
  81. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
  82. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
  83. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
  84. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/results/DefaultResultRow.tsx +109 -0
  85. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +82 -0
  86. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useAsyncData.ts +56 -0
  87. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useDistinctValues.ts +59 -0
  88. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +442 -0
  89. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +80 -0
  90. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/loadConfig.ts +14 -0
  91. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +156 -0
  92. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +169 -0
  93. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/debounce.ts +17 -0
  94. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/fieldUtils.ts +14 -0
  95. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +272 -0
  96. package/dist/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/sortUtils.ts +34 -0
  97. package/dist/force-app/main/default/uiBundles/feature-react-search/src/hooks/useAsyncData.ts +67 -0
  98. package/dist/force-app/main/default/uiBundles/feature-react-search/src/lib/utils.ts +6 -0
  99. package/dist/force-app/main/default/uiBundles/feature-react-search/src/navigationMenu.tsx +80 -0
  100. package/dist/force-app/main/default/uiBundles/feature-react-search/src/pages/Home.tsx +11 -0
  101. package/dist/force-app/main/default/uiBundles/feature-react-search/src/pages/NotFound.tsx +18 -0
  102. package/dist/force-app/main/default/uiBundles/feature-react-search/src/router-utils.tsx +35 -0
  103. package/dist/force-app/main/default/uiBundles/feature-react-search/src/routes.tsx +22 -0
  104. package/dist/force-app/main/default/uiBundles/feature-react-search/src/styles/global.css +135 -0
  105. package/dist/force-app/main/default/uiBundles/feature-react-search/tsconfig.json +45 -0
  106. package/dist/force-app/main/default/uiBundles/feature-react-search/tsconfig.node.json +13 -0
  107. package/dist/force-app/main/default/uiBundles/feature-react-search/ui-bundle.json +7 -0
  108. package/dist/force-app/main/default/uiBundles/feature-react-search/vite-env.d.ts +4 -0
  109. package/dist/force-app/main/default/uiBundles/feature-react-search/vite.config.ts +106 -0
  110. package/dist/force-app/main/default/uiBundles/feature-react-search/vitest-env.d.ts +2 -0
  111. package/dist/force-app/main/default/uiBundles/feature-react-search/vitest.config.ts +11 -0
  112. package/dist/force-app/main/default/uiBundles/feature-react-search/vitest.setup.ts +1 -0
  113. package/dist/jest.config.js +6 -0
  114. package/dist/package-lock.json +9995 -0
  115. package/dist/package.json +44 -0
  116. package/dist/scripts/apex/hello.apex +10 -0
  117. package/dist/scripts/gitignore-templates.json +4 -0
  118. package/dist/scripts/graphql-search.sh +191 -0
  119. package/dist/scripts/org-setup-config-schema.mjs +96 -0
  120. package/dist/scripts/org-setup.config.json +5 -0
  121. package/dist/scripts/org-setup.mjs +1392 -0
  122. package/dist/scripts/sf-project-setup.mjs +103 -0
  123. package/dist/scripts/soql/account.soql +6 -0
  124. package/dist/scripts/validate-org-setup-config.mjs +38 -0
  125. package/dist/sfdx-project.json +12 -0
  126. package/package.json +51 -0
  127. package/src/force-app/main/default/uiBundles/feature-react-search/src/__inherit__appLayout.tsx +9 -0
  128. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__alert.tsx +39 -0
  129. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__button.tsx +45 -0
  130. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__checkbox.tsx +8 -0
  131. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__input.tsx +5 -0
  132. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__label.tsx +8 -0
  133. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__pagination.tsx +47 -0
  134. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__select.tsx +57 -0
  135. package/src/force-app/main/default/uiBundles/feature-react-search/src/components/ui/__inherit__skeleton.tsx +5 -0
  136. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/distinctValuesService.ts +84 -0
  137. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/api/searchService.ts +71 -0
  138. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/Search.tsx +160 -0
  139. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SearchResults.tsx +77 -0
  140. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/SourceSection.tsx +167 -0
  141. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/PaginationControls.tsx +115 -0
  142. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/ScopeSelector.tsx +55 -0
  143. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SearchBar.tsx +55 -0
  144. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/controls/SortControl.tsx +62 -0
  145. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/ActiveFilters.tsx +61 -0
  146. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/DefaultFilterPanel.tsx +122 -0
  147. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/FilterContext.tsx +70 -0
  148. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/BooleanFilter.tsx +50 -0
  149. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/DateRangeFilter.tsx +50 -0
  150. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/FilterFieldWrapper.tsx +26 -0
  151. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/MultiSelectFilter.tsx +47 -0
  152. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/NumericRangeFilter.tsx +78 -0
  153. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/SelectFilter.tsx +46 -0
  154. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/filters/inputs/TextFilter.tsx +52 -0
  155. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/components/results/DefaultResultRow.tsx +109 -0
  156. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/config.json +82 -0
  157. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useAsyncData.ts +56 -0
  158. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useDistinctValues.ts +59 -0
  159. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/hooks/useSearch.ts +442 -0
  160. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/index.ts +80 -0
  161. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/loadConfig.ts +14 -0
  162. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/queryBuilder.ts +156 -0
  163. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/types.ts +169 -0
  164. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/debounce.ts +17 -0
  165. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/fieldUtils.ts +14 -0
  166. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/filterUtils.ts +272 -0
  167. package/src/force-app/main/default/uiBundles/feature-react-search/src/features/search/utils/sortUtils.ts +34 -0
  168. package/src/force-app/main/default/uiBundles/feature-react-search/src/pages/Home.tsx +11 -0
  169. package/src/force-app/main/default/uiBundles/feature-react-search/src/routes.tsx +10 -0
@@ -0,0 +1,442 @@
1
+ /**
2
+ * useSearch — orchestrates configuration-driven multi-source search.
3
+ *
4
+ * Owns:
5
+ * - global `q` (one search box drives all sources)
6
+ * - per-source filters / sort / pagination (independent state per source)
7
+ * - URL sync (debounced; namespace `s.<key>.f.<field>=...`)
8
+ * - one batched GraphQL fetch on every state change
9
+ *
10
+ * Returns a per-source `controller` map plus aggregate loading/error flags.
11
+ * Rendering and routing are the caller's responsibility.
12
+ */
13
+
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
+ import { useSearchParams } from "react-router";
16
+ import { runSearch } from "../api/searchService";
17
+ import {
18
+ GLOBAL_QUERY_KEY,
19
+ readSourceParams,
20
+ writeSourceParams,
21
+ type ActiveFilterValue,
22
+ } from "../utils/filterUtils";
23
+ import type { SortState } from "../utils/sortUtils";
24
+ import { debounce } from "../utils/debounce";
25
+ import type {
26
+ SObjectSourceConfig,
27
+ SourceController,
28
+ SourceResult,
29
+ SearchConfig,
30
+ SearchHandle,
31
+ SearchScope,
32
+ } from "../types";
33
+ import { ALL_SCOPE } from "../types";
34
+
35
+ const URL_SYNC_DEBOUNCE_MS = 300;
36
+ const SCOPE_KEY = "scope";
37
+
38
+ /**
39
+ * Returns true when `source` should participate in the current scope —
40
+ * either the scope is "all" or the scope matches this source's key.
41
+ */
42
+ function isSourceInScope(scope: SearchScope, sourceKey: string): boolean {
43
+ return scope === ALL_SCOPE || scope === sourceKey;
44
+ }
45
+
46
+ interface SourceLocalState {
47
+ filters: ActiveFilterValue[];
48
+ sort: SortState | null;
49
+ pageSize: number;
50
+ pageIndex: number;
51
+ afterCursor: string | undefined;
52
+ cursorStack: string[];
53
+ }
54
+
55
+ function defaultPageSize(source: SObjectSourceConfig): number {
56
+ return source.pageSize ?? source.validPageSizes?.[0] ?? 10;
57
+ }
58
+
59
+ function validatePageSize(source: SObjectSourceConfig, size: number): number {
60
+ const valid = source.validPageSizes;
61
+ if (!valid || valid.length === 0) return size;
62
+ return valid.includes(size) ? size : defaultPageSize(source);
63
+ }
64
+
65
+ function initSourceState(source: SObjectSourceConfig, params: URLSearchParams): SourceLocalState {
66
+ const read = readSourceParams(params, source.key, source.filterFields ?? []);
67
+ const sort = read.sort ?? source.defaultSort ?? null;
68
+ const pageSize = validatePageSize(source, read.pageSize ?? defaultPageSize(source));
69
+ return {
70
+ filters: read.filters,
71
+ sort,
72
+ pageSize,
73
+ pageIndex: read.pageIndex,
74
+ afterCursor: undefined,
75
+ cursorStack: [],
76
+ };
77
+ }
78
+
79
+ export interface UseSearchOptions {
80
+ /**
81
+ * Lock the search to a single source key (or "all"). When set:
82
+ * - `scope` is forced to this value and `setScope` becomes a no-op.
83
+ * - The URL never writes `?scope=` (it's implicit in the page route).
84
+ * - The dropdown should be hidden by the caller.
85
+ *
86
+ * Use when you embed the search inside a page that already represents
87
+ * the source (e.g. `/accounts/search` rendering only Accounts).
88
+ */
89
+ lockedScope?: SearchScope;
90
+ }
91
+
92
+ export function useSearch(config: SearchConfig, options?: UseSearchOptions): SearchHandle {
93
+ const [searchParams, setSearchParams] = useSearchParams();
94
+ const lockedScope = options?.lockedScope;
95
+
96
+ // One-time seed from URL.
97
+ const initialSourceStates = useMemo(() => {
98
+ const map: Record<string, SourceLocalState> = {};
99
+ for (const source of config.sources) {
100
+ map[source.key] = initSourceState(source, searchParams);
101
+ }
102
+ return map;
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
104
+ }, []);
105
+ const initialQ = useMemo(() => searchParams.get(GLOBAL_QUERY_KEY) ?? "", []);
106
+ const initialScope = useMemo<SearchScope>(() => {
107
+ // When the caller locks the scope, that always wins over the URL.
108
+ if (lockedScope !== undefined) {
109
+ if (lockedScope === ALL_SCOPE) return ALL_SCOPE;
110
+ return config.sources.some((s) => s.key === lockedScope) ? lockedScope : ALL_SCOPE;
111
+ }
112
+ const raw = searchParams.get(SCOPE_KEY);
113
+ if (!raw || raw === ALL_SCOPE) return ALL_SCOPE;
114
+ return config.sources.some((s) => s.key === raw) ? raw : ALL_SCOPE;
115
+ // eslint-disable-next-line react-hooks/exhaustive-deps
116
+ }, []);
117
+
118
+ const [q, setQState] = useState(initialQ);
119
+ const [scope, setScopeState] = useState<SearchScope>(initialScope);
120
+ const [sourceStates, setSourceStates] =
121
+ useState<Record<string, SourceLocalState>>(initialSourceStates);
122
+
123
+ // Snapshot ref so debounced URL sync sees the latest state without
124
+ // recreating callbacks on every render.
125
+ const stateRef = useRef({ q, scope, sourceStates });
126
+ useEffect(() => {
127
+ stateRef.current = { q, scope, sourceStates };
128
+ });
129
+
130
+ // -- URL sync --------------------------------------------------------------
131
+
132
+ const syncToUrl = useCallback(
133
+ (nextQ: string, nextScope: SearchScope, nextStates: Record<string, SourceLocalState>) => {
134
+ const params = new URLSearchParams();
135
+ if (nextQ) params.set(GLOBAL_QUERY_KEY, nextQ);
136
+ // Skip writing ?scope when caller has locked the scope — it's
137
+ // already implicit in the page route.
138
+ if (lockedScope === undefined && nextScope !== ALL_SCOPE) {
139
+ params.set(SCOPE_KEY, nextScope);
140
+ }
141
+ for (const source of config.sources) {
142
+ const state = nextStates[source.key];
143
+ if (!state) continue;
144
+ writeSourceParams(
145
+ params,
146
+ source.key,
147
+ state.filters,
148
+ state.sort,
149
+ state.pageSize,
150
+ state.pageIndex,
151
+ // Omit sort / page size from the URL when they equal this
152
+ // source's defaults, so a reset (or untouched source) leaves
153
+ // a clean query string instead of echoing the defaults.
154
+ { sort: source.defaultSort ?? null, pageSize: defaultPageSize(source) },
155
+ );
156
+ }
157
+ setSearchParams(params, { replace: true });
158
+ },
159
+ [config.sources, setSearchParams, lockedScope],
160
+ );
161
+
162
+ const debouncedSyncRef = useRef(debounce(syncToUrl, URL_SYNC_DEBOUNCE_MS));
163
+ useEffect(() => {
164
+ debouncedSyncRef.current = debounce(syncToUrl, URL_SYNC_DEBOUNCE_MS);
165
+ }, [syncToUrl]);
166
+
167
+ // Update one source's state and (optionally) reset its pagination.
168
+ const updateSource = useCallback(
169
+ (
170
+ sourceKey: string,
171
+ updater: (prev: SourceLocalState) => SourceLocalState,
172
+ resetPagination: boolean,
173
+ ) => {
174
+ setSourceStates((prev) => {
175
+ const prior = prev[sourceKey];
176
+ if (!prior) return prev;
177
+ let next = updater(prior);
178
+ if (resetPagination) {
179
+ next = { ...next, pageIndex: 0, afterCursor: undefined, cursorStack: [] };
180
+ }
181
+ const merged = { ...prev, [sourceKey]: next };
182
+ debouncedSyncRef.current(stateRef.current.q, stateRef.current.scope, merged);
183
+ return merged;
184
+ });
185
+ },
186
+ [],
187
+ );
188
+
189
+ // -- Global q --------------------------------------------------------------
190
+
191
+ const setQ = useCallback((nextQ: string) => {
192
+ setQState(nextQ);
193
+ // Changing the global term invalidates every cursor.
194
+ setSourceStates((prev) => {
195
+ const next: Record<string, SourceLocalState> = {};
196
+ for (const [key, state] of Object.entries(prev)) {
197
+ next[key] = { ...state, pageIndex: 0, afterCursor: undefined, cursorStack: [] };
198
+ }
199
+ debouncedSyncRef.current(nextQ, stateRef.current.scope, next);
200
+ return next;
201
+ });
202
+ }, []);
203
+
204
+ // -- Scope -----------------------------------------------------------------
205
+
206
+ const setScope = useCallback(
207
+ (nextScope: SearchScope) => {
208
+ // Locked scope wins over user input — silently no-op.
209
+ if (lockedScope !== undefined) return;
210
+ // Reject unknown scope values; fall back to "all".
211
+ const validated: SearchScope =
212
+ nextScope === ALL_SCOPE || config.sources.some((s) => s.key === nextScope)
213
+ ? nextScope
214
+ : ALL_SCOPE;
215
+ setScopeState(validated);
216
+ // Narrowing or widening invalidates every cursor — the visible result
217
+ // set is changing.
218
+ setSourceStates((prev) => {
219
+ const next: Record<string, SourceLocalState> = {};
220
+ for (const [key, state] of Object.entries(prev)) {
221
+ next[key] = { ...state, pageIndex: 0, afterCursor: undefined, cursorStack: [] };
222
+ }
223
+ debouncedSyncRef.current(stateRef.current.q, validated, next);
224
+ return next;
225
+ });
226
+ },
227
+ [config.sources, lockedScope],
228
+ );
229
+
230
+ // -- Reset all -------------------------------------------------------------
231
+
232
+ const resetAll = useCallback(() => {
233
+ setQState("");
234
+ // Preserve locked scope; otherwise widen back to "all".
235
+ const nextScope: SearchScope = lockedScope ?? ALL_SCOPE;
236
+ setScopeState(nextScope);
237
+ const empty: Record<string, SourceLocalState> = {};
238
+ for (const source of config.sources) {
239
+ empty[source.key] = {
240
+ filters: [],
241
+ sort: source.defaultSort ?? null,
242
+ pageSize: defaultPageSize(source),
243
+ pageIndex: 0,
244
+ afterCursor: undefined,
245
+ cursorStack: [],
246
+ };
247
+ }
248
+ setSourceStates(empty);
249
+ syncToUrl("", nextScope, empty);
250
+ }, [config.sources, syncToUrl, lockedScope]);
251
+
252
+ // -- Fetch -----------------------------------------------------------------
253
+
254
+ const requestKey = useMemo(() => {
255
+ // Stable string key that changes only when something fetch-relevant changes.
256
+ // Sources outside the current scope are listed (with `skip: true`) so
257
+ // the key still changes when scope toggles, but they don't contribute
258
+ // per-source state to the diff.
259
+ return JSON.stringify({
260
+ q,
261
+ scope,
262
+ s: config.sources.map((source) => {
263
+ if (!isSourceInScope(scope, source.key)) {
264
+ return { k: source.key, skip: true };
265
+ }
266
+ const st = sourceStates[source.key];
267
+ return {
268
+ k: source.key,
269
+ f: st?.filters,
270
+ o: st?.sort,
271
+ p: st?.pageSize,
272
+ a: st?.afterCursor ?? null,
273
+ };
274
+ }),
275
+ });
276
+ }, [q, scope, sourceStates, config.sources]);
277
+
278
+ const [results, setResults] = useState<Record<string, SourceResult>>({});
279
+ const [loading, setLoading] = useState(true);
280
+ const [error, setError] = useState<string | null>(null);
281
+ const fetchGenRef = useRef(0);
282
+
283
+ useEffect(() => {
284
+ const generation = ++fetchGenRef.current;
285
+ setLoading(true);
286
+ setError(null);
287
+
288
+ const activeSources = config.sources.filter((source) => isSourceInScope(scope, source.key));
289
+
290
+ // All sources gated out — clear results and exit without firing a request.
291
+ if (activeSources.length === 0) {
292
+ setResults({});
293
+ setLoading(false);
294
+ return;
295
+ }
296
+
297
+ const requests = activeSources.map((source) => {
298
+ const st = sourceStates[source.key]!;
299
+ return {
300
+ source,
301
+ q,
302
+ filters: st.filters,
303
+ sort: st.sort,
304
+ pageSize: st.pageSize,
305
+ afterCursor: st.afterCursor,
306
+ };
307
+ });
308
+
309
+ runSearch(requests)
310
+ .then((resp) => {
311
+ if (generation !== fetchGenRef.current) return;
312
+ setResults(resp);
313
+ })
314
+ .catch((err: unknown) => {
315
+ if (generation !== fetchGenRef.current) return;
316
+ console.error(err);
317
+ setError(err instanceof Error ? err.message : "Unified search failed");
318
+ })
319
+ .finally(() => {
320
+ if (generation !== fetchGenRef.current) return;
321
+ setLoading(false);
322
+ });
323
+ // requestKey is the dependency-of-record; sourceStates and q are
324
+ // captured via the ref-like closure above each render.
325
+ // eslint-disable-next-line react-hooks/exhaustive-deps
326
+ }, [requestKey]);
327
+
328
+ // -- Per-source controllers ------------------------------------------------
329
+
330
+ const controllers = useMemo(() => {
331
+ const out: Record<string, SourceController> = {};
332
+ for (const source of config.sources) {
333
+ const state = sourceStates[source.key];
334
+ const result = results[source.key] ?? null;
335
+
336
+ const setFilter = (field: string, value: ActiveFilterValue | undefined) => {
337
+ updateSource(
338
+ source.key,
339
+ (prev) => {
340
+ const filtered = prev.filters.filter((f) => f.field !== field);
341
+ if (value) filtered.push(value);
342
+ return { ...prev, filters: filtered };
343
+ },
344
+ true,
345
+ );
346
+ };
347
+ const removeFilter = (field: string) => {
348
+ updateSource(
349
+ source.key,
350
+ (prev) => ({
351
+ ...prev,
352
+ filters: prev.filters.filter((f) => f.field !== field),
353
+ }),
354
+ true,
355
+ );
356
+ };
357
+ const setSort = (sort: SortState | null) => {
358
+ updateSource(source.key, (prev) => ({ ...prev, sort }), true);
359
+ };
360
+ const setPageSize = (size: number) => {
361
+ updateSource(
362
+ source.key,
363
+ (prev) => ({ ...prev, pageSize: validatePageSize(source, size) }),
364
+ true,
365
+ );
366
+ };
367
+ const goToNextPage = () => {
368
+ const cursor = result?.pageInfo?.endCursor;
369
+ if (!cursor) return;
370
+ updateSource(
371
+ source.key,
372
+ (prev) => ({
373
+ ...prev,
374
+ cursorStack: [...prev.cursorStack, cursor],
375
+ afterCursor: cursor,
376
+ pageIndex: prev.pageIndex + 1,
377
+ }),
378
+ false,
379
+ );
380
+ };
381
+ const goToPreviousPage = () => {
382
+ updateSource(
383
+ source.key,
384
+ (prev) => {
385
+ if (prev.pageIndex === 0) return prev;
386
+ const nextStack = prev.cursorStack.slice(0, -1);
387
+ return {
388
+ ...prev,
389
+ cursorStack: nextStack,
390
+ afterCursor: nextStack[nextStack.length - 1],
391
+ pageIndex: Math.max(0, prev.pageIndex - 1),
392
+ };
393
+ },
394
+ false,
395
+ );
396
+ };
397
+
398
+ out[source.key] = {
399
+ config: source,
400
+ result,
401
+ loading,
402
+ error,
403
+ filters: {
404
+ active: state?.filters ?? [],
405
+ set: setFilter,
406
+ remove: removeFilter,
407
+ },
408
+ sort: {
409
+ current: state?.sort ?? null,
410
+ set: setSort,
411
+ },
412
+ pagination: {
413
+ pageSize: state?.pageSize ?? defaultPageSize(source),
414
+ pageIndex: state?.pageIndex ?? 0,
415
+ hasNextPage: result?.pageInfo?.hasNextPage ?? false,
416
+ hasPreviousPage: (state?.pageIndex ?? 0) > 0,
417
+ setPageSize,
418
+ goToNextPage,
419
+ goToPreviousPage,
420
+ },
421
+ };
422
+ }
423
+ return out;
424
+ }, [config.sources, sourceStates, results, loading, error, updateSource]);
425
+
426
+ const scopeLocked = lockedScope !== undefined;
427
+
428
+ return useMemo(
429
+ () => ({
430
+ q,
431
+ setQ,
432
+ scope,
433
+ setScope,
434
+ scopeLocked,
435
+ sources: controllers,
436
+ loading,
437
+ error,
438
+ resetAll,
439
+ }),
440
+ [q, setQ, scope, setScope, scopeLocked, controllers, loading, error, resetAll],
441
+ );
442
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Public API for the search feature.
3
+ *
4
+ * Most apps only need:
5
+ * ```tsx
6
+ * import { Search, config } from ".../features/search";
7
+ * <Search config={config} />
8
+ * ```
9
+ *
10
+ * For more control, drop down to primitives:
11
+ * - {@link useSearch} hook + {@link SearchBar} + {@link SearchResults}
12
+ * - {@link DefaultResultRow} / {@link DefaultFilterPanel} for opt-in defaults
13
+ * - filter components (TextFilter, SelectFilter, ...) for custom sidebars
14
+ */
15
+
16
+ // Drop-in wrapper (most common entry point)
17
+ export { Search } from "./components/Search";
18
+
19
+ // Primitives
20
+ export { useSearch } from "./hooks/useSearch";
21
+ export { useAsyncData } from "./hooks/useAsyncData";
22
+ export { useDistinctValues } from "./hooks/useDistinctValues";
23
+
24
+ export { SearchBar } from "./components/controls/SearchBar";
25
+ export { SearchResults } from "./components/SearchResults";
26
+ export { SourceSection } from "./components/SourceSection";
27
+ export { SortControl } from "./components/controls/SortControl";
28
+ export { PaginationControls } from "./components/controls/PaginationControls";
29
+ export { ScopeSelector } from "./components/controls/ScopeSelector";
30
+ export { ActiveFilters } from "./components/filters/ActiveFilters";
31
+ export { DefaultResultRow } from "./components/results/DefaultResultRow";
32
+ export { DefaultFilterPanel } from "./components/filters/DefaultFilterPanel";
33
+ export {
34
+ FilterProvider,
35
+ FilterResetButton,
36
+ useFilterField,
37
+ useFilterPanel,
38
+ } from "./components/filters/FilterContext";
39
+
40
+ export { TextFilter } from "./components/filters/inputs/TextFilter";
41
+ export { SelectFilter } from "./components/filters/inputs/SelectFilter";
42
+ export { MultiSelectFilter } from "./components/filters/inputs/MultiSelectFilter";
43
+ export { NumericRangeFilter } from "./components/filters/inputs/NumericRangeFilter";
44
+ export { BooleanFilter } from "./components/filters/inputs/BooleanFilter";
45
+ export { DateRangeFilter } from "./components/filters/inputs/DateRangeFilter";
46
+ export { FilterFieldWrapper } from "./components/filters/inputs/FilterFieldWrapper";
47
+
48
+ export { fieldValue } from "./utils/fieldUtils";
49
+ export {
50
+ buildFilter,
51
+ buildGlobalQueryClause,
52
+ readSourceParams,
53
+ writeSourceParams,
54
+ GLOBAL_QUERY_KEY,
55
+ } from "./utils/filterUtils";
56
+ export type { ActiveFilterValue, FilterFieldConfig, FilterFieldType } from "./utils/filterUtils";
57
+ export { buildOrderBy } from "./utils/sortUtils";
58
+ export type { SortFieldConfig, SortState } from "./utils/sortUtils";
59
+
60
+ export { buildSearchQuery } from "./queryBuilder";
61
+ export type { SourceRequest, SearchQueryPayload } from "./queryBuilder";
62
+
63
+ export { runSearch } from "./api/searchService";
64
+ export { fetchDistinctValues } from "./api/distinctValuesService";
65
+ export type { PicklistOption } from "./api/distinctValuesService";
66
+
67
+ export { config } from "./loadConfig";
68
+
69
+ export { ALL_SCOPE } from "./types";
70
+ export type {
71
+ DisplayField,
72
+ SObjectSourceConfig,
73
+ SearchConfig,
74
+ SourceController,
75
+ SourceResult,
76
+ SourcePageInfo,
77
+ SearchHandle,
78
+ SearchScope,
79
+ RenderResultFn,
80
+ } from "./types";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Typed re-export of the static config.json that ships with the feature.
3
+ *
4
+ * Application code:
5
+ * import { config } from ".../features/search/loadConfig";
6
+ * const handle = useSearch(config);
7
+ *
8
+ * To customize, edit `config.json` directly (no code changes required).
9
+ */
10
+
11
+ import rawConfig from "./config.json";
12
+ import type { SearchConfig } from "./types";
13
+
14
+ export const config: SearchConfig = rawConfig as SearchConfig;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Builds a single multi-aliased GraphQL document for search.
3
+ *
4
+ * Each SObject source becomes one aliased child of `uiapi.query`, with its own
5
+ * `first / after / where / orderBy` variables. Variable names are
6
+ * `${sourceKey}_first` etc. so two sources with similar names never collide.
7
+ *
8
+ * Selection sets are generated from `displayFields`:
9
+ * - string `"Name"` → `Name @optional { value displayValue }`
10
+ * - `{ name, raw: true }` → `Name`
11
+ * - `{ name, subfields }` → nested `Name @optional { ... }`
12
+ *
13
+ * The `idField` (default "Id") is always emitted; it is **not** wrapped
14
+ * because uiapi's Id is a scalar.
15
+ */
16
+
17
+ import { buildFilter, buildGlobalQueryClause, type ActiveFilterValue } from "./utils/filterUtils";
18
+ import { buildOrderBy, type SortState } from "./utils/sortUtils";
19
+ import type { DisplayField, SObjectSourceConfig } from "./types";
20
+
21
+ const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
22
+
23
+ function assertValidKey(key: string): void {
24
+ if (!KEY_PATTERN.test(key)) {
25
+ throw new Error(
26
+ `Invalid source key "${key}". Must match /^[A-Za-z_][A-Za-z0-9_]*$/ to be a valid GraphQL alias and variable prefix.`,
27
+ );
28
+ }
29
+ }
30
+
31
+ export interface SearchQueryPayload {
32
+ document: string;
33
+ variables: Record<string, unknown>;
34
+ }
35
+
36
+ export interface SourceRequest {
37
+ source: SObjectSourceConfig;
38
+ q: string;
39
+ filters: ActiveFilterValue[];
40
+ sort: SortState | null;
41
+ pageSize: number;
42
+ afterCursor: string | undefined;
43
+ }
44
+
45
+ /**
46
+ * Combines a per-source structured-filter clause with the global-q `or` clause.
47
+ * Returns `undefined` when neither side has any constraints.
48
+ */
49
+ function buildSourceWhere(
50
+ source: SObjectSourceConfig,
51
+ q: string,
52
+ filters: ActiveFilterValue[],
53
+ ): unknown {
54
+ const clauses: unknown[] = [];
55
+ const globalClause = buildGlobalQueryClause(q, source.searchableFields);
56
+ if (globalClause) clauses.push(globalClause);
57
+ const structured = buildFilter(filters, source.filterFields ?? []);
58
+ if (structured) clauses.push(structured);
59
+ if (clauses.length === 0) return undefined;
60
+ if (clauses.length === 1) return clauses[0];
61
+ return { and: clauses };
62
+ }
63
+
64
+ /** Builds the multi-aliased query document and a flat variables map. */
65
+ export function buildSearchQuery(requests: SourceRequest[]): SearchQueryPayload {
66
+ if (requests.length === 0) {
67
+ throw new Error("buildSearchQuery requires at least one source request");
68
+ }
69
+
70
+ const variableDeclarations: string[] = [];
71
+ const querySelections: string[] = [];
72
+ const variables: Record<string, unknown> = {};
73
+
74
+ for (const request of requests) {
75
+ const { source } = request;
76
+ assertValidKey(source.key);
77
+
78
+ const firstVar = `${source.key}_first`;
79
+ const afterVar = `${source.key}_after`;
80
+ const whereVar = `${source.key}_where`;
81
+ const orderByVar = `${source.key}_orderBy`;
82
+ const whereType = source.whereTypeName ?? `${source.objectName}_Filter`;
83
+ const orderByType = source.orderByTypeName ?? `${source.objectName}_OrderBy`;
84
+
85
+ variableDeclarations.push(
86
+ `$${firstVar}: Int`,
87
+ `$${afterVar}: String`,
88
+ `$${whereVar}: ${whereType}`,
89
+ `$${orderByVar}: ${orderByType}`,
90
+ );
91
+
92
+ querySelections.push(
93
+ `${source.key}: ${source.objectName}(` +
94
+ `first: $${firstVar}, ` +
95
+ `after: $${afterVar}, ` +
96
+ `where: $${whereVar}, ` +
97
+ `orderBy: $${orderByVar}` +
98
+ `) {\n` +
99
+ buildSelectionBody(source) +
100
+ `\n}`,
101
+ );
102
+
103
+ variables[firstVar] = request.pageSize;
104
+ variables[afterVar] = request.afterCursor ?? null;
105
+ variables[whereVar] = buildSourceWhere(source, request.q, request.filters) ?? null;
106
+ variables[orderByVar] = buildOrderBy(request.sort) ?? null;
107
+ }
108
+
109
+ const document =
110
+ `query Search(${variableDeclarations.join(", ")}) {\n` +
111
+ ` uiapi {\n` +
112
+ ` query {\n` +
113
+ querySelections.map((s) => indent(s, " ")).join("\n") +
114
+ `\n }\n` +
115
+ ` }\n` +
116
+ `}\n`;
117
+
118
+ return { document, variables };
119
+ }
120
+
121
+ function buildSelectionBody(source: SObjectSourceConfig): string {
122
+ const idField = source.idField ?? "Id";
123
+ const fieldLines = [` ${idField}`];
124
+ for (const field of source.displayFields) {
125
+ fieldLines.push(...renderField(field, " "));
126
+ }
127
+ return [
128
+ ` edges { node {`,
129
+ ...fieldLines,
130
+ ` } }`,
131
+ ` pageInfo { hasNextPage hasPreviousPage startCursor endCursor }`,
132
+ ` totalCount`,
133
+ ].join("\n");
134
+ }
135
+
136
+ function renderField(field: DisplayField, indentStr: string): string[] {
137
+ if (typeof field === "string") {
138
+ return [`${indentStr}${field} @optional { value displayValue }`];
139
+ }
140
+ if ("raw" in field) {
141
+ return [`${indentStr}${field.name}`];
142
+ }
143
+ const lines = [`${indentStr}${field.name} @optional {`];
144
+ for (const sub of field.subfields) {
145
+ lines.push(...renderField(sub, indentStr + " "));
146
+ }
147
+ lines.push(`${indentStr}}`);
148
+ return lines;
149
+ }
150
+
151
+ function indent(s: string, prefix: string): string {
152
+ return s
153
+ .split("\n")
154
+ .map((line) => prefix + line)
155
+ .join("\n");
156
+ }