@oneuptime/common 12.0.12 → 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 (234) 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/Utils/APIKey/AccessPermission.ts +16 -40
  27. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +144 -0
  28. package/Server/Utils/LogRedaction.ts +576 -0
  29. package/Server/Utils/Logger.ts +123 -46
  30. package/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.ts +94 -23
  31. package/Server/Utils/Monitor/MonitorResource.ts +29 -9
  32. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +28 -7
  33. package/Server/Utils/SessionReplay/SessionReplayGateCacheStore.ts +56 -10
  34. package/Server/Utils/StartServer.ts +30 -9
  35. package/Server/Utils/StatusPageSearchEngineIndexing.ts +33 -0
  36. package/Server/Utils/Telemetry/ResourceEntityFilter.ts +441 -0
  37. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +488 -46
  38. package/Tests/App/Dashboard/MonitorTypePicker.test.tsx +300 -0
  39. package/Tests/App/StatusPage/StatusPageIndexPageRobotsMeta.test.ts +130 -0
  40. package/Tests/Models/DatabaseModels/DatabaseBaseModelToJSONObjectCache.test.ts +180 -0
  41. package/Tests/Models/StatusPageEnableSearchEngineIndexing.test.ts +157 -0
  42. package/Tests/Server/API/AIChatCancelAndFeedback.test.ts +23 -1
  43. package/Tests/Server/API/AIChatRouteAuthorization.test.ts +785 -0
  44. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +21 -8
  45. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +290 -0
  46. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +7 -1
  47. package/Tests/Server/API/StatusPageOverviewCache.test.ts +474 -0
  48. package/Tests/Server/API/StatusPageSeoSearchEngineIndexing.test.ts +189 -0
  49. package/Tests/Server/Infrastructure/InMemoryTTLCache.test.ts +158 -0
  50. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +9 -60
  51. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +24 -15
  52. package/Tests/Server/Middleware/UserAuthorization.test.ts +79 -0
  53. package/Tests/Server/Services/AddNetworkDeviceReachabilityColumnsMigration.test.ts +332 -0
  54. package/Tests/Server/Services/ApiKeyPermissionService.test.ts +347 -0
  55. package/Tests/Server/Services/DatabaseServiceUpdateDebugLogging.test.ts +282 -0
  56. package/Tests/Server/Services/NetworkSiteService.test.ts +56 -2
  57. package/Tests/Server/Services/TelemetryResourceFacetFilters.test.ts +267 -0
  58. package/Tests/Server/Services/WorkspaceNotificationRuleTestRuleChannels.test.ts +702 -0
  59. package/Tests/Server/Types/Database/Permissions/AccessControlPermission.test.ts +165 -0
  60. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +48 -36
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +213 -0
  62. package/Tests/Server/Utils/LogRedaction.test.ts +415 -0
  63. package/Tests/Server/Utils/LoggerCredentialLeak.test.ts +245 -0
  64. package/Tests/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.test.ts +374 -4
  65. package/Tests/Server/Utils/Monitor/MonitorResourceIngestedStepSelection.test.ts +455 -0
  66. package/Tests/Server/Utils/Monitor/NetworkDeviceReachabilityRoundTrip.test.ts +369 -0
  67. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +86 -8
  68. package/Tests/Server/Utils/PrivacyFilterUtil.test.ts +64 -0
  69. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCacheStore.test.ts +207 -0
  70. package/Tests/Server/Utils/StartServerBodyVerify.test.ts +335 -0
  71. package/Tests/Server/Utils/StatusPageSearchEngineIndexing.test.ts +60 -0
  72. package/Tests/Server/Utils/Telemetry/ResourceEntityFilter.test.ts +435 -0
  73. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelSend.test.ts +607 -62
  74. package/Tests/Server/Utils/Workspace/MicrosoftTeamsInstalledTeamIdSpace.test.ts +778 -0
  75. package/Tests/Server/Utils/Workspace/MicrosoftTeamsTeamInstalls.test.ts +44 -10
  76. package/Tests/Types/API/HostnameFromAuthority.test.ts +164 -0
  77. package/Tests/Types/Database/AccessControl/ColumnAccessControlCache.test.ts +344 -0
  78. package/Tests/Types/Database/DatabasePropertyFindOperator.test.ts +518 -0
  79. package/Tests/Types/Database/TableColumnMetadataCache.test.ts +286 -0
  80. package/Tests/Types/Monitor/MonitorStep.test.ts +100 -0
  81. package/Tests/Types/Monitor/MonitorTypeKeywords.test.ts +360 -0
  82. package/Tests/Types/StatusPage/SearchEngineIndexing.test.ts +86 -0
  83. package/Tests/Types/Telemetry/ResourceEntityFacet.test.ts +254 -0
  84. package/Tests/UI/Components/CardSelect.test.tsx +1021 -0
  85. package/Tests/UI/Components/MasterPage.test.tsx +36 -1
  86. package/Tests/Utils/Monitor/MonitorMetricType.test.ts +66 -1
  87. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +115 -6
  88. package/Tests/Utils/NetworkDevice/DeviceReachabilityUtil.test.ts +610 -0
  89. package/Tests/Utils/NetworkSite/SiteStatusRollupUtil.test.ts +114 -45
  90. package/Types/API/Hostname.ts +79 -0
  91. package/Types/Database/AccessControl/ColumnAccessControl.ts +33 -11
  92. package/Types/Database/AccessControl/ColumnBillingAccessControl.ts +33 -11
  93. package/Types/Database/DatabaseProperty.ts +38 -0
  94. package/Types/Database/TableColumn.ts +34 -7
  95. package/Types/Events/Recurring.ts +16 -1
  96. package/Types/Monitor/MonitorStep.ts +13 -1
  97. package/Types/Monitor/MonitorType.ts +345 -40
  98. package/Types/Monitor/SSLMonitor/SslMonitorResponse.ts +43 -0
  99. package/Types/StatusPage/SearchEngineIndexing.ts +61 -0
  100. package/Types/Telemetry/ResourceEntityFacet.ts +211 -0
  101. package/UI/Components/CardSelect/CardSelect.tsx +714 -105
  102. package/UI/Components/EmptyState/EmptyState.tsx +8 -1
  103. package/UI/Components/Footer/Footer.tsx +32 -6
  104. package/UI/Components/Forms/Fields/FormField.tsx +3 -0
  105. package/UI/Components/Forms/Types/Field.ts +8 -0
  106. package/UI/Components/Loader/PageLoader.tsx +29 -8
  107. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +26 -4
  108. package/UI/Components/Markdown.tsx/LazyMarkdownViewer.tsx +17 -1
  109. package/UI/Components/MasterPage/MasterPage.tsx +24 -11
  110. package/Utils/Monitor/MonitorMetricType.ts +16 -1
  111. package/Utils/Monitor/NetworkTopologyUtil.ts +60 -8
  112. package/Utils/NetworkDevice/DeviceReachabilityUtil.ts +262 -0
  113. package/Utils/NetworkSite/SiteStatusRollupUtil.ts +41 -24
  114. package/Utils/Telemetry/CrossSignalScope.ts +75 -4
  115. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js +20 -4
  116. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/NetworkDevice.js +66 -0
  118. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/StatusPage.js +60 -0
  120. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPageOidc.js +2 -0
  122. package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -1
  123. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  124. package/build/dist/Server/API/AIChatAPI.js +48 -35
  125. package/build/dist/Server/API/AIChatAPI.js.map +1 -1
  126. package/build/dist/Server/API/CommonAPI.js +59 -0
  127. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  128. package/build/dist/Server/API/MicrosoftTeamsAPI.js +20 -6
  129. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  130. package/build/dist/Server/API/SlackAPI.js +13 -10
  131. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  132. package/build/dist/Server/API/StatusPageAPI.js +745 -673
  133. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  134. package/build/dist/Server/API/TelemetryAPI.js +34 -3
  135. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js +23 -0
  137. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787500000000-AddEnableSearchEngineIndexingToStatusPage.js.map +1 -0
  138. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js +34 -0
  139. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787600000000-AddNetworkDeviceReachabilityColumns.js.map +1 -0
  140. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  141. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  142. package/build/dist/Server/Middleware/UserAuthorization.js +6 -2
  143. package/build/dist/Server/Middleware/UserAuthorization.js.map +1 -1
  144. package/build/dist/Server/Services/ApiKeyPermissionService.js +90 -1
  145. package/build/dist/Server/Services/ApiKeyPermissionService.js.map +1 -1
  146. package/build/dist/Server/Services/DatabaseService.js +15 -6
  147. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  148. package/build/dist/Server/Services/LogAggregationService.js +2 -0
  149. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  150. package/build/dist/Server/Services/LogService.js +16 -0
  151. package/build/dist/Server/Services/LogService.js.map +1 -1
  152. package/build/dist/Server/Services/NetworkSiteService.js +12 -0
  153. package/build/dist/Server/Services/NetworkSiteService.js.map +1 -1
  154. package/build/dist/Server/Services/SpanService.js +16 -0
  155. package/build/dist/Server/Services/SpanService.js.map +1 -1
  156. package/build/dist/Server/Services/TraceAggregationService.js +2 -0
  157. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  158. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +137 -164
  159. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  160. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +9 -0
  161. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  162. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js +21 -13
  163. package/build/dist/Server/Types/Database/Permissions/AccessControlPermission.js.map +1 -1
  164. package/build/dist/Server/Utils/APIKey/AccessPermission.js +9 -32
  165. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  166. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +87 -0
  167. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  168. package/build/dist/Server/Utils/LogRedaction.js +449 -0
  169. package/build/dist/Server/Utils/LogRedaction.js.map +1 -0
  170. package/build/dist/Server/Utils/Logger.js +98 -45
  171. package/build/dist/Server/Utils/Logger.js.map +1 -1
  172. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js +65 -13
  173. package/build/dist/Server/Utils/Monitor/Criteria/SSLMonitorCriteria.js.map +1 -1
  174. package/build/dist/Server/Utils/Monitor/MonitorResource.js +20 -3
  175. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  176. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +27 -7
  177. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  178. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js +29 -10
  179. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCacheStore.js.map +1 -1
  180. package/build/dist/Server/Utils/StartServer.js +10 -7
  181. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  182. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js +24 -0
  183. package/build/dist/Server/Utils/StatusPageSearchEngineIndexing.js.map +1 -0
  184. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js +311 -0
  185. package/build/dist/Server/Utils/Telemetry/ResourceEntityFilter.js.map +1 -0
  186. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +366 -37
  187. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  188. package/build/dist/Types/API/Hostname.js +63 -0
  189. package/build/dist/Types/API/Hostname.js.map +1 -1
  190. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js +20 -6
  191. package/build/dist/Types/Database/AccessControl/ColumnAccessControl.js.map +1 -1
  192. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js +20 -6
  193. package/build/dist/Types/Database/AccessControl/ColumnBillingAccessControl.js.map +1 -1
  194. package/build/dist/Types/Database/DatabaseProperty.js +38 -0
  195. package/build/dist/Types/Database/DatabaseProperty.js.map +1 -1
  196. package/build/dist/Types/Database/TableColumn.js +24 -6
  197. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  198. package/build/dist/Types/Events/Recurring.js +13 -1
  199. package/build/dist/Types/Events/Recurring.js.map +1 -1
  200. package/build/dist/Types/Monitor/MonitorStep.js +9 -1
  201. package/build/dist/Types/Monitor/MonitorStep.js.map +1 -1
  202. package/build/dist/Types/Monitor/MonitorType.js +330 -31
  203. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  204. package/build/dist/Types/StatusPage/SearchEngineIndexing.js +55 -0
  205. package/build/dist/Types/StatusPage/SearchEngineIndexing.js.map +1 -0
  206. package/build/dist/Types/Telemetry/ResourceEntityFacet.js +156 -0
  207. package/build/dist/Types/Telemetry/ResourceEntityFacet.js.map +1 -0
  208. package/build/dist/UI/Components/CardSelect/CardSelect.js +360 -40
  209. package/build/dist/UI/Components/CardSelect/CardSelect.js.map +1 -1
  210. package/build/dist/UI/Components/EmptyState/EmptyState.js +1 -1
  211. package/build/dist/UI/Components/EmptyState/EmptyState.js.map +1 -1
  212. package/build/dist/UI/Components/Footer/Footer.js +7 -4
  213. package/build/dist/UI/Components/Footer/Footer.js.map +1 -1
  214. package/build/dist/UI/Components/Forms/Fields/FormField.js +1 -1
  215. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  216. package/build/dist/UI/Components/Loader/PageLoader.js +15 -5
  217. package/build/dist/UI/Components/Loader/PageLoader.js.map +1 -1
  218. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +16 -3
  219. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  220. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js +13 -1
  221. package/build/dist/UI/Components/Markdown.tsx/LazyMarkdownViewer.js.map +1 -1
  222. package/build/dist/UI/Components/MasterPage/MasterPage.js +10 -2
  223. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  224. package/build/dist/Utils/Monitor/MonitorMetricType.js +13 -1
  225. package/build/dist/Utils/Monitor/MonitorMetricType.js.map +1 -1
  226. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +34 -7
  227. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  228. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js +177 -0
  229. package/build/dist/Utils/NetworkDevice/DeviceReachabilityUtil.js.map +1 -0
  230. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js +23 -27
  231. package/build/dist/Utils/NetworkSite/SiteStatusRollupUtil.js.map +1 -1
  232. package/build/dist/Utils/Telemetry/CrossSignalScope.js +44 -4
  233. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  234. package/package.json +1 -1
@@ -0,0 +1,1021 @@
1
+ import "@testing-library/jest-dom";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import React from "react";
4
+ import { describe, expect, test } from "@jest/globals";
5
+ import IconProp from "../../../Types/Icon/IconProp";
6
+ import getJestMockFunction, { MockFunction } from "../../MockType";
7
+ import CardSelect, {
8
+ CardSelectOption,
9
+ CardSelectOptionGroup,
10
+ ComponentProps,
11
+ cardSelectOptionMatchesSearch,
12
+ getCardSelectOptionSearchScore,
13
+ getCardSelectSearchTokens,
14
+ isCardSelectOptionGroup,
15
+ normalizeCardSelectGroups,
16
+ } from "../../../UI/Components/CardSelect/CardSelect";
17
+
18
+ /*
19
+ * CardSelect grew a search box and collapsible groups for the monitor type
20
+ * picker, where 29 cards under nine headings made picking one a scrolling
21
+ * exercise. Both are opt in, so the two callers that do not ask for them -
22
+ * the team role picker and the metrics pipeline rule picker - must keep
23
+ * exactly the plain grid they had. That is what the first block pins.
24
+ */
25
+
26
+ const website: CardSelectOption = {
27
+ value: "Website",
28
+ title: "Website",
29
+ description: "Check a page loads and responds.",
30
+ icon: IconProp.Globe,
31
+ keywords: ["url", "http", "https"],
32
+ };
33
+
34
+ const kubernetes: CardSelectOption = {
35
+ value: "Kubernetes",
36
+ title: "Kubernetes",
37
+ description: "Cluster, node, workload, and pod health.",
38
+ icon: IconProp.Cube,
39
+ keywords: ["k8s", "cluster", "pod"],
40
+ };
41
+
42
+ const sqlQuery: CardSelectOption = {
43
+ value: "SQL Query",
44
+ title: "SQL Query",
45
+ description: "Run a read only query on a schedule.",
46
+ icon: IconProp.Database,
47
+ keywords: ["postgres", "mysql", "database"],
48
+ };
49
+
50
+ const manual: CardSelectOption = {
51
+ value: "Manual",
52
+ title: "Manual",
53
+ description: "No automatic checks.",
54
+ icon: IconProp.EmptyCircle,
55
+ };
56
+
57
+ const basicGroup: CardSelectOptionGroup = {
58
+ label: "Basic Monitoring",
59
+ options: [website],
60
+ };
61
+
62
+ const infrastructureGroup: CardSelectOptionGroup = {
63
+ label: "Infrastructure",
64
+ options: [kubernetes],
65
+ };
66
+
67
+ const databaseGroup: CardSelectOptionGroup = {
68
+ label: "Database Monitoring",
69
+ options: [sqlQuery],
70
+ };
71
+
72
+ const otherGroup: CardSelectOptionGroup = {
73
+ label: "Other",
74
+ options: [manual],
75
+ };
76
+
77
+ const groupedOptions: Array<CardSelectOptionGroup> = [
78
+ basicGroup,
79
+ infrastructureGroup,
80
+ databaseGroup,
81
+ otherGroup,
82
+ ];
83
+
84
+ type RenderComponentFunction = (props: Partial<ComponentProps>) => {
85
+ onChange: MockFunction;
86
+ };
87
+
88
+ const renderComponent: RenderComponentFunction = (
89
+ props: Partial<ComponentProps>,
90
+ ): { onChange: MockFunction } => {
91
+ const onChange: MockFunction = getJestMockFunction();
92
+
93
+ render(
94
+ <CardSelect
95
+ options={props.options || groupedOptions}
96
+ onChange={onChange}
97
+ {...props}
98
+ />,
99
+ );
100
+
101
+ return { onChange: onChange };
102
+ };
103
+
104
+ type TypeSearchFunction = (value: string) => void;
105
+
106
+ const typeSearch: TypeSearchFunction = (value: string): void => {
107
+ fireEvent.change(screen.getByTestId("card-select-search"), {
108
+ target: { value: value },
109
+ });
110
+ };
111
+
112
+ describe("CardSelect", () => {
113
+ describe("default behaviour is unchanged for callers that opt into nothing", () => {
114
+ test("renders every flat option as a card", () => {
115
+ renderComponent({ options: [website, kubernetes, manual] });
116
+
117
+ expect(screen.getByTestId("card-select-option-Website")).toBeVisible();
118
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
119
+ expect(screen.getByTestId("card-select-option-Manual")).toBeVisible();
120
+ });
121
+
122
+ test("renders every group heading and every card under it", () => {
123
+ renderComponent({});
124
+
125
+ for (const group of groupedOptions) {
126
+ expect(screen.getByText(group.label)).toBeInTheDocument();
127
+ }
128
+
129
+ expect(screen.getByTestId("card-select-option-Website")).toBeVisible();
130
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
131
+ expect(screen.getByTestId("card-select-option-SQL Query")).toBeVisible();
132
+ expect(screen.getByTestId("card-select-option-Manual")).toBeVisible();
133
+ });
134
+
135
+ test("renders a run of flat options interleaved between groups", () => {
136
+ renderComponent({ options: [manual, basicGroup, kubernetes] });
137
+
138
+ expect(screen.getByTestId("card-select-option-Manual")).toBeVisible();
139
+ expect(screen.getByTestId("card-select-option-Website")).toBeVisible();
140
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
141
+ expect(screen.getByText("Basic Monitoring")).toBeInTheDocument();
142
+ });
143
+
144
+ test("shows no search box", () => {
145
+ renderComponent({});
146
+
147
+ expect(
148
+ screen.queryByTestId("card-select-search"),
149
+ ).not.toBeInTheDocument();
150
+ });
151
+
152
+ test("shows no collapse control, so every group stays open", () => {
153
+ renderComponent({});
154
+
155
+ expect(
156
+ screen.queryByTestId("card-select-group-Infrastructure"),
157
+ ).not.toBeInTheDocument();
158
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
159
+ });
160
+ });
161
+
162
+ describe("selection", () => {
163
+ test("calls onChange with the option value when a card is clicked", () => {
164
+ const { onChange } = renderComponent({});
165
+
166
+ fireEvent.click(screen.getByTestId("card-select-option-Website"));
167
+
168
+ expect(onChange).toHaveBeenCalledWith("Website");
169
+ });
170
+
171
+ test("calls onChange when Enter is pressed on a card", () => {
172
+ const { onChange } = renderComponent({});
173
+
174
+ fireEvent.keyDown(screen.getByTestId("card-select-option-Website"), {
175
+ key: "Enter",
176
+ });
177
+
178
+ expect(onChange).toHaveBeenCalledWith("Website");
179
+ });
180
+
181
+ test("calls onChange when Space is pressed on a card", () => {
182
+ const { onChange } = renderComponent({});
183
+
184
+ fireEvent.keyDown(screen.getByTestId("card-select-option-Website"), {
185
+ key: " ",
186
+ });
187
+
188
+ expect(onChange).toHaveBeenCalledWith("Website");
189
+ });
190
+
191
+ test("does not call onChange for an unrelated key", () => {
192
+ const { onChange } = renderComponent({});
193
+
194
+ fireEvent.keyDown(screen.getByTestId("card-select-option-Website"), {
195
+ key: "a",
196
+ });
197
+
198
+ expect(onChange).not.toHaveBeenCalled();
199
+ });
200
+
201
+ test("marks only the selected card aria-checked", () => {
202
+ renderComponent({ value: "Kubernetes" });
203
+
204
+ expect(
205
+ screen.getByTestId("card-select-option-Kubernetes"),
206
+ ).toHaveAttribute("aria-checked", "true");
207
+ expect(screen.getByTestId("card-select-option-Website")).toHaveAttribute(
208
+ "aria-checked",
209
+ "false",
210
+ );
211
+ });
212
+
213
+ test("every card is a radio inside a radiogroup", () => {
214
+ renderComponent({});
215
+
216
+ expect(screen.getByRole("radiogroup")).toBeInTheDocument();
217
+ expect(screen.getAllByRole("radio")).toHaveLength(4);
218
+ });
219
+ });
220
+
221
+ describe("error and labelling", () => {
222
+ test("renders the error as an alert", () => {
223
+ renderComponent({ error: "Monitor type is required." });
224
+
225
+ const error: HTMLElement = screen.getByRole("alert");
226
+
227
+ expect(error).toHaveTextContent("Monitor type is required.");
228
+ });
229
+
230
+ test("renders no alert when there is no error", () => {
231
+ renderComponent({});
232
+
233
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
234
+ });
235
+
236
+ test("points the radiogroup at the field label", () => {
237
+ renderComponent({ ariaLabelledby: "monitor-type-label" });
238
+
239
+ expect(screen.getByRole("radiogroup")).toHaveAttribute(
240
+ "aria-labelledby",
241
+ "monitor-type-label",
242
+ );
243
+ });
244
+
245
+ test("exposes the dataTestId on the wrapper", () => {
246
+ renderComponent({ dataTestId: "monitor-type-picker" });
247
+
248
+ expect(screen.getByTestId("monitor-type-picker")).toBeInTheDocument();
249
+ });
250
+ });
251
+
252
+ describe("search", () => {
253
+ test("shows the search box only when asked", () => {
254
+ renderComponent({ searchable: true });
255
+
256
+ expect(screen.getByTestId("card-select-search")).toBeInTheDocument();
257
+ });
258
+
259
+ test("uses the caller's placeholder as the accessible name", () => {
260
+ renderComponent({ searchable: true, searchPlaceholder: "Search types" });
261
+
262
+ expect(screen.getByLabelText("Search types")).toBeInTheDocument();
263
+ });
264
+
265
+ test("counts everything on offer before anything is typed", () => {
266
+ renderComponent({ searchable: true });
267
+
268
+ expect(
269
+ screen.getByTestId("card-select-search-summary"),
270
+ ).toHaveTextContent("4 to choose from");
271
+ });
272
+
273
+ test("narrows to the cards that match a title", () => {
274
+ renderComponent({ searchable: true });
275
+
276
+ typeSearch("kubernetes");
277
+
278
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
279
+ expect(
280
+ screen.queryByTestId("card-select-option-Website"),
281
+ ).not.toBeInTheDocument();
282
+ });
283
+
284
+ test("matches on a keyword that appears nowhere on the card", () => {
285
+ renderComponent({ searchable: true });
286
+
287
+ typeSearch("k8s");
288
+
289
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
290
+ expect(screen.getAllByRole("radio")).toHaveLength(1);
291
+ });
292
+
293
+ test("matches on the description", () => {
294
+ renderComponent({ searchable: true });
295
+
296
+ typeSearch("workload");
297
+
298
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
299
+ });
300
+
301
+ test("matches on the group heading the card sits under", () => {
302
+ renderComponent({ searchable: true });
303
+
304
+ typeSearch("infrastructure");
305
+
306
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
307
+ expect(
308
+ screen.queryByTestId("card-select-option-Website"),
309
+ ).not.toBeInTheDocument();
310
+ });
311
+
312
+ test("ignores case on both sides", () => {
313
+ renderComponent({ searchable: true });
314
+
315
+ typeSearch("K8S");
316
+
317
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
318
+ });
319
+
320
+ test("matches part of a word", () => {
321
+ renderComponent({ searchable: true });
322
+
323
+ typeSearch("kube");
324
+
325
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
326
+ });
327
+
328
+ /*
329
+ * The words are matched separately and can come from different fields, so
330
+ * a user describing what they want in their own order still lands on the
331
+ * card. Requiring every word is what keeps that from matching everything.
332
+ */
333
+ test("requires every word typed, taking them from different fields", () => {
334
+ renderComponent({ searchable: true });
335
+
336
+ typeSearch("postgres query");
337
+
338
+ expect(screen.getByTestId("card-select-option-SQL Query")).toBeVisible();
339
+ expect(screen.getAllByRole("radio")).toHaveLength(1);
340
+ });
341
+
342
+ test("does not care what order the words are typed in", () => {
343
+ renderComponent({ searchable: true });
344
+
345
+ typeSearch("query postgres");
346
+
347
+ expect(screen.getByTestId("card-select-option-SQL Query")).toBeVisible();
348
+ });
349
+
350
+ /*
351
+ * The strict pass drops it - nothing contains "mainframe" - so what comes
352
+ * back is labelled a closest match rather than presented as a hit. The
353
+ * strict rule itself is pinned on cardSelectOptionMatchesSearch below.
354
+ */
355
+ test("stops treating a card as a match when one of the words misses", () => {
356
+ renderComponent({ searchable: true });
357
+
358
+ typeSearch("postgres mainframe");
359
+
360
+ expect(screen.getByTestId("card-select-closest-matches")).toBeVisible();
361
+ expect(
362
+ screen.getByTestId("card-select-search-summary"),
363
+ ).toHaveTextContent("closest of");
364
+ });
365
+
366
+ test("hides the group headings while a search is running", () => {
367
+ renderComponent({ searchable: true });
368
+
369
+ typeSearch("kubernetes");
370
+
371
+ expect(screen.queryByText("Infrastructure")).not.toBeInTheDocument();
372
+ });
373
+
374
+ test("reports how many of the total are showing", () => {
375
+ renderComponent({ searchable: true });
376
+
377
+ typeSearch("k8s");
378
+
379
+ expect(
380
+ screen.getByTestId("card-select-search-summary"),
381
+ ).toHaveTextContent("Showing 1 of 4");
382
+ });
383
+
384
+ test("a card can still be picked out of the search results", () => {
385
+ const { onChange } = renderComponent({ searchable: true });
386
+
387
+ typeSearch("k8s");
388
+ fireEvent.click(screen.getByTestId("card-select-option-Kubernetes"));
389
+
390
+ expect(onChange).toHaveBeenCalledWith("Kubernetes");
391
+ });
392
+
393
+ describe("when nothing matches", () => {
394
+ test("says so rather than rendering an empty grid", () => {
395
+ renderComponent({ searchable: true });
396
+
397
+ typeSearch("mainframe");
398
+
399
+ expect(screen.getByTestId("card-select-no-results")).toBeVisible();
400
+ expect(screen.queryAllByRole("radio")).toHaveLength(0);
401
+ });
402
+
403
+ test("offers a way back to the full list", () => {
404
+ renderComponent({ searchable: true });
405
+
406
+ typeSearch("mainframe");
407
+ fireEvent.click(screen.getByText("Clear search"));
408
+
409
+ expect(
410
+ screen.queryByTestId("card-select-no-results"),
411
+ ).not.toBeInTheDocument();
412
+ expect(screen.getByTestId("card-select-option-Website")).toBeVisible();
413
+ });
414
+ });
415
+
416
+ test("the clear button restores the full list", () => {
417
+ renderComponent({ searchable: true });
418
+
419
+ typeSearch("k8s");
420
+ fireEvent.click(screen.getByTestId("card-select-search-clear"));
421
+
422
+ expect(screen.getByTestId("card-select-option-Website")).toBeVisible();
423
+ expect(screen.getByText("Basic Monitoring")).toBeInTheDocument();
424
+ });
425
+
426
+ test("there is nothing to clear before anything is typed", () => {
427
+ renderComponent({ searchable: true });
428
+
429
+ expect(
430
+ screen.queryByTestId("card-select-search-clear"),
431
+ ).not.toBeInTheDocument();
432
+ });
433
+
434
+ /*
435
+ * The picker is used inside forms that live in modals and side overs. An
436
+ * Escape meant for the search box must not also close the form around it.
437
+ */
438
+ test("Escape clears the search without escaping the surrounding form", () => {
439
+ renderComponent({ searchable: true });
440
+
441
+ typeSearch("k8s");
442
+
443
+ const searchInput: HTMLElement = screen.getByTestId("card-select-search");
444
+ const escape: boolean = fireEvent.keyDown(searchInput, {
445
+ key: "Escape",
446
+ });
447
+
448
+ expect(escape).toBe(false);
449
+ expect(screen.getByTestId("card-select-option-Website")).toBeVisible();
450
+ });
451
+
452
+ test("Escape on an empty search box is left to the form", () => {
453
+ renderComponent({ searchable: true });
454
+
455
+ const escape: boolean = fireEvent.keyDown(
456
+ screen.getByTestId("card-select-search"),
457
+ { key: "Escape" },
458
+ );
459
+
460
+ expect(escape).toBe(true);
461
+ });
462
+ });
463
+
464
+ describe("keyboard", () => {
465
+ /*
466
+ * Every card used to get its own increasing tabIndex. With the 0 that
467
+ * FormField passes, that produced 1, 2, 3 ... 28 - positive tab indices,
468
+ * which jump the whole grid ahead of every other control on the page and
469
+ * make 29 stops out of what should be one.
470
+ */
471
+ test("the group is a single tab stop, not one per card", () => {
472
+ renderComponent({});
473
+
474
+ const tabbable: Array<HTMLElement> = screen
475
+ .getAllByRole("radio")
476
+ .filter((card: HTMLElement) => {
477
+ return card.getAttribute("tabindex") === "0";
478
+ });
479
+
480
+ expect(tabbable).toHaveLength(1);
481
+ });
482
+
483
+ test("no card carries a positive tab index", () => {
484
+ renderComponent({});
485
+
486
+ for (const card of screen.getAllByRole("radio")) {
487
+ expect(Number(card.getAttribute("tabindex"))).toBeLessThanOrEqual(0);
488
+ }
489
+ });
490
+
491
+ test("the tab stop is the first card when nothing is selected", () => {
492
+ renderComponent({});
493
+
494
+ expect(screen.getByTestId("card-select-option-Website")).toHaveAttribute(
495
+ "tabindex",
496
+ "0",
497
+ );
498
+ });
499
+
500
+ test("the tab stop follows the selection", () => {
501
+ renderComponent({ value: "SQL Query" });
502
+
503
+ expect(
504
+ screen.getByTestId("card-select-option-SQL Query"),
505
+ ).toHaveAttribute("tabindex", "0");
506
+ expect(screen.getByTestId("card-select-option-Website")).toHaveAttribute(
507
+ "tabindex",
508
+ "-1",
509
+ );
510
+ });
511
+
512
+ test("arrow right moves focus to the next card", () => {
513
+ renderComponent({});
514
+
515
+ const first: HTMLElement = screen.getByTestId(
516
+ "card-select-option-Website",
517
+ );
518
+
519
+ first.focus();
520
+ fireEvent.keyDown(first, { key: "ArrowRight" });
521
+
522
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toHaveFocus();
523
+ });
524
+
525
+ test("arrow down moves focus to the next card", () => {
526
+ renderComponent({});
527
+
528
+ const first: HTMLElement = screen.getByTestId(
529
+ "card-select-option-Website",
530
+ );
531
+
532
+ first.focus();
533
+ fireEvent.keyDown(first, { key: "ArrowDown" });
534
+
535
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toHaveFocus();
536
+ });
537
+
538
+ test("arrow left moves focus back", () => {
539
+ renderComponent({});
540
+
541
+ const second: HTMLElement = screen.getByTestId(
542
+ "card-select-option-Kubernetes",
543
+ );
544
+
545
+ second.focus();
546
+ fireEvent.keyDown(second, { key: "ArrowLeft" });
547
+
548
+ expect(screen.getByTestId("card-select-option-Website")).toHaveFocus();
549
+ });
550
+
551
+ test("arrows stop at the ends rather than wrapping round", () => {
552
+ renderComponent({});
553
+
554
+ const first: HTMLElement = screen.getByTestId(
555
+ "card-select-option-Website",
556
+ );
557
+
558
+ first.focus();
559
+ fireEvent.keyDown(first, { key: "ArrowLeft" });
560
+
561
+ expect(first).toHaveFocus();
562
+ });
563
+
564
+ /*
565
+ * Moving focus must not choose anything: on the create form choosing a
566
+ * type resets the criteria built below it, so arrowing past a card cannot
567
+ * be allowed to fire onChange.
568
+ */
569
+ test("moving focus chooses nothing", () => {
570
+ const { onChange } = renderComponent({});
571
+
572
+ const first: HTMLElement = screen.getByTestId(
573
+ "card-select-option-Website",
574
+ );
575
+
576
+ first.focus();
577
+ fireEvent.keyDown(first, { key: "ArrowRight" });
578
+
579
+ expect(onChange).not.toHaveBeenCalled();
580
+ });
581
+
582
+ test("arrows skip the cards folded away in closed groups", () => {
583
+ renderComponent({ collapsibleGroups: true });
584
+
585
+ const first: HTMLElement = screen.getByTestId(
586
+ "card-select-option-Website",
587
+ );
588
+
589
+ first.focus();
590
+ fireEvent.keyDown(first, { key: "ArrowRight" });
591
+
592
+ // Website is the only card on screen, so there is nowhere to go.
593
+ expect(first).toHaveFocus();
594
+ });
595
+
596
+ test("Enter in the search box picks the top result", () => {
597
+ const { onChange } = renderComponent({ searchable: true });
598
+
599
+ typeSearch("k8s");
600
+ fireEvent.keyDown(screen.getByTestId("card-select-search"), {
601
+ key: "Enter",
602
+ });
603
+
604
+ expect(onChange).toHaveBeenCalledWith("Kubernetes");
605
+ });
606
+
607
+ test("Enter in an empty search box picks nothing", () => {
608
+ const { onChange } = renderComponent({ searchable: true });
609
+
610
+ fireEvent.keyDown(screen.getByTestId("card-select-search"), {
611
+ key: "Enter",
612
+ });
613
+
614
+ expect(onChange).not.toHaveBeenCalled();
615
+ });
616
+
617
+ test("arrow down from the search box moves into the results", () => {
618
+ renderComponent({ searchable: true });
619
+
620
+ typeSearch("k8s");
621
+ fireEvent.keyDown(screen.getByTestId("card-select-search"), {
622
+ key: "ArrowDown",
623
+ });
624
+
625
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toHaveFocus();
626
+ });
627
+ });
628
+
629
+ describe("closest matches", () => {
630
+ /*
631
+ * A dead end is the worst answer a search can give. When no card carries
632
+ * every word, the cards carrying any of them beat an empty panel.
633
+ */
634
+ test("falls back to the cards matching any word", () => {
635
+ renderComponent({ searchable: true });
636
+
637
+ typeSearch("postgres uptime");
638
+
639
+ expect(screen.getByTestId("card-select-option-SQL Query")).toBeVisible();
640
+ expect(screen.getByTestId("card-select-closest-matches")).toBeVisible();
641
+ });
642
+
643
+ test("says the results are only the closest ones", () => {
644
+ renderComponent({ searchable: true });
645
+
646
+ typeSearch("postgres uptime");
647
+
648
+ expect(
649
+ screen.getByTestId("card-select-search-summary"),
650
+ ).toHaveTextContent("closest of");
651
+ });
652
+
653
+ test("does not claim closest matches when every word landed", () => {
654
+ renderComponent({ searchable: true });
655
+
656
+ typeSearch("k8s");
657
+
658
+ expect(
659
+ screen.queryByTestId("card-select-closest-matches"),
660
+ ).not.toBeInTheDocument();
661
+ });
662
+
663
+ test("a card from the closest matches can still be picked", () => {
664
+ const { onChange } = renderComponent({ searchable: true });
665
+
666
+ typeSearch("postgres uptime");
667
+ fireEvent.click(screen.getByTestId("card-select-option-SQL Query"));
668
+
669
+ expect(onChange).toHaveBeenCalledWith("SQL Query");
670
+ });
671
+
672
+ test("a single word that matches nothing still says nothing matches", () => {
673
+ renderComponent({ searchable: true });
674
+
675
+ typeSearch("mainframe");
676
+
677
+ expect(screen.getByTestId("card-select-no-results")).toBeVisible();
678
+ expect(
679
+ screen.queryByTestId("card-select-closest-matches"),
680
+ ).not.toBeInTheDocument();
681
+ });
682
+
683
+ test("several words that all match nothing still say nothing matches", () => {
684
+ renderComponent({ searchable: true });
685
+
686
+ typeSearch("mainframe cobol");
687
+
688
+ expect(screen.getByTestId("card-select-no-results")).toBeVisible();
689
+ });
690
+ });
691
+
692
+ describe("search ranking", () => {
693
+ type TitlesFunction = () => Array<string>;
694
+
695
+ const renderedTitles: TitlesFunction = (): Array<string> => {
696
+ return screen.getAllByRole("radio").map((card: HTMLElement) => {
697
+ return card.getAttribute("data-testid") || "";
698
+ });
699
+ };
700
+
701
+ test("puts an exact title match first", () => {
702
+ const ping: CardSelectOption = {
703
+ value: "Ping",
704
+ title: "Ping",
705
+ description: "ICMP reachability.",
706
+ icon: IconProp.Signal,
707
+ };
708
+ const other: CardSelectOption = {
709
+ value: "Other",
710
+ title: "Other",
711
+ description: "Mentions ping in passing.",
712
+ icon: IconProp.Globe,
713
+ };
714
+
715
+ renderComponent({ options: [other, ping], searchable: true });
716
+
717
+ typeSearch("ping");
718
+
719
+ expect(renderedTitles()[0]).toBe("card-select-option-Ping");
720
+ });
721
+
722
+ test("puts a keyword match above a description-only match", () => {
723
+ const keywordCard: CardSelectOption = {
724
+ value: "Keyword",
725
+ title: "Keyword",
726
+ description: "Nothing relevant here.",
727
+ icon: IconProp.Globe,
728
+ keywords: ["postgres"],
729
+ };
730
+ const descriptionCard: CardSelectOption = {
731
+ value: "Description",
732
+ title: "Description",
733
+ description: "Talks about postgres somewhere in the copy.",
734
+ icon: IconProp.Globe,
735
+ };
736
+
737
+ renderComponent({
738
+ options: [descriptionCard, keywordCard],
739
+ searchable: true,
740
+ });
741
+
742
+ typeSearch("postgres");
743
+
744
+ expect(renderedTitles()[0]).toBe("card-select-option-Keyword");
745
+ });
746
+
747
+ test("falls back to alphabetical order when the scores tie", () => {
748
+ const beta: CardSelectOption = {
749
+ value: "Beta",
750
+ title: "Beta",
751
+ description: "shared",
752
+ icon: IconProp.Globe,
753
+ };
754
+ const alpha: CardSelectOption = {
755
+ value: "Alpha",
756
+ title: "Alpha",
757
+ description: "shared",
758
+ icon: IconProp.Globe,
759
+ };
760
+
761
+ renderComponent({ options: [beta, alpha], searchable: true });
762
+
763
+ typeSearch("shared");
764
+
765
+ expect(renderedTitles()).toEqual([
766
+ "card-select-option-Alpha",
767
+ "card-select-option-Beta",
768
+ ]);
769
+ });
770
+ });
771
+
772
+ describe("collapsible groups", () => {
773
+ test("opens the first group and closes the rest behind a count", () => {
774
+ renderComponent({ collapsibleGroups: true });
775
+
776
+ expect(screen.getByTestId("card-select-option-Website")).toBeVisible();
777
+ expect(
778
+ screen.queryByTestId("card-select-option-Kubernetes"),
779
+ ).not.toBeInTheDocument();
780
+ expect(
781
+ screen.queryByTestId("card-select-option-Manual"),
782
+ ).not.toBeInTheDocument();
783
+ });
784
+
785
+ test("every group header stays on screen so nothing is hidden outright", () => {
786
+ renderComponent({ collapsibleGroups: true });
787
+
788
+ for (const group of groupedOptions) {
789
+ expect(
790
+ screen.getByTestId(`card-select-group-${group.label}`),
791
+ ).toBeVisible();
792
+ }
793
+ });
794
+
795
+ test("says how many cards a closed group holds", () => {
796
+ renderComponent({ collapsibleGroups: true });
797
+
798
+ expect(
799
+ screen.getByTestId("card-select-group-Infrastructure"),
800
+ ).toHaveTextContent("1");
801
+ });
802
+
803
+ test("reports its state to assistive technology", () => {
804
+ renderComponent({ collapsibleGroups: true });
805
+
806
+ expect(
807
+ screen.getByTestId("card-select-group-Basic Monitoring"),
808
+ ).toHaveAttribute("aria-expanded", "true");
809
+ expect(
810
+ screen.getByTestId("card-select-group-Infrastructure"),
811
+ ).toHaveAttribute("aria-expanded", "false");
812
+ });
813
+
814
+ test("clicking a closed header opens it", () => {
815
+ renderComponent({ collapsibleGroups: true });
816
+
817
+ fireEvent.click(screen.getByTestId("card-select-group-Infrastructure"));
818
+
819
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
820
+ expect(
821
+ screen.getByTestId("card-select-group-Infrastructure"),
822
+ ).toHaveAttribute("aria-expanded", "true");
823
+ });
824
+
825
+ test("clicking an open header closes it", () => {
826
+ renderComponent({ collapsibleGroups: true });
827
+
828
+ fireEvent.click(screen.getByTestId("card-select-group-Basic Monitoring"));
829
+
830
+ expect(
831
+ screen.queryByTestId("card-select-option-Website"),
832
+ ).not.toBeInTheDocument();
833
+ });
834
+
835
+ /*
836
+ * Re-entering a half-filled form must show the choice already made, not
837
+ * hide it behind a closed heading and look like nothing was picked.
838
+ */
839
+ test("opens the group holding the current selection", () => {
840
+ renderComponent({ collapsibleGroups: true, value: "Manual" });
841
+
842
+ expect(screen.getByTestId("card-select-option-Manual")).toBeVisible();
843
+ expect(screen.getByTestId("card-select-group-Other")).toHaveAttribute(
844
+ "aria-expanded",
845
+ "true",
846
+ );
847
+ });
848
+
849
+ test("a group the user closed by hand stays closed", () => {
850
+ renderComponent({ collapsibleGroups: true, value: "Website" });
851
+
852
+ fireEvent.click(screen.getByTestId("card-select-group-Basic Monitoring"));
853
+
854
+ expect(
855
+ screen.queryByTestId("card-select-option-Website"),
856
+ ).not.toBeInTheDocument();
857
+ });
858
+
859
+ test("leaves a run of flat options alone, having no header to collapse", () => {
860
+ renderComponent({
861
+ options: [website, kubernetes],
862
+ collapsibleGroups: true,
863
+ });
864
+
865
+ expect(screen.getByTestId("card-select-option-Website")).toBeVisible();
866
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
867
+ });
868
+
869
+ test("a search reaches into closed groups", () => {
870
+ renderComponent({ collapsibleGroups: true, searchable: true });
871
+
872
+ typeSearch("k8s");
873
+
874
+ expect(screen.getByTestId("card-select-option-Kubernetes")).toBeVisible();
875
+ });
876
+
877
+ test("clearing the search puts the groups back as they were", () => {
878
+ renderComponent({ collapsibleGroups: true, searchable: true });
879
+
880
+ typeSearch("k8s");
881
+ fireEvent.click(screen.getByTestId("card-select-search-clear"));
882
+
883
+ expect(screen.getByTestId("card-select-option-Website")).toBeVisible();
884
+ expect(
885
+ screen.queryByTestId("card-select-option-Kubernetes"),
886
+ ).not.toBeInTheDocument();
887
+ });
888
+ });
889
+
890
+ describe("getCardSelectSearchTokens", () => {
891
+ test("splits into lower case words, ignoring runs of whitespace", () => {
892
+ expect(getCardSelectSearchTokens(" SSL Cert ")).toEqual([
893
+ "ssl",
894
+ "cert",
895
+ ]);
896
+ });
897
+
898
+ test("is empty when nothing was typed", () => {
899
+ expect(getCardSelectSearchTokens("")).toEqual([]);
900
+ expect(getCardSelectSearchTokens(" ")).toEqual([]);
901
+ });
902
+ });
903
+
904
+ describe("cardSelectOptionMatchesSearch", () => {
905
+ test("matches everything when nothing was typed", () => {
906
+ expect(cardSelectOptionMatchesSearch(website, [])).toBe(true);
907
+ });
908
+
909
+ test("matches on a keyword", () => {
910
+ expect(cardSelectOptionMatchesSearch(kubernetes, ["k8s"])).toBe(true);
911
+ });
912
+
913
+ test("matches on the group label when one is given", () => {
914
+ expect(
915
+ cardSelectOptionMatchesSearch(kubernetes, ["infra"], "Infrastructure"),
916
+ ).toBe(true);
917
+ });
918
+
919
+ test("does not match on a group label that was not given", () => {
920
+ expect(cardSelectOptionMatchesSearch(kubernetes, ["infra"])).toBe(false);
921
+ });
922
+
923
+ test("requires every word", () => {
924
+ expect(cardSelectOptionMatchesSearch(kubernetes, ["k8s", "pod"])).toBe(
925
+ true,
926
+ );
927
+ expect(
928
+ cardSelectOptionMatchesSearch(kubernetes, ["k8s", "mainframe"]),
929
+ ).toBe(false);
930
+ });
931
+
932
+ test("copes with an option carrying no keywords at all", () => {
933
+ expect(cardSelectOptionMatchesSearch(manual, ["manual"])).toBe(true);
934
+ expect(cardSelectOptionMatchesSearch(manual, ["k8s"])).toBe(false);
935
+ });
936
+ });
937
+
938
+ describe("getCardSelectOptionSearchScore", () => {
939
+ test("scores nothing when nothing was typed", () => {
940
+ expect(getCardSelectOptionSearchScore(website, [])).toBe(0);
941
+ });
942
+
943
+ test("scores an exact title above a title it merely starts", () => {
944
+ const exact: number = getCardSelectOptionSearchScore(website, [
945
+ "website",
946
+ ]);
947
+ const prefix: number = getCardSelectOptionSearchScore(website, ["web"]);
948
+
949
+ expect(exact).toBeGreaterThan(prefix);
950
+ });
951
+
952
+ test("scores an exact keyword above a keyword it merely starts", () => {
953
+ const exact: number = getCardSelectOptionSearchScore(sqlQuery, [
954
+ "postgres",
955
+ ]);
956
+ const prefix: number = getCardSelectOptionSearchScore(sqlQuery, ["post"]);
957
+
958
+ expect(exact).toBeGreaterThan(prefix);
959
+ });
960
+
961
+ test("adds up the words typed", () => {
962
+ const oneWord: number = getCardSelectOptionSearchScore(kubernetes, [
963
+ "k8s",
964
+ ]);
965
+ const twoWords: number = getCardSelectOptionSearchScore(kubernetes, [
966
+ "k8s",
967
+ "pod",
968
+ ]);
969
+
970
+ expect(twoWords).toBeGreaterThan(oneWord);
971
+ });
972
+
973
+ test("counts the group label when one is given", () => {
974
+ const withLabel: number = getCardSelectOptionSearchScore(
975
+ kubernetes,
976
+ ["infrastructure"],
977
+ "Infrastructure",
978
+ );
979
+
980
+ expect(withLabel).toBeGreaterThan(0);
981
+ expect(
982
+ getCardSelectOptionSearchScore(kubernetes, ["infrastructure"]),
983
+ ).toBe(0);
984
+ });
985
+ });
986
+
987
+ describe("normalizeCardSelectGroups", () => {
988
+ test("keeps groups as they are", () => {
989
+ expect(normalizeCardSelectGroups([basicGroup])).toEqual([
990
+ { label: "Basic Monitoring", options: [website] },
991
+ ]);
992
+ });
993
+
994
+ test("gathers a run of flat options into one unlabelled group", () => {
995
+ expect(normalizeCardSelectGroups([website, kubernetes])).toEqual([
996
+ { label: null, options: [website, kubernetes] },
997
+ ]);
998
+ });
999
+
1000
+ test("flushes the flat options before each group it precedes", () => {
1001
+ expect(
1002
+ normalizeCardSelectGroups([manual, basicGroup, kubernetes]),
1003
+ ).toEqual([
1004
+ { label: null, options: [manual] },
1005
+ { label: "Basic Monitoring", options: [website] },
1006
+ { label: null, options: [kubernetes] },
1007
+ ]);
1008
+ });
1009
+
1010
+ test("returns nothing for nothing", () => {
1011
+ expect(normalizeCardSelectGroups([])).toEqual([]);
1012
+ });
1013
+ });
1014
+
1015
+ describe("isCardSelectOptionGroup", () => {
1016
+ test("tells a group from an option", () => {
1017
+ expect(isCardSelectOptionGroup(basicGroup)).toBe(true);
1018
+ expect(isCardSelectOptionGroup(website)).toBe(false);
1019
+ });
1020
+ });
1021
+ });