@oneuptime/common 14.0.5 → 14.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -0,0 +1,1267 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ screen,
16
+ waitFor,
17
+ within,
18
+ } from "@testing-library/react";
19
+ import React, { ReactElement } from "react";
20
+ import getJestMockFunction, { MockFunction } from "../../MockType";
21
+ import Dictionary from "../../../Types/Dictionary";
22
+ import { JSONObject } from "../../../Types/JSON";
23
+ import ObjectID from "../../../Types/ObjectID";
24
+ import NetworkTopologyExplorer, {
25
+ ComponentProps as ExplorerProps,
26
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/NetworkTopologyExplorer";
27
+ import { ComponentProps as LiveViewProps } from "../../../../App/FeatureSet/Dashboard/src/Components/Topology/NetworkTopologyLiveView";
28
+
29
+ /*
30
+ * Issue #3981 — the Device Topology explorer's search box, rendered for real.
31
+ *
32
+ * The customer's complaint was that the box only filtered the level on
33
+ * screen: finding "Unit 104822" meant knowing which region and which market
34
+ * to open first, which is exactly the thing somebody searching does not
35
+ * know. The fix mounts the Network Map's SiteSearchBox in the explorer, so
36
+ * one box now gives two answers — typing still narrows this level's cards,
37
+ * and the dropdown lists matches from ANYWHERE in the hierarchy, each with
38
+ * the path to it, and picking one drills straight there.
39
+ *
40
+ * Everything between the keystroke and the drill is real here: the explorer,
41
+ * the search box, the Input underneath it, the cards, the breadcrumb and the
42
+ * health chips. Only the edges are stubbed — the HTTP layer answers
43
+ * /network-site/children and /network-site/search from an in-memory
44
+ * hierarchy, the URL helper records what the explorer writes, and the device
45
+ * graph is replaced by a stub that prints the scope it was handed. That
46
+ * keeps the assertions about behaviour a reader can see (which cards, which
47
+ * level, which graph) rather than about which setter ran.
48
+ */
49
+
50
+ type PostOptions = {
51
+ url: { toString: () => string };
52
+ data?: JSONObject | undefined;
53
+ headers?: Dictionary<string> | undefined;
54
+ };
55
+
56
+ const postMock: MockFunction = getJestMockFunction();
57
+ const setQueryStringMock: MockFunction = getJestMockFunction();
58
+ const getQueryStringByNameMock: MockFunction = getJestMockFunction();
59
+ const navigateMock: MockFunction = getJestMockFunction();
60
+
61
+ jest.mock("../../../UI/Utils/API/API", () => {
62
+ return {
63
+ __esModule: true,
64
+ default: {
65
+ post: (...args: Array<unknown>): unknown => {
66
+ return postMock(...args);
67
+ },
68
+ getFriendlyMessage: (error: unknown): string => {
69
+ return error instanceof Error ? error.message : "Request failed";
70
+ },
71
+ },
72
+ };
73
+ });
74
+
75
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
76
+ return {
77
+ __esModule: true,
78
+ default: {
79
+ getCommonHeaders: (): Dictionary<string> => {
80
+ return { tenantid: "project-3981" };
81
+ },
82
+ },
83
+ };
84
+ });
85
+
86
+ jest.mock("../../../UI/Utils/Navigation", () => {
87
+ return {
88
+ __esModule: true,
89
+ default: {
90
+ getQueryStringByName: (...args: Array<unknown>): unknown => {
91
+ return getQueryStringByNameMock(...args);
92
+ },
93
+ setQueryString: (...args: Array<unknown>): unknown => {
94
+ return setQueryStringMock(...args);
95
+ },
96
+ navigate: (...args: Array<unknown>): unknown => {
97
+ return navigateMock(...args);
98
+ },
99
+ },
100
+ };
101
+ });
102
+
103
+ jest.mock("../../../UI/Utils/Project", () => {
104
+ return {
105
+ __esModule: true,
106
+ default: {
107
+ getCurrentProjectId: (): ObjectID => {
108
+ return new ObjectID("dde060c6-fe0d-49ce-b44c-4035a13bc1db");
109
+ },
110
+ },
111
+ };
112
+ });
113
+
114
+ jest.mock("react-i18next", () => {
115
+ return {
116
+ useTranslation: () => {
117
+ return {
118
+ t: (value: string): string => {
119
+ return value;
120
+ },
121
+ };
122
+ },
123
+ };
124
+ });
125
+
126
+ /*
127
+ * The device graph is its own component with its own tests. Here it only
128
+ * has to say which site it was scoped to and which layout it opened on —
129
+ * the two things the drill decides.
130
+ */
131
+ jest.mock(
132
+ "../../../../App/FeatureSet/Dashboard/src/Components/Topology/NetworkTopologyLiveView",
133
+ () => {
134
+ return {
135
+ __esModule: true,
136
+ default: (props: LiveViewProps): ReactElement => {
137
+ return (
138
+ <div
139
+ data-testid="live-view-stub"
140
+ data-site-id={props.siteId || ""}
141
+ data-layout-mode={props.layoutMode || ""}
142
+ >
143
+ {`siteId=${props.siteId || "none"} layoutMode=${
144
+ props.layoutMode || "none"
145
+ }`}
146
+ </div>
147
+ );
148
+ },
149
+ };
150
+ },
151
+ );
152
+
153
+ /*
154
+ * ------------------------------------------------------------------
155
+ * An in-memory hierarchy: Franchise A > Region East > Market 5 > Unit
156
+ * 104822, plus two sibling regions so the root level has something to
157
+ * narrow and a second branch to drill sideways into.
158
+ * ------------------------------------------------------------------
159
+ */
160
+
161
+ const FRANCHISE_ID: string = "franchise-a";
162
+ const REGION_EAST_ID: string = "region-east";
163
+ const REGION_WEST_ID: string = "region-west";
164
+ const REGION_CENTRAL_ID: string = "region-central";
165
+ const MARKET_ID: string = "market-5";
166
+ const MARKET_WEST_ID: string = "market-9";
167
+ const UNIT_ID: string = "unit-104822";
168
+ const OTHER_UNIT_ID: string = "unit-200";
169
+
170
+ const UNIT_NAME: string = "Unit 104822 - Michigan Ave";
171
+ const UNIT_PATH: string = "Franchise A / Region East / Market 5";
172
+
173
+ const SEARCH_ID: string = "topology-hierarchy-search";
174
+
175
+ function deviceStats(input: {
176
+ down?: number;
177
+ degraded?: number;
178
+ healthy?: number;
179
+ }): JSONObject {
180
+ const down: number = input.down || 0;
181
+ const degraded: number = input.degraded || 0;
182
+ const healthy: number = input.healthy || 0;
183
+ return {
184
+ down: down,
185
+ degraded: degraded,
186
+ healthy: healthy,
187
+ unknown: 0,
188
+ total: down + degraded + healthy,
189
+ };
190
+ }
191
+
192
+ function childRow(input: {
193
+ id: string;
194
+ name: string;
195
+ siteType: string;
196
+ isUnitLevel?: boolean;
197
+ down?: number;
198
+ healthy?: number;
199
+ }): JSONObject {
200
+ const stats: JSONObject = deviceStats({
201
+ down: input.down || 0,
202
+ healthy: input.healthy || 0,
203
+ });
204
+ return {
205
+ id: input.id,
206
+ name: input.name,
207
+ siteType: input.siteType,
208
+ isUnitLevel: input.isUnitLevel === true,
209
+ childSiteCount: input.isUnitLevel ? 0 : 1,
210
+ deviceCount: stats["total"] as number,
211
+ deviceStats: stats,
212
+ unitStats: { totalUnits: 0, operationalUnits: 0 },
213
+ uptimePercent: null,
214
+ dailyUptimePercent: null,
215
+ isUnderMaintenance: false,
216
+ };
217
+ }
218
+
219
+ function crumb(
220
+ id: string,
221
+ name: string,
222
+ siteType: string,
223
+ isUnitLevel: boolean = false,
224
+ ): JSONObject {
225
+ return { id: id, name: name, siteType: siteType, isUnitLevel: isUnitLevel };
226
+ }
227
+
228
+ /*
229
+ * attachedDeviceCount must be above zero on every level: at the root a
230
+ * hierarchy nobody attached a device to falls back to the flat map, and
231
+ * this suite is about the hierarchy.
232
+ */
233
+ function level(
234
+ breadcrumb: Array<JSONObject>,
235
+ children: Array<JSONObject>,
236
+ ownDeviceStats: JSONObject = deviceStats({}),
237
+ ): JSONObject {
238
+ return {
239
+ breadcrumb: breadcrumb,
240
+ children: children,
241
+ links: [],
242
+ ownDeviceStats: ownDeviceStats,
243
+ deviceScope: { attachedDeviceCount: 24, unattachedDeviceCount: 0 },
244
+ childrenTruncated: false,
245
+ descendantCountsTruncated: false,
246
+ };
247
+ }
248
+
249
+ const ROOT_LEVEL: JSONObject = level(
250
+ [],
251
+ [
252
+ childRow({
253
+ id: REGION_CENTRAL_ID,
254
+ name: "Region Central",
255
+ siteType: "Region",
256
+ healthy: 3,
257
+ }),
258
+ childRow({
259
+ id: REGION_EAST_ID,
260
+ name: "Region East",
261
+ siteType: "Region",
262
+ healthy: 10,
263
+ }),
264
+ // The one region with a device down, so the "Down" chip has a match.
265
+ childRow({
266
+ id: REGION_WEST_ID,
267
+ name: "Region West",
268
+ siteType: "Region",
269
+ down: 2,
270
+ healthy: 5,
271
+ }),
272
+ ],
273
+ );
274
+
275
+ const MARKET_BREADCRUMB: Array<JSONObject> = [
276
+ crumb(FRANCHISE_ID, "Franchise A", "Franchise"),
277
+ crumb(REGION_EAST_ID, "Region East", "Region"),
278
+ crumb(MARKET_ID, "Market 5", "Market"),
279
+ ];
280
+
281
+ const LEVELS: Dictionary<JSONObject> = {
282
+ [REGION_EAST_ID]: level(
283
+ [
284
+ crumb(FRANCHISE_ID, "Franchise A", "Franchise"),
285
+ crumb(REGION_EAST_ID, "Region East", "Region"),
286
+ ],
287
+ [childRow({ id: MARKET_ID, name: "Market 5", siteType: "Market" })],
288
+ ),
289
+ [REGION_WEST_ID]: level(
290
+ [
291
+ crumb(FRANCHISE_ID, "Franchise A", "Franchise"),
292
+ crumb(REGION_WEST_ID, "Region West", "Region"),
293
+ ],
294
+ [
295
+ childRow({
296
+ id: MARKET_WEST_ID,
297
+ name: "Market 9",
298
+ siteType: "Market",
299
+ down: 2,
300
+ }),
301
+ ],
302
+ ),
303
+ [MARKET_ID]: level(MARKET_BREADCRUMB, [
304
+ childRow({
305
+ id: UNIT_ID,
306
+ name: UNIT_NAME,
307
+ siteType: "Unit",
308
+ isUnitLevel: true,
309
+ down: 1,
310
+ }),
311
+ childRow({
312
+ id: OTHER_UNIT_ID,
313
+ name: "Unit 200 - State St",
314
+ siteType: "Unit",
315
+ isUnitLevel: true,
316
+ healthy: 4,
317
+ }),
318
+ ]),
319
+ [UNIT_ID]: level(
320
+ [...MARKET_BREADCRUMB, crumb(UNIT_ID, UNIT_NAME, "Unit", true)],
321
+ [],
322
+ deviceStats({ down: 1, healthy: 3 }),
323
+ ),
324
+ };
325
+
326
+ interface IndexedSite {
327
+ id: string;
328
+ name: string;
329
+ siteType: string;
330
+ isUnitLevel: boolean;
331
+ path: string;
332
+ }
333
+
334
+ const SEARCH_INDEX: Array<IndexedSite> = [
335
+ {
336
+ id: FRANCHISE_ID,
337
+ name: "Franchise A",
338
+ siteType: "Franchise",
339
+ isUnitLevel: false,
340
+ path: "",
341
+ },
342
+ {
343
+ id: REGION_CENTRAL_ID,
344
+ name: "Region Central",
345
+ siteType: "Region",
346
+ isUnitLevel: false,
347
+ path: "Franchise A",
348
+ },
349
+ {
350
+ id: REGION_EAST_ID,
351
+ name: "Region East",
352
+ siteType: "Region",
353
+ isUnitLevel: false,
354
+ path: "Franchise A",
355
+ },
356
+ {
357
+ id: REGION_WEST_ID,
358
+ name: "Region West",
359
+ siteType: "Region",
360
+ isUnitLevel: false,
361
+ path: "Franchise A",
362
+ },
363
+ {
364
+ id: MARKET_ID,
365
+ name: "Market 5",
366
+ siteType: "Market",
367
+ isUnitLevel: false,
368
+ path: "Franchise A / Region East",
369
+ },
370
+ {
371
+ id: UNIT_ID,
372
+ name: UNIT_NAME,
373
+ siteType: "Unit",
374
+ isUnitLevel: true,
375
+ path: UNIT_PATH,
376
+ },
377
+ {
378
+ id: OTHER_UNIT_ID,
379
+ name: "Unit 200 - State St",
380
+ siteType: "Unit",
381
+ isUnitLevel: true,
382
+ path: UNIT_PATH,
383
+ },
384
+ ];
385
+
386
+ /*
387
+ * The server's rule, in miniature: every word, anywhere in the name. The
388
+ * word "unit" is also made to hit the result cap so the truncation line has
389
+ * a case of its own.
390
+ */
391
+ function searchFixture(searchText: string): JSONObject {
392
+ const words: Array<string> = searchText
393
+ .toLowerCase()
394
+ .split(/\s+/)
395
+ .filter((word: string): boolean => {
396
+ return word.length > 0;
397
+ });
398
+ const results: Array<JSONObject> = SEARCH_INDEX.filter(
399
+ (site: IndexedSite): boolean => {
400
+ return (
401
+ words.length > 0 &&
402
+ words.every((word: string): boolean => {
403
+ return site.name.toLowerCase().includes(word);
404
+ })
405
+ );
406
+ },
407
+ ).map((site: IndexedSite): JSONObject => {
408
+ return {
409
+ id: site.id,
410
+ name: site.name,
411
+ siteType: site.siteType,
412
+ isUnitLevel: site.isUnitLevel,
413
+ path: site.path,
414
+ currentMonitorStatus:
415
+ site.id === UNIT_ID
416
+ ? {
417
+ id: "status-operational",
418
+ name: "Operational",
419
+ color: "#16a34a",
420
+ priority: 1,
421
+ isOperationalState: true,
422
+ }
423
+ : undefined,
424
+ } as JSONObject;
425
+ });
426
+ return { results: results, isTruncated: searchText.trim() === "unit" };
427
+ }
428
+
429
+ function routeOf(options: PostOptions): string {
430
+ return options.url.toString();
431
+ }
432
+
433
+ function defaultPost(options: PostOptions): Promise<unknown> {
434
+ const route: string = routeOf(options);
435
+ if (route.includes("/network-site/children")) {
436
+ const siteId: string | undefined = options.data?.["siteId"] as
437
+ | string
438
+ | undefined;
439
+ const body: JSONObject | undefined = siteId ? LEVELS[siteId] : ROOT_LEVEL;
440
+ if (!body) {
441
+ return Promise.reject(new Error(`No level fixture for ${siteId}`));
442
+ }
443
+ return Promise.resolve({ data: body });
444
+ }
445
+ if (route.includes("/network-site/search")) {
446
+ return Promise.resolve({
447
+ data: searchFixture(String(options.data?.["searchText"] || "")),
448
+ });
449
+ }
450
+ return Promise.reject(new Error(`Unexpected request to ${route}`));
451
+ }
452
+
453
+ function postsTo(route: string): Array<PostOptions> {
454
+ return postMock.mock.calls
455
+ .map((call: Array<unknown>): PostOptions => {
456
+ return call[0] as PostOptions;
457
+ })
458
+ .filter((options: PostOptions): boolean => {
459
+ return routeOf(options).includes(route);
460
+ });
461
+ }
462
+
463
+ function childrenPosts(): Array<PostOptions> {
464
+ return postsTo("/network-site/children");
465
+ }
466
+
467
+ function searchPosts(): Array<PostOptions> {
468
+ return postsTo("/network-site/search");
469
+ }
470
+
471
+ function searchInput(): HTMLInputElement {
472
+ return screen.getByTestId(SEARCH_ID) as HTMLInputElement;
473
+ }
474
+
475
+ function typeSearch(value: string): void {
476
+ const input: HTMLInputElement = searchInput();
477
+ fireEvent.focus(input);
478
+ fireEvent.change(input, { target: { value: value } });
479
+ }
480
+
481
+ function resultCountText(): string {
482
+ return (
483
+ screen.getByTestId("topology-hierarchy-result-count").textContent || ""
484
+ );
485
+ }
486
+
487
+ function waitMs(ms: number): Promise<void> {
488
+ return new Promise<void>((resolve: () => void) => {
489
+ setTimeout(resolve, ms);
490
+ });
491
+ }
492
+
493
+ async function renderExplorer(
494
+ props: ExplorerProps = {},
495
+ firstCardId: string = REGION_EAST_ID,
496
+ ): Promise<void> {
497
+ render(<NetworkTopologyExplorer {...props} />);
498
+ await screen.findByTestId(`site-card-${firstCardId}`);
499
+ }
500
+
501
+ // Type the query and wait for the debounced dropdown to answer it.
502
+ async function searchFor(value: string, hitId: string): Promise<HTMLElement> {
503
+ typeSearch(value);
504
+ return screen.findByTestId(`${SEARCH_ID}-result-${hitId}`);
505
+ }
506
+
507
+ beforeEach(() => {
508
+ postMock.mockReset();
509
+ postMock.mockImplementation((...args: Array<unknown>): unknown => {
510
+ return defaultPost(args[0] as PostOptions);
511
+ });
512
+ setQueryStringMock.mockReset();
513
+ navigateMock.mockReset();
514
+ getQueryStringByNameMock.mockReset();
515
+ getQueryStringByNameMock.mockReturnValue(null);
516
+ });
517
+
518
+ afterEach(() => {
519
+ cleanup();
520
+ jest.restoreAllMocks();
521
+ });
522
+
523
+ /*
524
+ * The box itself: the explorer mounts the shared SiteSearchBox under its
525
+ * own test-id prefix and copy, and hides the box's local count because the
526
+ * explorer already prints one beside it.
527
+ */
528
+ describe("the explorer mounts the hierarchy-wide search box", () => {
529
+ test("the root renders its region cards and the new search box", async () => {
530
+ await renderExplorer();
531
+
532
+ expect(screen.getByTestId(`site-card-${REGION_EAST_ID}`)).toBeVisible();
533
+ expect(screen.getByTestId(`site-card-${REGION_WEST_ID}`)).toBeVisible();
534
+ expect(screen.getByTestId(`site-card-${REGION_CENTRAL_ID}`)).toBeVisible();
535
+
536
+ const input: HTMLInputElement = searchInput();
537
+ expect(input).toHaveAttribute(
538
+ "placeholder",
539
+ "Search sites by name — anywhere in your network",
540
+ );
541
+ expect(input).toHaveAttribute(
542
+ "aria-label",
543
+ "Search sites by name — anywhere in your network",
544
+ );
545
+ expect(input).toHaveValue("");
546
+ expect(resultCountText()).toBe("3 of 3 regions · Updates every minute");
547
+
548
+ // The root level was the only thing fetched: nothing searched yet.
549
+ expect(childrenPosts()).toHaveLength(1);
550
+ expect(childrenPosts()[0]!.data).toEqual({});
551
+ expect(searchPosts()).toHaveLength(0);
552
+ });
553
+
554
+ /*
555
+ * The Network Map keeps the default "network-map-search" prefix; the
556
+ * explorer must never render under it, or two pages' tests (and any
557
+ * automation built on them) would be reading each other's ids.
558
+ */
559
+ test("every id the box renders uses the explorer's prefix", async () => {
560
+ await renderExplorer();
561
+ await searchFor("104822", UNIT_ID);
562
+
563
+ expect(screen.getByTestId(`${SEARCH_ID}-results`)).toBeVisible();
564
+ expect(screen.getByTestId(`${SEARCH_ID}-clear`)).toBeVisible();
565
+ expect(screen.queryByTestId("network-map-search")).not.toBeInTheDocument();
566
+ expect(
567
+ screen.queryByTestId("network-map-search-results"),
568
+ ).not.toBeInTheDocument();
569
+ expect(
570
+ screen.queryByTestId(`network-map-search-result-${UNIT_ID}`),
571
+ ).not.toBeInTheDocument();
572
+ });
573
+
574
+ test("the box is a combobox wired to its listbox", async () => {
575
+ await renderExplorer();
576
+ const input: HTMLInputElement = searchInput();
577
+
578
+ expect(input).toHaveAttribute("role", "combobox");
579
+ expect(input).toHaveAttribute("aria-autocomplete", "list");
580
+ expect(input).toHaveAttribute("aria-haspopup", "listbox");
581
+ expect(input).toHaveAttribute("aria-expanded", "false");
582
+ // aria-controls only while there is something to control.
583
+ expect(input).not.toHaveAttribute("aria-controls");
584
+ expect(input).not.toHaveAttribute("aria-activedescendant");
585
+
586
+ const hit: HTMLElement = await searchFor("104822", UNIT_ID);
587
+
588
+ expect(input).toHaveAttribute("aria-expanded", "true");
589
+ expect(input).toHaveAttribute("aria-controls", `${SEARCH_ID}-listbox`);
590
+ const listbox: HTMLElement = screen.getByRole("listbox", {
591
+ name: "Site search results",
592
+ });
593
+ expect(listbox).toHaveAttribute("id", `${SEARCH_ID}-listbox`);
594
+ expect(hit).toHaveAttribute("id", `${SEARCH_ID}-option-${UNIT_ID}`);
595
+ expect(hit).toHaveAttribute("role", "option");
596
+ expect(hit).toHaveAttribute("aria-selected", "false");
597
+
598
+ fireEvent.keyDown(input, { key: "ArrowDown" });
599
+ expect(input).toHaveAttribute(
600
+ "aria-activedescendant",
601
+ `${SEARCH_ID}-option-${UNIT_ID}`,
602
+ );
603
+ expect(hit).toHaveAttribute("aria-selected", "true");
604
+ });
605
+
606
+ /*
607
+ * The explorer prints "x of y regions" to the right of the box already.
608
+ * The box's own "Showing x of y at this level" line would say the same
609
+ * thing twice, one line apart.
610
+ */
611
+ test("the box's own local-count line is not rendered", async () => {
612
+ await renderExplorer();
613
+ typeSearch("east");
614
+
615
+ expect(resultCountText()).toBe("1 of 3 regions · Updates every minute");
616
+ expect(
617
+ screen.queryByTestId(`${SEARCH_ID}-local-count`),
618
+ ).not.toBeInTheDocument();
619
+ expect(
620
+ screen.queryByTestId("network-map-search-local-count"),
621
+ ).not.toBeInTheDocument();
622
+ expect(screen.queryByText(/at this level$/)).not.toBeInTheDocument();
623
+ });
624
+ });
625
+
626
+ /*
627
+ * The customer's actual scenario, end to end: a unit number typed at the
628
+ * root, where no card matches it, found four levels down and opened in one
629
+ * click.
630
+ */
631
+ describe("finding a unit from the root", () => {
632
+ test("typing a unit number empties the level and lists the unit with its path", async () => {
633
+ await renderExplorer();
634
+ typeSearch("104822");
635
+
636
+ // The local half answers at once: nothing at this level matches.
637
+ expect(resultCountText()).toBe("0 of 3 regions · Updates every minute");
638
+ expect(
639
+ screen.queryByTestId(`site-card-${REGION_EAST_ID}`),
640
+ ).not.toBeInTheDocument();
641
+ expect(screen.getByText("No sites match your filters")).toBeVisible();
642
+
643
+ // The remote half is debounced, so nothing has been asked for yet.
644
+ expect(searchPosts()).toHaveLength(0);
645
+
646
+ const hit: HTMLElement = await screen.findByTestId(
647
+ `${SEARCH_ID}-result-${UNIT_ID}`,
648
+ );
649
+ expect(hit).toHaveTextContent(UNIT_NAME);
650
+ expect(hit).toHaveTextContent(UNIT_PATH);
651
+ expect(hit).toHaveTextContent("Unit");
652
+ expect(screen.getByText(UNIT_PATH)).toBeVisible();
653
+
654
+ expect(searchPosts()).toHaveLength(1);
655
+ expect(searchPosts()[0]!.data).toEqual({ searchText: "104822" });
656
+ });
657
+
658
+ test("clicking the unit opens its device topology, scoped and tiered", async () => {
659
+ await renderExplorer();
660
+ const hit: HTMLElement = await searchFor("104822", UNIT_ID);
661
+ const childrenBefore: number = childrenPosts().length;
662
+
663
+ fireEvent.click(hit);
664
+
665
+ const stub: HTMLElement = await screen.findByTestId("live-view-stub");
666
+ expect(stub).toHaveAttribute("data-site-id", UNIT_ID);
667
+ expect(stub).toHaveAttribute("data-layout-mode", "tiered");
668
+ expect(stub).toHaveTextContent(`siteId=${UNIT_ID} layoutMode=tiered`);
669
+
670
+ // Exactly one new level fetch, for the unit that was picked.
671
+ expect(childrenPosts()).toHaveLength(childrenBefore + 1);
672
+ expect(childrenPosts()[childrenPosts().length - 1]!.data).toEqual({
673
+ siteId: UNIT_ID,
674
+ });
675
+ expect(childrenPosts()[childrenPosts().length - 1]!.headers).toMatchObject({
676
+ tenantid: "project-3981",
677
+ });
678
+
679
+ // The level and its box are gone; the breadcrumb names the whole path.
680
+ expect(screen.queryByTestId(SEARCH_ID)).not.toBeInTheDocument();
681
+ const trail: HTMLElement = screen.getByRole("navigation", {
682
+ name: "Site breadcrumb",
683
+ });
684
+ expect(within(trail).getByText("Franchise A")).toBeVisible();
685
+ expect(within(trail).getByText("Region East")).toBeVisible();
686
+ expect(within(trail).getByText("Market 5")).toBeVisible();
687
+ expect(within(trail).getByText(UNIT_NAME)).toHaveAttribute(
688
+ "aria-current",
689
+ "page",
690
+ );
691
+ });
692
+
693
+ test("the drill is mirrored to the URL once the 200 ms debounce passes", async () => {
694
+ await renderExplorer();
695
+ const hit: HTMLElement = await searchFor("104822", UNIT_ID);
696
+
697
+ fireEvent.click(hit);
698
+ // Not synchronously: the write is debounced.
699
+ expect(setQueryStringMock).not.toHaveBeenCalled();
700
+
701
+ await waitFor(() => {
702
+ expect(setQueryStringMock).toHaveBeenCalledWith({
703
+ topologySite: UNIT_ID,
704
+ topologyDevices: null,
705
+ });
706
+ });
707
+ expect(setQueryStringMock).toHaveBeenCalledTimes(1);
708
+ });
709
+
710
+ test("a page opened with a device layout of its own keeps it for the picked unit", async () => {
711
+ await renderExplorer({ siteLayoutMode: "force" });
712
+ fireEvent.click(await searchFor("104822", UNIT_ID));
713
+
714
+ const stub: HTMLElement = await screen.findByTestId("live-view-stub");
715
+ expect(stub).toHaveAttribute("data-site-id", UNIT_ID);
716
+ expect(stub).toHaveAttribute("data-layout-mode", "force");
717
+ });
718
+
719
+ /*
720
+ * The box clears on the way through. Arriving back at a level with the
721
+ * old question still typed into it would leave that level filtered by a
722
+ * unit number that matches nothing on it.
723
+ */
724
+ test("walking back up from the unit finds an empty box and a full level", async () => {
725
+ await renderExplorer();
726
+ fireEvent.click(await searchFor("104822", UNIT_ID));
727
+ await screen.findByTestId("live-view-stub");
728
+
729
+ const trail: HTMLElement = screen.getByRole("navigation", {
730
+ name: "Site breadcrumb",
731
+ });
732
+ fireEvent.click(within(trail).getByText("Market 5"));
733
+
734
+ await screen.findByTestId(`site-card-${UNIT_ID}`);
735
+ expect(searchInput()).toHaveValue("");
736
+ expect(screen.getByTestId(`site-card-${OTHER_UNIT_ID}`)).toBeVisible();
737
+ expect(resultCountText()).toBe("2 of 2 units · Updates every minute");
738
+ });
739
+ });
740
+
741
+ /*
742
+ * Not every hit is a unit. A region or a market opens its own level of
743
+ * cards, exactly as clicking its card would have — the search only skips
744
+ * the levels in between.
745
+ */
746
+ describe("picking a mid-level site", () => {
747
+ test("a market hit renders that market's level with its breadcrumb", async () => {
748
+ await renderExplorer();
749
+ const hit: HTMLElement = await searchFor("market 5", MARKET_ID);
750
+ expect(hit).toHaveTextContent("Franchise A / Region East");
751
+
752
+ fireEvent.click(hit);
753
+
754
+ await screen.findByTestId(`site-card-${UNIT_ID}`);
755
+ expect(screen.getByTestId(`site-card-${OTHER_UNIT_ID}`)).toBeVisible();
756
+ expect(screen.queryByTestId("live-view-stub")).not.toBeInTheDocument();
757
+ expect(
758
+ screen.getByText(
759
+ "Explore the units in Market 5. Each card includes the health of the devices below it.",
760
+ ),
761
+ ).toBeVisible();
762
+ expect(resultCountText()).toBe("2 of 2 units · Updates every minute");
763
+
764
+ const trail: HTMLElement = screen.getByRole("navigation", {
765
+ name: "Site breadcrumb",
766
+ });
767
+ expect(within(trail).getByTestId("site-breadcrumb-root")).toBeVisible();
768
+ expect(within(trail).getByText("Franchise A").tagName).toBe("BUTTON");
769
+ expect(within(trail).getByText("Region East").tagName).toBe("BUTTON");
770
+ expect(within(trail).getByText("Market 5")).toHaveAttribute(
771
+ "aria-current",
772
+ "page",
773
+ );
774
+
775
+ expect(childrenPosts()[childrenPosts().length - 1]!.data).toEqual({
776
+ siteId: MARKET_ID,
777
+ });
778
+ });
779
+
780
+ test("the destination level's box is cleared and its dropdown closed", async () => {
781
+ await renderExplorer();
782
+ fireEvent.click(await searchFor("market 5", MARKET_ID));
783
+ await screen.findByTestId(`site-card-${UNIT_ID}`);
784
+
785
+ expect(searchInput()).toHaveValue("");
786
+ expect(searchInput()).toHaveAttribute("aria-expanded", "false");
787
+ expect(
788
+ screen.queryByTestId(`${SEARCH_ID}-results`),
789
+ ).not.toBeInTheDocument();
790
+ expect(
791
+ screen.queryByTestId("topology-hierarchy-reset-filters"),
792
+ ).not.toBeInTheDocument();
793
+ });
794
+
795
+ test("a hit in another branch drills sideways from a drilled level", async () => {
796
+ getQueryStringByNameMock.mockImplementation((name: unknown): unknown => {
797
+ return name === "topologySite" ? MARKET_ID : null;
798
+ });
799
+ await renderExplorer({}, UNIT_ID);
800
+
801
+ const hit: HTMLElement = await searchFor("region west", REGION_WEST_ID);
802
+ fireEvent.click(hit);
803
+
804
+ await screen.findByTestId(`site-card-${MARKET_WEST_ID}`);
805
+ const trail: HTMLElement = screen.getByRole("navigation", {
806
+ name: "Site breadcrumb",
807
+ });
808
+ expect(within(trail).getByText("Region West")).toHaveAttribute(
809
+ "aria-current",
810
+ "page",
811
+ );
812
+ expect(within(trail).queryByText("Market 5")).not.toBeInTheDocument();
813
+ await waitFor(() => {
814
+ expect(setQueryStringMock).toHaveBeenCalledWith({
815
+ topologySite: REGION_WEST_ID,
816
+ topologyDevices: null,
817
+ });
818
+ });
819
+ });
820
+
821
+ /*
822
+ * changeSite no-ops on an unchanged target. Picking the site already in
823
+ * view therefore only closes the question: the box clears, no level is
824
+ * refetched and the URL is left alone.
825
+ */
826
+ test("picking the site already in view refetches nothing", async () => {
827
+ getQueryStringByNameMock.mockImplementation((name: unknown): unknown => {
828
+ return name === "topologySite" ? MARKET_ID : null;
829
+ });
830
+ await renderExplorer({}, UNIT_ID);
831
+ expect(childrenPosts()).toHaveLength(1);
832
+
833
+ fireEvent.click(await searchFor("market 5", MARKET_ID));
834
+
835
+ expect(searchInput()).toHaveValue("");
836
+ expect(screen.getByTestId(`site-card-${UNIT_ID}`)).toBeVisible();
837
+ await waitMs(300);
838
+ expect(childrenPosts()).toHaveLength(1);
839
+ expect(setQueryStringMock).not.toHaveBeenCalled();
840
+ });
841
+ });
842
+
843
+ /*
844
+ * The local half of the box, which is what the explorer's search did
845
+ * before issue #3981 and must keep doing: typing narrows the cards of the
846
+ * level in view on every keystroke.
847
+ */
848
+ describe("typing still narrows the level in view", () => {
849
+ test("a region name narrows the cards and the count follows", async () => {
850
+ await renderExplorer();
851
+ typeSearch("west");
852
+
853
+ expect(screen.getByTestId(`site-card-${REGION_WEST_ID}`)).toBeVisible();
854
+ expect(
855
+ screen.queryByTestId(`site-card-${REGION_EAST_ID}`),
856
+ ).not.toBeInTheDocument();
857
+ expect(
858
+ screen.queryByTestId(`site-card-${REGION_CENTRAL_ID}`),
859
+ ).not.toBeInTheDocument();
860
+ expect(resultCountText()).toBe("1 of 3 regions · Updates every minute");
861
+
862
+ // Every word, in any order, the same rule the dropdown's server uses.
863
+ typeSearch("EAST region");
864
+ expect(screen.getByTestId(`site-card-${REGION_EAST_ID}`)).toBeVisible();
865
+ expect(resultCountText()).toBe("1 of 3 regions · Updates every minute");
866
+
867
+ typeSearch("region");
868
+ expect(resultCountText()).toBe("3 of 3 regions · Updates every minute");
869
+ });
870
+
871
+ test("the local narrowing never waits for the server", async () => {
872
+ await renderExplorer();
873
+ postMock.mockImplementation((...args: Array<unknown>): unknown => {
874
+ const options: PostOptions = args[0] as PostOptions;
875
+ if (routeOf(options).includes("/network-site/search")) {
876
+ return new Promise<unknown>(() => {
877
+ // Never answers.
878
+ });
879
+ }
880
+ return defaultPost(options);
881
+ });
882
+
883
+ typeSearch("central");
884
+
885
+ expect(screen.getByTestId(`site-card-${REGION_CENTRAL_ID}`)).toBeVisible();
886
+ expect(resultCountText()).toBe("1 of 3 regions · Updates every minute");
887
+ expect(await screen.findByTestId(`${SEARCH_ID}-searching`)).toBeVisible();
888
+ });
889
+
890
+ test("the search request carries the normalized text and the project header", async () => {
891
+ await renderExplorer();
892
+ typeSearch(" MICHIGAN 104822 ");
893
+
894
+ await screen.findByTestId(`${SEARCH_ID}-result-${UNIT_ID}`);
895
+ expect(searchPosts()).toHaveLength(1);
896
+ expect(searchPosts()[0]!.data).toEqual({ searchText: "michigan 104822" });
897
+ expect(searchPosts()[0]!.headers).toMatchObject({
898
+ tenantid: "project-3981",
899
+ });
900
+ expect(routeOf(searchPosts()[0]!)).toContain("/network-site/search");
901
+ });
902
+
903
+ test("a burst of keystrokes is one request, for the last text", async () => {
904
+ await renderExplorer();
905
+ for (const value of ["1", "10", "104", "1048", "10482", "104822"]) {
906
+ typeSearch(value);
907
+ }
908
+
909
+ await screen.findByTestId(`${SEARCH_ID}-result-${UNIT_ID}`);
910
+ await waitMs(300);
911
+ expect(searchPosts()).toHaveLength(1);
912
+ expect(searchPosts()[0]!.data).toEqual({ searchText: "104822" });
913
+ });
914
+
915
+ /*
916
+ * One character matches most of an estate, so the box does not ask the
917
+ * server below two. Measured after trimming: padding a single letter
918
+ * with spaces is still a single letter.
919
+ */
920
+ test.each(["e", " e ", " "])(
921
+ "%j narrows locally but never reaches the server",
922
+ async (value: string) => {
923
+ await renderExplorer();
924
+ typeSearch(value);
925
+
926
+ await waitMs(400);
927
+ expect(searchPosts()).toHaveLength(0);
928
+ expect(
929
+ screen.queryByTestId(`${SEARCH_ID}-results`),
930
+ ).not.toBeInTheDocument();
931
+ expect(searchInput()).toHaveAttribute("aria-expanded", "false");
932
+ },
933
+ );
934
+
935
+ test("typing never writes the URL", async () => {
936
+ await renderExplorer();
937
+ await searchFor("104822", UNIT_ID);
938
+ await waitMs(300);
939
+ expect(setQueryStringMock).not.toHaveBeenCalled();
940
+ });
941
+ });
942
+
943
+ /*
944
+ * The empty state is where a reader lands when the level has nothing for
945
+ * them. Before issue #3981 that was a dead end; now the answer is usually in
946
+ * the dropdown, and the copy has to say so — but only when the dropdown can
947
+ * actually have one.
948
+ */
949
+ describe("the empty level points at the dropdown only when it can help", () => {
950
+ test("two or more characters mention matches anywhere in the network", async () => {
951
+ await renderExplorer();
952
+ typeSearch("zz");
953
+
954
+ expect(screen.getByText("No sites match your filters")).toBeVisible();
955
+ const description: HTMLElement = screen.getByTestId(
956
+ "topology-hierarchy-empty-description",
957
+ );
958
+ expect(description).toHaveTextContent(
959
+ "No regions at this level match what you are looking for. Click the search box to see matching sites from anywhere in your network, or clear the search or the health filter to see the rest of this level.",
960
+ );
961
+ });
962
+
963
+ test("a single character keeps the old copy", async () => {
964
+ await renderExplorer();
965
+ typeSearch("z");
966
+
967
+ const description: HTMLElement = screen.getByTestId(
968
+ "topology-hierarchy-empty-description",
969
+ );
970
+ expect(description).toHaveTextContent(
971
+ "No regions at this level match what you are looking for. Clear the search or the health filter to see the rest.",
972
+ );
973
+ expect(description).not.toHaveTextContent("anywhere in your network");
974
+ });
975
+
976
+ test("a single character padded with spaces is still a single character", async () => {
977
+ await renderExplorer();
978
+ typeSearch(" z ");
979
+
980
+ expect(
981
+ screen.getByTestId("topology-hierarchy-empty-description"),
982
+ ).not.toHaveTextContent("anywhere in your network");
983
+ });
984
+
985
+ test("a health filter alone never points at the dropdown", async () => {
986
+ await renderExplorer();
987
+ fireEvent.click(screen.getByTestId("topology-hierarchy-filter-degraded"));
988
+
989
+ const description: HTMLElement = screen.getByTestId(
990
+ "topology-hierarchy-empty-description",
991
+ );
992
+ expect(description).toHaveTextContent(
993
+ "Clear the search or the health filter to see the rest.",
994
+ );
995
+ expect(description).not.toHaveTextContent("anywhere in your network");
996
+ });
997
+
998
+ test('"Clear filters" empties the box and brings the level back', async () => {
999
+ await renderExplorer();
1000
+ await searchFor("104822", UNIT_ID);
1001
+ expect(resultCountText()).toBe("0 of 3 regions · Updates every minute");
1002
+
1003
+ fireEvent.click(screen.getByTestId("topology-hierarchy-reset-filters"));
1004
+
1005
+ expect(searchInput()).toHaveValue("");
1006
+ expect(resultCountText()).toBe("3 of 3 regions · Updates every minute");
1007
+ expect(screen.getByTestId(`site-card-${REGION_EAST_ID}`)).toBeVisible();
1008
+ expect(
1009
+ screen.queryByTestId("topology-hierarchy-reset-filters"),
1010
+ ).not.toBeInTheDocument();
1011
+ expect(
1012
+ screen.queryByTestId(`${SEARCH_ID}-results`),
1013
+ ).not.toBeInTheDocument();
1014
+ expect(screen.queryByTestId(`${SEARCH_ID}-clear`)).not.toBeInTheDocument();
1015
+ });
1016
+
1017
+ test("the box's own clear button does the same for the text", async () => {
1018
+ await renderExplorer();
1019
+ typeSearch("west");
1020
+ expect(resultCountText()).toBe("1 of 3 regions · Updates every minute");
1021
+
1022
+ fireEvent.click(screen.getByTestId(`${SEARCH_ID}-clear`));
1023
+
1024
+ expect(searchInput()).toHaveValue("");
1025
+ expect(resultCountText()).toBe("3 of 3 regions · Updates every minute");
1026
+ });
1027
+ });
1028
+
1029
+ /*
1030
+ * A drill resets the health filter: "Down" at one level says nothing about
1031
+ * the next, and arriving inside a market with the filter still on would
1032
+ * hide most of it for a reason two rows up. A drill from the dropdown is a
1033
+ * drill like any other.
1034
+ */
1035
+ describe("a pick from the dropdown is a drill like any other", () => {
1036
+ test("it resets an active health filter", async () => {
1037
+ await renderExplorer();
1038
+ fireEvent.click(screen.getByTestId("topology-hierarchy-filter-down"));
1039
+ expect(
1040
+ screen.getByTestId("topology-hierarchy-filter-down"),
1041
+ ).toHaveAttribute("aria-pressed", "true");
1042
+ expect(resultCountText()).toBe("1 of 3 regions · Updates every minute");
1043
+
1044
+ fireEvent.click(await searchFor("market 5", MARKET_ID));
1045
+ await screen.findByTestId(`site-card-${UNIT_ID}`);
1046
+
1047
+ expect(screen.getByTestId("topology-hierarchy-filter-all")).toHaveAttribute(
1048
+ "aria-pressed",
1049
+ "true",
1050
+ );
1051
+ expect(
1052
+ screen.getByTestId("topology-hierarchy-filter-down"),
1053
+ ).toHaveAttribute("aria-pressed", "false");
1054
+ // The healthy unit is on screen: "Down" did not follow the reader in.
1055
+ expect(screen.getByTestId(`site-card-${OTHER_UNIT_ID}`)).toBeVisible();
1056
+ expect(resultCountText()).toBe("2 of 2 units · Updates every minute");
1057
+ });
1058
+
1059
+ /*
1060
+ * The box only exists on the hierarchy view, so a device view cannot be
1061
+ * on screen while a hit is picked — but the URL can still carry the
1062
+ * parameter, and the drill has to clear it or a reload would reopen the
1063
+ * picked site's device map instead of its level.
1064
+ */
1065
+ test("the URL write names the picked site and clears the device view", async () => {
1066
+ await renderExplorer();
1067
+ fireEvent.click(await searchFor("market 5", MARKET_ID));
1068
+ await screen.findByTestId(`site-card-${UNIT_ID}`);
1069
+
1070
+ await waitFor(() => {
1071
+ expect(setQueryStringMock).toHaveBeenCalledWith({
1072
+ topologySite: MARKET_ID,
1073
+ topologyDevices: null,
1074
+ });
1075
+ });
1076
+ });
1077
+ });
1078
+
1079
+ /*
1080
+ * The keyboard path to the same drill. Enter commits only an entry the
1081
+ * reader moved onto, and only while the panel is open — Escape hides the
1082
+ * panel AND drops the highlight, so a later Enter cannot drill somewhere
1083
+ * that is no longer on screen.
1084
+ */
1085
+ describe("keyboard selection", () => {
1086
+ test("ArrowDown then Enter drills to the highlighted site", async () => {
1087
+ await renderExplorer();
1088
+ await searchFor("104822", UNIT_ID);
1089
+ const input: HTMLInputElement = searchInput();
1090
+
1091
+ fireEvent.keyDown(input, { key: "ArrowDown" });
1092
+ fireEvent.keyDown(input, { key: "Enter" });
1093
+
1094
+ const stub: HTMLElement = await screen.findByTestId("live-view-stub");
1095
+ expect(stub).toHaveAttribute("data-site-id", UNIT_ID);
1096
+ });
1097
+
1098
+ test("a bare Enter commits nothing", async () => {
1099
+ await renderExplorer();
1100
+ await searchFor("104822", UNIT_ID);
1101
+ const childrenBefore: number = childrenPosts().length;
1102
+
1103
+ fireEvent.keyDown(searchInput(), { key: "Enter" });
1104
+
1105
+ await waitMs(50);
1106
+ expect(childrenPosts()).toHaveLength(childrenBefore);
1107
+ expect(screen.queryByTestId("live-view-stub")).not.toBeInTheDocument();
1108
+ });
1109
+
1110
+ test("Enter after Escape does not drill to the hidden highlight", async () => {
1111
+ await renderExplorer();
1112
+ await searchFor("104822", UNIT_ID);
1113
+ const input: HTMLInputElement = searchInput();
1114
+ const childrenBefore: number = childrenPosts().length;
1115
+
1116
+ fireEvent.keyDown(input, { key: "ArrowDown" });
1117
+ fireEvent.keyDown(input, { key: "Escape" });
1118
+
1119
+ expect(
1120
+ screen.queryByTestId(`${SEARCH_ID}-results`),
1121
+ ).not.toBeInTheDocument();
1122
+ expect(input).toHaveAttribute("aria-expanded", "false");
1123
+ expect(input).not.toHaveAttribute("aria-activedescendant");
1124
+
1125
+ fireEvent.keyDown(input, { key: "Enter" });
1126
+
1127
+ await waitMs(50);
1128
+ expect(childrenPosts()).toHaveLength(childrenBefore);
1129
+ expect(screen.queryByTestId("live-view-stub")).not.toBeInTheDocument();
1130
+ expect(input).toHaveValue("104822");
1131
+ });
1132
+
1133
+ test("ArrowUp re-opens the panel after Escape, on the last hit", async () => {
1134
+ await renderExplorer();
1135
+ await searchFor("unit", UNIT_ID);
1136
+ const input: HTMLInputElement = searchInput();
1137
+
1138
+ fireEvent.keyDown(input, { key: "Escape" });
1139
+ expect(
1140
+ screen.queryByTestId(`${SEARCH_ID}-results`),
1141
+ ).not.toBeInTheDocument();
1142
+
1143
+ fireEvent.keyDown(input, { key: "ArrowUp" });
1144
+
1145
+ expect(screen.getByTestId(`${SEARCH_ID}-results`)).toBeVisible();
1146
+ // Both units match "unit"; the last in the list is the one ArrowUp lands on.
1147
+ expect(input).toHaveAttribute(
1148
+ "aria-activedescendant",
1149
+ `${SEARCH_ID}-option-${OTHER_UNIT_ID}`,
1150
+ );
1151
+
1152
+ fireEvent.keyDown(input, { key: "Enter" });
1153
+ await waitFor(() => {
1154
+ expect(childrenPosts()[childrenPosts().length - 1]!.data).toEqual({
1155
+ siteId: OTHER_UNIT_ID,
1156
+ });
1157
+ });
1158
+ });
1159
+
1160
+ test("blurring the box closes the panel", async () => {
1161
+ await renderExplorer();
1162
+ await searchFor("104822", UNIT_ID);
1163
+
1164
+ fireEvent.blur(searchInput());
1165
+
1166
+ expect(
1167
+ screen.queryByTestId(`${SEARCH_ID}-results`),
1168
+ ).not.toBeInTheDocument();
1169
+ // The text, and the level it narrowed, are untouched.
1170
+ expect(searchInput()).toHaveValue("104822");
1171
+ expect(resultCountText()).toBe("0 of 3 regions · Updates every minute");
1172
+ });
1173
+ });
1174
+
1175
+ /*
1176
+ * The dropdown's other states, under the explorer's prefix. A failed or
1177
+ * empty search must say so inside the panel and leave the level alone.
1178
+ */
1179
+ describe("the dropdown's other answers", () => {
1180
+ test("no matches says so", async () => {
1181
+ await renderExplorer();
1182
+ typeSearch("qqq");
1183
+
1184
+ expect(
1185
+ await screen.findByTestId(`${SEARCH_ID}-no-results`),
1186
+ ).toHaveTextContent("No sites match that name.");
1187
+ });
1188
+
1189
+ test("a capped answer asks the reader to keep typing", async () => {
1190
+ await renderExplorer();
1191
+ await searchFor("unit", UNIT_ID);
1192
+
1193
+ expect(screen.getByTestId(`${SEARCH_ID}-truncated`)).toBeVisible();
1194
+ expect(
1195
+ screen.getByTestId(`${SEARCH_ID}-result-${OTHER_UNIT_ID}`),
1196
+ ).toBeVisible();
1197
+ });
1198
+
1199
+ test("a root site prints no path line", async () => {
1200
+ await renderExplorer();
1201
+ const hit: HTMLElement = await searchFor("franchise", FRANCHISE_ID);
1202
+
1203
+ expect(hit.textContent).toBe("Franchise AFranchise");
1204
+ });
1205
+
1206
+ test("a failed search stays inside the panel", async () => {
1207
+ await renderExplorer();
1208
+ postMock.mockImplementation((...args: Array<unknown>): unknown => {
1209
+ const options: PostOptions = args[0] as PostOptions;
1210
+ if (routeOf(options).includes("/network-site/search")) {
1211
+ return Promise.reject(new Error("Search is temporarily unavailable"));
1212
+ }
1213
+ return defaultPost(options);
1214
+ });
1215
+
1216
+ typeSearch("east");
1217
+
1218
+ expect(await screen.findByTestId(`${SEARCH_ID}-error`)).toHaveTextContent(
1219
+ "Search is temporarily unavailable",
1220
+ );
1221
+ // The level is still narrowed and still usable.
1222
+ expect(screen.getByTestId(`site-card-${REGION_EAST_ID}`)).toBeVisible();
1223
+ expect(resultCountText()).toBe("1 of 3 regions · Updates every minute");
1224
+ expect(
1225
+ screen.queryByTestId("topology-hierarchy-refresh-error"),
1226
+ ).not.toBeInTheDocument();
1227
+ });
1228
+
1229
+ /*
1230
+ * Cancel-stale: a slow answer for an earlier query must not replace the
1231
+ * answer for the text in the box now.
1232
+ */
1233
+ test("a slow answer for an earlier query is dropped", async () => {
1234
+ await renderExplorer();
1235
+ let resolveStale: (value: unknown) => void = (): void => {};
1236
+ postMock.mockImplementation((...args: Array<unknown>): unknown => {
1237
+ const options: PostOptions = args[0] as PostOptions;
1238
+ if (
1239
+ routeOf(options).includes("/network-site/search") &&
1240
+ options.data?.["searchText"] === "region"
1241
+ ) {
1242
+ return new Promise<unknown>((resolve: (value: unknown) => void) => {
1243
+ resolveStale = resolve;
1244
+ });
1245
+ }
1246
+ return defaultPost(options);
1247
+ });
1248
+
1249
+ typeSearch("region");
1250
+ await waitFor(() => {
1251
+ expect(searchPosts()).toHaveLength(1);
1252
+ });
1253
+
1254
+ typeSearch("104822");
1255
+ await screen.findByTestId(`${SEARCH_ID}-result-${UNIT_ID}`);
1256
+
1257
+ await act(async () => {
1258
+ resolveStale({ data: searchFixture("region") });
1259
+ await waitMs(0);
1260
+ });
1261
+
1262
+ expect(
1263
+ screen.queryByTestId(`${SEARCH_ID}-result-${REGION_EAST_ID}`),
1264
+ ).not.toBeInTheDocument();
1265
+ expect(screen.getByTestId(`${SEARCH_ID}-result-${UNIT_ID}`)).toBeVisible();
1266
+ });
1267
+ });