@oneuptime/common 12.0.11 → 12.0.13

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 (251) hide show
  1. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +32 -5
  2. package/Models/DatabaseModels/NetworkDevice.ts +64 -0
  3. package/Models/DatabaseModels/StatusPage.ts +59 -0
  4. package/Models/DatabaseModels/StatusPageOidc.ts +2 -0
  5. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +19 -2
  6. package/Server/API/AIChatAPI.ts +55 -56
  7. package/Server/API/CommonAPI.ts +89 -0
  8. package/Server/API/MicrosoftTeamsAPI.ts +20 -9
  9. package/Server/API/SlackAPI.ts +13 -10
  10. package/Server/API/StatusPageAPI.ts +2197 -2128
  11. package/Server/API/TelemetryAPI.ts +72 -3
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.ts +30 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.ts +47 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  15. package/Server/Middleware/UserAuthorization.ts +11 -2
  16. package/Server/Services/ApiKeyPermissionService.ts +116 -2
  17. package/Server/Services/DatabaseService.ts +17 -6
  18. package/Server/Services/LogAggregationService.ts +34 -0
  19. package/Server/Services/LogService.ts +21 -0
  20. package/Server/Services/NetworkSiteService.ts +12 -0
  21. package/Server/Services/SpanService.ts +21 -0
  22. package/Server/Services/TraceAggregationService.ts +15 -0
  23. package/Server/Services/WorkspaceNotificationRuleService.ts +172 -220
  24. package/Server/Types/AnalyticsDatabase/ModelPermission.ts +9 -0
  25. package/Server/Types/Database/Permissions/AccessControlPermission.ts +47 -16
  26. package/Server/Types/Workflow/Components/Conditions/IfElse.ts +9 -0
  27. package/Server/Types/Workflow/Components/JavaScript.ts +9 -0
  28. package/Server/Utils/APIKey/AccessPermission.ts +16 -40
  29. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +144 -0
  30. package/Server/Utils/LogRedaction.ts +576 -0
  31. package/Server/Utils/Logger.ts +123 -46
  32. package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +94 -23
  33. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +12 -0
  34. package/Server/Utils/Monitor/MonitorResource.ts +29 -9
  35. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +28 -7
  36. package/Server/Utils/SessionReplay/SessionReplayGateCacheStore.ts +56 -10
  37. package/Server/Utils/StartServer.ts +30 -9
  38. package/Server/Utils/StatusPageSearchEngineIndexing.ts +33 -0
  39. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +441 -0
  40. package/Server/Utils/VM/VMRunner.ts +693 -874
  41. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +488 -46
  42. package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +300 -0
  43. package/Tests/App/StatusPage/StatusPageIndexPageRobotsMeta.test.ts +130 -0
  44. package/Tests/Models/DatabaseModels/DatabaseBaseModelToJSONObjectCache.test.ts +180 -0
  45. package/Tests/Models/StatusPageEnableSearchEngineIndexing.test.ts +157 -0
  46. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +23 -1
  47. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +785 -0
  48. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +21 -8
  49. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +290 -0
  50. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +7 -1
  51. package/Tests/Server/API/StatusPageOverviewCache.test.ts +474 -0
  52. package/Tests/Server/API/StatusPageSeoSearchEngineIndexing.test.ts +189 -0
  53. package/Tests/Server/Infrastructure/InMemoryTTLCache.test.ts +158 -0
  54. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +9 -60
  55. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +24 -15
  56. package/Tests/Server/Middleware/UserAuthorization.test.ts +79 -0
  57. package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +332 -0
  58. package/Tests/Server/Services/ApiKeyPermissionService.test.ts +347 -0
  59. package/Tests/Server/Services/DatabaseServiceUpdateDebugLogging.test.ts +282 -0
  60. package/Tests/Server/Services/NetworkSiteService.test.ts +56 -2
  61. package/Tests/Server/Services/TelemetryResourceFacetFilters.test.ts +267 -0
  62. package/Tests/Server/Services/WorkspaceNotificationRuleTestRuleChannels.test.ts +702 -0
  63. package/Tests/Server/Types/Database/Permissions/AccessControlPermission.test.ts +165 -0
  64. package/Tests/Server/Types/Workflow/Components/CustomCodeScriptError.test.ts +121 -0
  65. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +48 -36
  66. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +213 -0
  67. package/Tests/Server/Utils/LogRedaction.test.ts +415 -0
  68. package/Tests/Server/Utils/LoggerCredentialLeak.test.ts +245 -0
  69. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +374 -4
  70. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorJavascriptExpression.test.ts +92 -0
  71. package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +455 -0
  72. package/Tests/Server/Utils/Monitor/NetworkDeviceReachabilityRoundTrip.test.ts +369 -0
  73. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +86 -8
  74. package/Tests/Server/Utils/PrivacyFilterUtil.test.ts +64 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCacheStore.test.ts +207 -0
  76. package/Tests/Server/Utils/StartServerBodyVerify.test.ts +335 -0
  77. package/Tests/Server/Utils/StatusPageSearchEngineIndexing.test.ts +60 -0
  78. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +435 -0
  79. package/Tests/Server/Utils/VM/VMRunnerIsolation.test.ts +385 -0
  80. package/Tests/Server/Utils/VM/VMRunnerSsrf.test.ts +3 -0
  81. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelSend.test.ts +607 -62
  82. package/Tests/Server/Utils/Workspace/MicrosoftTeamsInstalledTeamIdSpace.test.ts +778 -0
  83. package/Tests/Server/Utils/Workspace/MicrosoftTeamsTeamInstalls.test.ts +44 -10
  84. package/Tests/Types/API/HostnameFromAuthority.test.ts +164 -0
  85. package/Tests/Types/Database/AccessControl/ColumnAccessControlCache.test.ts +344 -0
  86. package/Tests/Types/Database/DatabasePropertyFindOperator.test.ts +518 -0
  87. package/Tests/Types/Database/TableColumnMetadataCache.test.ts +286 -0
  88. package/Tests/Types/Monitor/MonitorStep.test.ts +100 -0
  89. package/Tests/Types/Monitor/MonitorTypeKeywords.test.ts +360 -0
  90. package/Tests/Types/StatusPage/SearchEngineIndexing.test.ts +86 -0
  91. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +254 -0
  92. package/Tests/UI/Components/CardSelect.test.tsx +1021 -0
  93. package/Tests/UI/Components/MasterPage.test.tsx +36 -1
  94. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +66 -1
  95. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +115 -6
  96. package/Tests/Utils/NetworkDevice/DeviceReachabilityUtil.test.ts +610 -0
  97. package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +114 -45
  98. package/Types/API/Hostname.ts +79 -0
  99. package/Types/Database/AccessControl/ColumnAccessControl.ts +33 -11
  100. package/Types/Database/AccessControl/ColumnBillingAccessControl.ts +33 -11
  101. package/Types/Database/DatabaseProperty.ts +38 -0
  102. package/Types/Database/TableColumn.ts +34 -7
  103. package/Types/Events/Recurring.ts +16 -1
  104. package/Types/Monitor/CustomCodeMonitor/CustomCodeMonitorResponse.ts +14 -2
  105. package/Types/Monitor/MonitorStep.ts +13 -1
  106. package/Types/Monitor/MonitorType.ts +345 -40
  107. package/Types/Monitor/SSLMonitor/SslMonitorResponse.ts +43 -0
  108. package/Types/StatusPage/SearchEngineIndexing.ts +61 -0
  109. package/Types/Telemetry/ResourceEntityFacet.ts +211 -0
  110. package/UI/Components/CardSelect/CardSelect.tsx +714 -105
  111. package/UI/Components/EmptyState/EmptyState.tsx +8 -1
  112. package/UI/Components/Footer/Footer.tsx +32 -6
  113. package/UI/Components/Forms/Fields/FormField.tsx +3 -0
  114. package/UI/Components/Forms/Types/Field.ts +8 -0
  115. package/UI/Components/Loader/PageLoader.tsx +29 -8
  116. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +26 -4
  117. package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +17 -1
  118. package/UI/Components/MasterPage/MasterPage.tsx +24 -11
  119. package/Utils/Monitor/MonitorMetricType.ts +16 -1
  120. package/Utils/Monitor/NetworkTopologyUtil.ts +60 -8
  121. package/Utils/NetworkDevice/DeviceReachabilityUtil.ts +262 -0
  122. package/Utils/NetworkSite/SiteStatusRollupUtil.ts +41 -24
  123. package/Utils/Telemetry/CrossSignalScope.ts +75 -4
  124. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +20 -4
  125. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/NetworkDevice.js +66 -0
  127. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  128. package/build/dist/Models/DatabaseModels/StatusPage.js +60 -0
  129. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  130. package/build/dist/Models/DatabaseModels/StatusPageOidc.js +2 -0
  131. package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -1
  132. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  133. package/build/dist/Server/API/AIChatAPI.js +48 -35
  134. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  135. package/build/dist/Server/API/CommonAPI.js +59 -0
  136. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  137. package/build/dist/Server/API/MicrosoftTeamsAPI.js +20 -6
  138. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  139. package/build/dist/Server/API/SlackAPI.js +13 -10
  140. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  141. package/build/dist/Server/API/StatusPageAPI.js +745 -673
  142. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  143. package/build/dist/Server/API/TelemetryAPI.js +34 -3
  144. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js +23 -0
  146. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js.map +1 -0
  147. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js +34 -0
  148. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js.map +1 -0
  149. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  150. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  151. package/build/dist/Server/Middleware/UserAuthorization.js +6 -2
  152. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  153. package/build/dist/Server/Services/ApiKeyPermissionService.js +90 -1
  154. package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
  155. package/build/dist/Server/Services/DatabaseService.js +15 -6
  156. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  157. package/build/dist/Server/Services/LogAggregationService.js +2 -0
  158. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  159. package/build/dist/Server/Services/LogService.js +16 -0
  160. package/build/dist/Server/Services/LogService.js.map +1 -1
  161. package/build/dist/Server/Services/NetworkSiteService.js +12 -0
  162. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  163. package/build/dist/Server/Services/SpanService.js +16 -0
  164. package/build/dist/Server/Services/SpanService.js.map +1 -1
  165. package/build/dist/Server/Services/TraceAggregationService.js +2 -0
  166. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  167. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +137 -164
  168. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  169. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +9 -0
  170. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  171. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js +21 -13
  172. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js.map +1 -1
  173. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js +8 -0
  174. package/build/dist/Server/Types/Workflow/Components/Conditions/IfElse.js.map +1 -1
  175. package/build/dist/Server/Types/Workflow/Components/JavaScript.js +8 -0
  176. package/build/dist/Server/Types/Workflow/Components/JavaScript.js.map +1 -1
  177. package/build/dist/Server/Utils/APIKey/AccessPermission.js +9 -32
  178. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  179. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +87 -0
  180. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  181. package/build/dist/Server/Utils/LogRedaction.js +449 -0
  182. package/build/dist/Server/Utils/LogRedaction.js.map +1 -0
  183. package/build/dist/Server/Utils/Logger.js +98 -45
  184. package/build/dist/Server/Utils/Logger.js.map +1 -1
  185. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +65 -13
  186. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
  187. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +12 -1
  188. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  189. package/build/dist/Server/Utils/Monitor/MonitorResource.js +20 -3
  190. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  191. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +27 -7
  192. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  193. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js +29 -10
  194. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js.map +1 -1
  195. package/build/dist/Server/Utils/StartServer.js +10 -7
  196. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  197. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js +24 -0
  198. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js.map +1 -0
  199. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +311 -0
  200. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -0
  201. package/build/dist/Server/Utils/VM/VMRunner.js +402 -525
  202. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  203. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +366 -37
  204. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  205. package/build/dist/Types/API/Hostname.js +63 -0
  206. package/build/dist/Types/API/Hostname.js.map +1 -1
  207. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js +20 -6
  208. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js.map +1 -1
  209. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js +20 -6
  210. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js.map +1 -1
  211. package/build/dist/Types/Database/DatabaseProperty.js +38 -0
  212. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  213. package/build/dist/Types/Database/TableColumn.js +24 -6
  214. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +13 -1
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Monitor/MonitorStep.js +9 -1
  218. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  219. package/build/dist/Types/Monitor/MonitorType.js +330 -31
  220. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  221. package/build/dist/Types/StatusPage/SearchEngineIndexing.js +55 -0
  222. package/build/dist/Types/StatusPage/SearchEngineIndexing.js.map +1 -0
  223. package/build/dist/Types/Telemetry/ResourceEntityFacet.js +156 -0
  224. package/build/dist/Types/Telemetry/ResourceEntityFacet.js.map +1 -0
  225. package/build/dist/UI/Components/CardSelect/CardSelect.js +360 -40
  226. package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
  227. package/build/dist/UI/Components/EmptyState/EmptyState.js +1 -1
  228. package/build/dist/UI/Components/EmptyState/EmptyState.js.map +1 -1
  229. package/build/dist/UI/Components/Footer/Footer.js +7 -4
  230. package/build/dist/UI/Components/Footer/Footer.js.map +1 -1
  231. package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
  232. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  233. package/build/dist/UI/Components/Loader/PageLoader.js +15 -5
  234. package/build/dist/UI/Components/Loader/PageLoader.js.map +1 -1
  235. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +16 -3
  236. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  237. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js +13 -1
  238. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
  239. package/build/dist/UI/Components/MasterPage/MasterPage.js +10 -2
  240. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  241. package/build/dist/Utils/Monitor/MonitorMetricType.js +13 -1
  242. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  243. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +34 -7
  244. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  245. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js +177 -0
  246. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js.map +1 -0
  247. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +23 -27
  248. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -1
  249. package/build/dist/Utils/Telemetry/CrossSignalScope.js +44 -4
  250. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  251. package/package.json +1 -1
@@ -1,12 +1,24 @@
1
1
  import IconProp from "../../../Types/Icon/IconProp";
2
2
  import Icon, { SizeProp } from "../Icon/Icon";
3
- import React, { FunctionComponent, ReactElement } from "react";
3
+ import React, {
4
+ FunctionComponent,
5
+ ReactElement,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
4
10
 
5
11
  export interface CardSelectOption {
6
12
  value: string;
7
13
  title: string;
8
14
  description: string;
9
15
  icon: IconProp;
16
+ /*
17
+ * Words that should match this card in search but are not on it - product
18
+ * names, protocols, synonyms. Optional: a catalog small enough to read in
19
+ * one screen has no use for them.
20
+ */
21
+ keywords?: Array<string> | undefined;
10
22
  }
11
23
 
12
24
  export interface CardSelectOptionGroup {
@@ -33,6 +45,19 @@ export interface ComponentProps {
33
45
  ariaLabelledby?: string | undefined;
34
46
  // Force single-column (1 item per row). Default: responsive 1/2/3 grid.
35
47
  singleColumn?: boolean | undefined;
48
+ /*
49
+ * Opt in to the search box. Off by default so a picker with a handful of
50
+ * cards is not given a search box it does not need, and so existing callers
51
+ * keep the exact markup they had.
52
+ */
53
+ searchable?: boolean | undefined;
54
+ searchPlaceholder?: string | undefined;
55
+ /*
56
+ * Opt in to collapsing groups behind their headers. Off by default. Only
57
+ * has an effect on grouped options: the first group and the group holding
58
+ * the current selection start open, the rest start closed behind a count.
59
+ */
60
+ collapsibleGroups?: boolean | undefined;
36
61
  }
37
62
 
38
63
  interface RenderGroup {
@@ -40,16 +65,189 @@ interface RenderGroup {
40
65
  options: Array<CardSelectOption>;
41
66
  }
42
67
 
43
- const CardSelect: FunctionComponent<ComponentProps> = (
44
- props: ComponentProps,
45
- ): ReactElement => {
46
- // Normalize options into render groups
68
+ /**
69
+ * The words of a search, in lower case. Empty when nothing was typed.
70
+ */
71
+ export type CardSelectSearchTokensFunction = (search: string) => Array<string>;
72
+
73
+ export const getCardSelectSearchTokens: CardSelectSearchTokensFunction = (
74
+ search: string,
75
+ ): Array<string> => {
76
+ return search
77
+ .trim()
78
+ .toLowerCase()
79
+ .split(/\s+/)
80
+ .filter((token: string) => {
81
+ return token.length > 0;
82
+ });
83
+ };
84
+
85
+ /*
86
+ * The text a search runs against: everything visible on the card, the heading
87
+ * it sits under, and its hidden keywords.
88
+ */
89
+ type CardSelectHaystackFunction = (
90
+ option: CardSelectOption,
91
+ groupLabel: string | null,
92
+ ) => string;
93
+
94
+ const getHaystack: CardSelectHaystackFunction = (
95
+ option: CardSelectOption,
96
+ groupLabel: string | null,
97
+ ): string => {
98
+ return `${option.title} ${option.description} ${groupLabel || ""} ${(
99
+ option.keywords || []
100
+ ).join(" ")}`.toLowerCase();
101
+ };
102
+
103
+ /**
104
+ * Does this card match every word typed?
105
+ *
106
+ * Every word, anywhere across title, description, group heading and keywords -
107
+ * not the whole search string as one substring. Matching the words separately
108
+ * is what lets "expired cert" and "cert expired" both find SSL Certificate.
109
+ * The same rule the workflow component palette settled on, for the same
110
+ * reason: in a catalog this size, browsing is not a fallback.
111
+ */
112
+ export type CardSelectMatchesSearchFunction = (
113
+ option: CardSelectOption,
114
+ tokens: Array<string>,
115
+ groupLabel?: string | null | undefined,
116
+ ) => boolean;
117
+
118
+ export const cardSelectOptionMatchesSearch: CardSelectMatchesSearchFunction = (
119
+ option: CardSelectOption,
120
+ tokens: Array<string>,
121
+ groupLabel?: string | null | undefined,
122
+ ): boolean => {
123
+ if (tokens.length === 0) {
124
+ return true;
125
+ }
126
+
127
+ const haystack: string = getHaystack(option, groupLabel || null);
128
+
129
+ return tokens.every((token: string) => {
130
+ return haystack.includes(token);
131
+ });
132
+ };
133
+
134
+ type CardSelectScoreForTokenFunction = (
135
+ option: CardSelectOption,
136
+ groupLabel: string | null,
137
+ searchTerm: string,
138
+ ) => number;
139
+
140
+ const getSearchScoreForToken: CardSelectScoreForTokenFunction = (
141
+ option: CardSelectOption,
142
+ groupLabel: string | null,
143
+ searchTerm: string,
144
+ ): number => {
145
+ const title: string = option.title.toLowerCase();
146
+ const description: string = option.description.toLowerCase();
147
+ const label: string = (groupLabel || "").toLowerCase();
148
+ const keywords: Array<string> = (option.keywords || []).map(
149
+ (keyword: string) => {
150
+ return keyword.toLowerCase();
151
+ },
152
+ );
153
+
154
+ let score: number = 0;
155
+
156
+ if (title === searchTerm) {
157
+ score += 200;
158
+ } else if (title.startsWith(searchTerm)) {
159
+ score += 140;
160
+ } else if (title.includes(searchTerm)) {
161
+ score += 100;
162
+ }
163
+
164
+ /*
165
+ * An exact keyword hit is the strongest signal there is after the title
166
+ * itself: someone who types "k8s" or "postgres" has named the thing, they
167
+ * just have not named it the way the card does.
168
+ */
169
+ if (
170
+ keywords.some((keyword: string) => {
171
+ return keyword === searchTerm;
172
+ })
173
+ ) {
174
+ score += 120;
175
+ } else if (
176
+ keywords.some((keyword: string) => {
177
+ return keyword.startsWith(searchTerm);
178
+ })
179
+ ) {
180
+ score += 80;
181
+ } else if (
182
+ keywords.some((keyword: string) => {
183
+ return keyword.includes(searchTerm);
184
+ })
185
+ ) {
186
+ score += 50;
187
+ }
188
+
189
+ if (label.startsWith(searchTerm)) {
190
+ score += 75;
191
+ } else if (label.includes(searchTerm)) {
192
+ score += 55;
193
+ }
194
+
195
+ if (description.includes(searchTerm)) {
196
+ score += 35;
197
+ }
198
+
199
+ if (
200
+ title.split(" ").some((word: string) => {
201
+ return word.trim().startsWith(searchTerm);
202
+ })
203
+ ) {
204
+ score += 15;
205
+ }
206
+
207
+ return score;
208
+ };
209
+
210
+ /**
211
+ * Ranking, summed over the words typed.
212
+ */
213
+ export type CardSelectSearchScoreFunction = (
214
+ option: CardSelectOption,
215
+ tokens: Array<string>,
216
+ groupLabel?: string | null | undefined,
217
+ ) => number;
218
+
219
+ export const getCardSelectOptionSearchScore: CardSelectSearchScoreFunction = (
220
+ option: CardSelectOption,
221
+ tokens: Array<string>,
222
+ groupLabel?: string | null | undefined,
223
+ ): number => {
224
+ return tokens.reduce((total: number, token: string) => {
225
+ return total + getSearchScoreForToken(option, groupLabel || null, token);
226
+ }, 0);
227
+ };
228
+
229
+ interface ScoredOption {
230
+ option: CardSelectOption;
231
+ groupLabel: string | null;
232
+ score: number;
233
+ }
234
+
235
+ type NormalizeGroupsFunction = (
236
+ options: Array<CardSelectOption | CardSelectOptionGroup>,
237
+ ) => Array<RenderGroup>;
238
+
239
+ /*
240
+ * Flat options and groups can be interleaved by the caller, so a run of flat
241
+ * options becomes its own unlabelled group wherever it appears.
242
+ */
243
+ export const normalizeCardSelectGroups: NormalizeGroupsFunction = (
244
+ options: Array<CardSelectOption | CardSelectOptionGroup>,
245
+ ): Array<RenderGroup> => {
47
246
  const groups: Array<RenderGroup> = [];
48
247
  let ungroupedOptions: Array<CardSelectOption> = [];
49
248
 
50
- for (const option of props.options) {
249
+ for (const option of options) {
51
250
  if (isCardSelectOptionGroup(option)) {
52
- // Flush any accumulated ungrouped options first
53
251
  if (ungroupedOptions.length > 0) {
54
252
  groups.push({ label: null, options: ungroupedOptions });
55
253
  ungroupedOptions = [];
@@ -64,117 +262,528 @@ const CardSelect: FunctionComponent<ComponentProps> = (
64
262
  groups.push({ label: null, options: ungroupedOptions });
65
263
  }
66
264
 
67
- let cardIndex: number = 0;
265
+ return groups;
266
+ };
267
+
268
+ const CardSelect: FunctionComponent<ComponentProps> = (
269
+ props: ComponentProps,
270
+ ): ReactElement => {
271
+ const [search, setSearch] = useState<string>("");
272
+ const searchInputRef: React.RefObject<HTMLInputElement> =
273
+ useRef<HTMLInputElement>(null);
274
+ const containerRef: React.RefObject<HTMLDivElement> =
275
+ useRef<HTMLDivElement>(null);
276
+
277
+ /*
278
+ * Groups the user has toggled by hand, by label. Anything absent falls back
279
+ * to the default below, so a group does not spring shut again the moment
280
+ * the selection moves elsewhere.
281
+ */
282
+ const [toggledGroups, setToggledGroups] = useState<Record<string, boolean>>(
283
+ {},
284
+ );
285
+
286
+ const groups: Array<RenderGroup> = useMemo(() => {
287
+ return normalizeCardSelectGroups(props.options);
288
+ }, [props.options]);
289
+
290
+ const tokens: Array<string> = useMemo(() => {
291
+ return getCardSelectSearchTokens(search);
292
+ }, [search]);
293
+
294
+ const isSearching: boolean = tokens.length > 0;
295
+
296
+ const totalOptionCount: number = useMemo(() => {
297
+ return groups.reduce((total: number, group: RenderGroup) => {
298
+ return total + group.options.length;
299
+ }, 0);
300
+ }, [groups]);
301
+
302
+ /*
303
+ * A search flattens the groups. Ranked results in nine separate headed
304
+ * sections would bury the best match under whichever heading happens to
305
+ * come first, which is the problem the search is there to solve.
306
+ */
307
+ const { searchResults, isShowingClosestMatches } = useMemo((): {
308
+ searchResults: Array<ScoredOption>;
309
+ isShowingClosestMatches: boolean;
310
+ } => {
311
+ if (!isSearching) {
312
+ return { searchResults: [], isShowingClosestMatches: false };
313
+ }
314
+
315
+ type CollectFunction = (requireEveryWord: boolean) => Array<ScoredOption>;
316
+
317
+ const collect: CollectFunction = (
318
+ requireEveryWord: boolean,
319
+ ): Array<ScoredOption> => {
320
+ const scored: Array<ScoredOption> = [];
321
+
322
+ for (const group of groups) {
323
+ for (const option of group.options) {
324
+ const matches: boolean = requireEveryWord
325
+ ? cardSelectOptionMatchesSearch(option, tokens, group.label)
326
+ : tokens.some((token: string) => {
327
+ return cardSelectOptionMatchesSearch(
328
+ option,
329
+ [token],
330
+ group.label,
331
+ );
332
+ });
333
+
334
+ if (!matches) {
335
+ continue;
336
+ }
337
+
338
+ scored.push({
339
+ option: option,
340
+ groupLabel: group.label,
341
+ score: getCardSelectOptionSearchScore(option, tokens, group.label),
342
+ });
343
+ }
344
+ }
345
+
346
+ return scored.sort((a: ScoredOption, b: ScoredOption) => {
347
+ const scoreDifference: number = b.score - a.score;
348
+
349
+ if (scoreDifference !== 0) {
350
+ return scoreDifference;
351
+ }
352
+
353
+ return a.option.title.localeCompare(b.option.title);
354
+ });
355
+ };
356
+
357
+ const everyWord: Array<ScoredOption> = collect(true);
358
+
359
+ if (everyWord.length > 0) {
360
+ return { searchResults: everyWord, isShowingClosestMatches: false };
361
+ }
362
+
363
+ /*
364
+ * Nothing contains every word. Rather than a dead end, fall back to the
365
+ * cards matching any of them, ranked the same way - someone who typed
366
+ * "postgres uptime" gets SQL Query rather than an empty panel. With a
367
+ * single word this is the same query, so it correctly stays empty.
368
+ */
369
+ const anyWord: Array<ScoredOption> = collect(false);
370
+
371
+ return {
372
+ searchResults: anyWord,
373
+ isShowingClosestMatches: anyWord.length > 0,
374
+ };
375
+ }, [groups, tokens, isSearching]);
376
+
377
+ type IsGroupExpandedFunction = (
378
+ group: RenderGroup,
379
+ groupIndex: number,
380
+ ) => boolean;
381
+
382
+ const isGroupExpanded: IsGroupExpandedFunction = (
383
+ group: RenderGroup,
384
+ groupIndex: number,
385
+ ): boolean => {
386
+ // Unlabelled groups have no header to collapse behind.
387
+ if (!props.collapsibleGroups || !group.label) {
388
+ return true;
389
+ }
390
+
391
+ const toggled: boolean | undefined = toggledGroups[group.label];
392
+
393
+ if (toggled !== undefined) {
394
+ return toggled;
395
+ }
396
+
397
+ // The group holding the current selection, so re-entering a form shows it.
398
+ if (
399
+ props.value &&
400
+ group.options.some((option: CardSelectOption) => {
401
+ return option.value === props.value;
402
+ })
403
+ ) {
404
+ return true;
405
+ }
406
+
407
+ /*
408
+ * The first group is the common case in every catalog that bothers to
409
+ * order itself, so it is the one worth opening for a user who has not
410
+ * told us anything yet.
411
+ */
412
+ return groupIndex === 0;
413
+ };
414
+
415
+ const gridClassName: string = props.singleColumn
416
+ ? "grid grid-cols-1 gap-4"
417
+ : "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3";
418
+
419
+ /*
420
+ * The cards on screen, in the order they are rendered. A search reorders
421
+ * them and a folded group removes them, so this is recomputed rather than
422
+ * taken from props.
423
+ */
424
+ const visibleOptionValues: Array<string> = useMemo(() => {
425
+ if (isSearching) {
426
+ return searchResults.map((result: ScoredOption) => {
427
+ return result.option.value;
428
+ });
429
+ }
430
+
431
+ const values: Array<string> = [];
432
+
433
+ groups.forEach((group: RenderGroup, groupIndex: number) => {
434
+ if (!isGroupExpanded(group, groupIndex)) {
435
+ return;
436
+ }
437
+
438
+ for (const option of group.options) {
439
+ values.push(option.value);
440
+ }
441
+ });
442
+
443
+ return values;
444
+ /*
445
+ * isGroupExpanded is read here rather than listed: it is rebuilt every
446
+ * render, and everything it actually depends on - the groups, the manual
447
+ * toggles and the current value - is in the list.
448
+ */
449
+ }, [groups, isSearching, searchResults, toggledGroups, props.value]);
450
+
451
+ /*
452
+ * One tab stop for the whole group, which is what a radiogroup is supposed
453
+ * to be. Every card used to get its own increasing tabIndex - with the 0
454
+ * that FormField passes, that produced 1, 2, 3 ... 28: positive values,
455
+ * which jump ahead of every other control on the page in tab order. Arrow
456
+ * keys move between the cards once the group has focus.
457
+ */
458
+ const activeOptionValue: string | undefined =
459
+ props.value && visibleOptionValues.includes(props.value)
460
+ ? props.value
461
+ : visibleOptionValues[0];
462
+
463
+ type FocusCardFunction = (value: string) => void;
464
+
465
+ const focusCard: FocusCardFunction = (value: string): void => {
466
+ const card: HTMLElement | null =
467
+ containerRef.current?.querySelector(
468
+ `[data-card-select-value="${CSS.escape(value)}"]`,
469
+ ) || null;
470
+
471
+ card?.focus();
472
+ };
473
+
474
+ type MoveFocusFunction = (fromValue: string, offset: number) => void;
475
+
476
+ const moveFocus: MoveFocusFunction = (
477
+ fromValue: string,
478
+ offset: number,
479
+ ): void => {
480
+ const currentIndex: number = visibleOptionValues.indexOf(fromValue);
481
+
482
+ if (currentIndex < 0) {
483
+ return;
484
+ }
485
+
486
+ /*
487
+ * Clamped rather than wrapping: running off the end of a 29 card grid and
488
+ * landing back at the top reads as a glitch, not as navigation.
489
+ */
490
+ const nextIndex: number = Math.min(
491
+ Math.max(currentIndex + offset, 0),
492
+ visibleOptionValues.length - 1,
493
+ );
494
+
495
+ const nextValue: string | undefined = visibleOptionValues[nextIndex];
496
+
497
+ if (!nextValue || nextValue === fromValue) {
498
+ return;
499
+ }
500
+
501
+ focusCard(nextValue);
502
+ };
503
+
504
+ type RenderCardFunction = (option: CardSelectOption) => ReactElement;
505
+
506
+ const renderCard: RenderCardFunction = (
507
+ option: CardSelectOption,
508
+ ): ReactElement => {
509
+ const isSelected: boolean = props.value === option.value;
510
+
511
+ return (
512
+ <div
513
+ key={option.value}
514
+ tabIndex={option.value === activeOptionValue ? props.tabIndex || 0 : -1}
515
+ data-card-select-value={option.value}
516
+ onClick={() => {
517
+ props.onChange(option.value);
518
+ }}
519
+ onKeyDown={(e: React.KeyboardEvent) => {
520
+ /*
521
+ * Manual activation: arrows move focus, Enter or Space chooses.
522
+ * Selecting on focus would fire onChange for every card arrowed
523
+ * past, and on this form that resets the criteria below.
524
+ */
525
+ if (e.key === "Enter" || e.key === " ") {
526
+ e.preventDefault();
527
+ props.onChange(option.value);
528
+ return;
529
+ }
530
+
531
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
532
+ e.preventDefault();
533
+ moveFocus(option.value, 1);
534
+ return;
535
+ }
536
+
537
+ if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
538
+ e.preventDefault();
539
+ moveFocus(option.value, -1);
540
+ }
541
+ }}
542
+ className={`relative flex cursor-pointer rounded-lg border p-4 shadow-sm transition-all duration-200 hover:border-indigo-400 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 ${
543
+ isSelected
544
+ ? "border-indigo-500 bg-indigo-50/50"
545
+ : "border-gray-200 bg-white"
546
+ }`}
547
+ role="radio"
548
+ aria-checked={isSelected}
549
+ data-testid={`card-select-option-${option.value}`}
550
+ >
551
+ <div className="flex w-full items-start">
552
+ <div
553
+ className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${
554
+ isSelected ? "bg-indigo-100" : "bg-gray-100"
555
+ }`}
556
+ >
557
+ <Icon
558
+ icon={option.icon}
559
+ size={SizeProp.Large}
560
+ className={`h-5 w-5 ${
561
+ isSelected ? "text-indigo-600" : "text-gray-600"
562
+ }`}
563
+ />
564
+ </div>
565
+ <div className="ml-4 flex-1">
566
+ <span
567
+ className={`block text-sm font-semibold ${
568
+ isSelected ? "text-gray-900" : "text-gray-900"
569
+ }`}
570
+ >
571
+ {option.title}
572
+ </span>
573
+ <span
574
+ className={`mt-1 block text-sm ${
575
+ isSelected ? "text-gray-600" : "text-gray-500"
576
+ }`}
577
+ >
578
+ {option.description}
579
+ </span>
580
+ </div>
581
+ {isSelected && (
582
+ <div className="flex-shrink-0 ml-2">
583
+ <Icon
584
+ icon={IconProp.CheckCircle}
585
+ size={SizeProp.Large}
586
+ className="h-5 w-5 text-indigo-500"
587
+ />
588
+ </div>
589
+ )}
590
+ </div>
591
+ </div>
592
+ );
593
+ };
68
594
 
69
595
  return (
70
596
  <div data-testid={props.dataTestId}>
597
+ {props.searchable && (
598
+ <div className="mb-5">
599
+ <div className="relative flex items-center gap-3 rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm transition-all duration-200 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
600
+ <Icon
601
+ icon={IconProp.Search}
602
+ className="h-4 w-4 flex-shrink-0 text-gray-400"
603
+ />
604
+ <input
605
+ ref={searchInputRef}
606
+ type="text"
607
+ value={search}
608
+ placeholder={props.searchPlaceholder || "Search"}
609
+ autoComplete="off"
610
+ aria-label={props.searchPlaceholder || "Search"}
611
+ data-testid="card-select-search"
612
+ className="block w-full border-0 bg-transparent p-0 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-0"
613
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
614
+ setSearch(event.target.value);
615
+ }}
616
+ onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
617
+ if (event.key === "Escape" && search.length > 0) {
618
+ /*
619
+ * Stops the keypress reaching a modal or side over that
620
+ * would take a clear-the-search Escape as close-the-form.
621
+ */
622
+ event.stopPropagation();
623
+ event.preventDefault();
624
+ setSearch("");
625
+ return;
626
+ }
627
+
628
+ /*
629
+ * Type a couple of letters, press Enter, done - the whole
630
+ * point of the search box for someone who already knows what
631
+ * they want. Left alone when there is nothing to choose.
632
+ */
633
+ if (event.key === "Enter" && searchResults[0]) {
634
+ event.preventDefault();
635
+ props.onChange(searchResults[0].option.value);
636
+ return;
637
+ }
638
+
639
+ // The only way into the grid without reaching for the mouse.
640
+ if (event.key === "ArrowDown" && activeOptionValue) {
641
+ event.preventDefault();
642
+ focusCard(activeOptionValue);
643
+ }
644
+ }}
645
+ />
646
+ {search.length > 0 && (
647
+ <button
648
+ type="button"
649
+ aria-label="Clear search"
650
+ data-testid="card-select-search-clear"
651
+ className="flex flex-shrink-0 items-center text-gray-400 hover:text-gray-600"
652
+ onClick={() => {
653
+ setSearch("");
654
+ searchInputRef.current?.focus();
655
+ }}
656
+ >
657
+ <Icon icon={IconProp.Close} className="h-4 w-4" />
658
+ </button>
659
+ )}
660
+ </div>
661
+ <p
662
+ className="mt-2 text-xs text-gray-500"
663
+ data-testid="card-select-search-summary"
664
+ role="status"
665
+ >
666
+ {isSearching
667
+ ? isShowingClosestMatches
668
+ ? `Showing ${searchResults.length} closest of ${totalOptionCount}`
669
+ : `Showing ${searchResults.length} of ${totalOptionCount}`
670
+ : `${totalOptionCount} to choose from. Search by name, category, or what you want to watch.`}
671
+ </p>
672
+ </div>
673
+ )}
674
+
71
675
  <div
676
+ ref={containerRef}
72
677
  role="radiogroup"
73
678
  aria-label="Select an option"
74
679
  aria-labelledby={props.ariaLabelledby}
75
680
  >
76
- {groups.map((group: RenderGroup, groupIndex: number) => {
77
- return (
78
- <div key={groupIndex} className={groupIndex > 0 ? "mt-8" : ""}>
79
- {group.label && (
80
- <div className="relative mb-4">
81
- <div
82
- className="absolute inset-0 flex items-center"
83
- aria-hidden="true"
84
- >
85
- <div className="w-full border-t border-gray-200"></div>
86
- </div>
87
- <div className="relative flex justify-start">
88
- <span className="bg-white pr-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
89
- {group.label}
90
- </span>
91
- </div>
92
- </div>
93
- )}
94
- <div
95
- className={
96
- props.singleColumn
97
- ? "grid grid-cols-1 gap-4"
98
- : "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
99
- }
100
- >
101
- {group.options.map((option: CardSelectOption) => {
102
- const isSelected: boolean = props.value === option.value;
103
- const currentIndex: number = cardIndex++;
681
+ {isSearching && isShowingClosestMatches && (
682
+ <p
683
+ className="mb-4 text-sm text-gray-500"
684
+ data-testid="card-select-closest-matches"
685
+ >
686
+ Nothing matches every word you typed. Closest matches:
687
+ </p>
688
+ )}
689
+
690
+ {isSearching && (
691
+ <div className={gridClassName}>
692
+ {searchResults.map((result: ScoredOption) => {
693
+ return renderCard(result.option);
694
+ })}
695
+ </div>
696
+ )}
697
+
698
+ {isSearching && searchResults.length === 0 && (
699
+ <div
700
+ className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-6 text-center"
701
+ data-testid="card-select-no-results"
702
+ >
703
+ <p className="text-sm font-medium text-gray-900">
704
+ Nothing matches every word you typed.
705
+ </p>
706
+ <p className="mt-1 text-sm text-gray-500">
707
+ Try fewer words, or a protocol or product name.
708
+ </p>
709
+ <button
710
+ type="button"
711
+ className="mt-3 text-sm font-medium text-indigo-600 hover:text-indigo-500"
712
+ onClick={() => {
713
+ setSearch("");
714
+ searchInputRef.current?.focus();
715
+ }}
716
+ >
717
+ Clear search
718
+ </button>
719
+ </div>
720
+ )}
721
+
722
+ {!isSearching &&
723
+ groups.map((group: RenderGroup, groupIndex: number) => {
724
+ const expanded: boolean = isGroupExpanded(group, groupIndex);
725
+ const canCollapse: boolean = Boolean(
726
+ props.collapsibleGroups && group.label,
727
+ );
104
728
 
105
- return (
729
+ return (
730
+ <div key={groupIndex} className={groupIndex > 0 ? "mt-8" : ""}>
731
+ {group.label && !canCollapse && (
732
+ <div className="relative mb-4">
106
733
  <div
107
- key={option.value}
108
- tabIndex={
109
- props.tabIndex
110
- ? props.tabIndex + currentIndex
111
- : currentIndex
112
- }
113
- onClick={() => {
114
- props.onChange(option.value);
115
- }}
116
- onKeyDown={(e: React.KeyboardEvent) => {
117
- if (e.key === "Enter" || e.key === " ") {
118
- e.preventDefault();
119
- props.onChange(option.value);
120
- }
121
- }}
122
- className={`relative flex cursor-pointer rounded-lg border p-4 shadow-sm focus:outline-none transition-all duration-200 hover:border-indigo-400 hover:shadow-md ${
123
- isSelected
124
- ? "border-indigo-500 bg-indigo-50/50"
125
- : "border-gray-200 bg-white"
126
- }`}
127
- role="radio"
128
- aria-checked={isSelected}
129
- data-testid={`card-select-option-${option.value}`}
734
+ className="absolute inset-0 flex items-center"
735
+ aria-hidden="true"
130
736
  >
131
- <div className="flex w-full items-start">
132
- <div
133
- className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${
134
- isSelected ? "bg-indigo-100" : "bg-gray-100"
135
- }`}
136
- >
137
- <Icon
138
- icon={option.icon}
139
- size={SizeProp.Large}
140
- className={`h-5 w-5 ${
141
- isSelected ? "text-indigo-600" : "text-gray-600"
142
- }`}
143
- />
144
- </div>
145
- <div className="ml-4 flex-1">
146
- <span
147
- className={`block text-sm font-semibold ${
148
- isSelected ? "text-gray-900" : "text-gray-900"
149
- }`}
150
- >
151
- {option.title}
152
- </span>
153
- <span
154
- className={`mt-1 block text-sm ${
155
- isSelected ? "text-gray-600" : "text-gray-500"
156
- }`}
157
- >
158
- {option.description}
159
- </span>
160
- </div>
161
- {isSelected && (
162
- <div className="flex-shrink-0 ml-2">
163
- <Icon
164
- icon={IconProp.CheckCircle}
165
- size={SizeProp.Large}
166
- className="h-5 w-5 text-indigo-500"
167
- />
168
- </div>
169
- )}
170
- </div>
737
+ <div className="w-full border-t border-gray-200"></div>
738
+ </div>
739
+ <div className="relative flex justify-start">
740
+ <span className="bg-white pr-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
741
+ {group.label}
742
+ </span>
171
743
  </div>
172
- );
173
- })}
744
+ </div>
745
+ )}
746
+
747
+ {group.label && canCollapse && (
748
+ <button
749
+ type="button"
750
+ aria-expanded={expanded}
751
+ data-testid={`card-select-group-${group.label}`}
752
+ className="mb-4 flex w-full items-center gap-2 border-b border-gray-200 pb-2 text-left"
753
+ onClick={() => {
754
+ setToggledGroups((previous: Record<string, boolean>) => {
755
+ return {
756
+ ...previous,
757
+ [group.label as string]: !expanded,
758
+ };
759
+ });
760
+ }}
761
+ >
762
+ <Icon
763
+ icon={
764
+ expanded ? IconProp.ChevronDown : IconProp.ChevronRight
765
+ }
766
+ className="h-4 w-4 flex-shrink-0 text-gray-400"
767
+ />
768
+ <span className="text-xs font-semibold uppercase tracking-wider text-gray-500">
769
+ {group.label}
770
+ </span>
771
+ <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-500">
772
+ {group.options.length}
773
+ </span>
774
+ </button>
775
+ )}
776
+
777
+ {expanded && (
778
+ <div className={gridClassName}>
779
+ {group.options.map((option: CardSelectOption) => {
780
+ return renderCard(option);
781
+ })}
782
+ </div>
783
+ )}
174
784
  </div>
175
- </div>
176
- );
177
- })}
785
+ );
786
+ })}
178
787
  </div>
179
788
  {props.error && (
180
789
  <p className="mt-2 text-sm text-red-600" role="alert">