@salesforce/templates 66.13.5 → 66.13.7

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 (281) hide show
  1. package/lib/templates/project/angularextapp/CHANGELOG.md +258 -0
  2. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/angular.json +1 -1
  3. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/eslint-plugin-angular-graphql.js +148 -0
  4. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/eslint.config.js +9 -1
  5. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/package-lock.json +680 -408
  6. package/lib/templates/project/angularextapp/_p_/_m_/_w_/_a_/package.json +2 -2
  7. package/lib/templates/project/angularextapp/package.json +1 -1
  8. package/lib/templates/project/angularextapp/scripts/org-setup-config-schema.mjs +17 -0
  9. package/lib/templates/project/angularextapp/scripts/org-setup-utils.mjs +33 -3
  10. package/lib/templates/project/angularextapp/scripts/org-setup.mjs +535 -3
  11. package/lib/templates/project/angularintapp/CHANGELOG.md +266 -0
  12. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/angular.json +1 -1
  13. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/eslint-plugin-angular-graphql.js +148 -0
  14. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/eslint.config.js +9 -1
  15. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/package-lock.json +690 -418
  16. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/package.json +2 -2
  17. package/lib/templates/project/angularintapp/_p_/_m_/_w_/_a_/src/app/features/agentforce/agentforce-embed.service.ts +6 -1
  18. package/lib/templates/project/angularintapp/package.json +1 -1
  19. package/lib/templates/project/angularintapp/scripts/org-setup-config-schema.mjs +17 -0
  20. package/lib/templates/project/angularintapp/scripts/org-setup-utils.mjs +33 -3
  21. package/lib/templates/project/angularintapp/scripts/org-setup.mjs +535 -3
  22. package/lib/templates/project/reactexternalapp/CHANGELOG.md +1409 -0
  23. package/lib/templates/project/reactexternalapp/PRD.md +38 -0
  24. package/lib/templates/project/reactexternalapp/README.md +14 -10
  25. package/lib/templates/project/reactexternalapp/_p_/_m_/_ps_/reactexternalapp_Guest_User_Api_Access.permissionset-meta.xml +25 -0
  26. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/package-lock.json +953 -714
  27. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/package.json +9 -7
  28. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/scripts/get-graphql-schema.mjs +8 -5
  29. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_ls_/LanguageSwitcher.tsx +116 -0
  30. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_ls_/__examples__/language-switcher-example.tsx +36 -0
  31. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_ls_/__examples__/vite-config-site-example.ts +44 -0
  32. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_ls_/buildLanguageUrl.ts +158 -0
  33. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_ls_/index.ts +28 -0
  34. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_ls_/languages.ts +46 -0
  35. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/authentication/components/SocialLogin.tsx +141 -0
  36. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/authentication/pages/Login.tsx +2 -0
  37. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/README.md +246 -0
  38. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/api/apiUtils.ts +56 -0
  39. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/api/orgApiVersionService.ts +37 -0
  40. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
  41. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/channelResolver.ts +40 -0
  42. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/cmsQueryFragment.ts +97 -0
  43. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/contentTypeSessionCache.ts +134 -0
  44. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/contentTypeUtils.ts +38 -0
  45. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
  46. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/index.ts +54 -0
  47. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/parseResponse.ts +65 -0
  48. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/searchChannel.ts +15 -0
  49. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/types.ts +66 -0
  50. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/registry.ts +38 -0
  51. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/sobject/index.ts +19 -0
  52. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/sobject/parseResponse.ts +56 -0
  53. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/sobject/queryFragment.ts +141 -0
  54. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/types.ts +101 -0
  55. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/api/distinctValuesService.ts +84 -0
  56. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/api/searchService.ts +136 -0
  57. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/GlobalSearchBox.tsx +98 -0
  58. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/MergedSearchResults.tsx +294 -0
  59. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/Search.tsx +203 -0
  60. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/SearchResults.tsx +88 -0
  61. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/SourceSection.tsx +187 -0
  62. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/controls/PaginationControls.tsx +185 -0
  63. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/controls/ScopeSelector.tsx +107 -0
  64. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/controls/SearchBar.tsx +55 -0
  65. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/controls/SortControl.tsx +62 -0
  66. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/ActiveFilters.tsx +61 -0
  67. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/DefaultFilterPanel.tsx +125 -0
  68. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/{_os_/components → search/components/filters}/FilterContext.tsx +7 -20
  69. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/BooleanFilter.tsx +50 -0
  70. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/DateRangeFilter.tsx +160 -0
  71. package/lib/templates/project/{reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters → reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs}/FilterFieldWrapper.tsx +11 -6
  72. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/MultiSelectFilter.tsx +89 -0
  73. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/NumericRangeFilter.tsx +108 -0
  74. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/SelectFilter.tsx +46 -0
  75. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/TextFilter.tsx +52 -0
  76. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/results/CmsResultRow.tsx +101 -0
  77. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/results/DefaultResultRow.tsx +109 -0
  78. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/results/resolveResultRenderer.ts +49 -0
  79. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/config.json +88 -0
  80. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/constants.ts +8 -0
  81. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/{_os_ → search}/hooks/useAsyncData.ts +3 -10
  82. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/hooks/useDistinctValues.ts +59 -0
  83. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/hooks/useSearch.ts +966 -0
  84. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/index.ts +115 -0
  85. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/loadConfig.ts +14 -0
  86. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/queryBuilder.ts +100 -0
  87. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/types.ts +376 -0
  88. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/utils/debounce.ts +17 -0
  89. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/utils/fieldUtils.ts +14 -0
  90. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/utils/filterUtils.ts +285 -0
  91. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/search/utils/sortUtils.ts +34 -0
  92. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/api/graphqlClient.ts +24 -8
  93. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/appLayout.tsx +7 -0
  94. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/components/CustomerWebClientChat.tsx +74 -0
  95. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/{_f_/_os_/components → components}/ObjectBreadcrumb.tsx +7 -2
  96. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/components/ui/button.tsx +1 -1
  97. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/components/ui/datePicker.tsx +2 -2
  98. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/components/ui/table.tsx +9 -2
  99. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/index.ts +8 -0
  100. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/pages/AccountObjectDetailPage.tsx +53 -18
  101. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/pages/Home.tsx +3 -27
  102. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/pages/TestChatPage.tsx +26 -0
  103. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/routes.tsx +21 -8
  104. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/types/chat.ts +36 -0
  105. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/types/globals.d.ts +23 -0
  106. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/{_f_/_os_/utils/fieldUtils.ts → utils/accountFields.ts} +9 -0
  107. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/tsconfig.json +2 -2
  108. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/tsconfig.tsbuildinfo +1 -0
  109. package/lib/templates/project/reactexternalapp/_p_/_m_/classes/UIBundleSocialLoginConfig.cls +133 -0
  110. package/lib/templates/project/reactexternalapp/_p_/_m_/classes/UIBundleSocialLoginConfig.cls-meta.xml +5 -0
  111. package/lib/templates/project/reactexternalapp/_p_/_m_/classes/UIBundleSocialLoginConfig_Test.cls +101 -0
  112. package/lib/templates/project/reactexternalapp/_p_/_m_/classes/UIBundleSocialLoginConfig_Test.cls-meta.xml +5 -0
  113. package/lib/templates/project/reactexternalapp/_p_/_m_/package.xml +4 -0
  114. package/lib/templates/project/reactexternalapp/package.json +8 -2
  115. package/lib/templates/project/reactexternalapp/scripts/org-setup-config-schema.mjs +120 -0
  116. package/lib/templates/project/reactexternalapp/scripts/org-setup-dev.mjs +87 -0
  117. package/lib/templates/project/reactexternalapp/scripts/org-setup-utils.mjs +569 -0
  118. package/lib/templates/project/reactexternalapp/scripts/org-setup-xml.mjs +182 -0
  119. package/lib/templates/project/reactexternalapp/scripts/org-setup.config.json +6 -2
  120. package/lib/templates/project/reactexternalapp/scripts/org-setup.mjs +1509 -328
  121. package/lib/templates/project/reactexternalapp/scripts/validate-org-setup-config.mjs +38 -0
  122. package/lib/templates/project/reactinternalapp/CHANGELOG.md +1223 -0
  123. package/lib/templates/project/reactinternalapp/PRD.md +35 -0
  124. package/lib/templates/project/reactinternalapp/README.md +14 -10
  125. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/package-lock.json +813 -538
  126. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/package.json +10 -9
  127. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/README.md +246 -0
  128. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/api/apiUtils.ts +56 -0
  129. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/api/orgApiVersionService.ts +37 -0
  130. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/api/searchableContentTypesService.ts +116 -0
  131. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/channelResolver.ts +40 -0
  132. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/cmsQueryFragment.ts +97 -0
  133. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/contentTypeSessionCache.ts +134 -0
  134. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/contentTypeUtils.ts +38 -0
  135. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/hooks/useSearchableContentTypes.ts +128 -0
  136. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/index.ts +54 -0
  137. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/parseResponse.ts +65 -0
  138. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/searchChannel.ts +15 -0
  139. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/cms/types.ts +66 -0
  140. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/registry.ts +38 -0
  141. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/sobject/index.ts +19 -0
  142. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/sobject/parseResponse.ts +56 -0
  143. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/sobject/queryFragment.ts +141 -0
  144. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/adapters/types.ts +101 -0
  145. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/api/distinctValuesService.ts +84 -0
  146. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/api/searchService.ts +136 -0
  147. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/GlobalSearchBox.tsx +98 -0
  148. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/MergedSearchResults.tsx +294 -0
  149. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/Search.tsx +203 -0
  150. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/SearchResults.tsx +88 -0
  151. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/SourceSection.tsx +187 -0
  152. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/controls/PaginationControls.tsx +185 -0
  153. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/controls/ScopeSelector.tsx +107 -0
  154. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/controls/SearchBar.tsx +55 -0
  155. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/controls/SortControl.tsx +62 -0
  156. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/ActiveFilters.tsx +61 -0
  157. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/DefaultFilterPanel.tsx +125 -0
  158. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/{_os_/components → search/components/filters}/FilterContext.tsx +7 -20
  159. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/BooleanFilter.tsx +50 -0
  160. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/DateRangeFilter.tsx +160 -0
  161. package/lib/templates/project/{reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters → reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs}/FilterFieldWrapper.tsx +11 -6
  162. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/MultiSelectFilter.tsx +89 -0
  163. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/NumericRangeFilter.tsx +108 -0
  164. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/SelectFilter.tsx +46 -0
  165. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/filters/inputs/TextFilter.tsx +52 -0
  166. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/results/CmsResultRow.tsx +101 -0
  167. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/results/DefaultResultRow.tsx +109 -0
  168. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/components/results/resolveResultRenderer.ts +49 -0
  169. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/config.json +88 -0
  170. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/constants.ts +8 -0
  171. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/{_os_ → search}/hooks/useAsyncData.ts +3 -10
  172. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/hooks/useDistinctValues.ts +59 -0
  173. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/hooks/useSearch.ts +966 -0
  174. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/index.ts +115 -0
  175. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/loadConfig.ts +14 -0
  176. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/queryBuilder.ts +100 -0
  177. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/types.ts +376 -0
  178. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/utils/debounce.ts +17 -0
  179. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/utils/fieldUtils.ts +14 -0
  180. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/utils/filterUtils.ts +285 -0
  181. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/search/utils/sortUtils.ts +34 -0
  182. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/components/AgentforceConversationClient.tsx +16 -132
  183. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/{_f_/_os_/components → components}/ObjectBreadcrumb.tsx +7 -2
  184. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/components/ui/button.tsx +1 -1
  185. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/components/ui/datePicker.tsx +2 -2
  186. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/components/ui/table.tsx +9 -2
  187. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/pages/AccountObjectDetailPage.tsx +49 -16
  188. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/pages/Home.tsx +3 -27
  189. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/routes.tsx +12 -5
  190. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/{_f_/_os_/utils/fieldUtils.ts → utils/accountFields.ts} +9 -0
  191. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/tsconfig.json +2 -2
  192. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/tsconfig.tsbuildinfo +1 -0
  193. package/lib/templates/project/reactinternalapp/package.json +4 -2
  194. package/lib/templates/project/reactinternalapp/scripts/org-setup-config-schema.mjs +24 -0
  195. package/lib/templates/project/reactinternalapp/scripts/org-setup-dev.mjs +87 -0
  196. package/lib/templates/project/reactinternalapp/scripts/org-setup-utils.mjs +569 -0
  197. package/lib/templates/project/reactinternalapp/scripts/org-setup-xml.mjs +182 -0
  198. package/lib/templates/project/reactinternalapp/scripts/org-setup.mjs +1162 -214
  199. package/lib/templates/uiBundles/angularbasic/angular.json +1 -1
  200. package/lib/templates/uiBundles/angularbasic/eslint-plugin-angular-graphql.js +148 -0
  201. package/lib/templates/uiBundles/angularbasic/eslint.config.js +9 -1
  202. package/lib/templates/uiBundles/angularbasic/package-lock.json +680 -408
  203. package/lib/templates/uiBundles/angularbasic/package.json +2 -2
  204. package/lib/templates/uiBundles/reactbasic/package-lock.json +738 -440
  205. package/lib/utils/template-placeholders.js +14 -0
  206. package/lib/utils/template-placeholders.js.map +1 -1
  207. package/lib/utils/uiBundleTemplateUtils.d.ts +5 -1
  208. package/lib/utils/uiBundleTemplateUtils.js +7 -1
  209. package/lib/utils/uiBundleTemplateUtils.js.map +1 -1
  210. package/package.json +7 -7
  211. package/lib/templates/project/reactexternalapp/AGENT.md +0 -252
  212. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/accountSearchService.ts +0 -46
  213. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/query/distinctAccountIndustries.graphql +0 -19
  214. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/query/distinctAccountTypes.graphql +0 -19
  215. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/query/getAccountDetail.graphql +0 -121
  216. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/query/searchAccounts.graphql +0 -51
  217. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/pages/AccountObjectDetailPage.tsx +0 -355
  218. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/pages/AccountSearch.tsx +0 -304
  219. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/pages/Home.tsx +0 -34
  220. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/api/objectSearchService.ts +0 -82
  221. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/ActiveFilters.tsx +0 -89
  222. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/PaginationControls.tsx +0 -109
  223. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/SearchBar.tsx +0 -41
  224. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/SortControl.tsx +0 -143
  225. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/BooleanFilter.tsx +0 -78
  226. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/DateFilter.tsx +0 -128
  227. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/DateRangeFilter.tsx +0 -70
  228. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/MultiSelectFilter.tsx +0 -97
  229. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/NumericRangeFilter.tsx +0 -167
  230. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/SearchFilter.tsx +0 -52
  231. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/SelectFilter.tsx +0 -97
  232. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/TextFilter.tsx +0 -93
  233. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/hooks/useDebouncedCallback.ts +0 -34
  234. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/hooks/useObjectSearchParams.ts +0 -254
  235. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/utils/debounce.ts +0 -25
  236. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/utils/filterUtils.ts +0 -404
  237. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/utils/sortUtils.ts +0 -38
  238. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/api/account/accountSearchService.ts +0 -46
  239. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/api/account/query/distinctAccountIndustries.graphql +0 -19
  240. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/api/account/query/distinctAccountTypes.graphql +0 -19
  241. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/api/account/query/searchAccounts.graphql +0 -51
  242. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/api/graphql-operations-types.ts +0 -11260
  243. package/lib/templates/project/reactexternalapp/_p_/_m_/_w_/_a_/src/pages/AccountSearch.tsx +0 -297
  244. package/lib/templates/project/reactexternalapp/scripts/prepare-import-unique-fields.js +0 -122
  245. package/lib/templates/project/reactinternalapp/AGENT.md +0 -252
  246. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/accountSearchService.ts +0 -46
  247. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/query/distinctAccountIndustries.graphql +0 -19
  248. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/query/distinctAccountTypes.graphql +0 -19
  249. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/query/getAccountDetail.graphql +0 -121
  250. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/api/query/searchAccounts.graphql +0 -51
  251. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/pages/AccountObjectDetailPage.tsx +0 -355
  252. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/pages/AccountSearch.tsx +0 -304
  253. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/_ex_/pages/Home.tsx +0 -34
  254. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/api/objectSearchService.ts +0 -82
  255. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/ActiveFilters.tsx +0 -89
  256. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/PaginationControls.tsx +0 -109
  257. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/SearchBar.tsx +0 -41
  258. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/SortControl.tsx +0 -143
  259. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/BooleanFilter.tsx +0 -78
  260. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/DateFilter.tsx +0 -128
  261. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/DateRangeFilter.tsx +0 -70
  262. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/MultiSelectFilter.tsx +0 -97
  263. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/NumericRangeFilter.tsx +0 -167
  264. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/SearchFilter.tsx +0 -52
  265. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/SelectFilter.tsx +0 -97
  266. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/components/filters/TextFilter.tsx +0 -93
  267. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/hooks/useDebouncedCallback.ts +0 -34
  268. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/hooks/useObjectSearchParams.ts +0 -254
  269. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/utils/debounce.ts +0 -25
  270. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/utils/filterUtils.ts +0 -404
  271. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/_f_/_os_/utils/sortUtils.ts +0 -38
  272. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/api/account/accountSearchService.ts +0 -46
  273. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/api/account/query/distinctAccountIndustries.graphql +0 -19
  274. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/api/account/query/distinctAccountTypes.graphql +0 -19
  275. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/api/account/query/searchAccounts.graphql +0 -51
  276. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/api/graphql-operations-types.ts +0 -11260
  277. package/lib/templates/project/reactinternalapp/_p_/_m_/_w_/_a_/src/pages/AccountSearch.tsx +0 -297
  278. package/lib/templates/project/reactinternalapp/scripts/prepare-import-unique-fields.js +0 -122
  279. /package/lib/templates/project/angularextapp/_p_/_m_/{permissionsets → _ps_}/angularextapp_Guest_User_Api_Access.permissionset-meta.xml +0 -0
  280. /package/lib/templates/project/angularintapp/_p_/_m_/{permissionsets → _ps_}/angularintapp_Access.permissionset-meta.xml +0 -0
  281. /package/lib/templates/project/reactinternalapp/_p_/_m_/{permissionsets → _ps_}/reactinternalapp_Access.permissionset-meta.xml +0 -0
@@ -0,0 +1,966 @@
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
+ SourceConfig,
27
+ SourceController,
28
+ SourceResult,
29
+ SearchConfig,
30
+ SearchHandle,
31
+ SearchScope,
32
+ GlobalPagination,
33
+ MergedResultItem,
34
+ } from "../types";
35
+ import { ALL_SCOPE, parseScope } from "../types";
36
+ import type { SourceRequest } from "../adapters/types";
37
+ import { MIN_QUERY_LENGTH } from "../constants";
38
+ // `getUIBundleId` supplies the search-query UIBundle id (the isolated swap
39
+ // point) and is resolved by this caller BEFORE the sync query builder runs;
40
+ // `resolveChannelId` derives the discovery channel id from a CMS result;
41
+ // `useSearchableContentTypes` is the session-cached discovery hook;
42
+ // `isValidCmsFqn` validates a selected FQN before it enters state or a query.
43
+ import { getUIBundleId, isConfiguredUIBundleId } from "../adapters/cms/searchChannel";
44
+ import { getOrgSupportsCmsSearch } from "../adapters/cms/api/orgApiVersionService";
45
+ import { resolveChannelId } from "../adapters/cms/channelResolver";
46
+ import { useSearchableContentTypes } from "../adapters/cms/hooks/useSearchableContentTypes";
47
+ import { isValidCmsFqn } from "../adapters/cms/contentTypeUtils";
48
+ import { readLastChannelId, writeLastChannelId } from "../adapters/cms/contentTypeSessionCache";
49
+
50
+ const URL_SYNC_DEBOUNCE_MS = 300;
51
+
52
+ /**
53
+ * Returns true when `source` should participate in the current scope. Only the
54
+ * scope's `base` (`"all"` or a source key) decides membership — a
55
+ * `"<cmsKey>:<fqn>"` scope has `base === <cmsKey>`, so it naturally includes ONLY
56
+ * that CMS source and drops every SObject source (CMS-only search).
57
+ */
58
+ function isSourceInScope(scope: SearchScope, sourceKey: string): boolean {
59
+ const { base } = parseScope(scope);
60
+ return base === ALL_SCOPE || base === sourceKey;
61
+ }
62
+
63
+ /**
64
+ * Validates a raw scope string against the config. Accepts `"all"`, any source
65
+ * key, and the `"<cmsKey>:<fqn>"` form (base must be a CMS source and the FQN
66
+ * must pass `isValidCmsFqn`). Returns a safe scope, falling back to `"all"`.
67
+ */
68
+ function validateScope(config: SearchConfig, raw: string): SearchScope {
69
+ if (raw === ALL_SCOPE) return ALL_SCOPE;
70
+ const { base, cmsFqn } = parseScope(raw);
71
+ const source = config.sources.find((s) => s.key === base);
72
+ if (!source) return ALL_SCOPE;
73
+ if (cmsFqn !== undefined) {
74
+ // A content-type scope is only valid on a CMS source with a well-formed FQN.
75
+ return source.kind === "cms" && isValidCmsFqn(cmsFqn) ? raw : ALL_SCOPE;
76
+ }
77
+ return raw;
78
+ }
79
+
80
+ interface SourceLocalState {
81
+ filters: ActiveFilterValue[];
82
+ sort: SortState | null;
83
+ pageSize: number;
84
+ pageIndex: number;
85
+ afterCursor: string | undefined;
86
+ cursorStack: string[];
87
+ }
88
+
89
+ /** Defaults applied when `config.pagination` is omitted. */
90
+ const DEFAULT_PAGE_SIZE = 10;
91
+ const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50];
92
+
93
+ interface ResolvedPagination {
94
+ mode: "per-source" | "merged";
95
+ mergeOrder: "sequential" | "interleaved" | "proportional";
96
+ pageSize: number;
97
+ pageSizeOptions: number[];
98
+ }
99
+
100
+ /** Resolves the single global pagination config, filling in defaults. */
101
+ function resolvePagination(config: SearchConfig): ResolvedPagination {
102
+ const p = config.pagination;
103
+ const pageSizeOptions =
104
+ p?.pageSizeOptions && p.pageSizeOptions.length > 0
105
+ ? p.pageSizeOptions
106
+ : DEFAULT_PAGE_SIZE_OPTIONS;
107
+ return {
108
+ mode: p?.mode ?? "per-source",
109
+ mergeOrder: p?.mergeOrder ?? "sequential",
110
+ pageSize: p?.pageSize ?? pageSizeOptions[0] ?? DEFAULT_PAGE_SIZE,
111
+ pageSizeOptions,
112
+ };
113
+ }
114
+
115
+ /** Clamps a requested size to the configured options. */
116
+ function validatePageSize(pagination: ResolvedPagination, size: number): number {
117
+ const valid = pagination.pageSizeOptions;
118
+ if (valid.length === 0) return size;
119
+ return valid.includes(size) ? size : pagination.pageSize;
120
+ }
121
+
122
+ function initSourceState(
123
+ source: SourceConfig,
124
+ params: URLSearchParams,
125
+ pagination: ResolvedPagination,
126
+ ): SourceLocalState {
127
+ // `filterBy` / `defaultSort` are SObject-only — a CMS source has neither, so
128
+ // it seeds with no filters and no default sort.
129
+ const filterBy = source.kind === "sobject" ? (source.filterBy ?? []) : [];
130
+ const defaultSort = source.kind === "sobject" ? (source.defaultSort ?? null) : null;
131
+ const read = readSourceParams(params, source.key, filterBy);
132
+ const sort = read.sort ?? defaultSort;
133
+ const pageSize = validatePageSize(pagination, read.pageSize ?? pagination.pageSize);
134
+ return {
135
+ filters: read.filters,
136
+ sort,
137
+ pageSize,
138
+ pageIndex: read.pageIndex,
139
+ afterCursor: undefined,
140
+ cursorStack: [],
141
+ };
142
+ }
143
+
144
+ export interface UseSearchOptions {
145
+ /**
146
+ * Lock the search to a single source key (or "all"). When set:
147
+ * - `scope` is forced to this value and `setScope` becomes a no-op.
148
+ * - The URL never writes `?scope=` (it's implicit in the page route).
149
+ * - The dropdown should be hidden by the caller.
150
+ *
151
+ * Use when you embed the search inside a page that already represents
152
+ * the source (e.g. `/accounts/search` rendering only Accounts).
153
+ */
154
+ lockedScope?: SearchScope;
155
+ }
156
+
157
+ export function useSearch(config: SearchConfig, options?: UseSearchOptions): SearchHandle {
158
+ const [searchParams, setSearchParams] = useSearchParams();
159
+ const lockedScope = options?.lockedScope;
160
+
161
+ // Single global pagination config — page size, options, mode, merge order.
162
+ // There is no per-source pagination; this applies to every scope, including
163
+ // locked single-object pages and dropdown-narrowed scope.
164
+ const pagination = useMemo(
165
+ () => resolvePagination(config),
166
+ // config is stable for the hook's lifetime.
167
+ // eslint-disable-next-line react-hooks/exhaustive-deps
168
+ [],
169
+ );
170
+
171
+ // One-time seed from URL.
172
+ const initialSourceStates = useMemo(() => {
173
+ const map: Record<string, SourceLocalState> = {};
174
+ for (const source of config.sources) {
175
+ map[source.key] = initSourceState(source, searchParams, pagination);
176
+ }
177
+ return map;
178
+ // eslint-disable-next-line react-hooks/exhaustive-deps
179
+ }, []);
180
+ const initialQ = useMemo(() => searchParams.get(GLOBAL_QUERY_KEY) ?? "", []);
181
+ const initialScope = useMemo<SearchScope>(() => {
182
+ // When the caller locks the scope, that always wins. A locked scope is
183
+ // always a plain source key (or "all") — never a content-type form.
184
+ if (lockedScope !== undefined) {
185
+ if (lockedScope === ALL_SCOPE) return ALL_SCOPE;
186
+ return config.sources.some((s) => s.key === lockedScope) ? lockedScope : ALL_SCOPE;
187
+ }
188
+ // A fresh load always starts in "All" scope. We deliberately do NOT restore
189
+ // a persisted `?scope=` — a narrowed scope (especially a CMS content-type
190
+ // scope) depends on runtime-discovered content types that aren't known yet
191
+ // on first paint, so restoring it would show a blank/orphaned selection.
192
+ // Starting at "All" re-runs the full search, which re-drives discovery.
193
+ return ALL_SCOPE;
194
+ }, [lockedScope, config.sources]);
195
+
196
+ const [q, setQState] = useState(initialQ);
197
+ const [scope, setScopeState] = useState<SearchScope>(initialScope);
198
+ const [sourceStates, setSourceStates] =
199
+ useState<Record<string, SourceLocalState>>(initialSourceStates);
200
+
201
+ // Merged pagination treats the cumulative result set as one fixed-size-paged
202
+ // list (see SearchPaginationConfig). It needs a single global page index +
203
+ // size rather than the per-source cursors above; per-source state is still
204
+ // kept (for filters / sort) but its cursors stay at page 0.
205
+ const mergedMode = pagination.mode === "merged";
206
+ const mergeOrder = pagination.mergeOrder;
207
+
208
+ const [globalPageIndex, setGlobalPageIndex] = useState(0);
209
+ const [globalPageSize, setGlobalPageSize] = useState(pagination.pageSize);
210
+
211
+ // Snapshot ref so debounced URL sync sees the latest state without
212
+ // recreating callbacks on every render.
213
+ const stateRef = useRef({ q, scope, sourceStates });
214
+ useEffect(() => {
215
+ stateRef.current = { q, scope, sourceStates };
216
+ });
217
+
218
+ // -- URL sync --------------------------------------------------------------
219
+
220
+ const syncToUrl = useCallback(
221
+ (nextQ: string, nextScope: SearchScope, nextStates: Record<string, SourceLocalState>) => {
222
+ const params = new URLSearchParams();
223
+ if (nextQ) params.set(GLOBAL_QUERY_KEY, nextQ);
224
+ // Intentionally NOT persisting `?scope=`: a fresh load always starts in
225
+ // "All" (see `initialScope`), so writing the scope would leave a stale
226
+ // param the next load ignores. `nextScope` is kept in the signature for
227
+ // call-site compatibility.
228
+ void nextScope;
229
+ for (const source of config.sources) {
230
+ const state = nextStates[source.key];
231
+ if (!state) continue;
232
+ writeSourceParams(
233
+ params,
234
+ source.key,
235
+ state.filters,
236
+ state.sort,
237
+ state.pageSize,
238
+ state.pageIndex,
239
+ // Omit sort / page size from the URL when they equal the
240
+ // defaults, so a reset (or untouched source) leaves a clean
241
+ // query string instead of echoing the defaults. `defaultSort` is
242
+ // SObject-only (CMS has none).
243
+ {
244
+ sort: source.kind === "sobject" ? (source.defaultSort ?? null) : null,
245
+ pageSize: pagination.pageSize,
246
+ },
247
+ );
248
+ }
249
+ setSearchParams(params, { replace: true });
250
+ },
251
+ [config.sources, setSearchParams, lockedScope, pagination.pageSize],
252
+ );
253
+
254
+ const debouncedSyncRef = useRef(debounce(syncToUrl, URL_SYNC_DEBOUNCE_MS));
255
+ useEffect(() => {
256
+ debouncedSyncRef.current = debounce(syncToUrl, URL_SYNC_DEBOUNCE_MS);
257
+ }, [syncToUrl]);
258
+
259
+ // Update one source's state and (optionally) reset its pagination.
260
+ const updateSource = useCallback(
261
+ (
262
+ sourceKey: string,
263
+ updater: (prev: SourceLocalState) => SourceLocalState,
264
+ resetPagination: boolean,
265
+ ) => {
266
+ // A filter / sort / page-size change reshapes the result set, so the
267
+ // global merged page returns to the first page too.
268
+ if (resetPagination) setGlobalPageIndex(0);
269
+ setSourceStates((prev) => {
270
+ const prior = prev[sourceKey];
271
+ if (!prior) return prev;
272
+ let next = updater(prior);
273
+ if (resetPagination) {
274
+ next = { ...next, pageIndex: 0, afterCursor: undefined, cursorStack: [] };
275
+ }
276
+ const merged = { ...prev, [sourceKey]: next };
277
+ debouncedSyncRef.current(stateRef.current.q, stateRef.current.scope, merged);
278
+ return merged;
279
+ });
280
+ },
281
+ [],
282
+ );
283
+
284
+ // -- Global q --------------------------------------------------------------
285
+
286
+ const setQ = useCallback((nextQ: string) => {
287
+ setQState(nextQ);
288
+ // A new term changes the whole result set — back to the first page.
289
+ setGlobalPageIndex(0);
290
+ // Changing the global term invalidates every cursor.
291
+ setSourceStates((prev) => {
292
+ const next: Record<string, SourceLocalState> = {};
293
+ for (const [key, state] of Object.entries(prev)) {
294
+ next[key] = { ...state, pageIndex: 0, afterCursor: undefined, cursorStack: [] };
295
+ }
296
+ debouncedSyncRef.current(nextQ, stateRef.current.scope, next);
297
+ return next;
298
+ });
299
+ }, []);
300
+
301
+ // -- Scope -----------------------------------------------------------------
302
+
303
+ const setScope = useCallback(
304
+ (nextScope: SearchScope) => {
305
+ // Locked scope wins over user input — silently no-op.
306
+ if (lockedScope !== undefined) return;
307
+ // Reject unknown scope values; fall back to "all". Accepts a plain
308
+ // source key, "all", or the CMS-only "<cmsKey>:<fqn>" form.
309
+ const validated: SearchScope = validateScope(config, nextScope);
310
+ setScopeState(validated);
311
+ // The visible set changes — reset the global page too.
312
+ setGlobalPageIndex(0);
313
+ // Narrowing or widening invalidates every cursor — the visible result
314
+ // set is changing.
315
+ setSourceStates((prev) => {
316
+ const next: Record<string, SourceLocalState> = {};
317
+ for (const [key, state] of Object.entries(prev)) {
318
+ next[key] = { ...state, pageIndex: 0, afterCursor: undefined, cursorStack: [] };
319
+ }
320
+ debouncedSyncRef.current(stateRef.current.q, validated, next);
321
+ return next;
322
+ });
323
+ },
324
+ [config, lockedScope],
325
+ );
326
+
327
+ // -- Reset all -------------------------------------------------------------
328
+
329
+ const resetAll = useCallback(() => {
330
+ setQState("");
331
+ setGlobalPageIndex(0);
332
+ // Preserve locked scope; otherwise widen back to "all".
333
+ const nextScope: SearchScope = lockedScope ?? ALL_SCOPE;
334
+ setScopeState(nextScope);
335
+ const empty: Record<string, SourceLocalState> = {};
336
+ for (const source of config.sources) {
337
+ empty[source.key] = {
338
+ filters: [],
339
+ // `defaultSort` is SObject-only; CMS resets to no sort.
340
+ sort: source.kind === "sobject" ? (source.defaultSort ?? null) : null,
341
+ pageSize: pagination.pageSize,
342
+ pageIndex: 0,
343
+ afterCursor: undefined,
344
+ cursorStack: [],
345
+ };
346
+ }
347
+ setSourceStates(empty);
348
+ setGlobalPageSize(pagination.pageSize);
349
+ syncToUrl("", nextScope, empty);
350
+ }, [config.sources, syncToUrl, lockedScope, pagination.pageSize]);
351
+
352
+ // -- Fetch -----------------------------------------------------------------
353
+
354
+ // -- CMS content-type discovery --------------------------------------------
355
+ //
356
+ // The channel id used to DISCOVER content types is derived FROM a CMS search
357
+ // result (the permanent path — distinct from the temporary `getUIBundleId()`
358
+ // fed INTO the query). After a CMS result arrives, the fetch effect resolves
359
+ // it via `resolveChannelId` and stores it here; the session-cached hook then
360
+ // fetches the searchable content types ONCE per channel. The discovered FQNs
361
+ // (a) refine every subsequent all/cms-scope request to `contentTypes` and
362
+ // (b) are exposed for the scope dropdown to append per-type entries.
363
+ // Seed from the last discovered channel persisted in sessionStorage so a hard
364
+ // reload rehydrates the content-type scope entries immediately — even when
365
+ // the current scope is a non-CMS source (which would never re-query CMS and
366
+ // thus never re-derive the channel id on its own). The persisted pointer is
367
+ // keyed by UIBundle id, so this seed waits for `getUIBundleId()` (below) —
368
+ // starting null avoids seeding a DIFFERENT bundle's channel/content types.
369
+ const [discoveryChannelId, setDiscoveryChannelId] = useState<string | null>(null);
370
+ // Resolve the current UIBundle id once and seed the discovery channel from
371
+ // THIS bundle's persisted pointer. Keyed lookup prevents a second bundle
372
+ // opened in the same session from inheriting the first bundle's channel.
373
+ useEffect(() => {
374
+ let cancelled = false;
375
+ getUIBundleId()
376
+ .then((uiBundleId) => {
377
+ if (cancelled || !isConfiguredUIBundleId(uiBundleId)) return;
378
+ const seeded = readLastChannelId(uiBundleId);
379
+ if (seeded) setDiscoveryChannelId((prev) => prev ?? seeded);
380
+ })
381
+ .catch(() => {
382
+ // Seeding is best-effort; a CMS result will still drive discovery.
383
+ });
384
+ return () => {
385
+ cancelled = true;
386
+ };
387
+ }, []);
388
+ const { contentTypes: discoveredContentTypes } = useSearchableContentTypes(discoveryChannelId);
389
+ // The query filter and request key work in FQNs only; the `{ fqn, label }`
390
+ // objects are threaded to the handle for the scope dropdown's display labels.
391
+ const discoveredContentTypeFqns = useMemo(
392
+ () => discoveredContentTypes.map((ct) => ct.fqn),
393
+ [discoveredContentTypes],
394
+ );
395
+
396
+ const requestKey = useMemo(() => {
397
+ // Stable string key that changes only when something fetch-relevant changes.
398
+ // Sources outside the current scope are listed (with `skip: true`) so
399
+ // the key still changes when scope toggles, but they don't contribute
400
+ // per-source state to the diff.
401
+ return JSON.stringify({
402
+ q,
403
+ // `scope` already carries the selected CMS content-type fqn (the
404
+ // "<cmsKey>:<fqn>" form), so a content-type selection re-fires the fetch
405
+ // with no imperative refetch.
406
+ scope,
407
+ // Discovered content types are part of the key so that once discovery
408
+ // completes, the effect re-fires and every in-scope CMS request switches
409
+ // from the bootstrap call (no `contentTypes`) to the discovered set.
410
+ ct: discoveredContentTypeFqns,
411
+ // Merged mode paginates globally: every in-scope source fetches the
412
+ // same front-anchored window, so the global page/size drive the fetch
413
+ // rather than per-source cursors.
414
+ m: mergedMode ? { gp: globalPageIndex, gs: globalPageSize } : null,
415
+ s: config.sources.map((source) => {
416
+ if (!isSourceInScope(scope, source.key)) {
417
+ return { k: source.key, skip: true };
418
+ }
419
+ const st = sourceStates[source.key];
420
+ return {
421
+ k: source.key,
422
+ f: st?.filters,
423
+ o: st?.sort,
424
+ // In merged mode per-source size/cursor are overridden below.
425
+ p: mergedMode ? undefined : st?.pageSize,
426
+ a: mergedMode ? undefined : (st?.afterCursor ?? null),
427
+ };
428
+ }),
429
+ });
430
+ }, [
431
+ q,
432
+ scope,
433
+ discoveredContentTypes,
434
+ sourceStates,
435
+ config.sources,
436
+ mergedMode,
437
+ globalPageIndex,
438
+ globalPageSize,
439
+ ]);
440
+
441
+ const [results, setResults] = useState<Record<string, SourceResult>>({});
442
+ // Per-source errors, keyed by `source.key`. A source that failed while a
443
+ // sibling succeeded surfaces its own message here without blanking others.
444
+ const [sourceErrors, setSourceErrors] = useState<Record<string, string>>({});
445
+ const [loading, setLoading] = useState(true);
446
+ // Page-level error — set ONLY when EVERY in-scope source failed, or on a
447
+ // whole-request / network failure (`runSearch` throwing).
448
+ const [error, setError] = useState<string | null>(null);
449
+ const fetchGenRef = useRef(0);
450
+
451
+ useEffect(() => {
452
+ const generation = ++fetchGenRef.current;
453
+ setLoading(true);
454
+ setError(null);
455
+ setSourceErrors({});
456
+
457
+ const activeSources = config.sources.filter((source) => isSourceInScope(scope, source.key));
458
+
459
+ // All sources gated out — clear results and exit without firing a request.
460
+ if (activeSources.length === 0) {
461
+ setResults({});
462
+ setLoading(false);
463
+ return;
464
+ }
465
+
466
+ // Keyword gate: only fire a search once the user has typed at least
467
+ // MIN_QUERY_LENGTH characters. On landing (or after clearing the box) `q`
468
+ // is empty, so we clear results and exit WITHOUT firing a request — no
469
+ // unfiltered "browse the first page" query, and no CMS bootstrap call
470
+ // until there's a keyword. The minimum matters beyond UX: the SObject and
471
+ // CMS search backends reject terms shorter than MIN_QUERY_LENGTH, which
472
+ // otherwise surfaces as "Search failed for this source." across every
473
+ // source. Gating here keeps the too-short case client-side.
474
+ if (q.trim().length < MIN_QUERY_LENGTH) {
475
+ setResults({});
476
+ setLoading(false);
477
+ return;
478
+ }
479
+
480
+ // A "<cmsKey>:<fqn>" scope narrows to one content type. Because its `base`
481
+ // is the CMS source key, `activeSources` already excludes every SObject
482
+ // source (CMS-only search); the fqn flows through as `contentTypes = [fqn]`.
483
+ const { cmsFqn } = parseScope(scope);
484
+
485
+ // Merged mode: fetch the whole front-anchored window — (page + 1) * size
486
+ // rows — from every in-scope source, with no cursor. Concatenating these
487
+ // and slicing [page*size, (page+1)*size) yields the exact global page,
488
+ // because the first (page+1)*size items of the concatenation are always
489
+ // the true merged prefix regardless of how rows interleave.
490
+ const mergedFetchSize = (globalPageIndex + 1) * globalPageSize;
491
+
492
+ // Resolve the async CMS preconditions ONCE, up front, then thread them into
493
+ // each CMS request so the synchronous query builder never resolves a promise:
494
+ // - the UIBundle id (`getUIBundleId`), and
495
+ // - whether the build-time API version carries the CMS search backend
496
+ // (`getOrgSupportsCmsSearch`).
497
+ let cancelled = false;
498
+ // True once this run's request set is a DISCOVERY-driven refetch: an
499
+ // all/cms-scope search that auto-applies the content-type FQNs discovered
500
+ // from a prior successful CMS result (as opposed to a user-selected fqn).
501
+ // Such a refetch only NARROWS an already-successful bootstrap, so if it
502
+ // fails it must not destroy the good bootstrap results or raise a
503
+ // page-level error — content-type narrowing is an enhancement, not a
504
+ // hard requirement. Read in `.catch` below.
505
+ let isDiscoveryRefetch = false;
506
+ Promise.all([getUIBundleId(), getOrgSupportsCmsSearch()])
507
+ .then(([uiBundleId, orgSupportsCmsSearch]) => {
508
+ if (cancelled || generation !== fetchGenRef.current) return;
509
+
510
+ const requests: SourceRequest[] = activeSources.flatMap((source) => {
511
+ const st = sourceStates[source.key]!;
512
+ const base: SourceRequest = {
513
+ source,
514
+ q,
515
+ filters: st.filters,
516
+ sort: st.sort,
517
+ pageSize: mergedMode ? mergedFetchSize : st.pageSize,
518
+ afterCursor: mergedMode ? undefined : st.afterCursor,
519
+ };
520
+ if (source.kind !== "cms") return [base];
521
+ // CMS is gated on (a) a CONFIGURED (9YE-shaped) UIBundle id and
522
+ // (b) a build-time API version with the CMS search backend (v68+,
523
+ // core 264+). Skip silently when either is absent.
524
+ if (!isConfiguredUIBundleId(uiBundleId) || !orgSupportsCmsSearch) {
525
+ return [];
526
+ }
527
+ // CMS request: attach the resolved channel id and content types.
528
+ // - specific fqn selected → exactly that type.
529
+ // - all / cms-key scope → the discovered set if known, else
530
+ // omit `contentTypes` entirely (the bootstrap call).
531
+ const contentTypes = cmsFqn
532
+ ? [cmsFqn]
533
+ : discoveredContentTypeFqns.length > 0
534
+ ? discoveredContentTypeFqns
535
+ : undefined;
536
+ // A non-fqn scope that carries auto-discovered content types is a
537
+ // discovery-driven refetch — the bootstrap CMS call already
538
+ // succeeded (that's how the FQNs were discovered). Flag it so a
539
+ // failure here degrades to the bootstrap results instead of
540
+ // blanking them (see `.catch`).
541
+ if (!cmsFqn && discoveredContentTypeFqns.length > 0) {
542
+ isDiscoveryRefetch = true;
543
+ }
544
+ return [{ ...base, uiBundleId, contentTypes }];
545
+ });
546
+
547
+ // Every in-scope source was gated out (e.g. a CMS-only scope while CMS
548
+ // is not yet configured): nothing to fetch. Clear results and stop,
549
+ // mirroring the "all sources gated out" branch above.
550
+ if (requests.length === 0) {
551
+ setResults({});
552
+ setLoading(false);
553
+ return;
554
+ }
555
+
556
+ // The sources actually queried (CMS may have been skipped above). Used
557
+ // for the all-failed check so a skipped/not-configured CMS source is
558
+ // never counted as a failure.
559
+ const requestedSources = requests.map((r) => r.source);
560
+
561
+ return runSearch(requests).then((outcome) => {
562
+ if (cancelled || generation !== fetchGenRef.current) return;
563
+ const { results: nextResults, errors: nextErrors } = outcome;
564
+
565
+ // Discovery-driven refetch that came back with a failed source:
566
+ // the narrowing query erred, but the bootstrap results are still
567
+ // valid and on screen. Keep them — fall back to the prior result
568
+ // for any source this refetch failed to produce, and drop the
569
+ // spurious error rather than blanking the grid / raising a
570
+ // page-level error below. (Sources that DID return data still
571
+ // update to the narrowed set.)
572
+ if (isDiscoveryRefetch && requestedSources.some((s) => nextErrors[s.key] != null)) {
573
+ setResults((prev) => {
574
+ const merged = { ...prev };
575
+ for (const source of requestedSources) {
576
+ if (nextResults[source.key] != null) merged[source.key] = nextResults[source.key];
577
+ // else: refetch produced nothing for this source — retain prev.
578
+ }
579
+ return merged;
580
+ });
581
+ // Discovery is already complete for this channel, so there is no
582
+ // further channel id to resolve; just stop here without touching
583
+ // sourceErrors / the page-level error.
584
+ return;
585
+ }
586
+
587
+ setResults(nextResults);
588
+ setSourceErrors(nextErrors);
589
+
590
+ // Page-level error only when EVERY queried source failed; a mix of
591
+ // success + failure surfaces per-source (below) with the grid intact.
592
+ const allFailed =
593
+ requestedSources.length > 0 &&
594
+ requestedSources.every((source) => nextErrors[source.key] != null);
595
+ if (allFailed) {
596
+ // De-duplicate identical messages: when every source fails with the
597
+ // same backend error (e.g. "Search isn't enabled on this channel"),
598
+ // show it once rather than repeating it per source in the banner.
599
+ const combined = [
600
+ ...new Set(
601
+ requestedSources
602
+ .map((source) => nextErrors[source.key])
603
+ .filter((m): m is string => !!m),
604
+ ),
605
+ ].join("; ");
606
+ setError(combined || "Unified search failed");
607
+ }
608
+
609
+ // Content-type discovery: once a CMS source returns data, derive the
610
+ // discovery channel id from it and hand it to the session-cached hook.
611
+ for (const source of requestedSources) {
612
+ if (source.kind !== "cms") continue;
613
+ const channelId = resolveChannelId(nextResults[source.key]);
614
+ if (channelId) {
615
+ setDiscoveryChannelId((prev) => (prev === channelId ? prev : channelId));
616
+ // Persist the pointer keyed by THIS UIBundle so a reload seeds
617
+ // only this bundle's channel — never a different bundle's.
618
+ writeLastChannelId(uiBundleId, channelId);
619
+ break;
620
+ }
621
+ }
622
+ });
623
+ })
624
+ .catch((err: unknown) => {
625
+ if (cancelled || generation !== fetchGenRef.current) return;
626
+ console.error(err);
627
+ // A discovery-driven refetch (content-type narrowing over an
628
+ // already-successful bootstrap) that fails must NOT surface as a
629
+ // page-level error or blank the good bootstrap results — the user
630
+ // still has valid, un-narrowed results on screen. Degrade silently
631
+ // and keep them; `finally` resets loading below.
632
+ if (isDiscoveryRefetch) return;
633
+ // A whole-request / network failure (or a failure to resolve the
634
+ // channel id) is page-level, preserving the original behaviour.
635
+ setError(err instanceof Error ? err.message : "Unified search failed");
636
+ // Clear stale rows so a prior page's results don't linger under the
637
+ // error banner (the per-source layout would otherwise keep rendering
638
+ // them). `finally` resets loading below.
639
+ setResults({});
640
+ setSourceErrors({});
641
+ })
642
+ .finally(() => {
643
+ if (cancelled || generation !== fetchGenRef.current) return;
644
+ setLoading(false);
645
+ });
646
+
647
+ return () => {
648
+ cancelled = true;
649
+ };
650
+ // requestKey is the dependency-of-record; sourceStates and q are
651
+ // captured via the ref-like closure above each render.
652
+ // eslint-disable-next-line react-hooks/exhaustive-deps
653
+ }, [requestKey]);
654
+
655
+ // -- Per-source controllers ------------------------------------------------
656
+
657
+ const controllers = useMemo(() => {
658
+ const out: Record<string, SourceController> = {};
659
+ for (const source of config.sources) {
660
+ const state = sourceStates[source.key];
661
+ const result = results[source.key] ?? null;
662
+
663
+ const setFilter = (field: string, value: ActiveFilterValue | undefined) => {
664
+ updateSource(
665
+ source.key,
666
+ (prev) => {
667
+ const filtered = prev.filters.filter((f) => f.field !== field);
668
+ if (value) filtered.push(value);
669
+ return { ...prev, filters: filtered };
670
+ },
671
+ true,
672
+ );
673
+ };
674
+ const removeFilter = (field: string) => {
675
+ updateSource(
676
+ source.key,
677
+ (prev) => ({
678
+ ...prev,
679
+ filters: prev.filters.filter((f) => f.field !== field),
680
+ }),
681
+ true,
682
+ );
683
+ };
684
+ const setSort = (sort: SortState | null) => {
685
+ updateSource(source.key, (prev) => ({ ...prev, sort }), true);
686
+ };
687
+ const setPageSize = (size: number) => {
688
+ updateSource(
689
+ source.key,
690
+ (prev) => ({ ...prev, pageSize: validatePageSize(pagination, size) }),
691
+ true,
692
+ );
693
+ };
694
+ const goToNextPage = () => {
695
+ const cursor = result?.pageInfo?.endCursor;
696
+ if (!cursor) return;
697
+ updateSource(
698
+ source.key,
699
+ (prev) => ({
700
+ ...prev,
701
+ cursorStack: [...prev.cursorStack, cursor],
702
+ afterCursor: cursor,
703
+ pageIndex: prev.pageIndex + 1,
704
+ }),
705
+ false,
706
+ );
707
+ };
708
+ const goToPreviousPage = () => {
709
+ updateSource(
710
+ source.key,
711
+ (prev) => {
712
+ if (prev.pageIndex === 0) return prev;
713
+ const nextStack = prev.cursorStack.slice(0, -1);
714
+ return {
715
+ ...prev,
716
+ cursorStack: nextStack,
717
+ afterCursor: nextStack[nextStack.length - 1],
718
+ pageIndex: Math.max(0, prev.pageIndex - 1),
719
+ };
720
+ },
721
+ false,
722
+ );
723
+ };
724
+
725
+ out[source.key] = {
726
+ config: source,
727
+ result,
728
+ loading,
729
+ // Per-source error (set only when THIS source failed) — not the
730
+ // page-level `error` (which is set only when every source failed).
731
+ error: sourceErrors[source.key] ?? null,
732
+ filters: {
733
+ active: state?.filters ?? [],
734
+ set: setFilter,
735
+ remove: removeFilter,
736
+ },
737
+ sort: {
738
+ current: state?.sort ?? null,
739
+ set: setSort,
740
+ },
741
+ pagination: {
742
+ pageSize: state?.pageSize ?? pagination.pageSize,
743
+ pageSizeOptions: pagination.pageSizeOptions,
744
+ pageIndex: state?.pageIndex ?? 0,
745
+ hasNextPage: result?.pageInfo?.hasNextPage ?? false,
746
+ hasPreviousPage: (state?.pageIndex ?? 0) > 0,
747
+ setPageSize,
748
+ goToNextPage,
749
+ goToPreviousPage,
750
+ },
751
+ };
752
+ }
753
+ return out;
754
+ }, [config.sources, sourceStates, results, loading, sourceErrors, updateSource, pagination]);
755
+
756
+ const scopeLocked = lockedScope !== undefined;
757
+
758
+ // -- Aggregate (cross-source) view -----------------------------------------
759
+
760
+ const inScopeSources = useMemo(
761
+ () =>
762
+ config.sources
763
+ .map((s) => controllers[s.key])
764
+ .filter((c): c is SourceController => !!c && isSourceInScope(scope, c.config.key)),
765
+ [config.sources, controllers, scope],
766
+ );
767
+
768
+ // Cumulative result count is mode-independent: sum the in-scope totals.
769
+ const totalCount = useMemo(
770
+ () =>
771
+ inScopeSources.reduce(
772
+ (sum, c) => sum + (c.result?.totalCount ?? c.result?.nodes.length ?? 0),
773
+ 0,
774
+ ),
775
+ [inScopeSources],
776
+ );
777
+
778
+ // Every in-scope source's fetched nodes flattened into one list, ordered per
779
+ // `mergeOrder`:
780
+ // - sequential: all of source 1, then source 2, … (config order).
781
+ // - interleaved: round-robin, one node per source per round — equal priority.
782
+ // - proportional: each item gets a key (i + 0.5) / weight (weight = source's
783
+ // totalCount); sorting by key spreads each source evenly across the list
784
+ // in proportion to its size, so larger sources appear more often.
785
+ // All three stay exact in merged mode. Each source fetched (page + 1) * size
786
+ // rows from its front, so the first (page + 1) * size items of this list are
787
+ // the true cumulative prefix: the round index / sort position of any item in
788
+ // that prefix needs at most that many rows from a single source, and those
789
+ // rows were all fetched, so per-source truncation never disturbs the slice.
790
+ // Proportional uses the true totalCount as the weight (not the fetched
791
+ // length), keeping each item's key — and thus the order — stable across pages.
792
+ const concatenatedNodes = useMemo<MergedResultItem[]>(() => {
793
+ const lists = inScopeSources.map((c) => ({
794
+ key: c.config.key,
795
+ nodes: c.result?.nodes ?? [],
796
+ // Proportional weight: the source's true total, falling back to how many
797
+ // we actually have when totalCount is absent.
798
+ weight: c.result?.totalCount ?? c.result?.nodes.length ?? 0,
799
+ }));
800
+ const out: MergedResultItem[] = [];
801
+
802
+ if (mergeOrder === "interleaved") {
803
+ const maxLen = lists.reduce((m, l) => Math.max(m, l.nodes.length), 0);
804
+ for (let round = 0; round < maxLen; round++) {
805
+ for (const l of lists) {
806
+ if (round < l.nodes.length) out.push({ sourceKey: l.key, node: l.nodes[round] });
807
+ }
808
+ }
809
+ } else if (mergeOrder === "proportional") {
810
+ // Tag each node with its spread key + source index, then stable-sort.
811
+ const tagged = lists.flatMap((l, sIdx) =>
812
+ l.nodes.map((node, i) => ({
813
+ sourceKey: l.key,
814
+ node,
815
+ sIdx,
816
+ i,
817
+ sortKey: (i + 0.5) / (l.weight > 0 ? l.weight : 1),
818
+ })),
819
+ );
820
+ tagged.sort((a, b) => a.sortKey - b.sortKey || a.sIdx - b.sIdx || a.i - b.i);
821
+ for (const t of tagged) out.push({ sourceKey: t.sourceKey, node: t.node });
822
+ } else {
823
+ for (const l of lists) {
824
+ for (const node of l.nodes) out.push({ sourceKey: l.key, node });
825
+ }
826
+ }
827
+ return out;
828
+ }, [inScopeSources, mergeOrder]);
829
+
830
+ // -- Merged mode: one fixed-size-paged list over the cumulative results -----
831
+
832
+ const mergedGlobalPagination = useMemo<GlobalPagination>(() => {
833
+ // Options come from the global pagination config, NOT the in-scope source —
834
+ // so narrowing the dropdown to one object keeps the global page sizes.
835
+ const pageSizeOptions = pagination.pageSizeOptions;
836
+ const pageCount = totalCount === 0 ? 0 : Math.ceil(totalCount / globalPageSize);
837
+ // Clamp in case totalCount shrank (e.g. a filter) below the current page.
838
+ const pageIndex = pageCount === 0 ? 0 : Math.min(globalPageIndex, pageCount - 1);
839
+ return {
840
+ pageIndex,
841
+ pageCount,
842
+ pageSize: globalPageSize,
843
+ pageSizeOptions,
844
+ hasNextPage: pageIndex + 1 < pageCount,
845
+ hasPreviousPage: pageIndex > 0,
846
+ totalCount,
847
+ goToNextPage: () => setGlobalPageIndex((p) => p + 1),
848
+ goToPreviousPage: () => setGlobalPageIndex((p) => Math.max(0, p - 1)),
849
+ goToPage: (next: number) => setGlobalPageIndex(Math.max(0, Math.floor(next))),
850
+ // Validate the requested size against the configured options before it
851
+ // becomes `$cmsLimit` / `first`, matching the per-source setter.
852
+ setPageSize: (size: number) => {
853
+ setGlobalPageSize(validatePageSize(pagination, size));
854
+ setGlobalPageIndex(0);
855
+ },
856
+ };
857
+ }, [pagination, totalCount, globalPageSize, globalPageIndex]);
858
+
859
+ const mergedModeResults = useMemo<MergedResultItem[]>(() => {
860
+ const start = mergedGlobalPagination.pageIndex * globalPageSize;
861
+ return concatenatedNodes.slice(start, start + globalPageSize);
862
+ }, [concatenatedNodes, mergedGlobalPagination.pageIndex, globalPageSize]);
863
+
864
+ // -- Per-source mode: independent cursors, "global page" = furthest source --
865
+ //
866
+ // Sources paginate with independent cursors (there is no global offset), so a
867
+ // "global page" is the furthest-advanced in-scope source's page. A source
868
+ // that runs out of pages is left behind at a lower pageIndex and stops
869
+ // contributing nodes — its last page was already shown on an earlier global
870
+ // page, so re-emitting it would duplicate rows in the merged list.
871
+
872
+ const perSourceGlobalPagination = useMemo<GlobalPagination>(() => {
873
+ const lead = inScopeSources[0];
874
+ const pageSize = lead?.pagination.pageSize ?? pagination.pageSize;
875
+ const pageSizeOptions = pagination.pageSizeOptions;
876
+
877
+ const pageIndex = inScopeSources.reduce((max, c) => Math.max(max, c.pagination.pageIndex), 0);
878
+
879
+ // Pages a source spans: prefer totalCount; otherwise a lower bound from its
880
+ // own position (current page, plus one more if it reports a next page).
881
+ const pageCount = inScopeSources.reduce((max, c) => {
882
+ const tc = c.result?.totalCount;
883
+ const span =
884
+ tc != null && pageSize > 0
885
+ ? Math.max(1, Math.ceil(tc / pageSize))
886
+ : c.pagination.pageIndex + (c.pagination.hasNextPage ? 2 : 1);
887
+ return Math.max(max, span);
888
+ }, 0);
889
+
890
+ const hasNextPage = inScopeSources.some((c) => c.pagination.hasNextPage);
891
+ const hasPreviousPage = pageIndex > 0;
892
+
893
+ const goToNextPage = () =>
894
+ inScopeSources.forEach((c) => {
895
+ if (c.pagination.hasNextPage) c.pagination.goToNextPage();
896
+ });
897
+ const goToPreviousPage = () =>
898
+ inScopeSources.forEach((c) => {
899
+ if (c.pagination.pageIndex === pageIndex) c.pagination.goToPreviousPage();
900
+ });
901
+
902
+ return {
903
+ pageIndex,
904
+ pageCount: totalCount === 0 ? 0 : pageCount,
905
+ pageSize,
906
+ pageSizeOptions,
907
+ hasNextPage,
908
+ hasPreviousPage,
909
+ totalCount,
910
+ goToNextPage,
911
+ goToPreviousPage,
912
+ // Cursors can't seek to an arbitrary offset — honour adjacent pages only.
913
+ goToPage: (next: number) => {
914
+ if (next === pageIndex + 1 && hasNextPage) goToNextPage();
915
+ else if (next === pageIndex - 1 && hasPreviousPage) goToPreviousPage();
916
+ },
917
+ setPageSize: (size: number) => inScopeSources.forEach((c) => c.pagination.setPageSize(size)),
918
+ };
919
+ }, [inScopeSources, totalCount, pagination]);
920
+
921
+ const perSourceResults = useMemo<MergedResultItem[]>(() => {
922
+ const globalPageIndex = perSourceGlobalPagination.pageIndex;
923
+ return concatenatedNodes.filter((item) => {
924
+ const c = controllers[item.sourceKey];
925
+ // Only sources caught up to the current global page contribute; a
926
+ // left-behind source already showed its final page earlier.
927
+ return c?.pagination.pageIndex === globalPageIndex;
928
+ });
929
+ }, [concatenatedNodes, controllers, perSourceGlobalPagination.pageIndex]);
930
+
931
+ const globalPagination = mergedMode ? mergedGlobalPagination : perSourceGlobalPagination;
932
+ const mergedResults = mergedMode ? mergedModeResults : perSourceResults;
933
+
934
+ return useMemo(
935
+ () => ({
936
+ q,
937
+ setQ,
938
+ scope,
939
+ setScope,
940
+ scopeLocked,
941
+ sources: controllers,
942
+ inScopeSources,
943
+ mergedResults,
944
+ globalPagination,
945
+ loading,
946
+ error,
947
+ discoveredContentTypes,
948
+ resetAll,
949
+ }),
950
+ [
951
+ q,
952
+ setQ,
953
+ scope,
954
+ setScope,
955
+ scopeLocked,
956
+ controllers,
957
+ inScopeSources,
958
+ mergedResults,
959
+ globalPagination,
960
+ loading,
961
+ error,
962
+ discoveredContentTypes,
963
+ resetAll,
964
+ ],
965
+ );
966
+ }