@oneuptime/common 11.7.1 → 11.7.2

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 (237) hide show
  1. package/Models/DatabaseModels/DashboardDomain.ts +1 -0
  2. package/Models/DatabaseModels/Incident.ts +2 -0
  3. package/Models/DatabaseModels/IncidentEpisode.ts +1 -0
  4. package/Models/DatabaseModels/IncidentEpisodePublicNote.ts +1 -0
  5. package/Models/DatabaseModels/IncidentEpisodeStateTimeline.ts +1 -0
  6. package/Models/DatabaseModels/IncidentPublicNote.ts +1 -0
  7. package/Models/DatabaseModels/IncidentStateTimeline.ts +1 -0
  8. package/Models/DatabaseModels/Monitor.ts +1 -0
  9. package/Models/DatabaseModels/MonitorStatusTimeline.ts +1 -0
  10. package/Models/DatabaseModels/NetworkSite.ts +1 -0
  11. package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +1 -0
  12. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +1 -0
  13. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +2 -0
  14. package/Models/DatabaseModels/Probe.ts +13 -13
  15. package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -0
  16. package/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +1 -0
  17. package/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.ts +1 -0
  18. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -0
  19. package/Models/DatabaseModels/StatusPage.ts +1 -0
  20. package/Models/DatabaseModels/StatusPageAnnouncement.ts +1 -0
  21. package/Models/DatabaseModels/StatusPageDomain.ts +1 -0
  22. package/Models/DatabaseModels/WorkspaceNotificationSummary.ts +1 -0
  23. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +11 -0
  24. package/Server/API/BaseAPI.ts +17 -1
  25. package/Server/API/CommonAPI.ts +32 -0
  26. package/Server/API/MicrosoftTeamsAPI.ts +99 -2
  27. package/Server/API/ProbeAPI.ts +2 -0
  28. package/Server/API/SlackAPI.ts +62 -0
  29. package/Server/API/StatusPageAPI.ts +157 -192
  30. package/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.ts +89 -0
  31. package/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.ts +61 -0
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.ts +280 -0
  33. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  34. package/Server/Services/DatabaseService.ts +11 -3
  35. package/Server/Services/MonitorGroupService.ts +145 -0
  36. package/Server/Services/MonitorService.ts +231 -30
  37. package/Server/Services/ProbeService.ts +319 -16
  38. package/Server/Services/StatusPageService.ts +72 -42
  39. package/Server/Services/WorkspaceNotificationRuleService.ts +232 -2
  40. package/Server/Utils/Database/PostgresErrorTranslator.ts +131 -0
  41. package/Server/Utils/Monitor/MonitorResource.ts +88 -35
  42. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +21 -1
  43. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +158 -0
  44. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +706 -6
  45. package/Tests/Models/DatabaseModels/ProbeColumnAccessControl.test.ts +103 -0
  46. package/Tests/Server/API/BaseAPIUpdateNoOp.test.ts +135 -0
  47. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +317 -0
  48. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +228 -0
  49. package/Tests/Server/API/ProbeAPI.test.ts +6 -0
  50. package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +167 -0
  51. package/Tests/Server/Services/AddHotQueryIndexesMigration.test.ts +255 -0
  52. package/Tests/Server/Services/AddHotQueryIndexesSecondPassMigration.test.ts +258 -0
  53. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +1 -1
  54. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +121 -0
  55. package/Tests/Server/Services/HeartbeatWriteFastPathSafety.test.ts +200 -0
  56. package/Tests/Server/Services/HookFreeWriteSafetySecondPass.test.ts +181 -0
  57. package/Tests/Server/Services/MonitorGroupServiceBatchedStatuses.test.ts +513 -0
  58. package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +492 -0
  59. package/Tests/Server/Services/MonitorServiceProbeSelection.test.ts +401 -0
  60. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +453 -0
  61. package/Tests/Server/Services/ProbeAuthKeyCacheAndHeartbeat.test.ts +679 -0
  62. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +3 -3
  63. package/Tests/Server/Services/StatusPageDomainResolutionCache.test.ts +245 -0
  64. package/Tests/Server/Services/StatusPageOverviewTimelineWindow.test.ts +283 -0
  65. package/Tests/Server/Services/StatusPageServiceMcp.test.ts +17 -1
  66. package/Tests/Server/Services/StatusPageServiceMonitorGroupBatching.test.ts +331 -0
  67. package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +12 -12
  68. package/Tests/Server/Services/WorkspaceNotificationRuleChats.test.ts +1390 -0
  69. package/Tests/Server/Utils/AI/Insights/InsightFixRouting.test.ts +4 -4
  70. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +2 -2
  71. package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +1 -3
  72. package/Tests/Server/Utils/AI/Insights/InsightStoreHardening.test.ts +1 -3
  73. package/Tests/Server/Utils/AI/Insights/InsightTriage.test.ts +1 -1
  74. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +5 -5
  75. package/Tests/Server/Utils/Alert/AlertPrivacyFilter.test.ts +187 -0
  76. package/Tests/Server/Utils/AlertEpisode/AlertEpisodePrivacyFilter.test.ts +170 -0
  77. package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +125 -0
  78. package/Tests/Server/Utils/Incident/IncidentPrivacyFilter.test.ts +264 -0
  79. package/Tests/Server/Utils/IncidentEpisode/IncidentEpisodePrivacyFilter.test.ts +175 -0
  80. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +181 -0
  81. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +1 -1
  82. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +2 -2
  83. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelsList.test.ts +341 -0
  84. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +2175 -0
  85. package/Tests/Server/Utils/Workspace/SlackChannelsList.test.ts +585 -0
  86. package/Tests/Types/Billing/MeteredPlan.test.ts +46 -0
  87. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +219 -1
  88. package/Tests/UI/Components/BulkUpdateForm.test.tsx +174 -0
  89. package/Tests/UI/Components/CardModelDetailEdit.test.tsx +315 -0
  90. package/Tests/UI/Components/ModelDetailSelect.test.tsx +229 -0
  91. package/Tests/UI/Components/ModelTable/BaseModelTableBulkSelectAll.test.tsx +1258 -0
  92. package/Tests/UI/Components/ModelTableExportFromColumns.test.ts +153 -0
  93. package/Tests/UI/Components/MonacoLoader.test.ts +67 -0
  94. package/Tests/UI/Components/MonacoRuntime.test.ts +121 -0
  95. package/Tests/UI/Components/TableBulkCsvExport.test.tsx +212 -3
  96. package/Tests/UI/Utils/Project.test.ts +809 -0
  97. package/Tests/UI/Utils/TableColumnsToCsv.test.ts +360 -0
  98. package/Tests/Utils/Alerts/AlertMetricType.test.ts +143 -0
  99. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +219 -0
  100. package/Tests/Utils/Incident/IncidentMetricType.test.ts +174 -0
  101. package/Tests/Utils/Monitor/MonitorProbeSelectionUtil.test.ts +145 -0
  102. package/Tests/Utils/StatusPage/ResourceUptime.test.ts +263 -0
  103. package/Types/Workspace/NotificationRules/BaseNotificationRule.ts +4 -0
  104. package/Types/Workspace/NotificationRules/NotificationRuleCondition.ts +25 -10
  105. package/Types/Workspace/WorkspaceMessagePayload.ts +1 -0
  106. package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +63 -8
  107. package/UI/Components/CodeEditor/CodeEditor.tsx +3 -0
  108. package/UI/Components/CodeEditor/MonacoLoader.ts +36 -0
  109. package/UI/Components/Forms/ModelForm.tsx +41 -18
  110. package/UI/Components/ModelDetail/CardModelDetail.tsx +23 -1
  111. package/UI/Components/ModelDetail/ModelDetail.tsx +46 -1
  112. package/UI/Components/ModelTable/BaseModelTable.tsx +300 -30
  113. package/UI/Components/ModelTable/Column.ts +12 -0
  114. package/UI/Components/ModelTable/ExportFromColumns.ts +57 -0
  115. package/UI/Components/Navbar/NavBar.tsx +66 -1
  116. package/UI/Components/Table/Table.tsx +52 -13
  117. package/UI/Components/Table/Types/Column.ts +18 -0
  118. package/UI/Utils/Project.ts +214 -18
  119. package/UI/Utils/TableColumnsToCsv.ts +86 -6
  120. package/UI/esbuild-config.js +34 -0
  121. package/Utils/Monitor/MonitorProbeSelectionUtil.ts +72 -0
  122. package/build/dist/Models/DatabaseModels/DashboardDomain.js +1 -0
  123. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Incident.js +2 -0
  125. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/IncidentEpisode.js +1 -0
  127. package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
  128. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js +1 -0
  129. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js.map +1 -1
  130. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js +1 -0
  131. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js.map +1 -1
  132. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js +1 -0
  133. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js.map +1 -1
  134. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js +1 -0
  135. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js.map +1 -1
  136. package/build/dist/Models/DatabaseModels/Monitor.js +1 -0
  137. package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
  138. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +1 -0
  139. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  140. package/build/dist/Models/DatabaseModels/NetworkSite.js +1 -0
  141. package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -1
  142. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +1 -0
  143. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -1
  144. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +1 -0
  145. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  146. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +2 -0
  147. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  148. package/build/dist/Models/DatabaseModels/Probe.js +13 -13
  149. package/build/dist/Models/DatabaseModels/Probe.js.map +1 -1
  150. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -0
  151. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  152. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js +1 -0
  153. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js.map +1 -1
  154. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js +1 -0
  155. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js.map +1 -1
  156. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -0
  157. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  158. package/build/dist/Models/DatabaseModels/StatusPage.js +1 -0
  159. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  160. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +1 -0
  161. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  162. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +1 -0
  163. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  164. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js +1 -0
  165. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js.map +1 -1
  166. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  167. package/build/dist/Server/API/BaseAPI.js +12 -1
  168. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  169. package/build/dist/Server/API/CommonAPI.js +21 -0
  170. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  171. package/build/dist/Server/API/MicrosoftTeamsAPI.js +72 -2
  172. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  173. package/build/dist/Server/API/ProbeAPI.js +2 -0
  174. package/build/dist/Server/API/ProbeAPI.js.map +1 -1
  175. package/build/dist/Server/API/SlackAPI.js +44 -0
  176. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  177. package/build/dist/Server/API/StatusPageAPI.js +98 -152
  178. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  179. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js +36 -0
  180. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js.map +1 -0
  181. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js +26 -0
  182. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js.map +1 -0
  183. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js +254 -0
  184. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js.map +1 -0
  185. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  186. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  187. package/build/dist/Server/Services/DatabaseService.js +10 -2
  188. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  189. package/build/dist/Server/Services/MonitorGroupService.js +118 -0
  190. package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
  191. package/build/dist/Server/Services/MonitorService.js +198 -24
  192. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  193. package/build/dist/Server/Services/ProbeService.js +238 -9
  194. package/build/dist/Server/Services/ProbeService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +46 -39
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +178 -3
  198. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  199. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +80 -0
  200. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -0
  201. package/build/dist/Server/Utils/Monitor/MonitorResource.js +67 -29
  202. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  203. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +31 -14
  204. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  205. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +117 -0
  206. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -0
  207. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +549 -7
  208. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  209. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js +17 -7
  210. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js.map +1 -1
  211. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +37 -11
  212. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
  213. package/build/dist/UI/Components/CodeEditor/CodeEditor.js +2 -0
  214. package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
  215. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js +34 -0
  216. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js.map +1 -0
  217. package/build/dist/UI/Components/Forms/ModelForm.js +37 -17
  218. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  219. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +22 -1
  220. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
  221. package/build/dist/UI/Components/ModelDetail/ModelDetail.js +31 -1
  222. package/build/dist/UI/Components/ModelDetail/ModelDetail.js.map +1 -1
  223. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +234 -21
  224. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js +30 -0
  226. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js.map +1 -0
  227. package/build/dist/UI/Components/Navbar/NavBar.js +39 -1
  228. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  229. package/build/dist/UI/Components/Table/Table.js +31 -11
  230. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  231. package/build/dist/UI/Utils/Project.js +161 -15
  232. package/build/dist/UI/Utils/Project.js.map +1 -1
  233. package/build/dist/UI/Utils/TableColumnsToCsv.js +67 -6
  234. package/build/dist/UI/Utils/TableColumnsToCsv.js.map +1 -1
  235. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js +52 -0
  236. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js.map +1 -0
  237. package/package.json +4 -3
@@ -0,0 +1,1258 @@
1
+ import "@testing-library/jest-dom/extend-expect";
2
+ import {
3
+ act,
4
+ cleanup,
5
+ configure,
6
+ fireEvent,
7
+ render,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import React from "react";
11
+
12
+ /*
13
+ * Selecting thousands of rows renders and re-renders a real table several
14
+ * times over, which comfortably outruns the 1s default when the whole suite
15
+ * is competing for the same box. A regression still fails, just later.
16
+ */
17
+ configure({ asyncUtilTimeout: 15000 });
18
+ import {
19
+ afterEach,
20
+ beforeEach,
21
+ describe,
22
+ expect,
23
+ jest,
24
+ test,
25
+ } from "@jest/globals";
26
+
27
+ /*
28
+ * BaseModelTable pulls in permissions, the current project and the i18n
29
+ * provider. None of those are what these tests are about, so they are stubbed
30
+ * to their permissive/no-op form and the tests focus on one thing: what
31
+ * "Select All" actually asks the API for, and what ends up selected.
32
+ */
33
+ jest.mock("../../../../UI/Utils/Permission", () => {
34
+ return {
35
+ __esModule: true,
36
+ default: {
37
+ getAllPermissions: () => {
38
+ return [];
39
+ },
40
+ getProjectPermissions: () => {
41
+ return [];
42
+ },
43
+ getGlobalPermissions: () => {
44
+ return [];
45
+ },
46
+ },
47
+ };
48
+ });
49
+
50
+ jest.mock("../../../../UI/Utils/User", () => {
51
+ return {
52
+ __esModule: true,
53
+ default: {
54
+ isMasterAdmin: () => {
55
+ return true;
56
+ },
57
+ getUserId: () => {
58
+ return null;
59
+ },
60
+ },
61
+ };
62
+ });
63
+
64
+ jest.mock("../../../../UI/Utils/Translation", () => {
65
+ return {
66
+ __esModule: true,
67
+ default: () => {
68
+ return {
69
+ translateString: (value: string | undefined) => {
70
+ return value;
71
+ },
72
+ translateValue: (value: unknown) => {
73
+ return value;
74
+ },
75
+ };
76
+ },
77
+ };
78
+ });
79
+
80
+ import BaseModelTable, {
81
+ BaseTableCallbacks,
82
+ BULK_SELECT_ALL_PAGE_SIZE,
83
+ ComponentProps as BaseModelTableProps,
84
+ } from "../../../../UI/Components/ModelTable/BaseModelTable";
85
+ import TableColumnsToCsv from "../../../../UI/Utils/TableColumnsToCsv";
86
+ import TableFilterUrlState from "../../../../UI/Utils/TableFilterUrlState";
87
+ import FieldType from "../../../../UI/Components/Types/FieldType";
88
+ import { ButtonStyleType } from "../../../../UI/Components/Button/Button";
89
+ import Monitor from "../../../../Models/DatabaseModels/Monitor";
90
+ import { LIMIT_PER_PROJECT } from "../../../../Types/Database/LimitMax";
91
+ import Query from "../../../../Types/BaseDatabase/Query";
92
+ import SortOrder from "../../../../Types/BaseDatabase/SortOrder";
93
+ import ListResult from "../../../../Types/BaseDatabase/ListResult";
94
+ import { JSONObject } from "../../../../Types/JSON";
95
+
96
+ type GetListCall = {
97
+ query: Query<Monitor>;
98
+ limit: number;
99
+ skip: number;
100
+ sort: JSONObject;
101
+ select: JSONObject;
102
+ };
103
+
104
+ type Row = {
105
+ _id: string;
106
+ name: string;
107
+ description: string;
108
+ createdAt: string;
109
+ };
110
+
111
+ type MakeRowsFunction = (count: number) => Array<Row>;
112
+
113
+ const makeRows: MakeRowsFunction = (count: number): Array<Row> => {
114
+ const rows: Array<Row> = [];
115
+
116
+ for (let i: number = 0; i < count; i++) {
117
+ rows.push({
118
+ _id: `id-${String(i).padStart(6, "0")}`,
119
+ name: `Monitor ${i}`,
120
+ description: `Description ${i}`,
121
+ createdAt: "2026-01-01T00:00:00.000Z",
122
+ });
123
+ }
124
+
125
+ return rows;
126
+ };
127
+
128
+ const COLUMNS: Array<unknown> = [
129
+ { field: { name: true }, title: "Name", type: FieldType.Text },
130
+ {
131
+ field: { description: true },
132
+ title: "Description",
133
+ type: FieldType.LongText,
134
+ },
135
+ ];
136
+
137
+ type CallPredicate = (call: GetListCall) => boolean;
138
+
139
+ type RenderOptions = {
140
+ rows: Array<Row>;
141
+ /*
142
+ * Matched against each recorded getList call. Predicates rather than call
143
+ * indexes, so that a future extra fetch on mount cannot silently retarget
144
+ * the injected failure at the table's own page request.
145
+ */
146
+ failWhen?: CallPredicate | undefined;
147
+ deferWhen?: CallPredicate | undefined;
148
+ /*
149
+ * Simulates rows being inserted while the selection is being paged: every
150
+ * page after the first is served from `overlapPages` rows earlier than
151
+ * asked, so the boundary rows come back twice.
152
+ */
153
+ overlapPages?: number | undefined;
154
+ /*
155
+ * Serve the analytics shape, where the endpoint skips COUNT(*) and reports
156
+ * `hasMore` with a `count` that is only a lower bound.
157
+ */
158
+ useHasMore?: boolean | undefined;
159
+ selectMoreFields?: JSONObject | undefined;
160
+ query?: Query<Monitor> | undefined;
161
+ // Renders the search box, which is otherwise hidden.
162
+ searchableFields?: Array<string> | undefined;
163
+ };
164
+
165
+ /*
166
+ * The bulk selection pages at BULK_SELECT_ALL_PAGE_SIZE; the table's own page
167
+ * fetches use its (much smaller) page size, so the limit tells them apart.
168
+ */
169
+ type IsBulkCallFunction = (call: GetListCall) => boolean;
170
+
171
+ const isBulkCall: IsBulkCallFunction = (call: GetListCall): boolean => {
172
+ return call.limit === BULK_SELECT_ALL_PAGE_SIZE;
173
+ };
174
+
175
+ describe("BaseModelTable bulk Select All", () => {
176
+ let calls: Array<GetListCall> = [];
177
+ let deferredResolve: (() => void) | null = null;
178
+ let downloadedCsvFiles: Array<{ csv: string; filename: string }> = [];
179
+
180
+ /*
181
+ * Split out from renderTable so a test can re-render the same table with a
182
+ * different query - which is how a facet/filter change reaches the table
183
+ * from its parent.
184
+ */
185
+ type MakePropsFunction = (
186
+ options: RenderOptions,
187
+ ) => BaseModelTableProps<Monitor>;
188
+
189
+ const makeProps: MakePropsFunction = (
190
+ options: RenderOptions,
191
+ ): BaseModelTableProps<Monitor> => {
192
+ const callbacks: BaseTableCallbacks<Monitor> = {
193
+ deleteItem: async () => {
194
+ return undefined;
195
+ },
196
+ getModelFromJSON: (item: JSONObject) => {
197
+ return item as unknown as Monitor;
198
+ },
199
+ getJSONFromModel: (item: Monitor) => {
200
+ return item as unknown as JSONObject;
201
+ },
202
+ addSlugToSelect: (select: unknown) => {
203
+ return select;
204
+ },
205
+ getList: async (data: GetListCall): Promise<ListResult<Monitor>> => {
206
+ const call: GetListCall = {
207
+ query: data.query,
208
+ limit: data.limit,
209
+ skip: data.skip,
210
+ sort: data.sort as unknown as JSONObject,
211
+ select: data.select as unknown as JSONObject,
212
+ };
213
+
214
+ calls.push(call);
215
+
216
+ if (options.failWhen?.(call)) {
217
+ throw new Error("Select all blew up");
218
+ }
219
+
220
+ if (options.deferWhen?.(call)) {
221
+ await new Promise<void>((resolve: () => void) => {
222
+ deferredResolve = resolve;
223
+ });
224
+ }
225
+
226
+ const skip: number =
227
+ options.overlapPages && data.skip > 0
228
+ ? Math.max(0, data.skip - options.overlapPages)
229
+ : data.skip;
230
+
231
+ const page: Array<Row> = options.rows.slice(skip, skip + data.limit);
232
+
233
+ /*
234
+ * The API only returns the fields the caller selected. Honouring that
235
+ * here is what makes these tests able to see the bug at all: a row
236
+ * fetched with `select: { _id: true }` comes back as a bare id with
237
+ * every other field missing.
238
+ */
239
+ const projected: Array<JSONObject> = page.map((row: Row) => {
240
+ const projectedRow: JSONObject = {};
241
+
242
+ for (const field of Object.keys(data.select || {})) {
243
+ if (field in row) {
244
+ projectedRow[field] = (row as unknown as JSONObject)[field];
245
+ }
246
+ }
247
+
248
+ return projectedRow;
249
+ });
250
+
251
+ if (options.useHasMore) {
252
+ const hasMore: boolean = skip + page.length < options.rows.length;
253
+
254
+ return {
255
+ data: projected as unknown as Array<Monitor>,
256
+ count: skip + page.length + (hasMore ? 1 : 0),
257
+ hasMore: hasMore,
258
+ skip: data.skip,
259
+ limit: data.limit,
260
+ };
261
+ }
262
+
263
+ return {
264
+ data: projected as unknown as Array<Monitor>,
265
+ count: options.rows.length,
266
+ skip: data.skip,
267
+ limit: data.limit,
268
+ };
269
+ },
270
+ toJSONArray: () => {
271
+ return [];
272
+ },
273
+ updateById: async () => {
274
+ return undefined;
275
+ },
276
+ showCreateEditModal: () => {
277
+ return <></>;
278
+ },
279
+ } as unknown as BaseTableCallbacks<Monitor>;
280
+
281
+ const props: BaseModelTableProps<Monitor> = {
282
+ modelType: Monitor,
283
+ id: "monitors-table",
284
+ name: "Monitors",
285
+ singularName: "Monitor",
286
+ pluralName: "Monitors",
287
+ userPreferencesKey: "monitors-bulk-table",
288
+ urlStateKey: "monitors-bulk-table",
289
+ columns: COLUMNS,
290
+ filters: [],
291
+ isDeleteable: false,
292
+ isCreateable: false,
293
+ isViewable: false,
294
+ isEditable: false,
295
+ callbacks: callbacks,
296
+ bulkActions: {
297
+ buttons: [
298
+ {
299
+ title: "Archive",
300
+ buttonStyleType: ButtonStyleType.NORMAL,
301
+ onClick: async (): Promise<void> => {
302
+ return Promise.resolve();
303
+ },
304
+ },
305
+ ],
306
+ },
307
+ ...(options.selectMoreFields
308
+ ? { selectMoreFields: options.selectMoreFields }
309
+ : {}),
310
+ ...(options.query ? { query: options.query } : {}),
311
+ ...(options.searchableFields
312
+ ? { searchableFields: options.searchableFields }
313
+ : {}),
314
+ } as unknown as BaseModelTableProps<Monitor>;
315
+
316
+ return props;
317
+ };
318
+
319
+ type RenderTableFunction = (
320
+ options: RenderOptions,
321
+ ) => ReturnType<typeof render>;
322
+
323
+ const renderTable: RenderTableFunction = (
324
+ options: RenderOptions,
325
+ ): ReturnType<typeof render> => {
326
+ return render(<BaseModelTable<Monitor> {...makeProps(options)} />);
327
+ };
328
+
329
+ type RerenderTableFunction = (
330
+ rerender: ReturnType<typeof render>["rerender"],
331
+ options: RenderOptions,
332
+ ) => void;
333
+
334
+ const rerenderTable: RerenderTableFunction = (
335
+ rerender: ReturnType<typeof render>["rerender"],
336
+ options: RenderOptions,
337
+ ): void => {
338
+ rerender(<BaseModelTable<Monitor> {...makeProps(options)} />);
339
+ };
340
+
341
+ type SelectCurrentPageFunction = (container: HTMLElement) => Promise<void>;
342
+
343
+ /*
344
+ * Ticks the header checkbox, which selects the rows on screen and is what
345
+ * makes the bulk bar (and its "Select All" button) appear in the first
346
+ * place.
347
+ */
348
+ const selectCurrentPage: SelectCurrentPageFunction = async (
349
+ container: HTMLElement,
350
+ ): Promise<void> => {
351
+ // The header checkbox is disabled until the first page has rendered.
352
+ await waitFor(() => {
353
+ expect(
354
+ container.querySelectorAll('input[type="checkbox"]:not([disabled])')
355
+ .length,
356
+ ).toBeGreaterThan(0);
357
+ });
358
+
359
+ const checkboxes: NodeListOf<HTMLInputElement> =
360
+ container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]');
361
+
362
+ fireEvent.click(checkboxes[0]!);
363
+ };
364
+
365
+ type BulkCallsFunction = () => Array<GetListCall>;
366
+
367
+ const bulkCalls: BulkCallsFunction = (): Array<GetListCall> => {
368
+ return calls.filter(isBulkCall);
369
+ };
370
+
371
+ const isSelectAllCall: CallPredicate = (call: GetListCall): boolean => {
372
+ return isBulkCall(call);
373
+ };
374
+
375
+ // Only the opening page, so later pages resolve on their own.
376
+ const isFirstSelectAllCall: CallPredicate = (call: GetListCall): boolean => {
377
+ return isBulkCall(call) && call.skip === 0;
378
+ };
379
+
380
+ beforeEach(() => {
381
+ calls = [];
382
+ deferredResolve = null;
383
+ downloadedCsvFiles = [];
384
+ window.history.replaceState(
385
+ window.history.state,
386
+ "",
387
+ "/dashboard/monitors",
388
+ );
389
+ TableFilterUrlState.resetClaimedKeys();
390
+ window.localStorage.clear();
391
+
392
+ /*
393
+ * Intercepting the download rather than the Blob keeps the assertion on
394
+ * the exact bytes the user would have opened in a spreadsheet.
395
+ */
396
+ jest
397
+ .spyOn(TableColumnsToCsv, "downloadCsv")
398
+ .mockImplementation((data: { csv: string; filename: string }): void => {
399
+ downloadedCsvFiles.push(data);
400
+ });
401
+ });
402
+
403
+ afterEach(() => {
404
+ cleanup();
405
+ jest.restoreAllMocks();
406
+ });
407
+
408
+ test("it fetches the same columns as the page fetch, not just _id", async () => {
409
+ /*
410
+ * Regression for the reported bug: "Select All" then "Export CSV"
411
+ * produced a file with a header row and nothing else. Select All asked
412
+ * for `select: { _id: true }`, so every selected row was an id with no
413
+ * fields on it and every exported cell came out empty.
414
+ */
415
+ const { container, getByText } = renderTable({ rows: makeRows(25) });
416
+
417
+ await waitFor(() => {
418
+ expect(calls.length).toBeGreaterThan(0);
419
+ });
420
+
421
+ const pageSelect: JSONObject = calls[0]!.select;
422
+
423
+ await selectCurrentPage(container);
424
+
425
+ fireEvent.click(getByText("Select All Monitors"));
426
+
427
+ await waitFor(() => {
428
+ expect(bulkCalls().length).toBe(1);
429
+ });
430
+
431
+ const bulkSelect: JSONObject = bulkCalls()[0]!.select;
432
+
433
+ // Every column the table renders is fetched for the selection too.
434
+ for (const field of Object.keys(pageSelect)) {
435
+ expect(bulkSelect[field]).toEqual(pageSelect[field]);
436
+ }
437
+
438
+ expect(bulkSelect["name"]).toBe(true);
439
+ expect(bulkSelect["description"]).toBe(true);
440
+ expect(bulkSelect["_id"]).toBe(true);
441
+ });
442
+
443
+ test("it selects the column it orders by, which the database requires", async () => {
444
+ const { container, getByText } = renderTable({ rows: makeRows(25) });
445
+
446
+ await waitFor(() => {
447
+ expect(calls.length).toBeGreaterThan(0);
448
+ });
449
+
450
+ await selectCurrentPage(container);
451
+
452
+ fireEvent.click(getByText("Select All Monitors"));
453
+
454
+ await waitFor(() => {
455
+ expect(bulkCalls().length).toBe(1);
456
+ });
457
+
458
+ for (const sortColumn of Object.keys(bulkCalls()[0]!.sort)) {
459
+ expect(bulkCalls()[0]!.select[sortColumn]).toBe(true);
460
+ }
461
+ });
462
+
463
+ test("the exported CSV of a select-all carries real values on every row", async () => {
464
+ // More rows than fit on a page, so this cannot pass on the page selection.
465
+ const { container, getByText } = renderTable({ rows: makeRows(25) });
466
+
467
+ await waitFor(() => {
468
+ expect(calls.length).toBeGreaterThan(0);
469
+ });
470
+
471
+ await selectCurrentPage(container);
472
+
473
+ fireEvent.click(getByText("Select All Monitors"));
474
+
475
+ await waitFor(() => {
476
+ expect(getByText("25 Monitors Selected")).toBeTruthy();
477
+ });
478
+
479
+ fireEvent.click(getByText("Bulk Actions"));
480
+ fireEvent.click(getByText("Export CSV"));
481
+
482
+ await waitFor(() => {
483
+ expect(downloadedCsvFiles.length).toBe(1);
484
+ });
485
+
486
+ const lines: Array<string> = downloadedCsvFiles[0]!.csv.split("\r\n");
487
+
488
+ expect(lines.length).toBe(26);
489
+ expect(lines[0]).toBe("Name,Description");
490
+ expect(lines[1]).toBe("Monitor 0,Description 0");
491
+ expect(lines[25]).toBe("Monitor 24,Description 24");
492
+ });
493
+
494
+ test("it selects every matching row, not just the current page", async () => {
495
+ const { container, getByText, queryByText } = renderTable({
496
+ rows: makeRows(1262),
497
+ });
498
+
499
+ await waitFor(() => {
500
+ expect(calls.length).toBeGreaterThan(0);
501
+ });
502
+
503
+ await selectCurrentPage(container);
504
+
505
+ // The page holds 10 rows at the default page size.
506
+ expect(getByText("10 Monitors Selected")).toBeTruthy();
507
+
508
+ fireEvent.click(getByText("Select All Monitors"));
509
+
510
+ await waitFor(() => {
511
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
512
+ });
513
+
514
+ // Everything is selected, so there is nothing left for the button to do.
515
+ expect(queryByText("Select All Monitors")).toBeNull();
516
+ });
517
+
518
+ test("it pages the fetch instead of asking for everything at once", async () => {
519
+ const { container, getByText } = renderTable({ rows: makeRows(1262) });
520
+
521
+ await waitFor(() => {
522
+ expect(calls.length).toBeGreaterThan(0);
523
+ });
524
+
525
+ await selectCurrentPage(container);
526
+
527
+ fireEvent.click(getByText("Select All Monitors"));
528
+
529
+ await waitFor(() => {
530
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
531
+ });
532
+
533
+ expect(
534
+ bulkCalls().map((call: GetListCall) => {
535
+ return call.skip;
536
+ }),
537
+ ).toEqual([0, BULK_SELECT_ALL_PAGE_SIZE]);
538
+ });
539
+
540
+ test("it stops at the first short page", async () => {
541
+ const { container, getByText } = renderTable({ rows: makeRows(300) });
542
+
543
+ await waitFor(() => {
544
+ expect(calls.length).toBeGreaterThan(0);
545
+ });
546
+
547
+ await selectCurrentPage(container);
548
+
549
+ fireEvent.click(getByText("Select All Monitors"));
550
+
551
+ await waitFor(() => {
552
+ expect(getByText("300 Monitors Selected")).toBeTruthy();
553
+ });
554
+
555
+ expect(bulkCalls().length).toBe(1);
556
+ });
557
+
558
+ test("it does not spend a round trip proving an exact multiple is finished", async () => {
559
+ /*
560
+ * Every list request also runs an unbounded count over the filtered set,
561
+ * so the empty page at the end is not free.
562
+ */
563
+ const { container, getByText } = renderTable({
564
+ rows: makeRows(BULK_SELECT_ALL_PAGE_SIZE * 2),
565
+ });
566
+
567
+ await waitFor(() => {
568
+ expect(calls.length).toBeGreaterThan(0);
569
+ });
570
+
571
+ await selectCurrentPage(container);
572
+
573
+ fireEvent.click(getByText("Select All Monitors"));
574
+
575
+ await waitFor(() => {
576
+ expect(
577
+ getByText(`${BULK_SELECT_ALL_PAGE_SIZE * 2} Monitors Selected`),
578
+ ).toBeTruthy();
579
+ });
580
+
581
+ expect(bulkCalls().length).toBe(2);
582
+ });
583
+
584
+ test("it stops on hasMore for endpoints that report no exact count", async () => {
585
+ const { container, getByText, queryByText } = renderTable({
586
+ rows: makeRows(BULK_SELECT_ALL_PAGE_SIZE),
587
+ useHasMore: true,
588
+ });
589
+
590
+ await waitFor(() => {
591
+ expect(calls.length).toBeGreaterThan(0);
592
+ });
593
+
594
+ await selectCurrentPage(container);
595
+
596
+ fireEvent.click(getByText("Select All Monitors"));
597
+
598
+ await waitFor(() => {
599
+ expect(
600
+ getByText(`${BULK_SELECT_ALL_PAGE_SIZE} Monitors Selected`),
601
+ ).toBeTruthy();
602
+ });
603
+
604
+ expect(bulkCalls().length).toBe(1);
605
+ // Nothing was left behind, so there is nothing to warn about.
606
+ expect(queryByText(/matching Monitors/)).toBeNull();
607
+ });
608
+
609
+ test("a row served twice across pages is only selected once", async () => {
610
+ /*
611
+ * Offset paging over a table that is still taking writes can hand back a
612
+ * row an earlier page already returned. Selecting it twice would
613
+ * duplicate it in the export and inflate the count.
614
+ */
615
+ const { container, getByText } = renderTable({
616
+ rows: makeRows(1262),
617
+ overlapPages: 5,
618
+ });
619
+
620
+ await waitFor(() => {
621
+ expect(calls.length).toBeGreaterThan(0);
622
+ });
623
+
624
+ await selectCurrentPage(container);
625
+
626
+ fireEvent.click(getByText("Select All Monitors"));
627
+
628
+ await waitFor(() => {
629
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
630
+ });
631
+
632
+ fireEvent.click(getByText("Bulk Actions"));
633
+ fireEvent.click(getByText("Export CSV"));
634
+
635
+ await waitFor(() => {
636
+ expect(downloadedCsvFiles.length).toBe(1);
637
+ });
638
+
639
+ const lines: Array<string> = downloadedCsvFiles[0]!.csv.split("\r\n");
640
+
641
+ expect(lines.length).toBe(1263);
642
+ expect(new Set(lines.slice(1)).size).toBe(1262);
643
+ });
644
+
645
+ test("an unsorted table keeps the order it is displayed in", async () => {
646
+ /*
647
+ * The API only applies its default ordering when no sort is sent, so the
648
+ * selection has to name that ordering itself - otherwise a select-all
649
+ * exports in an order unrelated to the table, and a selection capped at
650
+ * the ceiling keeps an arbitrary slice instead of the newest rows.
651
+ */
652
+ const { container, getByText } = renderTable({ rows: makeRows(25) });
653
+
654
+ await waitFor(() => {
655
+ expect(calls.length).toBeGreaterThan(0);
656
+ });
657
+
658
+ await selectCurrentPage(container);
659
+
660
+ fireEvent.click(getByText("Select All Monitors"));
661
+
662
+ await waitFor(() => {
663
+ expect(bulkCalls().length).toBe(1);
664
+ });
665
+
666
+ expect(bulkCalls()[0]!.sort).toEqual({
667
+ createdAt: SortOrder.Descending,
668
+ _id: SortOrder.Ascending,
669
+ });
670
+ });
671
+
672
+ test("an active column sort is kept, with the id appended as a tiebreaker", async () => {
673
+ const { container, getByText } = renderTable({ rows: makeRows(25) });
674
+
675
+ await waitFor(() => {
676
+ expect(calls.length).toBeGreaterThan(0);
677
+ });
678
+
679
+ // Sorting by the "Name" header puts a sort on the table.
680
+ fireEvent.click(getByText("Name"));
681
+
682
+ await waitFor(() => {
683
+ expect(calls[calls.length - 1]!.sort).toEqual({
684
+ name: SortOrder.Descending,
685
+ });
686
+ });
687
+
688
+ await selectCurrentPage(container);
689
+
690
+ fireEvent.click(getByText("Select All Monitors"));
691
+
692
+ await waitFor(() => {
693
+ expect(bulkCalls().length).toBe(1);
694
+ });
695
+
696
+ expect(bulkCalls()[0]!.sort).toEqual({
697
+ name: SortOrder.Descending,
698
+ _id: SortOrder.Ascending,
699
+ });
700
+ });
701
+
702
+ test("it carries the table's query so it never selects outside the filters", async () => {
703
+ const query: Query<Monitor> = {
704
+ projectId: "project-1",
705
+ } as unknown as Query<Monitor>;
706
+
707
+ const { container, getByText } = renderTable({
708
+ rows: makeRows(25),
709
+ query: query,
710
+ });
711
+
712
+ await waitFor(() => {
713
+ expect(calls.length).toBeGreaterThan(0);
714
+ });
715
+
716
+ await selectCurrentPage(container);
717
+
718
+ fireEvent.click(getByText("Select All Monitors"));
719
+
720
+ await waitFor(() => {
721
+ expect(bulkCalls().length).toBe(1);
722
+ });
723
+
724
+ expect(bulkCalls()[0]!.query).toEqual(calls[0]!.query);
725
+ expect(bulkCalls()[0]!.query["projectId"]).toBe("project-1");
726
+ });
727
+
728
+ test("it honours selectMoreFields, which a hand-rolled column list would drop", async () => {
729
+ const { container, getByText } = renderTable({
730
+ rows: makeRows(25),
731
+ selectMoreFields: { monitorType: true },
732
+ });
733
+
734
+ await waitFor(() => {
735
+ expect(calls.length).toBeGreaterThan(0);
736
+ });
737
+
738
+ await selectCurrentPage(container);
739
+
740
+ fireEvent.click(getByText("Select All Monitors"));
741
+
742
+ await waitFor(() => {
743
+ expect(bulkCalls().length).toBe(1);
744
+ });
745
+
746
+ expect(bulkCalls()[0]!.select["monitorType"]).toBe(true);
747
+ });
748
+
749
+ test("it stops at the selection ceiling and says so", async () => {
750
+ const { container, getByText } = renderTable({
751
+ rows: makeRows(LIMIT_PER_PROJECT + 250),
752
+ });
753
+
754
+ await waitFor(() => {
755
+ expect(calls.length).toBeGreaterThan(0);
756
+ });
757
+
758
+ await selectCurrentPage(container);
759
+
760
+ fireEvent.click(getByText("Select All Monitors"));
761
+
762
+ await waitFor(() => {
763
+ expect(getByText(`${LIMIT_PER_PROJECT} Monitors Selected`)).toBeTruthy();
764
+ });
765
+
766
+ expect(bulkCalls().length).toBe(
767
+ LIMIT_PER_PROJECT / BULK_SELECT_ALL_PAGE_SIZE,
768
+ );
769
+
770
+ // The warning names the real numbers rather than inferring truncation.
771
+ expect(
772
+ getByText(
773
+ new RegExp(
774
+ `Selected ${LIMIT_PER_PROJECT.toLocaleString()} of ${(
775
+ LIMIT_PER_PROJECT + 250
776
+ ).toLocaleString()} matching Monitors`,
777
+ ),
778
+ ),
779
+ ).toBeTruthy();
780
+ });
781
+
782
+ test("it does not warn about truncation when everything was selected", async () => {
783
+ const { container, getByText, queryByText } = renderTable({
784
+ rows: makeRows(25),
785
+ });
786
+
787
+ await waitFor(() => {
788
+ expect(calls.length).toBeGreaterThan(0);
789
+ });
790
+
791
+ await selectCurrentPage(container);
792
+
793
+ fireEvent.click(getByText("Select All Monitors"));
794
+
795
+ await waitFor(() => {
796
+ expect(getByText("25 Monitors Selected")).toBeTruthy();
797
+ });
798
+
799
+ expect(queryByText(/matching Monitors/)).toBeNull();
800
+ });
801
+
802
+ test("a failed select-all keeps the previous selection and stays retryable", async () => {
803
+ /*
804
+ * The failure mode behind "even though I selected Select All, only the
805
+ * records on the current page are selected": the request failed, the
806
+ * error was swallowed, and the bulk bar hid the Select All button while
807
+ * still holding just the page rows.
808
+ */
809
+ const { container, getByText, queryByText } = renderTable({
810
+ rows: makeRows(1262),
811
+ failWhen: isSelectAllCall,
812
+ });
813
+
814
+ await waitFor(() => {
815
+ expect(calls.length).toBeGreaterThan(0);
816
+ });
817
+
818
+ await selectCurrentPage(container);
819
+
820
+ fireEvent.click(getByText("Select All Monitors"));
821
+
822
+ await waitFor(() => {
823
+ expect(getByText("Select all blew up")).toBeTruthy();
824
+ });
825
+
826
+ // The page selection survived untouched...
827
+ expect(getByText("10 Monitors Selected")).toBeTruthy();
828
+ // ...and the button is still there to try again.
829
+ expect(queryByText("Select All Monitors")).not.toBeNull();
830
+ });
831
+
832
+ test("a failed select-all can be retried, and the error goes away", async () => {
833
+ let shouldFail: boolean = true;
834
+
835
+ const { container, getByText, queryByText } = renderTable({
836
+ rows: makeRows(1262),
837
+ failWhen: (call: GetListCall): boolean => {
838
+ return isSelectAllCall(call) && shouldFail;
839
+ },
840
+ });
841
+
842
+ await waitFor(() => {
843
+ expect(calls.length).toBeGreaterThan(0);
844
+ });
845
+
846
+ await selectCurrentPage(container);
847
+
848
+ fireEvent.click(getByText("Select All Monitors"));
849
+
850
+ await waitFor(() => {
851
+ expect(getByText("Select all blew up")).toBeTruthy();
852
+ });
853
+
854
+ shouldFail = false;
855
+
856
+ fireEvent.click(getByText("Select All Monitors"));
857
+
858
+ await waitFor(() => {
859
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
860
+ });
861
+
862
+ // A stale failure banner under a complete selection would be a lie.
863
+ expect(queryByText("Select all blew up")).toBeNull();
864
+ });
865
+
866
+ test("it reports progress and blocks a second run while it is loading", async () => {
867
+ const { container, getByText, queryByText } = renderTable({
868
+ rows: makeRows(1262),
869
+ deferWhen: isFirstSelectAllCall,
870
+ });
871
+
872
+ await waitFor(() => {
873
+ expect(calls.length).toBeGreaterThan(0);
874
+ });
875
+
876
+ await selectCurrentPage(container);
877
+
878
+ fireEvent.click(getByText("Select All Monitors"));
879
+
880
+ await waitFor(() => {
881
+ expect(getByText("Selecting All Monitors...")).toBeTruthy();
882
+ });
883
+
884
+ // A second click while it runs must not start an overlapping fetch.
885
+ fireEvent.click(getByText("Selecting All Monitors..."));
886
+
887
+ await act(async () => {
888
+ deferredResolve!();
889
+ await new Promise<void>((resolve: () => void) => {
890
+ setTimeout(resolve, 50);
891
+ });
892
+ });
893
+
894
+ await waitFor(() => {
895
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
896
+ });
897
+
898
+ expect(queryByText("Selecting All Monitors...")).toBeNull();
899
+ expect(bulkCalls().length).toBe(2);
900
+ });
901
+
902
+ test("clearing the selection wins over a select-all still in flight", async () => {
903
+ const { container, getByText, queryByText } = renderTable({
904
+ rows: makeRows(1262),
905
+ deferWhen: isFirstSelectAllCall,
906
+ });
907
+
908
+ await waitFor(() => {
909
+ expect(calls.length).toBeGreaterThan(0);
910
+ });
911
+
912
+ await selectCurrentPage(container);
913
+
914
+ fireEvent.click(getByText("Select All Monitors"));
915
+
916
+ await waitFor(() => {
917
+ expect(deferredResolve).not.toBeNull();
918
+ });
919
+
920
+ fireEvent.click(getByText("Clear Selection"));
921
+
922
+ // The bulk bar is gone once nothing is selected.
923
+ await waitFor(() => {
924
+ expect(queryByText("Clear Selection")).toBeNull();
925
+ });
926
+
927
+ await act(async () => {
928
+ deferredResolve!();
929
+ await new Promise<void>((resolve: () => void) => {
930
+ setTimeout(resolve, 50);
931
+ });
932
+ });
933
+
934
+ // The superseded run must not resurrect a selection the user cleared...
935
+ expect(queryByText(/Monitors Selected/)).toBeNull();
936
+ // ...nor keep paging for it.
937
+ expect(bulkCalls().length).toBe(1);
938
+ });
939
+
940
+ /*
941
+ * A selection names rows the *previous* query returned. The dangerous shape
942
+ * is "select all 6,000, then filter down to 12": the bulk bar kept claiming
943
+ * 6,000 selected, kept hiding "Select All" as though the selection matched
944
+ * what was on screen, and a bulk Delete would have deleted 6,000 rows the
945
+ * user could no longer see.
946
+ */
947
+ describe("a selection does not outlive the query that produced it", () => {
948
+ const PROJECT_1: Query<Monitor> = {
949
+ projectId: "project-1",
950
+ } as unknown as Query<Monitor>;
951
+
952
+ const PROJECT_2: Query<Monitor> = {
953
+ projectId: "project-2",
954
+ } as unknown as Query<Monitor>;
955
+
956
+ type GetSearchInputFunction = (container: HTMLElement) => HTMLInputElement;
957
+
958
+ const getSearchInput: GetSearchInputFunction = (
959
+ container: HTMLElement,
960
+ ): HTMLInputElement => {
961
+ return container.querySelector<HTMLInputElement>('input[type="text"]')!;
962
+ };
963
+
964
+ type GoToNextPageFunction = (container: HTMLElement) => void;
965
+
966
+ /*
967
+ * Pagination renders a desktop and a mobile control, so take whichever one
968
+ * is currently enabled rather than assuming a layout.
969
+ */
970
+ const goToNextPage: GoToNextPageFunction = (
971
+ container: HTMLElement,
972
+ ): void => {
973
+ const nextButtons: NodeListOf<HTMLElement> =
974
+ container.querySelectorAll<HTMLElement>(
975
+ '[aria-label="Go to next page"]',
976
+ );
977
+
978
+ for (const button of Array.from(nextButtons)) {
979
+ if (!button.hasAttribute("disabled")) {
980
+ fireEvent.click(button);
981
+ return;
982
+ }
983
+ }
984
+
985
+ throw new Error("No enabled next page button was found");
986
+ };
987
+
988
+ test("a narrowing query change drops a selection made under the old one", async () => {
989
+ const { container, getByText, queryByText, rerender } = renderTable({
990
+ rows: makeRows(1262),
991
+ query: PROJECT_1,
992
+ });
993
+
994
+ await waitFor(() => {
995
+ expect(calls.length).toBeGreaterThan(0);
996
+ });
997
+
998
+ await selectCurrentPage(container);
999
+
1000
+ fireEvent.click(getByText("Select All Monitors"));
1001
+
1002
+ await waitFor(() => {
1003
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
1004
+ });
1005
+
1006
+ // The parent narrows the table - a facet click, say - down to 12 rows.
1007
+ rerenderTable(rerender, { rows: makeRows(12), query: PROJECT_2 });
1008
+
1009
+ await waitFor(() => {
1010
+ expect(queryByText(/Monitors Selected/)).toBeNull();
1011
+ });
1012
+
1013
+ /*
1014
+ * The whole bar goes with it. Leaving "Bulk Actions" on screen over a
1015
+ * stale selection is the part that deletes rows the user cannot see.
1016
+ */
1017
+ expect(queryByText("Bulk Actions")).toBeNull();
1018
+ expect(queryByText("Clear Selection")).toBeNull();
1019
+ });
1020
+
1021
+ test("the Select All button comes back after the query changes", async () => {
1022
+ /*
1023
+ * "All items selected" lives in the table component, and used to survive
1024
+ * every clear except an explicit one - so the next row the user ticked
1025
+ * re-opened the bar with no way to select all of the *new* result set.
1026
+ */
1027
+ const { container, getByText, queryByText, rerender } = renderTable({
1028
+ rows: makeRows(1262),
1029
+ query: PROJECT_1,
1030
+ });
1031
+
1032
+ await waitFor(() => {
1033
+ expect(calls.length).toBeGreaterThan(0);
1034
+ });
1035
+
1036
+ await selectCurrentPage(container);
1037
+
1038
+ fireEvent.click(getByText("Select All Monitors"));
1039
+
1040
+ await waitFor(() => {
1041
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
1042
+ });
1043
+
1044
+ // Everything is selected, so the button is gone.
1045
+ expect(queryByText("Select All Monitors")).toBeNull();
1046
+
1047
+ // Fewer rows than fit on a page, so the page selection is all of them.
1048
+ rerenderTable(rerender, { rows: makeRows(6), query: PROJECT_2 });
1049
+
1050
+ await waitFor(() => {
1051
+ expect(queryByText(/Monitors Selected/)).toBeNull();
1052
+ });
1053
+
1054
+ /*
1055
+ * The old page stays on screen (then a loader) until the refetch lands,
1056
+ * and ticking before the new rows arrive would select the rows on their
1057
+ * way out. There is one checkbox per row, plus the header's.
1058
+ */
1059
+ await waitFor(() => {
1060
+ expect(
1061
+ container.querySelectorAll('input[type="checkbox"]').length,
1062
+ ).toBe(7);
1063
+ });
1064
+
1065
+ await selectCurrentPage(container);
1066
+
1067
+ await waitFor(() => {
1068
+ expect(getByText("6 Monitors Selected")).toBeTruthy();
1069
+ });
1070
+
1071
+ expect(queryByText("Select All Monitors")).not.toBeNull();
1072
+ });
1073
+
1074
+ test("a query change supersedes a select-all still in flight", async () => {
1075
+ const { container, getByText, queryByText, rerender } = renderTable({
1076
+ rows: makeRows(1262),
1077
+ deferWhen: isFirstSelectAllCall,
1078
+ query: PROJECT_1,
1079
+ });
1080
+
1081
+ await waitFor(() => {
1082
+ expect(calls.length).toBeGreaterThan(0);
1083
+ });
1084
+
1085
+ await selectCurrentPage(container);
1086
+
1087
+ fireEvent.click(getByText("Select All Monitors"));
1088
+
1089
+ await waitFor(() => {
1090
+ expect(deferredResolve).not.toBeNull();
1091
+ });
1092
+
1093
+ // No deferWhen this time, so a run that kept going would be visible.
1094
+ rerenderTable(rerender, { rows: makeRows(12), query: PROJECT_2 });
1095
+
1096
+ await act(async () => {
1097
+ deferredResolve!();
1098
+ await new Promise<void>((resolve: () => void) => {
1099
+ setTimeout(resolve, 50);
1100
+ });
1101
+ });
1102
+
1103
+ // The superseded run must not select rows matching the old query...
1104
+ expect(queryByText(/Monitors Selected/)).toBeNull();
1105
+ // ...nor spend more requests paging for them.
1106
+ expect(bulkCalls().length).toBe(1);
1107
+ });
1108
+
1109
+ test("typing a search term drops the selection", async () => {
1110
+ const { container, getByText, queryByText } = renderTable({
1111
+ rows: makeRows(1262),
1112
+ searchableFields: ["name"],
1113
+ });
1114
+
1115
+ await waitFor(() => {
1116
+ expect(calls.length).toBeGreaterThan(0);
1117
+ });
1118
+
1119
+ await selectCurrentPage(container);
1120
+
1121
+ fireEvent.click(getByText("Select All Monitors"));
1122
+
1123
+ await waitFor(() => {
1124
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
1125
+ });
1126
+
1127
+ fireEvent.change(getSearchInput(container), {
1128
+ target: { value: "gateway" },
1129
+ });
1130
+
1131
+ // The search is debounced, so this lands a little after the keystroke.
1132
+ await waitFor(() => {
1133
+ expect(queryByText(/Monitors Selected/)).toBeNull();
1134
+ });
1135
+ });
1136
+
1137
+ test("clearing a search term drops the selection too", async () => {
1138
+ /*
1139
+ * Widening is as wrong as narrowing: the selection would then be a
1140
+ * strict subset presented as "all of them".
1141
+ */
1142
+ const { container, getByText, queryByText } = renderTable({
1143
+ rows: makeRows(300),
1144
+ searchableFields: ["name"],
1145
+ });
1146
+
1147
+ await waitFor(() => {
1148
+ expect(calls.length).toBeGreaterThan(0);
1149
+ });
1150
+
1151
+ fireEvent.change(getSearchInput(container), {
1152
+ target: { value: "gateway" },
1153
+ });
1154
+
1155
+ await waitFor(() => {
1156
+ const lastQuery: JSONObject = calls[calls.length - 1]!
1157
+ .query as unknown as JSONObject;
1158
+ expect(lastQuery["_multiFieldSearch"]).toBeTruthy();
1159
+ });
1160
+
1161
+ await selectCurrentPage(container);
1162
+
1163
+ fireEvent.click(getByText("Select All Monitors"));
1164
+
1165
+ await waitFor(() => {
1166
+ expect(getByText("300 Monitors Selected")).toBeTruthy();
1167
+ });
1168
+
1169
+ fireEvent.change(getSearchInput(container), { target: { value: "" } });
1170
+
1171
+ await waitFor(() => {
1172
+ expect(queryByText(/Monitors Selected/)).toBeNull();
1173
+ });
1174
+ });
1175
+
1176
+ test("paging keeps the selection, because the matching rows are the same", async () => {
1177
+ /*
1178
+ * Deliberate: a page turn re-windows the same result set, and ticking
1179
+ * rows across pages before acting on them is a real workflow.
1180
+ */
1181
+ const { container, getByText } = renderTable({ rows: makeRows(1262) });
1182
+
1183
+ await waitFor(() => {
1184
+ expect(calls.length).toBeGreaterThan(0);
1185
+ });
1186
+
1187
+ await selectCurrentPage(container);
1188
+
1189
+ expect(getByText("10 Monitors Selected")).toBeTruthy();
1190
+
1191
+ goToNextPage(container);
1192
+
1193
+ await waitFor(() => {
1194
+ expect(calls[calls.length - 1]!.skip).toBe(10);
1195
+ });
1196
+
1197
+ expect(getByText("10 Monitors Selected")).toBeTruthy();
1198
+ });
1199
+
1200
+ test("sorting keeps the selection, because the matching rows are the same", async () => {
1201
+ /*
1202
+ * Also deliberate. A sort re-orders the matching set without changing
1203
+ * it, and a truncated select-all keeps reporting "selected N of M
1204
+ * matching", which stays true afterwards.
1205
+ */
1206
+ const { container, getByText, queryByText } = renderTable({
1207
+ rows: makeRows(1262),
1208
+ });
1209
+
1210
+ await waitFor(() => {
1211
+ expect(calls.length).toBeGreaterThan(0);
1212
+ });
1213
+
1214
+ await selectCurrentPage(container);
1215
+
1216
+ fireEvent.click(getByText("Select All Monitors"));
1217
+
1218
+ await waitFor(() => {
1219
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
1220
+ });
1221
+
1222
+ fireEvent.click(getByText("Name"));
1223
+
1224
+ await waitFor(() => {
1225
+ expect(calls[calls.length - 1]!.sort).toEqual({
1226
+ name: SortOrder.Descending,
1227
+ });
1228
+ });
1229
+
1230
+ expect(getByText("1262 Monitors Selected")).toBeTruthy();
1231
+ // Still everything, so the button stays away.
1232
+ expect(queryByText("Select All Monitors")).toBeNull();
1233
+ });
1234
+ });
1235
+
1236
+ test("selecting only the current page still exports just those rows", async () => {
1237
+ const { container, getByText } = renderTable({ rows: makeRows(25) });
1238
+
1239
+ await waitFor(() => {
1240
+ expect(calls.length).toBeGreaterThan(0);
1241
+ });
1242
+
1243
+ await selectCurrentPage(container);
1244
+
1245
+ fireEvent.click(getByText("Bulk Actions"));
1246
+ fireEvent.click(getByText("Export CSV"));
1247
+
1248
+ await waitFor(() => {
1249
+ expect(downloadedCsvFiles.length).toBe(1);
1250
+ });
1251
+
1252
+ const lines: Array<string> = downloadedCsvFiles[0]!.csv.split("\r\n");
1253
+
1254
+ // Header plus the ten rows on screen.
1255
+ expect(lines.length).toBe(11);
1256
+ expect(lines[1]).toBe("Monitor 0,Description 0");
1257
+ });
1258
+ });