@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,1051 @@
1
+ import "@testing-library/jest-dom";
2
+ import { fireEvent, render, waitFor } from "@testing-library/react";
3
+ import React from "react";
4
+ import { beforeEach, describe, expect, jest, test } from "@jest/globals";
5
+ import fs from "fs";
6
+ import path from "path";
7
+
8
+ /*
9
+ * This modal only COLLECTS. It asks for a project, one of that project's teams
10
+ * and whether to accept the invitations, then hands the three of them to
11
+ * props.onSubmit - the page is what creates the TeamMembers and feeds the
12
+ * table's shared bulk progress modal. So every test here is about what comes
13
+ * out of onSubmit, and nothing here mocks a create: a create appearing in this
14
+ * file would mean the collecting and the creating had been fused back together,
15
+ * which is what keeps the picker off the progress modal in the first place.
16
+ */
17
+
18
+ /*
19
+ * Common/jest.config.json sets no testTimeout, so the default 5s has to cover
20
+ * mounting Modal + BasicForm + the async teams fetch, twice over in the tests
21
+ * that walk both steps.
22
+ */
23
+ jest.setTimeout(30000);
24
+
25
+ /*
26
+ * waitFor's own default ceiling is 1s, which is the tighter of the two and the
27
+ * one that actually bites on a loaded CI box.
28
+ */
29
+ const WAIT_FOR_TIMEOUT: number = 20000;
30
+
31
+ const PROJECT_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162001";
32
+ const TEAM_ENGINEERING_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162002";
33
+ const TEAM_SUPPORT_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162003";
34
+ /*
35
+ * A second project with a team of its own. The two team sets are disjoint on
36
+ * purpose: it is the only way a test can tell "the team of the project that is
37
+ * selected now" apart from "the team of the project that was selected before",
38
+ * which is the whole of the stale-selection bug.
39
+ */
40
+ const OTHER_PROJECT_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162004";
41
+ const TEAM_BILLING_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162005";
42
+ const USER_ONE_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162011";
43
+ const USER_TWO_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162012";
44
+ const USER_THREE_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194162013";
45
+
46
+ interface TranslationCall {
47
+ key: string;
48
+ options: Record<string, unknown> | undefined;
49
+ }
50
+
51
+ const translationCalls: Array<TranslationCall> = [];
52
+
53
+ /*
54
+ * t() echoes its key so assertions can target the raw key strings rather than
55
+ * today's English wording. The second argument matters as much as the first:
56
+ * the description is interpolated, and a key-only echo would happily pass a
57
+ * modal that had stopped passing the user count at all.
58
+ */
59
+ const translate: (key: string, options?: Record<string, unknown>) => string = (
60
+ key: string,
61
+ options?: Record<string, unknown>,
62
+ ): string => {
63
+ translationCalls.push({ key: key, options: options });
64
+
65
+ if (options && "userCount" in options) {
66
+ return `${key} userCount=${String(options["userCount"])}`;
67
+ }
68
+
69
+ return key;
70
+ };
71
+
72
+ jest.mock("react-i18next", () => {
73
+ return {
74
+ __esModule: true,
75
+ useTranslation: (): {
76
+ t: (key: string, options?: Record<string, unknown>) => string;
77
+ } => {
78
+ return { t: translate };
79
+ },
80
+ };
81
+ });
82
+
83
+ /*
84
+ * The project field is an entity dropdown, so the real one would lazily search
85
+ * the Projects endpoint. All this file needs from it is a way to say "the admin
86
+ * picked this project", in the shape the real component reports a single
87
+ * selection in: the bare id string, not an option object. Two projects are on
88
+ * offer so a test can change its mind about which one, which is what surfaces
89
+ * the stale-team bug.
90
+ */
91
+ jest.mock("Common/UI/Components/EntityDropdown/EntityDropdown", () => {
92
+ return {
93
+ __esModule: true,
94
+ default: (props: {
95
+ onChange?: ((value: string) => void) | undefined;
96
+ }): React.ReactElement => {
97
+ return (
98
+ <div>
99
+ <button
100
+ type="button"
101
+ onClick={() => {
102
+ props.onChange?.(PROJECT_ID);
103
+ }}
104
+ >
105
+ Choose a project
106
+ </button>
107
+ <button
108
+ type="button"
109
+ onClick={() => {
110
+ props.onChange?.(OTHER_PROJECT_ID);
111
+ }}
112
+ >
113
+ Choose the other project
114
+ </button>
115
+ </div>
116
+ );
117
+ },
118
+ };
119
+ });
120
+
121
+ interface DropdownStubOption {
122
+ label: string;
123
+ value: string;
124
+ }
125
+
126
+ interface DropdownStubProps {
127
+ isMultiSelect?: boolean | undefined;
128
+ options: Array<DropdownStubOption>;
129
+ /*
130
+ * Captured, not rendered: in single-select mode the real Dropdown is handed
131
+ * one option and in multi-select an array of them, so the shape of this is
132
+ * the picker's own answer to which mode it thinks it is in.
133
+ */
134
+ value?: unknown;
135
+ placeholder?: string | undefined;
136
+ onChange?: ((value: string) => void) | undefined;
137
+ }
138
+
139
+ let capturedDropdownProps: DropdownStubProps | null = null;
140
+
141
+ /*
142
+ * The team picker renders this. react-select in jsdom is more machinery than
143
+ * signal, so it is stood in for - but DROPDOWN_MENU_Z_INDEX has to come along:
144
+ * it is a real value export of the module and EntityDropdown imports it, so a
145
+ * stub without it crashes that importer instead of this file's subject.
146
+ */
147
+ jest.mock("Common/UI/Components/Dropdown/Dropdown", () => {
148
+ return {
149
+ __esModule: true,
150
+ DROPDOWN_MENU_Z_INDEX: 60,
151
+ default: (props: DropdownStubProps): React.ReactElement => {
152
+ capturedDropdownProps = props;
153
+
154
+ return (
155
+ <div>
156
+ {props.options.map((option: DropdownStubOption) => {
157
+ return (
158
+ <button
159
+ key={option.value}
160
+ type="button"
161
+ onClick={() => {
162
+ props.onChange?.(option.value);
163
+ }}
164
+ >
165
+ Pick {option.label}
166
+ </button>
167
+ );
168
+ })}
169
+ </div>
170
+ );
171
+ },
172
+ };
173
+ });
174
+
175
+ /*
176
+ * The teams come from the admin ModelAPI - the one that sends no tenant header,
177
+ * because a master admin is not inside any project.
178
+ */
179
+ jest.mock(
180
+ "../../../../App/FeatureSet/AdminDashboard/src/Utils/ModelAPI",
181
+ () => {
182
+ return {
183
+ __esModule: true,
184
+ default: {
185
+ getList: jest.fn(),
186
+ },
187
+ };
188
+ },
189
+ );
190
+
191
+ import BulkAddUsersToProjectModal, {
192
+ BulkAddUsersToProjectSelection,
193
+ } from "../../../../App/FeatureSet/AdminDashboard/src/Components/User/BulkAddUsersToProjectModal";
194
+ /*
195
+ * The picker is rendered by this modal, but a few of its rules cannot be driven
196
+ * through the modal at all - an in-flight fetch and a failed fetch are states
197
+ * the step rail cannot hold still in - so it is also exercised on its own here,
198
+ * with the same isMultiSelect={false} wiring the modal gives it.
199
+ */
200
+ import ProjectScopedTeamsPicker from "../../../../App/FeatureSet/AdminDashboard/src/Components/GlobalProvider/ProjectScopedTeamsPicker";
201
+ import AdminModelAPI from "../../../../App/FeatureSet/AdminDashboard/src/Utils/ModelAPI";
202
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
203
+ import ObjectID from "../../../Types/ObjectID";
204
+ import Team from "../../../Models/DatabaseModels/Team";
205
+ import User from "../../../Models/DatabaseModels/User";
206
+
207
+ const mockGetList: jest.MockedFunction<any> =
208
+ AdminModelAPI.getList as unknown as jest.MockedFunction<any>;
209
+
210
+ const LOCALE_FILE_PATH: string = path.join(
211
+ __dirname,
212
+ "../../../../App/FeatureSet/AdminDashboard/src/Locales/en.json",
213
+ );
214
+
215
+ type MakeTeamFunction = (id: string, name: string) => Team;
216
+
217
+ const makeTeam: MakeTeamFunction = (id: string, name: string): Team => {
218
+ const team: Team = new Team();
219
+ team._id = id;
220
+ team.name = name;
221
+ return team;
222
+ };
223
+
224
+ type TeamsOfProjectFunction = (projectId: string) => Array<Team>;
225
+
226
+ /*
227
+ * Two projects with nothing in common. `getList` is scoped by projectId, so a
228
+ * mock that ignored the query would hand back the same teams whichever project
229
+ * was chosen - and a picker that kept showing the old project's teams would
230
+ * look exactly like one that had refetched.
231
+ */
232
+ const teamsOfProject: TeamsOfProjectFunction = (
233
+ projectId: string,
234
+ ): Array<Team> => {
235
+ if (projectId === OTHER_PROJECT_ID) {
236
+ return [makeTeam(TEAM_BILLING_ID, "Billing")];
237
+ }
238
+
239
+ return [
240
+ makeTeam(TEAM_ENGINEERING_ID, "Engineering"),
241
+ makeTeam(TEAM_SUPPORT_ID, "Support"),
242
+ ];
243
+ };
244
+
245
+ type MakeUserFunction = (id: string) => User;
246
+
247
+ // The modal reads nothing off a user but how many of them there are.
248
+ const makeUser: MakeUserFunction = (id: string): User => {
249
+ const user: User = new User();
250
+ user._id = id;
251
+ return user;
252
+ };
253
+
254
+ type RenderModalFunction = (
255
+ overrides?: Partial<React.ComponentProps<typeof BulkAddUsersToProjectModal>>,
256
+ ) => ReturnType<typeof render>;
257
+
258
+ const renderModal: RenderModalFunction = (
259
+ overrides: Partial<
260
+ React.ComponentProps<typeof BulkAddUsersToProjectModal>
261
+ > = {},
262
+ ): ReturnType<typeof render> => {
263
+ return render(
264
+ <BulkAddUsersToProjectModal
265
+ users={[makeUser(USER_ONE_ID), makeUser(USER_TWO_ID)]}
266
+ onClose={jest.fn()}
267
+ onSubmit={jest.fn()}
268
+ {...overrides}
269
+ />,
270
+ );
271
+ };
272
+
273
+ type ChooseProjectFunction = (view: ReturnType<typeof render>) => void;
274
+
275
+ const chooseProject: ChooseProjectFunction = (
276
+ view: ReturnType<typeof render>,
277
+ ): void => {
278
+ fireEvent.click(view.getByRole("button", { name: "Choose a project" }));
279
+ };
280
+
281
+ const chooseOtherProject: ChooseProjectFunction = (
282
+ view: ReturnType<typeof render>,
283
+ ): void => {
284
+ fireEvent.click(
285
+ view.getByRole("button", { name: "Choose the other project" }),
286
+ );
287
+ };
288
+
289
+ type GoBackToProjectStepFunction = (
290
+ view: ReturnType<typeof render>,
291
+ ) => Promise<void>;
292
+
293
+ /*
294
+ * The way back is the step rail, the same as in the browser: a completed step
295
+ * is clickable and returns the form to it. There is no back button - the modal
296
+ * footer only ever goes forward - so this is the only route an admin has to
297
+ * change their mind about the project after picking a team.
298
+ */
299
+ const goBackToProjectStep: GoBackToProjectStepFunction = async (
300
+ view: ReturnType<typeof render>,
301
+ ): Promise<void> => {
302
+ fireEvent.click(view.getByText("pages.users.bulkAddToProjectStepProject"));
303
+
304
+ await waitFor(
305
+ () => {
306
+ expect(view.getByTestId("modal-footer-submit-button")).toHaveTextContent(
307
+ "pages.users.bulkAddToProjectNext",
308
+ );
309
+ },
310
+ { timeout: WAIT_FOR_TIMEOUT },
311
+ );
312
+ };
313
+
314
+ type SubmitFunction = (view: ReturnType<typeof render>) => void;
315
+
316
+ const submitModal: SubmitFunction = (view: ReturnType<typeof render>): void => {
317
+ fireEvent.click(view.getByTestId("modal-footer-submit-button"));
318
+ };
319
+
320
+ type GoToTeamStepFunction = (
321
+ view: ReturnType<typeof render>,
322
+ expectedTeamName?: string,
323
+ ) => Promise<void>;
324
+
325
+ /*
326
+ * Step two is where every interesting assertion lives, and getting there means
327
+ * both the step change and the teams fetch have to have settled. The team named
328
+ * here is the one the fetch is waited on through, so it has to be a team of the
329
+ * project that was just chosen.
330
+ */
331
+ const goToTeamStep: GoToTeamStepFunction = async (
332
+ view: ReturnType<typeof render>,
333
+ expectedTeamName: string = "Engineering",
334
+ ): Promise<void> => {
335
+ submitModal(view);
336
+
337
+ await waitFor(
338
+ () => {
339
+ expect(view.getByTestId("modal-footer-submit-button")).toHaveTextContent(
340
+ "pages.users.bulkAddToProjectSubmit",
341
+ );
342
+ },
343
+ { timeout: WAIT_FOR_TIMEOUT },
344
+ );
345
+
346
+ await waitFor(
347
+ () => {
348
+ expect(
349
+ view.getByRole("button", { name: `Pick ${expectedTeamName}` }),
350
+ ).toBeVisible();
351
+ },
352
+ { timeout: WAIT_FOR_TIMEOUT },
353
+ );
354
+ };
355
+
356
+ type OnSubmitFunction = (selection: BulkAddUsersToProjectSelection) => void;
357
+
358
+ type MakeOnSubmitFunction = () => OnSubmitFunction;
359
+
360
+ const makeOnSubmit: MakeOnSubmitFunction = (): OnSubmitFunction => {
361
+ return jest.fn((_selection: BulkAddUsersToProjectSelection): void => {});
362
+ };
363
+
364
+ type SubmittedSelectionFunction = (
365
+ onSubmit: OnSubmitFunction,
366
+ ) => BulkAddUsersToProjectSelection;
367
+
368
+ const submittedSelection: SubmittedSelectionFunction = (
369
+ onSubmit: OnSubmitFunction,
370
+ ): BulkAddUsersToProjectSelection => {
371
+ const submitMock: jest.MockedFunction<any> =
372
+ onSubmit as unknown as jest.MockedFunction<any>;
373
+
374
+ return submitMock.mock.calls[0]![0];
375
+ };
376
+
377
+ type ChooseTeamFunction = (
378
+ view: ReturnType<typeof render>,
379
+ teamName: string,
380
+ ) => void;
381
+
382
+ const chooseTeam: ChooseTeamFunction = (
383
+ view: ReturnType<typeof render>,
384
+ teamName: string,
385
+ ): void => {
386
+ fireEvent.click(view.getByRole("button", { name: `Pick ${teamName}` }));
387
+ };
388
+
389
+ describe("BulkAddUsersToProjectModal", () => {
390
+ beforeEach(() => {
391
+ jest.clearAllMocks();
392
+ translationCalls.length = 0;
393
+ capturedDropdownProps = null;
394
+
395
+ mockGetList.mockImplementation(
396
+ async (args: {
397
+ query?: { projectId?: ObjectID | undefined } | undefined;
398
+ }): Promise<any> => {
399
+ const teams: Array<Team> = teamsOfProject(
400
+ args?.query?.projectId?.toString() || "",
401
+ );
402
+
403
+ return {
404
+ data: teams,
405
+ count: teams.length,
406
+ };
407
+ },
408
+ );
409
+ });
410
+
411
+ type RenderPickerFunction = (overrides: {
412
+ projectId: ObjectID;
413
+ selectedTeamIds: Array<string>;
414
+ onChange: (teamIds: Array<string>) => void;
415
+ }) => ReturnType<typeof render>;
416
+
417
+ /*
418
+ * The picker exactly as this modal configures it: one team, not a list.
419
+ */
420
+ const renderPicker: RenderPickerFunction = (overrides: {
421
+ projectId: ObjectID;
422
+ selectedTeamIds: Array<string>;
423
+ onChange: (teamIds: Array<string>) => void;
424
+ }): ReturnType<typeof render> => {
425
+ return render(
426
+ <ProjectScopedTeamsPicker
427
+ isMultiSelect={false}
428
+ projectId={overrides.projectId}
429
+ selectedTeamIds={overrides.selectedTeamIds}
430
+ onChange={overrides.onChange}
431
+ />,
432
+ );
433
+ };
434
+
435
+ type SettleFunction = () => Promise<void>;
436
+
437
+ /*
438
+ * Drains the microtask queue so a `.then` that was going to fire has fired.
439
+ * "onChange was not called" is only worth anything once the promise chain the
440
+ * call would have come from has had its turn.
441
+ */
442
+ const settle: SettleFunction = async (): Promise<void> => {
443
+ for (let index: number = 0; index < 10; index++) {
444
+ await Promise.resolve();
445
+ }
446
+ };
447
+
448
+ /*
449
+ * The teams on offer are the chosen project's, so there is nothing to ask on
450
+ * step one but the project. A team picker rendered alongside it would either
451
+ * sit there empty or - worse - fetch before a project exists and offer the
452
+ * teams of no project at all.
453
+ */
454
+ test("asks for the project on its own step, with no team picker beside it", () => {
455
+ const view: ReturnType<typeof render> = renderModal();
456
+
457
+ expect(
458
+ view.getByText("pages.users.bulkAddToProjectFieldProject"),
459
+ ).toBeVisible();
460
+ expect(
461
+ view.queryByText("pages.users.bulkAddToProjectFieldTeam"),
462
+ ).not.toBeInTheDocument();
463
+ expect(capturedDropdownProps).toBeNull();
464
+ expect(mockGetList).not.toHaveBeenCalled();
465
+ });
466
+
467
+ /*
468
+ * The query is the whole of the scoping. Without projectId on it the picker
469
+ * would offer every team on the instance, and an admin picking one would move
470
+ * users into a project they never chose.
471
+ */
472
+ test("fetches the chosen project's teams, by name, on the team step", async () => {
473
+ const view: ReturnType<typeof render> = renderModal();
474
+
475
+ chooseProject(view);
476
+ await goToTeamStep(view);
477
+
478
+ expect(mockGetList).toHaveBeenCalledTimes(1);
479
+
480
+ const getListArgs: {
481
+ modelType: unknown;
482
+ query: Record<string, ObjectID>;
483
+ sort: Record<string, SortOrder>;
484
+ } = mockGetList.mock.calls[0]![0];
485
+
486
+ expect(getListArgs.modelType).toBe(Team);
487
+ expect(Object.keys(getListArgs.query)).toEqual(["projectId"]);
488
+ expect(getListArgs.query["projectId"]?.toString()).toBe(PROJECT_ID);
489
+ expect(getListArgs.sort).toEqual({ name: SortOrder.Ascending });
490
+ });
491
+
492
+ /*
493
+ * The one thing this component exists to produce. hasAcceptedInvitation has
494
+ * to come out false when the box was never ticked rather than undefined -
495
+ * TeamMemberService rejects an already-accepted membership for anyone who is
496
+ * not a master admin, so the flag is not a field to leave unset.
497
+ */
498
+ test("hands the caller the chosen project and team once", async () => {
499
+ const onSubmit: OnSubmitFunction = makeOnSubmit();
500
+ const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
501
+
502
+ chooseProject(view);
503
+ await goToTeamStep(view);
504
+ chooseTeam(view, "Engineering");
505
+ submitModal(view);
506
+
507
+ await waitFor(
508
+ () => {
509
+ expect(onSubmit).toHaveBeenCalledTimes(1);
510
+ },
511
+ { timeout: WAIT_FOR_TIMEOUT },
512
+ );
513
+
514
+ const selection: BulkAddUsersToProjectSelection =
515
+ submittedSelection(onSubmit);
516
+
517
+ expect(selection.projectId.toString()).toBe(PROJECT_ID);
518
+ expect(selection.teamId.toString()).toBe(TEAM_ENGINEERING_ID);
519
+ expect(selection.hasAcceptedInvitation).toBe(false);
520
+ });
521
+
522
+ /*
523
+ * The checkbox is the difference between users who are invited and users who
524
+ * are members. Dropping it on the way out would silently leave every bulk-added
525
+ * user pending, which looks identical in the progress modal.
526
+ */
527
+ test("reports the auto-accept checkbox when it is ticked", async () => {
528
+ const onSubmit: OnSubmitFunction = makeOnSubmit();
529
+ const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
530
+
531
+ chooseProject(view);
532
+ await goToTeamStep(view);
533
+ chooseTeam(view, "Engineering");
534
+ fireEvent.click(view.getByRole("checkbox"));
535
+ submitModal(view);
536
+
537
+ await waitFor(
538
+ () => {
539
+ expect(onSubmit).toHaveBeenCalledTimes(1);
540
+ },
541
+ { timeout: WAIT_FOR_TIMEOUT },
542
+ );
543
+
544
+ expect(submittedSelection(onSubmit).hasAcceptedInvitation).toBe(true);
545
+ });
546
+
547
+ /*
548
+ * A submit with no team must not reach the caller. It would either create
549
+ * memberships against an undefined team or - since the page closes this modal
550
+ * before it starts creating - tear the picker down over nothing.
551
+ *
552
+ * Which layer refuses: BasicForm's own required-field validation. `team` is
553
+ * `required: true`, so submitForm collects a validation error and returns
554
+ * before it ever calls props.onSubmit, which means the modal's own
555
+ * `!teamId` guard is not entered and its
556
+ * `pages.users.bulkAddToProjectSelectTeam` message is not what is being
557
+ * pinned here. This test pins the outcome - nothing reaches the caller and
558
+ * the form stays on the team step - not the guard.
559
+ */
560
+ test("does not submit while no team has been chosen", async () => {
561
+ const onSubmit: OnSubmitFunction = makeOnSubmit();
562
+ const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
563
+
564
+ chooseProject(view);
565
+ await goToTeamStep(view);
566
+ submitModal(view);
567
+
568
+ // Still on the last step, still asking - it held rather than advanced.
569
+ await waitFor(
570
+ () => {
571
+ expect(
572
+ view.getByTestId("modal-footer-submit-button"),
573
+ ).toHaveTextContent("pages.users.bulkAddToProjectSubmit");
574
+ },
575
+ { timeout: WAIT_FOR_TIMEOUT },
576
+ );
577
+
578
+ expect(onSubmit).not.toHaveBeenCalled();
579
+ });
580
+
581
+ /*
582
+ * Changing your mind about the project after having picked a team.
583
+ *
584
+ * The team lives in the form value, and the form value survives the walk back
585
+ * to step one. Refetching the teams alone does not fix that: the dropdown
586
+ * filters its selection against the options it now has, so the old project's
587
+ * team stops being *shown* while it is still *set*. Required-validation reads
588
+ * the value, not the dropdown, so the form would happily submit, and nothing
589
+ * downstream would catch it - TeamMemberService checks that the team exists,
590
+ * not that it belongs to the project being written - leaving memberships
591
+ * whose team is in one project and whose projectId is another.
592
+ *
593
+ * So: pick a project, pick its team, go back, pick the other project. The
594
+ * team of the first project must not come out the other end.
595
+ */
596
+ test("does not carry a team of the old project into the new one", async () => {
597
+ const onSubmit: OnSubmitFunction = makeOnSubmit();
598
+ const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
599
+
600
+ chooseProject(view);
601
+ await goToTeamStep(view);
602
+ chooseTeam(view, "Engineering");
603
+
604
+ await goBackToProjectStep(view);
605
+ chooseOtherProject(view);
606
+ await goToTeamStep(view, "Billing");
607
+
608
+ // The refetch really was scoped to the newly chosen project.
609
+ expect(mockGetList).toHaveBeenCalledTimes(2);
610
+ expect(
611
+ (
612
+ mockGetList.mock.calls[1]![0] as {
613
+ query: Record<string, ObjectID>;
614
+ }
615
+ ).query["projectId"]?.toString(),
616
+ ).toBe(OTHER_PROJECT_ID);
617
+
618
+ // The old project's team is not on offer, and nothing is selected anymore.
619
+ expect(
620
+ view.queryByRole("button", { name: "Pick Engineering" }),
621
+ ).not.toBeInTheDocument();
622
+ expect(capturedDropdownProps!.value).toBeUndefined();
623
+
624
+ submitModal(view);
625
+
626
+ /*
627
+ * Nothing may reach the caller carrying the first project's team. In
628
+ * practice the cleared value fails required-validation and the form stays
629
+ * on the team step, but the assertion is written to accept any outcome
630
+ * except a membership pointing at a team of the project that was
631
+ * abandoned.
632
+ */
633
+ await waitFor(
634
+ () => {
635
+ expect(
636
+ view.getByTestId("modal-footer-submit-button"),
637
+ ).toHaveTextContent("pages.users.bulkAddToProjectSubmit");
638
+ },
639
+ { timeout: WAIT_FOR_TIMEOUT },
640
+ );
641
+
642
+ await settle();
643
+
644
+ const submitMock: jest.MockedFunction<any> =
645
+ onSubmit as unknown as jest.MockedFunction<any>;
646
+
647
+ const submittedTeamIds: Array<string> = submitMock.mock.calls.map(
648
+ (call: Array<BulkAddUsersToProjectSelection>) => {
649
+ return call[0]!.teamId.toString();
650
+ },
651
+ );
652
+
653
+ expect(submittedTeamIds).not.toContain(TEAM_ENGINEERING_ID);
654
+ expect(submittedTeamIds).not.toContain(TEAM_SUPPORT_ID);
655
+ expect(onSubmit).not.toHaveBeenCalled();
656
+ });
657
+
658
+ /*
659
+ * The same invariant one layer down, where it is stated rather than inferred:
660
+ * a selection the fetched project does not offer is dropped, and only that
661
+ * one - ids that ARE the project's teams stay. A blanket clear would be just
662
+ * as wrong in the other direction, wiping a valid pick on every refetch.
663
+ */
664
+ test("drops only the team ids the fetched project does not offer", async () => {
665
+ const onChange: (teamIds: Array<string>) => void = jest.fn(
666
+ (_teamIds: Array<string>): void => {},
667
+ );
668
+
669
+ renderPicker({
670
+ projectId: new ObjectID(OTHER_PROJECT_ID),
671
+ selectedTeamIds: [TEAM_ENGINEERING_ID, TEAM_BILLING_ID],
672
+ onChange: onChange,
673
+ });
674
+
675
+ await waitFor(
676
+ () => {
677
+ expect(onChange).toHaveBeenCalledTimes(1);
678
+ },
679
+ { timeout: WAIT_FOR_TIMEOUT },
680
+ );
681
+
682
+ const onChangeMock: jest.MockedFunction<any> =
683
+ onChange as unknown as jest.MockedFunction<any>;
684
+
685
+ expect(onChangeMock.mock.calls[0]![0]).toEqual([TEAM_BILLING_ID]);
686
+ });
687
+
688
+ /*
689
+ * The other half of that: when everything selected is still a team of the
690
+ * project, the picker must leave the value alone. Reporting a change here
691
+ * would make the form dirty - and, in a form that reacted to its own onChange
692
+ * by re-rendering the custom element, would be a loop.
693
+ */
694
+ test("leaves a selection the fetched project does offer alone", async () => {
695
+ const onChange: (teamIds: Array<string>) => void = jest.fn(
696
+ (_teamIds: Array<string>): void => {},
697
+ );
698
+
699
+ const view: ReturnType<typeof render> = renderPicker({
700
+ projectId: new ObjectID(PROJECT_ID),
701
+ selectedTeamIds: [TEAM_ENGINEERING_ID],
702
+ onChange: onChange,
703
+ });
704
+
705
+ await waitFor(
706
+ () => {
707
+ expect(
708
+ view.getByRole("button", { name: "Pick Engineering" }),
709
+ ).toBeVisible();
710
+ },
711
+ { timeout: WAIT_FOR_TIMEOUT },
712
+ );
713
+
714
+ await settle();
715
+
716
+ expect(onChange).not.toHaveBeenCalled();
717
+ });
718
+
719
+ /*
720
+ * Clearing is bound to the fetch's success path, and this is why. While the
721
+ * request is in flight the picker knows nothing about which teams the project
722
+ * has, so a selection cleared here would be one thrown away on no evidence -
723
+ * and the admin, watching "Loading teams...", would never see it go.
724
+ */
725
+ test("does not clear the selection while the teams are still loading", async () => {
726
+ mockGetList.mockImplementation((): Promise<any> => {
727
+ // Never settles: the picker is held in its loading state for the test.
728
+ return new Promise((): void => {});
729
+ });
730
+
731
+ const onChange: (teamIds: Array<string>) => void = jest.fn(
732
+ (_teamIds: Array<string>): void => {},
733
+ );
734
+
735
+ const view: ReturnType<typeof render> = renderPicker({
736
+ projectId: new ObjectID(OTHER_PROJECT_ID),
737
+ selectedTeamIds: [TEAM_ENGINEERING_ID],
738
+ onChange: onChange,
739
+ });
740
+
741
+ expect(view.getByText("Loading teams...")).toBeVisible();
742
+
743
+ await settle();
744
+
745
+ expect(onChange).not.toHaveBeenCalled();
746
+ });
747
+
748
+ /*
749
+ * And when the request fails outright. The teams may well be fine and the
750
+ * network not; discarding the pick on an error would turn a retryable blip
751
+ * into silently lost input, on top of the error the admin is already reading.
752
+ */
753
+ test("does not clear the selection when the teams fail to load", async () => {
754
+ mockGetList.mockImplementation(async (): Promise<any> => {
755
+ throw new Error("Teams could not be loaded.");
756
+ });
757
+
758
+ const onChange: (teamIds: Array<string>) => void = jest.fn(
759
+ (_teamIds: Array<string>): void => {},
760
+ );
761
+
762
+ const view: ReturnType<typeof render> = renderPicker({
763
+ projectId: new ObjectID(OTHER_PROJECT_ID),
764
+ selectedTeamIds: [TEAM_ENGINEERING_ID],
765
+ onChange: onChange,
766
+ });
767
+
768
+ await waitFor(
769
+ () => {
770
+ expect(view.getByText("Teams could not be loaded.")).toBeVisible();
771
+ },
772
+ { timeout: WAIT_FOR_TIMEOUT },
773
+ );
774
+
775
+ await settle();
776
+
777
+ expect(onChange).not.toHaveBeenCalled();
778
+ });
779
+
780
+ /*
781
+ * A project with no teams cannot be a destination: there is no team to create
782
+ * a TeamMember in. The picker has to say so rather than render an empty
783
+ * dropdown that looks like it is still loading, and the form must not be
784
+ * submittable out of that state.
785
+ */
786
+ test("cannot be submitted when the chosen project has no teams", async () => {
787
+ mockGetList.mockImplementation(async (): Promise<any> => {
788
+ return { data: [], count: 0 };
789
+ });
790
+
791
+ const onSubmit: OnSubmitFunction = makeOnSubmit();
792
+ const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
793
+
794
+ chooseProject(view);
795
+ submitModal(view);
796
+
797
+ await waitFor(
798
+ () => {
799
+ expect(
800
+ view.getByText("This project has no teams to choose from."),
801
+ ).toBeVisible();
802
+ },
803
+ { timeout: WAIT_FOR_TIMEOUT },
804
+ );
805
+
806
+ // No dropdown at all, so there is nothing that could be picked.
807
+ expect(capturedDropdownProps).toBeNull();
808
+
809
+ submitModal(view);
810
+
811
+ await waitFor(
812
+ () => {
813
+ expect(
814
+ view.getByTestId("modal-footer-submit-button"),
815
+ ).toHaveTextContent("pages.users.bulkAddToProjectSubmit");
816
+ },
817
+ { timeout: WAIT_FOR_TIMEOUT },
818
+ );
819
+
820
+ await settle();
821
+
822
+ expect(onSubmit).not.toHaveBeenCalled();
823
+ });
824
+
825
+ /*
826
+ * Two presses of the footer button, one run.
827
+ *
828
+ * The button is not disabled after the first press - the page closes this
829
+ * modal on its own schedule, and BasicForm.submitForm is happy to be called
830
+ * again - so without a guard the second press starts a second pass over the
831
+ * same selection. The two passes share the page's bulk progress modal: the
832
+ * first one finishing re-enables its Close button while the second is still
833
+ * creating memberships, so the admin is invited to close a run that is only
834
+ * half done.
835
+ */
836
+ test("submits once even when the button is pressed twice", async () => {
837
+ const onSubmit: OnSubmitFunction = makeOnSubmit();
838
+ const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
839
+
840
+ chooseProject(view);
841
+ await goToTeamStep(view);
842
+ chooseTeam(view, "Engineering");
843
+
844
+ submitModal(view);
845
+
846
+ await waitFor(
847
+ () => {
848
+ expect(onSubmit).toHaveBeenCalledTimes(1);
849
+ },
850
+ { timeout: WAIT_FOR_TIMEOUT },
851
+ );
852
+
853
+ // The modal is still mounted and still shows a live submit button.
854
+ expect(view.getByTestId("modal-footer-submit-button")).toBeVisible();
855
+
856
+ submitModal(view);
857
+
858
+ await settle();
859
+
860
+ expect(onSubmit).toHaveBeenCalledTimes(1);
861
+ });
862
+
863
+ /*
864
+ * One TeamMember is created per user, in one team. The picker defaults to
865
+ * multi-select - it was written for the SSO attachment forms, which provision
866
+ * into several teams - so this modal has to turn that off.
867
+ *
868
+ * What is pinned here is the two things the components actually decide:
869
+ * `isMultiSelect={false}` reaching the dropdown, and the single-select `value`
870
+ * being one option rather than an array of them (the picker's
871
+ * `isMultiSelect ? selectedOptions : selectedOptions[0]`). Then that a second
872
+ * pick replaces the first instead of piling up next to it, which is the
873
+ * modal's `teamIds[0]`.
874
+ *
875
+ * What is NOT pinned: that the emitted teamId is a scalar and not an array.
876
+ * The Dropdown here is a stub that hands its onChange a single string in
877
+ * either mode, so the shape of what comes back is the stub's doing, not the
878
+ * component's - asserting on it would be asserting on this file.
879
+ */
880
+ test("takes one team, not a list of them", async () => {
881
+ const onSubmit: OnSubmitFunction = makeOnSubmit();
882
+ const view: ReturnType<typeof render> = renderModal({ onSubmit: onSubmit });
883
+
884
+ chooseProject(view);
885
+ await goToTeamStep(view);
886
+
887
+ expect(capturedDropdownProps!.isMultiSelect).toBe(false);
888
+
889
+ chooseTeam(view, "Support");
890
+
891
+ await waitFor(
892
+ () => {
893
+ expect(capturedDropdownProps!.value).toEqual({
894
+ label: "Support",
895
+ value: TEAM_SUPPORT_ID,
896
+ });
897
+ },
898
+ { timeout: WAIT_FOR_TIMEOUT },
899
+ );
900
+
901
+ chooseTeam(view, "Engineering");
902
+ submitModal(view);
903
+
904
+ await waitFor(
905
+ () => {
906
+ expect(onSubmit).toHaveBeenCalledTimes(1);
907
+ },
908
+ { timeout: WAIT_FOR_TIMEOUT },
909
+ );
910
+
911
+ expect(submittedSelection(onSubmit).teamId.toString()).toBe(
912
+ TEAM_ENGINEERING_ID,
913
+ );
914
+ });
915
+
916
+ /*
917
+ * The modal owns the footer button, and BasicForm's own submit button is
918
+ * hidden, so the two have to be kept in step by hand. A button still reading
919
+ * "next" on the last step is the one that looks like it did nothing.
920
+ */
921
+ test("labels the footer button for the step it is on", async () => {
922
+ const view: ReturnType<typeof render> = renderModal();
923
+
924
+ expect(view.getByTestId("modal-footer-submit-button")).toHaveTextContent(
925
+ "pages.users.bulkAddToProjectNext",
926
+ );
927
+
928
+ chooseProject(view);
929
+ await goToTeamStep(view);
930
+
931
+ expect(view.getByTestId("modal-footer-submit-button")).toHaveTextContent(
932
+ "pages.users.bulkAddToProjectSubmit",
933
+ );
934
+ });
935
+
936
+ /*
937
+ * How many users this is about is the one thing the admin cannot see from
938
+ * inside the modal - the table's selection is behind it. The count is passed
939
+ * as `userCount` and not `count` on purpose: i18next reads a `count` option as
940
+ * a request for the plural forms (`..._one` / `..._other`), which these keys
941
+ * do not define, so the lookup would miss and the sentence would come out as
942
+ * the raw key.
943
+ */
944
+ test("says how many users are being added", () => {
945
+ const view: ReturnType<typeof render> = renderModal({
946
+ users: [
947
+ makeUser(USER_ONE_ID),
948
+ makeUser(USER_TWO_ID),
949
+ makeUser(USER_THREE_ID),
950
+ ],
951
+ });
952
+
953
+ expect(view.getByTestId("modal-description")).toHaveTextContent(
954
+ "pages.users.bulkAddToProjectDescription userCount=3",
955
+ );
956
+
957
+ const descriptionCall: TranslationCall | undefined = translationCalls.find(
958
+ (call: TranslationCall) => {
959
+ return call.key === "pages.users.bulkAddToProjectDescription";
960
+ },
961
+ );
962
+
963
+ expect(descriptionCall?.options).toEqual({ userCount: 3 });
964
+ });
965
+
966
+ /*
967
+ * Closing has to stay a pure cancel. The page treats onSubmit as the signal to
968
+ * close the picker and start creating memberships, so a close that also
969
+ * submitted would add every selected user to a team the admin just backed out
970
+ * of.
971
+ */
972
+ test("closing cancels and never submits", () => {
973
+ const onClose: () => void = jest.fn((): void => {});
974
+ const onSubmit: OnSubmitFunction = makeOnSubmit();
975
+ const view: ReturnType<typeof render> = renderModal({
976
+ onClose: onClose,
977
+ onSubmit: onSubmit,
978
+ });
979
+
980
+ fireEvent.click(view.getByTestId("modal-footer-close-button"));
981
+ expect(onClose).toHaveBeenCalledTimes(1);
982
+
983
+ // The header's X is the other way out, and it has to mean the same thing.
984
+ fireEvent.click(view.getByTestId("close-button"));
985
+ expect(onClose).toHaveBeenCalledTimes(2);
986
+
987
+ expect(onSubmit).not.toHaveBeenCalled();
988
+ });
989
+
990
+ /*
991
+ * Every string in here is an i18n key, and a key with no entry renders as the
992
+ * key itself - "pages.users.bulkAddToProjectFieldTeam" in the middle of the
993
+ * form. Nothing else catches that: the modal renders, the form works, and the
994
+ * only symptom is the wording. So the keys are collected from the component
995
+ * as it is actually driven, and each one is looked up in the locale file.
996
+ *
997
+ * Only the modal's own lookups count. Modal and BasicForm re-translate the
998
+ * strings handed to them through the same t(), always with a `defaultValue` -
999
+ * those are whole sentences, not keys, and are filtered back out here.
1000
+ */
1001
+ test("asks only for keys that exist in the admin locale file", async () => {
1002
+ const view: ReturnType<typeof render> = renderModal();
1003
+
1004
+ chooseProject(view);
1005
+ await goToTeamStep(view);
1006
+
1007
+ const localeStrings: unknown = JSON.parse(
1008
+ fs.readFileSync(LOCALE_FILE_PATH, "utf8"),
1009
+ );
1010
+
1011
+ type ResolveKeyFunction = (key: string) => unknown;
1012
+
1013
+ const resolveKey: ResolveKeyFunction = (key: string): unknown => {
1014
+ return key
1015
+ .split(".")
1016
+ .reduce((current: unknown, part: string): unknown => {
1017
+ if (current && typeof current === "object") {
1018
+ return (current as Record<string, unknown>)[part];
1019
+ }
1020
+
1021
+ return undefined;
1022
+ }, localeStrings);
1023
+ };
1024
+
1025
+ const requestedKeys: Array<string> = Array.from(
1026
+ new Set(
1027
+ translationCalls
1028
+ .filter((call: TranslationCall) => {
1029
+ return !call.options || !("defaultValue" in call.options);
1030
+ })
1031
+ .map((call: TranslationCall) => {
1032
+ return call.key;
1033
+ }),
1034
+ ),
1035
+ );
1036
+
1037
+ // Guards the guard: an empty list would make the loop below vacuously true.
1038
+ expect(requestedKeys).toContain("pages.users.bulkAddToProjectTitle");
1039
+ expect(requestedKeys).toContain("pages.users.bulkAddToProjectFieldTeam");
1040
+
1041
+ const unresolvedKeys: Array<string> = requestedKeys.filter(
1042
+ (key: string) => {
1043
+ const value: unknown = resolveKey(key);
1044
+
1045
+ return typeof value !== "string" || value.length === 0;
1046
+ },
1047
+ );
1048
+
1049
+ expect(unresolvedKeys).toEqual([]);
1050
+ });
1051
+ });