@oneuptime/common 12.0.9 → 12.0.11

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 (262) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Models/DatabaseModels/RunnerJob.ts +17 -2
  4. package/Server/API/LlmProviderAPI.ts +8 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +240 -0
  8. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  9. package/Server/Services/NetworkDeviceService.ts +265 -12
  10. package/Server/Services/RunnerJobService.ts +24 -1
  11. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  12. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  13. package/Server/Utils/LLM/LLMService.ts +108 -19
  14. package/Server/Utils/Monitor/MonitorAlert.ts +22 -115
  15. package/Server/Utils/Monitor/MonitorIncident.ts +7 -337
  16. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +27 -2
  17. package/Server/Utils/Monitor/SeriesResourceLabels.ts +32 -0
  18. package/Server/Utils/Monitor/SeriesResourceLinker.ts +469 -0
  19. package/Server/Utils/SSRFProtection.ts +5 -4
  20. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  21. package/Tests/App/AdminDashboard/AdminUsersBulkAddToProject.test.tsx +955 -0
  22. package/Tests/App/AdminDashboard/BulkAddUsersToProject.test.ts +778 -0
  23. package/Tests/App/AdminDashboard/BulkAddUsersToProjectModal.test.tsx +1051 -0
  24. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  25. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +81 -6
  26. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  27. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  28. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  29. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  30. package/Tests/App/Dashboard/UserElementEmail.test.tsx +447 -0
  31. package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +284 -0
  32. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  33. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  34. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  35. package/Tests/Server/Services/NetworkDeviceLabelRuleRun.test.ts +618 -0
  36. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  37. package/Tests/Server/Services/NetworkSiteAssignmentRuleRun.test.ts +706 -0
  38. package/Tests/Server/Services/RunnerJobEnqueue.test.ts +412 -0
  39. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  40. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  41. package/Tests/Server/Utils/AI/LLMServiceRequestPolicy.test.ts +2 -2
  42. package/Tests/Server/Utils/AI/LLMServiceRetryPolicy.test.ts +450 -0
  43. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +556 -0
  44. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +547 -0
  45. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +30 -0
  46. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +699 -0
  47. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +55 -0
  48. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  49. package/Tests/Types/API/URLMalformedTolerance.test.ts +301 -0
  50. package/Tests/Types/API/URLTelScheme.test.ts +261 -0
  51. package/Tests/Types/API/URLUnsupportedScheme.test.ts +148 -0
  52. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  53. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  54. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  55. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +184 -0
  56. package/Tests/Types/Runbook/RunbookStepType.test.ts +124 -0
  57. package/Tests/Types/Workflow/BaseModelComponents.test.ts +17 -0
  58. package/Tests/UI/Components/Button.test.tsx +279 -0
  59. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  60. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  61. package/Tests/UI/Components/DictionaryValuePlaceholderAndSuggestions.test.tsx +595 -0
  62. package/Tests/UI/Components/Icon.test.tsx +57 -0
  63. package/Tests/UI/Components/List.test.tsx +20 -3
  64. package/Tests/UI/Components/LogsSavedViewsDropdown.test.tsx +304 -0
  65. package/Tests/UI/Components/LogsViewerClearSavedView.test.tsx +179 -0
  66. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  67. package/Tests/UI/Components/Pagination.test.tsx +721 -146
  68. package/Tests/UI/Components/PaginationIntegration.test.tsx +386 -0
  69. package/Tests/UI/Components/PaginationUtil.test.ts +417 -0
  70. package/Tests/UI/Components/TelemetrySavedViewsDropdown.test.tsx +343 -0
  71. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnControl.test.ts +289 -0
  72. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnOperators.test.ts +271 -0
  73. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRow.test.ts +119 -0
  74. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.test.ts +968 -0
  75. package/Tests/UI/Components/Workflow/ModelColumnEditor.render.test.tsx +766 -0
  76. package/Tests/UI/Utils/RunNetworkRule.test.ts +320 -0
  77. package/Tests/Utils/APIRetryPolicy.test.ts +823 -0
  78. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  79. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  80. package/Tests/Utils/NetworkAutomation/RuleRunSummary.test.ts +286 -0
  81. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  82. package/Types/API/Protocol.ts +9 -0
  83. package/Types/API/URL.ts +286 -20
  84. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  85. package/Types/CustomField/CustomFieldType.ts +28 -0
  86. package/Types/Monitor/MonitorType.ts +11 -0
  87. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  88. package/Types/NetworkAutomation/RuleRunResult.ts +121 -0
  89. package/Types/Runbook/RunbookStepType.ts +23 -0
  90. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +7 -0
  91. package/UI/Components/Button/Button.tsx +14 -22
  92. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  93. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  94. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  95. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  96. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  97. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  98. package/UI/Components/Dictionary/Dictionary.tsx +4 -39
  99. package/UI/Components/Icon/Icon.tsx +1 -1
  100. package/UI/Components/LogsViewer/LogsViewer.tsx +5 -1
  101. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +8 -0
  102. package/UI/Components/LogsViewer/components/LogsPagination.tsx +29 -91
  103. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +2 -0
  104. package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +66 -1
  105. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  106. package/UI/Components/Pagination/Pagination.tsx +373 -328
  107. package/UI/Components/Pagination/PaginationUtil.ts +263 -0
  108. package/UI/Components/Pagination/Screenshots/README.md +15 -0
  109. package/UI/Components/Pagination/Screenshots/pagination-before-after.png +0 -0
  110. package/UI/Components/Pagination/Screenshots/pagination-go-to-page-modal.png +0 -0
  111. package/UI/Components/Pagination/Screenshots/pagination-long-list.png +0 -0
  112. package/UI/Components/Pagination/Screenshots/pagination-mobile.png +0 -0
  113. package/UI/Components/Pagination/Screenshots/pagination-telemetry.png +0 -0
  114. package/UI/Components/TelemetryViewer/components/SavedViewsDropdown.tsx +66 -1
  115. package/UI/Components/TelemetryViewer/components/TelemetryPagination.tsx +38 -92
  116. package/UI/Components/TextArea/TextArea.tsx +3 -0
  117. package/UI/Components/Workflow/ArgumentsForm.tsx +18 -1
  118. package/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.tsx +177 -0
  119. package/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.tsx +243 -0
  120. package/UI/Components/Workflow/ColumnEditor/ColumnControl.ts +255 -0
  121. package/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.tsx +178 -0
  122. package/UI/Components/Workflow/ColumnEditor/ColumnOperators.ts +206 -0
  123. package/UI/Components/Workflow/ColumnEditor/ColumnRow.ts +157 -0
  124. package/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.ts +517 -0
  125. package/UI/Components/Workflow/ColumnEditor/ColumnValueInput.tsx +482 -0
  126. package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +131 -0
  127. package/UI/Components/Workflow/ColumnEditor/ModelRecordForm.tsx +163 -0
  128. package/UI/Components/Workflow/ModelColumnEditor.tsx +284 -100
  129. package/UI/Utils/NetworkAutomation/RunNetworkRule.ts +122 -0
  130. package/Utils/API.ts +243 -36
  131. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  132. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  133. package/Utils/NetworkAutomation/RuleRunSummary.ts +147 -0
  134. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  135. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  136. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  137. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  138. package/build/dist/Models/DatabaseModels/RunnerJob.js +17 -2
  139. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  140. package/build/dist/Server/API/LlmProviderAPI.js +9 -1
  141. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  143. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  144. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  146. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +202 -0
  147. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
  148. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  149. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  150. package/build/dist/Server/Services/NetworkDeviceService.js +223 -12
  151. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  152. package/build/dist/Server/Services/RunnerJobService.js +20 -2
  153. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  154. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  155. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  156. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  157. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  158. package/build/dist/Server/Utils/LLM/LLMService.js +78 -22
  159. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  160. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -98
  161. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  162. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +7 -254
  163. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  164. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +14 -2
  165. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  166. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +27 -0
  167. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  168. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +285 -0
  169. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -0
  170. package/build/dist/Server/Utils/SSRFProtection.js +5 -4
  171. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  172. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  173. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  174. package/build/dist/Types/API/Protocol.js +9 -0
  175. package/build/dist/Types/API/Protocol.js.map +1 -1
  176. package/build/dist/Types/API/URL.js +241 -18
  177. package/build/dist/Types/API/URL.js.map +1 -1
  178. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  179. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  180. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  181. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  182. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  183. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  184. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +39 -0
  185. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -0
  186. package/build/dist/Types/Runbook/RunbookStepType.js +21 -0
  187. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  188. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +1 -1
  189. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
  190. package/build/dist/UI/Components/Button/Button.js +10 -16
  191. package/build/dist/UI/Components/Button/Button.js.map +1 -1
  192. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  193. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  194. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  195. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  196. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  197. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  198. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  199. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  200. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  201. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  202. package/build/dist/UI/Components/Dictionary/Dictionary.js +4 -18
  203. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  204. package/build/dist/UI/Components/Icon/Icon.js +1 -1
  205. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  206. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +3 -3
  207. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  208. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +5 -1
  209. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  210. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +14 -44
  211. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
  212. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
  213. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  214. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +20 -2
  215. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -1
  216. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  217. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  218. package/build/dist/UI/Components/Pagination/Pagination.js +174 -199
  219. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  220. package/build/dist/UI/Components/Pagination/PaginationUtil.js +160 -0
  221. package/build/dist/UI/Components/Pagination/PaginationUtil.js.map +1 -0
  222. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js +20 -2
  223. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js.map +1 -1
  224. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js +23 -47
  225. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js.map +1 -1
  226. package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
  227. package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
  228. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +14 -2
  229. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  230. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js +86 -0
  231. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js.map +1 -0
  232. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js +119 -0
  233. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js.map +1 -0
  234. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js +206 -0
  235. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js.map +1 -0
  236. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js +57 -0
  237. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js.map +1 -0
  238. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js +150 -0
  239. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js.map +1 -0
  240. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js +67 -0
  241. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js.map +1 -0
  242. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js +309 -0
  243. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js.map +1 -0
  244. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js +204 -0
  245. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js.map +1 -0
  246. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +66 -0
  247. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -0
  248. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js +83 -0
  249. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js.map +1 -0
  250. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +187 -68
  251. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
  252. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js +75 -0
  253. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js.map +1 -0
  254. package/build/dist/Utils/API.js +175 -34
  255. package/build/dist/Utils/API.js.map +1 -1
  256. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  257. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  258. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  259. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  260. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js +78 -0
  261. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js.map +1 -0
  262. package/package.json +1 -1
@@ -0,0 +1,955 @@
1
+ import "@testing-library/jest-dom";
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ /*
4
+ * `act` from React itself rather than from @testing-library/react: on React
5
+ * 18.3 the latter still forwards to the deprecated ReactDOMTestUtils.act, which
6
+ * React asks you not to import.
7
+ */
8
+ import React, { act } from "react";
9
+ import {
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ jest,
15
+ test,
16
+ } from "@jest/globals";
17
+ import type { Mock } from "jest-mock";
18
+ import { Location, MemoryRouter } from "react-router-dom";
19
+
20
+ /*
21
+ * "Add to Project" is the one bulk action on the Users table that cannot run
22
+ * from the bulk bar alone - it needs a project and one of that project's teams
23
+ * first. So the page splits the action in two: the click only opens a picker,
24
+ * and the memberships are created after the picker is submitted.
25
+ *
26
+ * That split is the whole risk. The shared bulk machinery hands the click four
27
+ * callbacks and then waits: the progress modal appears on `onBulkActionStart`,
28
+ * its Close button stays disabled until `onBulkActionEnd`, and only closing it
29
+ * clears the selection and refetches the table. Call them in the wrong order,
30
+ * or miss one on a path, and the admin is left either staring at an empty
31
+ * progress modal behind the picker or at a modal they can never close.
32
+ *
33
+ * None of that is visible from either component on its own - the creates live
34
+ * in BulkAddUsersToProject and the picker only collects - so this file pins the
35
+ * page's sequencing, with both of them stubbed.
36
+ */
37
+
38
+ jest.mock("react-i18next", () => {
39
+ return {
40
+ useTranslation: () => {
41
+ return {
42
+ t: (key: string): string => {
43
+ return key;
44
+ },
45
+ };
46
+ },
47
+ };
48
+ });
49
+
50
+ jest.mock("../../../UI/Utils/Permission", () => {
51
+ return {
52
+ __esModule: true,
53
+ default: {
54
+ getAllPermissions: () => {
55
+ return [];
56
+ },
57
+ getProjectPermissions: () => {
58
+ return [];
59
+ },
60
+ getGlobalPermissions: () => {
61
+ return [];
62
+ },
63
+ },
64
+ };
65
+ });
66
+
67
+ jest.mock("../../../UI/Utils/User", () => {
68
+ return {
69
+ __esModule: true,
70
+ default: {
71
+ isMasterAdmin: () => {
72
+ return true;
73
+ },
74
+ getUserId: () => {
75
+ return null;
76
+ },
77
+ },
78
+ };
79
+ });
80
+
81
+ /*
82
+ * The real ModelTable would drag in the pager, the URL state and a live fetch.
83
+ * What this file is about is the props the page hands it - here the bulk action
84
+ * declarations - so a stand-in that records them is both enough and more
85
+ * precise.
86
+ */
87
+ type CapturedTableProps = {
88
+ bulkActions?: { buttons: Array<BulkActionButtonSchema<User>> } | undefined;
89
+ };
90
+
91
+ let capturedTableProps: CapturedTableProps | null = null;
92
+
93
+ jest.mock("../../../UI/Components/ModelTable/ModelTable", () => {
94
+ return {
95
+ __esModule: true,
96
+ default: (props: CapturedTableProps) => {
97
+ capturedTableProps = props;
98
+ return null;
99
+ },
100
+ };
101
+ });
102
+
103
+ /*
104
+ * Every step the page takes, in the order it took it. The three bulk callbacks
105
+ * are recorded here rather than only counted because "was called" is not the
106
+ * property that matters - `onBulkActionEnd` firing before the creates finish
107
+ * would re-enable the progress modal's Close button mid-run, and the selection
108
+ * would clear while memberships were still being created.
109
+ */
110
+ const recordedEvents: Array<string> = [];
111
+
112
+ const PICKER_TEST_ID: string = "bulk-add-users-to-project-picker";
113
+
114
+ type CapturedModalProps = {
115
+ users: Array<User>;
116
+ onClose: () => void;
117
+ onSubmit: (selection: BulkAddUsersToProjectSelection) => void;
118
+ };
119
+
120
+ let capturedModalProps: CapturedModalProps | null = null;
121
+
122
+ /*
123
+ * The picker stands in for the two-step project/team form. Stubbing it lets the
124
+ * test submit a selection directly, and - because the stub renders a node and
125
+ * records its own teardown - lets the test see exactly when the page took the
126
+ * picker off the screen.
127
+ */
128
+ jest.mock(
129
+ "../../../../App/FeatureSet/AdminDashboard/src/Components/User/BulkAddUsersToProjectModal",
130
+ () => {
131
+ return {
132
+ __esModule: true,
133
+ default: (props: CapturedModalProps) => {
134
+ capturedModalProps = props;
135
+
136
+ React.useEffect(() => {
137
+ return () => {
138
+ recordedEvents.push("picker-closed");
139
+ };
140
+ }, []);
141
+
142
+ return React.createElement("div", { "data-testid": PICKER_TEST_ID });
143
+ },
144
+ };
145
+ },
146
+ );
147
+
148
+ type BulkAddUsersToProjectFunction = (
149
+ options: BulkAddUsersToProjectOptions,
150
+ ) => Promise<BulkAddUsersToProjectResult>;
151
+
152
+ const bulkAddUsersToProjectMock: Mock<BulkAddUsersToProjectFunction> =
153
+ jest.fn<BulkAddUsersToProjectFunction>();
154
+
155
+ /*
156
+ * The creates themselves have their own tests. Stubbing them here leaves only
157
+ * the page's half under test: what it passes down, how it renames the running
158
+ * tally on the way back up, and what it does when the run blows up.
159
+ */
160
+ jest.mock(
161
+ "../../../../App/FeatureSet/AdminDashboard/src/Components/User/BulkAddUsersToProject",
162
+ () => {
163
+ return {
164
+ __esModule: true,
165
+ bulkAddUsersToProject: (options: BulkAddUsersToProjectOptions) => {
166
+ recordedEvents.push("creates-started");
167
+ return bulkAddUsersToProjectMock(options);
168
+ },
169
+ };
170
+ },
171
+ );
172
+
173
+ import Users from "../../../../App/FeatureSet/AdminDashboard/src/Pages/Users/Index";
174
+ import {
175
+ BulkAddUsersToProjectOptions,
176
+ BulkAddUsersToProjectResult,
177
+ } from "../../../../App/FeatureSet/AdminDashboard/src/Components/User/BulkAddUsersToProject";
178
+ import { BulkAddUsersToProjectSelection } from "../../../../App/FeatureSet/AdminDashboard/src/Components/User/BulkAddUsersToProjectModal";
179
+ import {
180
+ BulkActionButtonSchema,
181
+ ProgressInfo,
182
+ } from "../../../UI/Components/BulkUpdate/BulkUpdateForm";
183
+ import IconProp from "../../../Types/Icon/IconProp";
184
+ import ObjectID from "../../../Types/ObjectID";
185
+ import Navigation from "../../../UI/Utils/Navigation";
186
+ import User from "../../../Models/DatabaseModels/User";
187
+
188
+ const ADD_TO_PROJECT_TITLE_KEY: string = "pages.users.bulkAddToProject";
189
+
190
+ /*
191
+ * What the server throws when a selected user is already on the team. It is a
192
+ * normal outcome of a bulk add - a few of the selected users being on the
193
+ * project already must not stop the rest - so it travels as a per-user message
194
+ * rather than as a failure of the run.
195
+ */
196
+ const ALREADY_INVITED_MESSAGE: string =
197
+ "This user has already been invited to this team";
198
+
199
+ /*
200
+ * What the run itself failing looks like - the request never left, the session
201
+ * expired. Distinct from ALREADY_INVITED_MESSAGE above, which is a per-user
202
+ * outcome the creates report and carry on from.
203
+ */
204
+ const RUN_BROKE_MESSAGE: string =
205
+ "Error connecting to server. Please try again.";
206
+
207
+ const SELECTION: BulkAddUsersToProjectSelection = {
208
+ projectId: ObjectID.generate(),
209
+ teamId: ObjectID.generate(),
210
+ hasAcceptedInvitation: true,
211
+ };
212
+
213
+ /*
214
+ * The same pick with the auto-accept checkbox left alone, which is its default.
215
+ * It exists so the flag is exercised in both positions: a page that ignored the
216
+ * picker's answer and always sent `true` would be indistinguishable from a
217
+ * correct one if every test submitted the same selection.
218
+ */
219
+ const SELECTION_WITHOUT_AUTO_ACCEPT: BulkAddUsersToProjectSelection = {
220
+ projectId: ObjectID.generate(),
221
+ teamId: ObjectID.generate(),
222
+ hasAcceptedInvitation: false,
223
+ };
224
+
225
+ type UserFactoryFunction = () => User;
226
+
227
+ const aUser: UserFactoryFunction = (): User => {
228
+ const user: User = new User();
229
+ user.id = ObjectID.generate();
230
+ return user;
231
+ };
232
+
233
+ let userOne: User = aUser();
234
+ let userTwo: User = aUser();
235
+ let userThree: User = aUser();
236
+ let userFour: User = aUser();
237
+ let userFive: User = aUser();
238
+
239
+ type ResultFactoryFunction = (
240
+ users: Array<User>,
241
+ ) => BulkAddUsersToProjectResult;
242
+
243
+ const allSucceeded: ResultFactoryFunction = (
244
+ users: Array<User>,
245
+ ): BulkAddUsersToProjectResult => {
246
+ return {
247
+ totalUsers: users,
248
+ inProgressUsers: [],
249
+ succeededUsers: users,
250
+ failedUsers: [],
251
+ };
252
+ };
253
+
254
+ type BulkActionButtonsFunction = () => Array<BulkActionButtonSchema<User>>;
255
+
256
+ const bulkActionButtons: BulkActionButtonsFunction = (): Array<
257
+ BulkActionButtonSchema<User>
258
+ > => {
259
+ return capturedTableProps?.bulkActions?.buttons || [];
260
+ };
261
+
262
+ type BulkActionTitledFunction = (title: string) => BulkActionButtonSchema<User>;
263
+
264
+ const bulkActionTitled: BulkActionTitledFunction = (
265
+ title: string,
266
+ ): BulkActionButtonSchema<User> => {
267
+ const button: BulkActionButtonSchema<User> | undefined =
268
+ bulkActionButtons().find((candidate: BulkActionButtonSchema<User>) => {
269
+ return candidate.title === title;
270
+ });
271
+
272
+ if (!button) {
273
+ throw new Error(
274
+ `No "${title}" bulk action. Bulk actions: ${bulkActionButtons()
275
+ .map((candidate: BulkActionButtonSchema<User>) => {
276
+ return candidate.title;
277
+ })
278
+ .join(", ")}`,
279
+ );
280
+ }
281
+
282
+ return button;
283
+ };
284
+
285
+ /*
286
+ * Stand-ins for the four things the bulk bar hands an action's onClick. Kept as
287
+ * mocks rather than as a real BulkUpdateForm because what is being pinned is
288
+ * which of them the page calls, and when.
289
+ */
290
+ type BulkActionCallbackMocks = {
291
+ items: Array<User>;
292
+ onProgressInfo: Mock<(progressInfo: ProgressInfo<User>) => void>;
293
+ onBulkActionStart: Mock<() => void>;
294
+ onBulkActionEnd: Mock<() => void>;
295
+ };
296
+
297
+ type BulkActionCallbacksFactoryFunction = (
298
+ items: Array<User>,
299
+ ) => BulkActionCallbackMocks;
300
+
301
+ const bulkActionCallbacks: BulkActionCallbacksFactoryFunction = (
302
+ items: Array<User>,
303
+ ): BulkActionCallbackMocks => {
304
+ return {
305
+ items: items,
306
+ onProgressInfo: jest.fn<(progressInfo: ProgressInfo<User>) => void>(),
307
+ onBulkActionStart: jest.fn<() => void>((): void => {
308
+ recordedEvents.push("bulk-action-start");
309
+ }),
310
+ onBulkActionEnd: jest.fn<() => void>((): void => {
311
+ recordedEvents.push("bulk-action-end");
312
+ }),
313
+ };
314
+ };
315
+
316
+ type InActFunction = (work: () => Promise<void> | void) => Promise<void>;
317
+
318
+ /*
319
+ * `await act(...)` with a hand-attached continuation.
320
+ *
321
+ * act() returns a bare thenable and then, two microtasks later, warns if
322
+ * nothing has called its `then` yet. A plain `await` only attaches that handler
323
+ * on the next microtask, and zone.js - loaded here through the page's
324
+ * dependencies, and it patches Promise - lands the check first. The result is
325
+ * an "act without await" warning on every call that IS awaited, which is
326
+ * exactly the warning a reader of a sequencing test must be able to trust.
327
+ * Calling `then` directly enters the scope's continuation there and then.
328
+ */
329
+ const inAct: InActFunction = (
330
+ work: () => Promise<void> | void,
331
+ ): Promise<void> => {
332
+ const actScope: Promise<void> = act(async () => {
333
+ await work();
334
+ });
335
+
336
+ return new Promise<void>(
337
+ (
338
+ resolve: (value: void | PromiseLike<void>) => void,
339
+ reject: (reason: unknown) => void,
340
+ ): void => {
341
+ actScope.then(resolve, reject);
342
+ },
343
+ );
344
+ };
345
+
346
+ type ClickAddToProjectFunction = (
347
+ callbacks: BulkActionCallbackMocks,
348
+ ) => Promise<void>;
349
+
350
+ const clickAddToProject: ClickAddToProjectFunction = async (
351
+ callbacks: BulkActionCallbackMocks,
352
+ ): Promise<void> => {
353
+ await inAct(() => {
354
+ return bulkActionTitled(ADD_TO_PROJECT_TITLE_KEY).onClick(callbacks);
355
+ });
356
+ };
357
+
358
+ type SubmitPickerFunction = (
359
+ selection?: BulkAddUsersToProjectSelection,
360
+ ) => Promise<void>;
361
+
362
+ const submitPicker: SubmitPickerFunction = async (
363
+ selection: BulkAddUsersToProjectSelection = SELECTION,
364
+ ): Promise<void> => {
365
+ const picker: CapturedModalProps | null = capturedModalProps;
366
+
367
+ if (!picker) {
368
+ throw new Error("The picker was never rendered.");
369
+ }
370
+
371
+ await inAct(() => {
372
+ picker.onSubmit(selection);
373
+ });
374
+ };
375
+
376
+ type ClosePickerFunction = () => Promise<void>;
377
+
378
+ const closePicker: ClosePickerFunction = async (): Promise<void> => {
379
+ const picker: CapturedModalProps | null = capturedModalProps;
380
+
381
+ if (!picker) {
382
+ throw new Error("The picker was never rendered.");
383
+ }
384
+
385
+ await inAct(() => {
386
+ picker.onClose();
387
+ });
388
+ };
389
+
390
+ type CreateOptionsFunction = () => BulkAddUsersToProjectOptions;
391
+
392
+ const createOptions: CreateOptionsFunction =
393
+ (): BulkAddUsersToProjectOptions => {
394
+ const options: BulkAddUsersToProjectOptions | undefined =
395
+ bulkAddUsersToProjectMock.mock.calls[0]?.[0];
396
+
397
+ if (!options) {
398
+ throw new Error("bulkAddUsersToProject was never called.");
399
+ }
400
+
401
+ return options;
402
+ };
403
+
404
+ describe("Admin > Users > Add to Project bulk action", () => {
405
+ beforeEach(async () => {
406
+ capturedTableProps = null;
407
+ capturedModalProps = null;
408
+ recordedEvents.length = 0;
409
+
410
+ userOne = aUser();
411
+ userTwo = aUser();
412
+ userThree = aUser();
413
+ userFour = aUser();
414
+ userFive = aUser();
415
+
416
+ bulkAddUsersToProjectMock.mockReset();
417
+ bulkAddUsersToProjectMock.mockImplementation(
418
+ (
419
+ options: BulkAddUsersToProjectOptions,
420
+ ): Promise<BulkAddUsersToProjectResult> => {
421
+ return Promise.resolve(allSucceeded(options.users));
422
+ },
423
+ );
424
+
425
+ /*
426
+ * The page reads the current route to build the row's view link. In the
427
+ * app the router pushes this in; under test nothing does.
428
+ */
429
+ Navigation.setLocation({
430
+ pathname: "/admin/users",
431
+ search: "",
432
+ hash: "",
433
+ state: null,
434
+ key: "test",
435
+ } as Location);
436
+
437
+ render(
438
+ <MemoryRouter>
439
+ <Users />
440
+ </MemoryRouter>,
441
+ );
442
+
443
+ await waitFor(() => {
444
+ expect(capturedTableProps).not.toBeNull();
445
+ });
446
+ });
447
+
448
+ afterEach(() => {
449
+ cleanup();
450
+ });
451
+
452
+ describe("how it is declared", () => {
453
+ /*
454
+ * The bulk menu puts the destructive actions last, behind a divider, so the
455
+ * first entry is the one an admin's eye lands on. Adding users to a project
456
+ * is the everyday reason to select rows here; blocking and deleting are
457
+ * not.
458
+ */
459
+ test("is the first bulk action offered", () => {
460
+ expect(bulkActionButtons()[0]?.title).toBe(ADD_TO_PROJECT_TITLE_KEY);
461
+ });
462
+
463
+ /*
464
+ * The action was inserted at the head of an existing array. A stray edit
465
+ * there would drop or reorder the actions the page already had, and the
466
+ * bulk menu renders whatever it is given without complaint.
467
+ */
468
+ test("has not displaced Block, Unblock or Delete", () => {
469
+ const legacyTitles: Array<string> = ["Block", "Unblock", "Delete"];
470
+
471
+ expect(
472
+ bulkActionButtons()
473
+ .map((button: BulkActionButtonSchema<User>) => {
474
+ return button.title;
475
+ })
476
+ .filter((title: string) => {
477
+ return legacyTitles.includes(title);
478
+ }),
479
+ ).toEqual(legacyTitles);
480
+ });
481
+
482
+ /*
483
+ * BulkUpdateForm shows a ConfirmModal *instead of* running the click when
484
+ * `confirmMessage` is set, and only runs the click once it is submitted.
485
+ * For this action the click is what opens the picker, so a confirm message
486
+ * would put an "are you sure" dialog in front of a form the admin has not
487
+ * even filled in yet - it has nothing to confirm.
488
+ */
489
+ test("declares no confirm dialog to stack in front of the picker", () => {
490
+ expect(
491
+ bulkActionTitled(ADD_TO_PROJECT_TITLE_KEY).confirmMessage,
492
+ ).toBeUndefined();
493
+ });
494
+
495
+ /*
496
+ * The title is a translation key, not an English string: this page is
497
+ * rendered in every locale the admin dashboard ships, and a hardcoded label
498
+ * would silently stay English. The icon is what tells the two "add"-ish
499
+ * entries apart in the menu.
500
+ */
501
+ test("declares its icon and its translated title key", () => {
502
+ expect(bulkActionTitled(ADD_TO_PROJECT_TITLE_KEY).title).toBe(
503
+ "pages.users.bulkAddToProject",
504
+ );
505
+ expect(bulkActionTitled(ADD_TO_PROJECT_TITLE_KEY).icon).toBe(
506
+ IconProp.FolderPlus,
507
+ );
508
+ });
509
+ });
510
+
511
+ describe("clicking it", () => {
512
+ /*
513
+ * The click is the only thing that opens the picker. The page renders it
514
+ * off a piece of state that starts null, and a regression that rendered it
515
+ * unconditionally would put a project/team form in front of an admin who
516
+ * came only to read the table.
517
+ *
518
+ * Every other test in this file clicks first, so each of them would still
519
+ * find a picker on screen if it had been there all along - which makes
520
+ * "opens the picker" a claim none of them can actually support without
521
+ * this one.
522
+ */
523
+ test("renders no picker before the action is clicked", () => {
524
+ expect(screen.queryByTestId(PICKER_TEST_ID)).not.toBeInTheDocument();
525
+ expect(capturedModalProps).toBeNull();
526
+ });
527
+
528
+ /*
529
+ * `onBulkActionStart` is what puts the progress modal on screen and flips
530
+ * the run to in-progress. Calling it here - before the admin has chosen a
531
+ * project - would park an empty progress bar behind the picker, with a
532
+ * Close button disabled for a run that has not started.
533
+ */
534
+ test("starts nothing", async () => {
535
+ const callbacks: BulkActionCallbackMocks = bulkActionCallbacks([
536
+ userOne,
537
+ userTwo,
538
+ ]);
539
+
540
+ await clickAddToProject(callbacks);
541
+
542
+ expect(callbacks.onBulkActionStart).not.toHaveBeenCalled();
543
+ expect(bulkAddUsersToProjectMock).not.toHaveBeenCalled();
544
+ });
545
+
546
+ /*
547
+ * The picker has to be handed the rows the click carried, not the page's
548
+ * own idea of the selection - the page never sees the table's selection
549
+ * state, it only ever gets it through the click. Handing it anything else
550
+ * would create memberships for users the admin did not tick.
551
+ */
552
+ test("opens the picker over exactly the selected users", async () => {
553
+ const callbacks: BulkActionCallbackMocks = bulkActionCallbacks([
554
+ userOne,
555
+ userTwo,
556
+ ]);
557
+
558
+ /*
559
+ * Pinned here too, and not only in the test above, so that this test's
560
+ * own "opens" is about the click rather than about a picker that was
561
+ * already there.
562
+ */
563
+ expect(screen.queryByTestId(PICKER_TEST_ID)).not.toBeInTheDocument();
564
+
565
+ await clickAddToProject(callbacks);
566
+
567
+ expect(screen.getByTestId(PICKER_TEST_ID)).toBeInTheDocument();
568
+ expect(capturedModalProps?.users).toBe(callbacks.items);
569
+ });
570
+
571
+ /*
572
+ * The bulk bar hands a fresh set of props to every click, and the page has
573
+ * to be looking at the latest one - both when it renders the picker and
574
+ * when it finally runs the creates.
575
+ *
576
+ * An admin who opens the picker, backs out, changes their ticks and opens
577
+ * it again is the ordinary way to get two clicks with different rows. If
578
+ * the page held onto the first click - a stale closure over it, or a picker
579
+ * React reuses across the two opens without re-reading the state - the
580
+ * memberships would be created for rows that are no longer selected, and
581
+ * the progress modal would report against the first click's callbacks while
582
+ * the second click's are left waiting forever.
583
+ */
584
+ test("reopening after a cancel uses the second click's users, not the first's", async () => {
585
+ const firstClick: BulkActionCallbackMocks = bulkActionCallbacks([
586
+ userOne,
587
+ ]);
588
+
589
+ await clickAddToProject(firstClick);
590
+
591
+ expect(capturedModalProps?.users).toBe(firstClick.items);
592
+
593
+ await closePicker();
594
+
595
+ expect(screen.queryByTestId(PICKER_TEST_ID)).not.toBeInTheDocument();
596
+
597
+ const secondClick: BulkActionCallbackMocks = bulkActionCallbacks([
598
+ userTwo,
599
+ userThree,
600
+ ]);
601
+
602
+ await clickAddToProject(secondClick);
603
+
604
+ expect(screen.getByTestId(PICKER_TEST_ID)).toBeInTheDocument();
605
+ expect(capturedModalProps?.users).toBe(secondClick.items);
606
+
607
+ await submitPicker();
608
+
609
+ expect(createOptions().users).toBe(secondClick.items);
610
+ expect(secondClick.onBulkActionStart).toHaveBeenCalledTimes(1);
611
+ expect(secondClick.onBulkActionEnd).toHaveBeenCalledTimes(1);
612
+
613
+ /*
614
+ * The abandoned click's run must stay unstarted and unended: ending it
615
+ * would clear a selection the admin never submitted.
616
+ */
617
+ expect(firstClick.onBulkActionStart).not.toHaveBeenCalled();
618
+ expect(firstClick.onBulkActionEnd).not.toHaveBeenCalled();
619
+ });
620
+ });
621
+
622
+ describe("closing the picker without submitting", () => {
623
+ /*
624
+ * Backing out is not a run. Starting one would leave a progress modal the
625
+ * admin never asked for, and - worse - ending one that never started makes
626
+ * the table clear the selection, throwing away the rows they picked. The
627
+ * selection has to survive so they can reopen the picker and try again.
628
+ */
629
+ test("leaves the run unstarted and unended", async () => {
630
+ const callbacks: BulkActionCallbackMocks = bulkActionCallbacks([
631
+ userOne,
632
+ userTwo,
633
+ ]);
634
+
635
+ await clickAddToProject(callbacks);
636
+
637
+ const picker: CapturedModalProps | null = capturedModalProps;
638
+
639
+ await inAct(() => {
640
+ picker?.onClose();
641
+ });
642
+
643
+ expect(screen.queryByTestId(PICKER_TEST_ID)).not.toBeInTheDocument();
644
+ expect(bulkAddUsersToProjectMock).not.toHaveBeenCalled();
645
+ expect(callbacks.onBulkActionStart).not.toHaveBeenCalled();
646
+ expect(callbacks.onBulkActionEnd).not.toHaveBeenCalled();
647
+ });
648
+ });
649
+
650
+ describe("submitting the picker", () => {
651
+ /*
652
+ * The order is the feature, but read the asserted list carefully: it is
653
+ * NOT evidence that the picker leaves the screen before the creates begin,
654
+ * and no test could be.
655
+ *
656
+ * Dismissing the picker and starting the run are two state updates made
657
+ * from one handler, so React commits them together. "picker-closed" is the
658
+ * stub's unmount effect, which runs when that single commit flushes - after
659
+ * the handler has already called `onBulkActionStart` and reached the
660
+ * creates synchronously. Hence start, creates, picker-closed. Both updates
661
+ * landing in one commit is the actual guarantee: the admin never sees a
662
+ * frame with the picker up and the run already going.
663
+ *
664
+ * What this pins is the part that does matter. `onBulkActionStart` runs
665
+ * before the creates, and `onBulkActionEnd` only once they settle - the
666
+ * mid-run assertions below hold the creates open to prove `end` has not
667
+ * fired yet. Ending early re-enables the progress modal's Close button, and
668
+ * closing it is what clears the selection and refetches the table, so an
669
+ * early end throws away the selection while memberships are still being
670
+ * created. And by the time the run is observably in flight the picker is
671
+ * already off the screen, so the progress modal is not stacked behind it.
672
+ */
673
+ test("closes the picker, then runs start -> creates -> end in order", async () => {
674
+ const callbacks: BulkActionCallbackMocks = bulkActionCallbacks([
675
+ userOne,
676
+ userTwo,
677
+ ]);
678
+
679
+ await clickAddToProject(callbacks);
680
+
681
+ /*
682
+ * Held open so the assertions can look at the page mid-run, which is the
683
+ * only moment at which "the picker is gone but the run is not finished"
684
+ * is a distinguishable state.
685
+ */
686
+ const run: { finish: () => void } = {
687
+ finish: (): void => {
688
+ throw new Error("The creates were never started.");
689
+ },
690
+ };
691
+
692
+ bulkAddUsersToProjectMock.mockImplementation(
693
+ (
694
+ options: BulkAddUsersToProjectOptions,
695
+ ): Promise<BulkAddUsersToProjectResult> => {
696
+ return new Promise<BulkAddUsersToProjectResult>(
697
+ (resolve: (result: BulkAddUsersToProjectResult) => void) => {
698
+ run.finish = (): void => {
699
+ resolve(allSucceeded(options.users));
700
+ };
701
+ },
702
+ );
703
+ },
704
+ );
705
+
706
+ await submitPicker();
707
+
708
+ expect(screen.queryByTestId(PICKER_TEST_ID)).not.toBeInTheDocument();
709
+ expect(callbacks.onBulkActionEnd).not.toHaveBeenCalled();
710
+
711
+ await inAct(() => {
712
+ run.finish();
713
+ });
714
+
715
+ expect(callbacks.onBulkActionEnd).toHaveBeenCalledTimes(1);
716
+ expect(recordedEvents).toEqual([
717
+ "bulk-action-start",
718
+ "creates-started",
719
+ "picker-closed",
720
+ "bulk-action-end",
721
+ ]);
722
+ });
723
+
724
+ /*
725
+ * The selection the picker collected has to reach the creates intact, and
726
+ * against the users the click carried. `hasAcceptedInvitation` in
727
+ * particular is the difference between adding members outright and sending
728
+ * invitations nobody asked for - only a master admin may do the former, so
729
+ * dropping the flag silently downgrades the whole batch to pending.
730
+ */
731
+ test("hands the creates the clicked users and the picked project, team and auto-accept", async () => {
732
+ const callbacks: BulkActionCallbackMocks = bulkActionCallbacks([
733
+ userOne,
734
+ userTwo,
735
+ ]);
736
+
737
+ await clickAddToProject(callbacks);
738
+ await submitPicker();
739
+
740
+ expect(createOptions().users).toBe(callbacks.items);
741
+ expect(createOptions().projectId).toBe(SELECTION.projectId);
742
+ expect(createOptions().teamId).toBe(SELECTION.teamId);
743
+ expect(createOptions().hasAcceptedInvitation).toBe(true);
744
+ });
745
+
746
+ /*
747
+ * The other half of that flag, and the reason it needs its own test: the
748
+ * checkbox defaults to unticked, so `false` is what an ordinary submit
749
+ * carries. A page that ignored `selection.hasAcceptedInvitation` and always
750
+ * sent `true` would satisfy the test above and every other test in this
751
+ * file, because they all submit the same auto-accept selection.
752
+ *
753
+ * Sending `true` there is a consent bug, not a cosmetic one. `true` makes
754
+ * each user a member of the project outright - the invitation they were
755
+ * meant to accept for themselves is never offered, and they are on a
756
+ * project they never agreed to join. Unticked has to reach the creates as
757
+ * unticked.
758
+ */
759
+ test("forwards an unticked auto-accept as false rather than assuming true", async () => {
760
+ const callbacks: BulkActionCallbackMocks = bulkActionCallbacks([
761
+ userOne,
762
+ userTwo,
763
+ ]);
764
+
765
+ await clickAddToProject(callbacks);
766
+ await submitPicker(SELECTION_WITHOUT_AUTO_ACCEPT);
767
+
768
+ expect(createOptions().hasAcceptedInvitation).toBe(false);
769
+
770
+ /*
771
+ * The rest of the pick travels from the same selection object, so pin it
772
+ * here too: reading the flag off one selection and the ids off another
773
+ * would be a stranger bug, but a silent one.
774
+ */
775
+ expect(createOptions().projectId).toBe(
776
+ SELECTION_WITHOUT_AUTO_ACCEPT.projectId,
777
+ );
778
+ expect(createOptions().teamId).toBe(SELECTION_WITHOUT_AUTO_ACCEPT.teamId);
779
+ });
780
+
781
+ /*
782
+ * The two sides speak different dialects: the creates count users, the
783
+ * shared progress modal counts items. The page is the only translator, and
784
+ * every one of the four names differs. Get one wrong and the field arrives
785
+ * `undefined` - the progress bar reads 0 of 0, or the per-user failure list
786
+ * renders empty, which is exactly the list an admin came here to read.
787
+ */
788
+ test("renames the running tally into the shared progress modal's shape", async () => {
789
+ const callbacks: BulkActionCallbackMocks = bulkActionCallbacks([
790
+ userOne,
791
+ userTwo,
792
+ ]);
793
+
794
+ await clickAddToProject(callbacks);
795
+ await submitPicker();
796
+
797
+ createOptions().onProgress?.({
798
+ totalUsers: [userOne, userTwo],
799
+ inProgressUsers: [userTwo],
800
+ succeededUsers: [userOne],
801
+ failedUsers: [
802
+ {
803
+ user: userTwo,
804
+ failedMessage: ALREADY_INVITED_MESSAGE,
805
+ },
806
+ ],
807
+ });
808
+
809
+ expect(callbacks.onProgressInfo).toHaveBeenCalledTimes(1);
810
+ expect(callbacks.onProgressInfo).toHaveBeenCalledWith({
811
+ totalItems: [userOne, userTwo],
812
+ inProgressItems: [userTwo],
813
+ successItems: [userOne],
814
+ failed: [
815
+ {
816
+ item: userTwo,
817
+ failedMessage: ALREADY_INVITED_MESSAGE,
818
+ },
819
+ ],
820
+ });
821
+ });
822
+
823
+ /*
824
+ * A user who is already on the team comes back as a per-user failure, so
825
+ * reaching the catch means the run itself broke - the request never left,
826
+ * the session expired, something unforeseen. The progress modal's Close
827
+ * button is disabled for as long as the action is in progress, and closing
828
+ * it is the only thing that clears the selection and refetches the table.
829
+ * So a missed `onBulkActionEnd` here is not a lost error message, it is a
830
+ * modal the admin can never dismiss.
831
+ *
832
+ * This is the reject that happens before a single user was attempted, so
833
+ * "nobody was added" is the truth and the whole selection is reported
834
+ * failed. The test below covers the reject that lands part-way through,
835
+ * where reporting the whole selection would be a lie.
836
+ */
837
+ test("still ends the run, and reports every user failed, when the creates reject before any user is attempted", async () => {
838
+ const callbacks: BulkActionCallbackMocks = bulkActionCallbacks([
839
+ userOne,
840
+ userTwo,
841
+ ]);
842
+
843
+ await clickAddToProject(callbacks);
844
+
845
+ bulkAddUsersToProjectMock.mockImplementation(
846
+ (): Promise<BulkAddUsersToProjectResult> => {
847
+ return Promise.reject(new Error(RUN_BROKE_MESSAGE));
848
+ },
849
+ );
850
+
851
+ await submitPicker();
852
+
853
+ expect(callbacks.onBulkActionEnd).toHaveBeenCalledTimes(1);
854
+ expect(callbacks.onProgressInfo).toHaveBeenCalledWith({
855
+ totalItems: callbacks.items,
856
+ inProgressItems: [],
857
+ successItems: [],
858
+ failed: [
859
+ {
860
+ item: userOne,
861
+ failedMessage: RUN_BROKE_MESSAGE,
862
+ },
863
+ {
864
+ item: userTwo,
865
+ failedMessage: RUN_BROKE_MESSAGE,
866
+ },
867
+ ],
868
+ });
869
+ });
870
+
871
+ /*
872
+ * The creates run one user at a time and report after each, so a run that
873
+ * breaks half way has already created real memberships. Those users are on
874
+ * the project whatever happens next.
875
+ *
876
+ * The final report is the only record the admin gets - the progress modal
877
+ * is what they read, and closing it clears the selection and refetches. So
878
+ * the recovery has to keep what the last report said: the users already
879
+ * added stay added, the per-user failure already recorded (here: already on
880
+ * the team) keeps its own message rather than being overwritten by the
881
+ * run's, and only the users never attempted are added as failures. Blanket
882
+ * "everything failed" would send the admin to re-add users who are already
883
+ * members, and blanket "everything succeeded" would leave the not-attempted
884
+ * ones off the project with nothing on screen saying so.
885
+ *
886
+ * `onBulkActionEnd` still has to run on this path too, for the same reason
887
+ * as above - otherwise the Close button never unlocks.
888
+ */
889
+ test("keeps the already-added users and fails only the not-yet-attempted ones when the creates reject mid-run", async () => {
890
+ const callbacks: BulkActionCallbackMocks = bulkActionCallbacks([
891
+ userOne,
892
+ userTwo,
893
+ userThree,
894
+ userFour,
895
+ userFive,
896
+ ]);
897
+
898
+ await clickAddToProject(callbacks);
899
+
900
+ bulkAddUsersToProjectMock.mockImplementation(
901
+ (
902
+ options: BulkAddUsersToProjectOptions,
903
+ ): Promise<BulkAddUsersToProjectResult> => {
904
+ /*
905
+ * Three users dealt with - two added, one already on the team - and
906
+ * then the run breaks with userFour and userFive never attempted.
907
+ */
908
+ options.onProgress?.({
909
+ totalUsers: [userOne, userTwo, userThree, userFour, userFive],
910
+ inProgressUsers: [userFour, userFive],
911
+ succeededUsers: [userOne, userTwo],
912
+ failedUsers: [
913
+ {
914
+ user: userThree,
915
+ failedMessage: ALREADY_INVITED_MESSAGE,
916
+ },
917
+ ],
918
+ });
919
+
920
+ return Promise.reject(new Error(RUN_BROKE_MESSAGE));
921
+ },
922
+ );
923
+
924
+ await submitPicker();
925
+
926
+ expect(callbacks.onBulkActionEnd).toHaveBeenCalledTimes(1);
927
+
928
+ /*
929
+ * Once for the mid-run tally, once for the recovery. The recovery report
930
+ * is what the admin is left looking at, so it is the last one that has to
931
+ * be right.
932
+ */
933
+ expect(callbacks.onProgressInfo).toHaveBeenCalledTimes(2);
934
+ expect(callbacks.onProgressInfo).toHaveBeenLastCalledWith({
935
+ totalItems: [userOne, userTwo, userThree, userFour, userFive],
936
+ inProgressItems: [],
937
+ successItems: [userOne, userTwo],
938
+ failed: [
939
+ {
940
+ item: userThree,
941
+ failedMessage: ALREADY_INVITED_MESSAGE,
942
+ },
943
+ {
944
+ item: userFour,
945
+ failedMessage: RUN_BROKE_MESSAGE,
946
+ },
947
+ {
948
+ item: userFive,
949
+ failedMessage: RUN_BROKE_MESSAGE,
950
+ },
951
+ ],
952
+ });
953
+ });
954
+ });
955
+ });