@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,169 @@
1
+ /**
2
+ * Configuration types for unified (multi-source) search.
3
+ *
4
+ * The application developer declares a SearchConfig with one entry per
5
+ * searchable backend. Today every source is an SObject queried via the uiapi
6
+ * GraphQL bridge; future adapters (CMS, REST, etc.) can plug in by introducing
7
+ * a new `kind` and a matching runtime adapter without changing any consumer.
8
+ *
9
+ * The library handles query construction, filter/sort/pagination state, URL
10
+ * sync, and result fan-out. The application owns rendering and routing.
11
+ */
12
+
13
+ import type { FilterFieldConfig, ActiveFilterValue } from "./utils/filterUtils";
14
+ import type { SortFieldConfig, SortState } from "./utils/sortUtils";
15
+
16
+ /**
17
+ * A single field to include in the GraphQL selection set of an SObject source.
18
+ *
19
+ * - `"Name"` (string shorthand): emits `Name @optional { value displayValue }`.
20
+ * - `{ name, subfields }`: emits a nested selection (relationship traversal).
21
+ * - `{ name, raw: true }`: emits the bare field name (e.g. for Id-like fields
22
+ * that are not wrapped in `{ value displayValue }`).
23
+ *
24
+ * The configured `idField` (default: "Id") is added automatically; do not list
25
+ * it explicitly.
26
+ */
27
+ export type DisplayField =
28
+ | string
29
+ | { name: string; raw: true }
30
+ | { name: string; subfields: DisplayField[] };
31
+
32
+ /** Configuration for one SObject source in a search experience. */
33
+ export interface SObjectSourceConfig {
34
+ /** Discriminator — selects the runtime adapter. */
35
+ kind: "sobject";
36
+ /**
37
+ * Stable identifier. Used as the GraphQL alias, the URL namespace, and the
38
+ * result-map key. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`.
39
+ */
40
+ key: string;
41
+ /** GraphQL object name (e.g. "Account", "Contact"). */
42
+ objectName: string;
43
+ /** Display label used in section headers. */
44
+ label: string;
45
+ /** Field name that uniquely identifies a record. Defaults to "Id". */
46
+ idField?: string;
47
+ /**
48
+ * URL pattern used by the default renderer to build a result link.
49
+ *
50
+ * Tokens of the form `:fieldName` are replaced with the corresponding
51
+ * field's value at render time (e.g. `:id` resolves to the record's
52
+ * `idField` value, `:Name` resolves to the Name field's `value`).
53
+ *
54
+ * Example: `"/accounts/:id"` produces `<Link to="/accounts/001xx0000003DGb">`.
55
+ *
56
+ * Optional — when omitted, the default renderer emits non-clickable rows.
57
+ */
58
+ routePattern?: string;
59
+ /**
60
+ * Fields the global `q` term should match against.
61
+ * Built into an `or` of `like %q%`. Supports dot-notation for relationship
62
+ * fields (e.g. "Owner.Name").
63
+ */
64
+ searchableFields: string[];
65
+ /**
66
+ * Fields to include in the GraphQL selection set. The id field is added
67
+ * automatically; do not list it.
68
+ */
69
+ displayFields: DisplayField[];
70
+ /** Optional structured filters surfaced in the per-source filter panel. */
71
+ filterFields?: FilterFieldConfig[];
72
+ /** Optional sort options surfaced in the per-source sort dropdown. */
73
+ sortFields?: SortFieldConfig[];
74
+ /** Initial sort applied when no URL sort is present. */
75
+ defaultSort?: SortState;
76
+ /** Default page size for this source. */
77
+ pageSize?: number;
78
+ /** Allowed page sizes. */
79
+ validPageSizes?: number[];
80
+ /**
81
+ * GraphQL type name for the `where` variable.
82
+ * Defaults to `${objectName}_Filter` — override only for non-conventional schemas.
83
+ */
84
+ whereTypeName?: string;
85
+ /** GraphQL type name for the `orderBy` variable. Defaults to `${objectName}_OrderBy`. */
86
+ orderByTypeName?: string;
87
+ }
88
+
89
+ /** Top-level config passed to {@link useSearch}. */
90
+ export interface SearchConfig {
91
+ sources: SObjectSourceConfig[];
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Runtime types
96
+ // ---------------------------------------------------------------------------
97
+
98
+ export interface SourcePageInfo {
99
+ hasNextPage: boolean;
100
+ hasPreviousPage: boolean;
101
+ endCursor: string | null;
102
+ startCursor: string | null;
103
+ }
104
+
105
+ /**
106
+ * Per-source result. Nodes are typed as `unknown` at the library boundary —
107
+ * the application narrows them in its `renderResult` callback (typically by
108
+ * casting to a codegen-generated node type).
109
+ */
110
+ export interface SourceResult {
111
+ nodes: unknown[];
112
+ pageInfo: SourcePageInfo | null;
113
+ totalCount: number | null;
114
+ }
115
+
116
+ export interface SourceController {
117
+ config: SObjectSourceConfig;
118
+ result: SourceResult | null;
119
+ loading: boolean;
120
+ error: string | null;
121
+ filters: {
122
+ active: ActiveFilterValue[];
123
+ set: (field: string, value: ActiveFilterValue | undefined) => void;
124
+ remove: (field: string) => void;
125
+ };
126
+ sort: {
127
+ current: SortState | null;
128
+ set: (sort: SortState | null) => void;
129
+ };
130
+ pagination: {
131
+ pageSize: number;
132
+ pageIndex: number;
133
+ hasNextPage: boolean;
134
+ hasPreviousPage: boolean;
135
+ setPageSize: (size: number) => void;
136
+ goToNextPage: () => void;
137
+ goToPreviousPage: () => void;
138
+ };
139
+ }
140
+
141
+ /**
142
+ * The currently-selected search scope.
143
+ *
144
+ * - `"all"`: every source in the config is queried and rendered.
145
+ * - any source `key`: only that source is queried and rendered. Other sources
146
+ * skip both the network request and the UI section.
147
+ */
148
+ export type SearchScope = "all" | string;
149
+
150
+ export const ALL_SCOPE = "all" as const;
151
+
152
+ export interface SearchHandle {
153
+ q: string;
154
+ setQ: (q: string) => void;
155
+ scope: SearchScope;
156
+ setScope: (scope: SearchScope) => void;
157
+ /**
158
+ * True when the caller passed `lockedScope` to `useSearch`. Consumers
159
+ * should hide the scope dropdown and read-only display the locked source.
160
+ */
161
+ scopeLocked: boolean;
162
+ sources: Record<string, SourceController>;
163
+ loading: boolean;
164
+ error: string | null;
165
+ resetAll: () => void;
166
+ }
167
+
168
+ /** Type alias for a render callback per source key. */
169
+ export type RenderResultFn = (sourceKey: string, node: unknown) => React.ReactNode;
@@ -0,0 +1,17 @@
1
+ /** Default debounce delay for keystroke-driven inputs. */
2
+ export const FILTER_DEBOUNCE_MS = 300;
3
+
4
+ /**
5
+ * Creates a debounced version of `fn`. Each call resets the internal timer;
6
+ * `fn` runs only after the timer expires without being reset.
7
+ */
8
+ export function debounce<T extends (...args: any[]) => void>(
9
+ fn: T,
10
+ ms: number,
11
+ ): (...args: Parameters<T>) => void {
12
+ let timer: ReturnType<typeof setTimeout> | undefined;
13
+ return (...args: Parameters<T>) => {
14
+ clearTimeout(timer);
15
+ timer = setTimeout(() => fn(...args), ms);
16
+ };
17
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Helpers for reading values out of uiapi GraphQL response nodes.
3
+ *
4
+ * Nodes typically come back wrapped as `{ value, displayValue }`. `fieldValue`
5
+ * picks the user-facing string (preferring displayValue) or returns null.
6
+ */
7
+
8
+ export function fieldValue(
9
+ field: { displayValue?: string | null; value?: unknown } | null | undefined,
10
+ ): string | null {
11
+ if (field?.displayValue != null) return field.displayValue;
12
+ if (field?.value != null) return String(field.value);
13
+ return null;
14
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Filter primitives for search.
3
+ *
4
+ * Two responsibilities:
5
+ * 1. Build a GraphQL `where` clause from a list of active filter values.
6
+ * 2. Serialize/deserialize active filters to/from URLSearchParams under a
7
+ * per-source namespace prefix (so multiple sources can coexist on one URL).
8
+ *
9
+ * The URL helpers are namespaced: each source declares its key, and filter
10
+ * params are written as `s.<key>.f.<field>=...`. Sort uses
11
+ * `s.<key>.sort=<field>&s.<key>.dir=ASC|DESC`. Page size and page index use
12
+ * `s.<key>.ps=<size>` and `s.<key>.page=<n>`.
13
+ *
14
+ * The global search term `q=...` is **not** scoped to a source — it's broadcast
15
+ * to every source's `searchableFields` by the query builder.
16
+ */
17
+
18
+ import type { SortState } from "./sortUtils";
19
+
20
+ export type FilterFieldType =
21
+ | "text"
22
+ | "picklist"
23
+ | "numeric"
24
+ | "boolean"
25
+ | "date"
26
+ | "daterange"
27
+ | "datetime"
28
+ | "datetimerange"
29
+ | "multipicklist";
30
+
31
+ export type FilterFieldConfig<TFieldName extends string = string> = {
32
+ field: TFieldName;
33
+ label: string;
34
+ type: FilterFieldType;
35
+ placeholder?: string;
36
+ /** Required for picklist / multipicklist. */
37
+ options?: Array<{ value: string; label: string }>;
38
+ helpText?: string;
39
+ };
40
+
41
+ export type ActiveFilterValue<TFieldName extends string = string> = {
42
+ field: TFieldName;
43
+ label: string;
44
+ type: FilterFieldType;
45
+ value?: string;
46
+ min?: string;
47
+ max?: string;
48
+ };
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // URL serialization (per-source namespace)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const SOURCE_PREFIX = "s.";
55
+ const FILTER_INFIX = ".f.";
56
+ const SORT_SUFFIX = ".sort";
57
+ const DIR_SUFFIX = ".dir";
58
+ const PAGE_SIZE_SUFFIX = ".ps";
59
+ const PAGE_SUFFIX = ".page";
60
+
61
+ /** Global search term key (not source-scoped). */
62
+ export const GLOBAL_QUERY_KEY = "q";
63
+
64
+ function filterKey(sourceKey: string, field: string, suffix?: string) {
65
+ return `${SOURCE_PREFIX}${sourceKey}${FILTER_INFIX}${field}${suffix ? `.${suffix}` : ""}`;
66
+ }
67
+
68
+ /**
69
+ * The source's own default sort / page size. When a source's current state
70
+ * matches these, the corresponding URL params are omitted so the query string
71
+ * only ever reflects an *explicit* user choice — resetting filters or returning
72
+ * a source to its defaults yields a clean URL instead of one littered with the
73
+ * defaults. The read path falls back to these same defaults when the params are
74
+ * absent, so the round-trip is lossless.
75
+ */
76
+ export interface SourceParamDefaults {
77
+ sort?: SortState | null;
78
+ pageSize?: number;
79
+ }
80
+
81
+ function sortMatchesDefault(sort: SortState, defaultSort: SortState | null | undefined): boolean {
82
+ return (
83
+ !!defaultSort && sort.field === defaultSort.field && sort.direction === defaultSort.direction
84
+ );
85
+ }
86
+
87
+ export function writeSourceParams(
88
+ params: URLSearchParams,
89
+ sourceKey: string,
90
+ filters: ActiveFilterValue[],
91
+ sort: SortState | null,
92
+ pageSize?: number,
93
+ pageIndex?: number,
94
+ defaults?: SourceParamDefaults,
95
+ ): void {
96
+ for (const filter of filters) {
97
+ if (filter.value !== undefined && filter.value !== "") {
98
+ params.set(filterKey(sourceKey, filter.field), filter.value);
99
+ }
100
+ if (filter.min !== undefined && filter.min !== "") {
101
+ params.set(filterKey(sourceKey, filter.field, "min"), filter.min);
102
+ }
103
+ if (filter.max !== undefined && filter.max !== "") {
104
+ params.set(filterKey(sourceKey, filter.field, "max"), filter.max);
105
+ }
106
+ }
107
+ // Only serialize sort when it differs from the source default — otherwise
108
+ // the URL would carry the default the user never chose.
109
+ if (sort && !sortMatchesDefault(sort, defaults?.sort)) {
110
+ params.set(`${SOURCE_PREFIX}${sourceKey}${SORT_SUFFIX}`, sort.field);
111
+ params.set(`${SOURCE_PREFIX}${sourceKey}${DIR_SUFFIX}`, sort.direction);
112
+ }
113
+ if (pageSize !== undefined && pageSize !== defaults?.pageSize) {
114
+ params.set(`${SOURCE_PREFIX}${sourceKey}${PAGE_SIZE_SUFFIX}`, String(pageSize));
115
+ }
116
+ if (pageIndex !== undefined && pageIndex > 0) {
117
+ params.set(`${SOURCE_PREFIX}${sourceKey}${PAGE_SUFFIX}`, String(pageIndex + 1));
118
+ }
119
+ }
120
+
121
+ export interface ReadSourceParamsResult {
122
+ filters: ActiveFilterValue[];
123
+ sort: SortState | null;
124
+ pageSize: number | undefined;
125
+ pageIndex: number;
126
+ }
127
+
128
+ export function readSourceParams(
129
+ params: URLSearchParams,
130
+ sourceKey: string,
131
+ configs: FilterFieldConfig[],
132
+ ): ReadSourceParamsResult {
133
+ const filters: ActiveFilterValue[] = [];
134
+ for (const config of configs) {
135
+ const { field, label, type } = config;
136
+ const value = params.get(filterKey(sourceKey, field)) ?? undefined;
137
+ const min = params.get(filterKey(sourceKey, field, "min")) ?? undefined;
138
+ const max = params.get(filterKey(sourceKey, field, "max")) ?? undefined;
139
+
140
+ const hasValue = value !== undefined && value !== "";
141
+ const hasRange = (min !== undefined && min !== "") || (max !== undefined && max !== "");
142
+ if (hasValue || hasRange) {
143
+ filters.push({ field, label, type, value, min, max });
144
+ }
145
+ }
146
+
147
+ let sort: SortState | null = null;
148
+ const sortField = params.get(`${SOURCE_PREFIX}${sourceKey}${SORT_SUFFIX}`);
149
+ const sortDir = params.get(`${SOURCE_PREFIX}${sourceKey}${DIR_SUFFIX}`);
150
+ if (sortField) {
151
+ sort = { field: sortField, direction: sortDir === "DESC" ? "DESC" : "ASC" };
152
+ }
153
+
154
+ const pageSizeRaw = params.get(`${SOURCE_PREFIX}${sourceKey}${PAGE_SIZE_SUFFIX}`);
155
+ const pageSize = pageSizeRaw ? parseInt(pageSizeRaw, 10) : undefined;
156
+ const pageRaw = params.get(`${SOURCE_PREFIX}${sourceKey}${PAGE_SUFFIX}`);
157
+ const page = pageRaw ? parseInt(pageRaw, 10) : 1;
158
+ const pageIndex = !isNaN(page) && page > 1 ? page - 1 : 0;
159
+
160
+ return {
161
+ filters,
162
+ sort,
163
+ pageSize: pageSize && !isNaN(pageSize) ? pageSize : undefined,
164
+ pageIndex,
165
+ };
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // GraphQL filter building
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Combines active filter values into a GraphQL `where` clause for one source.
174
+ * Returns `undefined` when no constraints are active.
175
+ */
176
+ export function buildFilter(filters: ActiveFilterValue[], configs: FilterFieldConfig[]): unknown {
177
+ const configMap = new Map(configs.map((c) => [c.field, c]));
178
+ const clauses: unknown[] = [];
179
+ for (const filter of filters) {
180
+ const clause = buildSingleFilter(filter, configMap.get(filter.field));
181
+ if (clause) clauses.push(clause);
182
+ }
183
+ if (clauses.length === 0) return undefined;
184
+ if (clauses.length === 1) return clauses[0];
185
+ return { and: clauses };
186
+ }
187
+
188
+ function toStartOfDay(d: string) {
189
+ return `${d}T00:00:00.000Z`;
190
+ }
191
+ function toEndOfDay(d: string) {
192
+ return `${d}T23:59:59.999Z`;
193
+ }
194
+
195
+ function buildSingleFilter(filter: ActiveFilterValue, _config?: FilterFieldConfig): unknown {
196
+ const { field, type, value, min, max } = filter;
197
+ switch (type) {
198
+ case "text": {
199
+ if (!value) return null;
200
+ return { [field]: { like: `%${value}%` } };
201
+ }
202
+ case "picklist": {
203
+ if (!value) return null;
204
+ return { [field]: { eq: value } };
205
+ }
206
+ case "numeric": {
207
+ if (!min && !max) return null;
208
+ const ops: Record<string, number> = {};
209
+ if (min) ops.gte = Number(min);
210
+ if (max) ops.lte = Number(max);
211
+ return { [field]: ops };
212
+ }
213
+ case "boolean": {
214
+ if (value === undefined || value === "") return null;
215
+ return { [field]: { eq: value === "true" } };
216
+ }
217
+ case "multipicklist": {
218
+ if (!value) return null;
219
+ const values = value.split(",").filter(Boolean);
220
+ if (values.length === 0) return null;
221
+ if (values.length === 1) return { [field]: { eq: values[0] } };
222
+ return { [field]: { in: values } };
223
+ }
224
+ case "date": {
225
+ if (!min && !max) return null;
226
+ const op = min ? "gte" : "lte";
227
+ const dateStr = min ?? max;
228
+ return { [field]: { [op]: { value: dateStr } } };
229
+ }
230
+ case "daterange": {
231
+ if (!min && !max) return null;
232
+ const clauses: unknown[] = [];
233
+ if (min) clauses.push({ [field]: { gte: { value: min } } });
234
+ if (max) clauses.push({ [field]: { lte: { value: max } } });
235
+ return clauses.length === 1 ? clauses[0] : { and: clauses };
236
+ }
237
+ case "datetime": {
238
+ if (!min && !max) return null;
239
+ const op = min ? "gte" : "lte";
240
+ const dateStr = min ?? max;
241
+ const isoStr = op === "gte" ? toStartOfDay(dateStr!) : toEndOfDay(dateStr!);
242
+ return { [field]: { [op]: { value: isoStr } } };
243
+ }
244
+ case "datetimerange": {
245
+ if (!min && !max) return null;
246
+ const clauses: unknown[] = [];
247
+ if (min) clauses.push({ [field]: { gte: { value: toStartOfDay(min) } } });
248
+ if (max) clauses.push({ [field]: { lte: { value: toEndOfDay(max) } } });
249
+ return clauses.length === 1 ? clauses[0] : { and: clauses };
250
+ }
251
+ default:
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Builds a global-q `or` clause across the source's searchable fields.
258
+ * Returns `undefined` when q is empty or the source has no searchable fields.
259
+ */
260
+ export function buildGlobalQueryClause(q: string, searchableFields: string[]): unknown {
261
+ const trimmed = q.trim();
262
+ if (!trimmed || searchableFields.length === 0) return undefined;
263
+ const clauses = searchableFields.map((path) => {
264
+ const parts = path.split(".");
265
+ let clause: Record<string, unknown> = { like: `%${trimmed}%` };
266
+ for (let i = parts.length - 1; i >= 0; i--) {
267
+ clause = { [parts[i]]: clause };
268
+ }
269
+ return clause;
270
+ });
271
+ return clauses.length === 1 ? clauses[0] : { or: clauses };
272
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Sort state and order-by builder for search.
3
+ *
4
+ * The order-by output shape matches the Salesforce uiapi GraphQL convention:
5
+ * { [field]: { order: ASC|DESC, nulls: LAST } }
6
+ *
7
+ * The constants are stringly-typed here (rather than imported from codegen)
8
+ * because search builds its query at runtime and isn't tied to a
9
+ * specific generated schema.
10
+ */
11
+
12
+ export type SortFieldConfig<TFieldName extends string = string> = {
13
+ field: TFieldName;
14
+ label: string;
15
+ };
16
+
17
+ export type SortState<TFieldName extends string = string> = {
18
+ field: TFieldName;
19
+ direction: "ASC" | "DESC";
20
+ };
21
+
22
+ const ORDER_ASC = "ASC";
23
+ const ORDER_DESC = "DESC";
24
+ const NULLS_LAST = "LAST";
25
+
26
+ export function buildOrderBy(sort: SortState | null): unknown {
27
+ if (!sort) return undefined;
28
+ return {
29
+ [sort.field]: {
30
+ order: sort.direction === "ASC" ? ORDER_ASC : ORDER_DESC,
31
+ nulls: NULLS_LAST,
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,67 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ interface UseAsyncDataResult<T> {
4
+ data: T | null;
5
+ loading: boolean;
6
+ error: string | null;
7
+ }
8
+
9
+ /**
10
+ * Runs an async fetcher on mount and whenever `deps` change.
11
+ * Returns the loading/error/data state. Does not cache — every call
12
+ * to the fetcher hits the source directly.
13
+ *
14
+ * A cleanup flag prevents state updates if the component unmounts
15
+ * or deps change before the fetch completes (avoids React warnings
16
+ * and stale updates from out-of-order responses).
17
+ */
18
+ export function useAsyncData<T>(
19
+ fetcher: () => Promise<T>,
20
+ deps: React.DependencyList
21
+ ): UseAsyncDataResult<T> {
22
+ const [data, setData] = useState<T | null>(null);
23
+ const [loading, setLoading] = useState(true);
24
+ const [error, setError] = useState<string | null>(null);
25
+ const [generation, setGeneration] = useState(0);
26
+
27
+ const fetcherRef = useRef(fetcher);
28
+ useEffect(() => {
29
+ fetcherRef.current = fetcher;
30
+ });
31
+
32
+ // Detect dep changes during render to reset loading state and bump generation
33
+ const [prevDeps, setPrevDeps] = useState(deps);
34
+ if (
35
+ deps.length !== prevDeps.length ||
36
+ deps.some((d, i) => d !== prevDeps[i])
37
+ ) {
38
+ setPrevDeps(deps);
39
+ setGeneration(g => g + 1);
40
+ if (!loading) setLoading(true);
41
+ if (error !== null) setError(null);
42
+ }
43
+
44
+ useEffect(() => {
45
+ let cancelled = false;
46
+
47
+ fetcherRef
48
+ .current()
49
+ .then(result => {
50
+ if (!cancelled) setData(result);
51
+ })
52
+ .catch(err => {
53
+ console.error(err);
54
+ if (!cancelled)
55
+ setError(err instanceof Error ? err.message : 'An error occurred');
56
+ })
57
+ .finally(() => {
58
+ if (!cancelled) setLoading(false);
59
+ });
60
+
61
+ return () => {
62
+ cancelled = true;
63
+ };
64
+ }, [generation]);
65
+
66
+ return { data, loading, error };
67
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,80 @@
1
+ import { Link, useLocation } from 'react-router';
2
+ import { getAllRoutes } from './router-utils';
3
+ import { useState } from 'react';
4
+
5
+ export default function NavigationMenu() {
6
+ const [isOpen, setIsOpen] = useState(false);
7
+ const location = useLocation();
8
+
9
+ const isActive = (path: string) => location.pathname === path;
10
+
11
+ const toggleMenu = () => setIsOpen(!isOpen);
12
+
13
+ const navigationRoutes: { path: string; label: string }[] = getAllRoutes()
14
+ .filter(
15
+ route =>
16
+ route.handle?.showInNavigation === true &&
17
+ route.fullPath !== undefined &&
18
+ route.handle?.label !== undefined
19
+ )
20
+ .map(
21
+ route =>
22
+ ({
23
+ path: route.fullPath,
24
+ label: route.handle?.label,
25
+ }) as { path: string; label: string }
26
+ );
27
+
28
+ return (
29
+ <nav className="bg-white border-b border-gray-200">
30
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
31
+ <div className="flex justify-between items-center h-16">
32
+ <Link to="/" className="text-xl font-semibold text-gray-900">
33
+ React App
34
+ </Link>
35
+ <button
36
+ onClick={toggleMenu}
37
+ className="p-2 rounded-md text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
38
+ aria-label="Toggle menu"
39
+ >
40
+ <div className="w-6 h-6 flex flex-col justify-center space-y-1.5">
41
+ <span
42
+ className={`block h-0.5 w-6 bg-current transition-all ${
43
+ isOpen ? 'rotate-45 translate-y-2' : ''
44
+ }`}
45
+ />
46
+ <span
47
+ className={`block h-0.5 w-6 bg-current transition-all ${isOpen ? 'opacity-0' : ''}`}
48
+ />
49
+ <span
50
+ className={`block h-0.5 w-6 bg-current transition-all ${
51
+ isOpen ? '-rotate-45 -translate-y-2' : ''
52
+ }`}
53
+ />
54
+ </div>
55
+ </button>
56
+ </div>
57
+ {isOpen && (
58
+ <div className="pb-4">
59
+ <div className="flex flex-col space-y-2">
60
+ {navigationRoutes.map(item => (
61
+ <Link
62
+ key={item.path}
63
+ to={item.path}
64
+ onClick={() => setIsOpen(false)}
65
+ className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
66
+ isActive(item.path)
67
+ ? 'bg-blue-100 text-blue-700'
68
+ : 'text-gray-700 hover:bg-gray-100'
69
+ }`}
70
+ >
71
+ {item.label}
72
+ </Link>
73
+ ))}
74
+ </div>
75
+ </div>
76
+ )}
77
+ </div>
78
+ </nav>
79
+ );
80
+ }
@@ -0,0 +1,11 @@
1
+ import { Search, config } from "../features/search";
2
+
3
+ /**
4
+ * Home page for the search feature. Renders the drop-in `<Search>` against the
5
+ * static `config.json` so the index route ("/") shows the full search
6
+ * experience. This file overrides the base app's placeholder Home page when the
7
+ * feature is applied.
8
+ */
9
+ export default function Home() {
10
+ return <Search config={config} title="Search" />;
11
+ }
@@ -0,0 +1,18 @@
1
+ import { Link } from 'react-router';
2
+
3
+ export default function NotFound() {
4
+ return (
5
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
6
+ <div className="text-center">
7
+ <h1 className="text-4xl font-bold text-gray-900 mb-4">404</h1>
8
+ <p className="text-lg text-gray-600 mb-8">Page not found</p>
9
+ <Link
10
+ to="/"
11
+ className="inline-block px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
12
+ >
13
+ Go to Home
14
+ </Link>
15
+ </div>
16
+ </div>
17
+ );
18
+ }