@oneuptime/common 11.7.1 → 11.7.3

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 (285) hide show
  1. package/Models/AnalyticsModels/KubernetesCostAllocation.ts +103 -0
  2. package/Models/DatabaseModels/DashboardDomain.ts +1 -0
  3. package/Models/DatabaseModels/Incident.ts +2 -0
  4. package/Models/DatabaseModels/IncidentEpisode.ts +1 -0
  5. package/Models/DatabaseModels/IncidentEpisodePublicNote.ts +1 -0
  6. package/Models/DatabaseModels/IncidentEpisodeStateTimeline.ts +1 -0
  7. package/Models/DatabaseModels/IncidentPublicNote.ts +1 -0
  8. package/Models/DatabaseModels/IncidentStateTimeline.ts +1 -0
  9. package/Models/DatabaseModels/Monitor.ts +1 -0
  10. package/Models/DatabaseModels/MonitorStatusTimeline.ts +1 -0
  11. package/Models/DatabaseModels/NetworkSite.ts +1 -0
  12. package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +1 -0
  13. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +1 -0
  14. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +2 -0
  15. package/Models/DatabaseModels/Probe.ts +13 -13
  16. package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -0
  17. package/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +1 -0
  18. package/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.ts +1 -0
  19. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -0
  20. package/Models/DatabaseModels/StatusPage.ts +1 -0
  21. package/Models/DatabaseModels/StatusPageAnnouncement.ts +1 -0
  22. package/Models/DatabaseModels/StatusPageDomain.ts +1 -0
  23. package/Models/DatabaseModels/TableView.ts +40 -0
  24. package/Models/DatabaseModels/WorkspaceNotificationSummary.ts +1 -0
  25. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +11 -0
  26. package/Server/API/BaseAPI.ts +17 -1
  27. package/Server/API/CommonAPI.ts +32 -0
  28. package/Server/API/MicrosoftTeamsAPI.ts +99 -2
  29. package/Server/API/ProbeAPI.ts +2 -0
  30. package/Server/API/SlackAPI.ts +62 -0
  31. package/Server/API/StatusPageAPI.ts +157 -192
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.ts +89 -0
  33. package/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.ts +61 -0
  34. package/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.ts +280 -0
  35. package/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.ts +13 -0
  36. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  37. package/Server/Services/DatabaseService.ts +11 -3
  38. package/Server/Services/MonitorGroupService.ts +145 -0
  39. package/Server/Services/MonitorService.ts +231 -30
  40. package/Server/Services/ProbeService.ts +319 -16
  41. package/Server/Services/StatusPageService.ts +72 -42
  42. package/Server/Services/WorkspaceNotificationRuleService.ts +232 -2
  43. package/Server/Utils/Database/PostgresErrorTranslator.ts +131 -0
  44. package/Server/Utils/Monitor/MonitorResource.ts +88 -35
  45. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +21 -1
  46. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +158 -0
  47. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +706 -6
  48. package/Tests/Models/AnalyticsModels/KubernetesCostAllocation.test.ts +16 -0
  49. package/Tests/Models/DatabaseModels/ProbeColumnAccessControl.test.ts +103 -0
  50. package/Tests/Server/API/BaseAPIUpdateNoOp.test.ts +135 -0
  51. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +317 -0
  52. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +228 -0
  53. package/Tests/Server/API/ProbeAPI.test.ts +6 -0
  54. package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +167 -0
  55. package/Tests/Server/Services/AddHotQueryIndexesMigration.test.ts +255 -0
  56. package/Tests/Server/Services/AddHotQueryIndexesSecondPassMigration.test.ts +258 -0
  57. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +1 -1
  58. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +121 -0
  59. package/Tests/Server/Services/HeartbeatWriteFastPathSafety.test.ts +200 -0
  60. package/Tests/Server/Services/HookFreeWriteSafetySecondPass.test.ts +181 -0
  61. package/Tests/Server/Services/MonitorGroupServiceBatchedStatuses.test.ts +513 -0
  62. package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +492 -0
  63. package/Tests/Server/Services/MonitorServiceProbeSelection.test.ts +401 -0
  64. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +453 -0
  65. package/Tests/Server/Services/ProbeAuthKeyCacheAndHeartbeat.test.ts +679 -0
  66. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +3 -3
  67. package/Tests/Server/Services/StatusPageDomainResolutionCache.test.ts +245 -0
  68. package/Tests/Server/Services/StatusPageOverviewTimelineWindow.test.ts +283 -0
  69. package/Tests/Server/Services/StatusPageServiceMcp.test.ts +17 -1
  70. package/Tests/Server/Services/StatusPageServiceMonitorGroupBatching.test.ts +331 -0
  71. package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +12 -12
  72. package/Tests/Server/Services/WorkspaceNotificationRuleChats.test.ts +1390 -0
  73. package/Tests/Server/Utils/AI/Insights/InsightFixRouting.test.ts +4 -4
  74. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +2 -2
  75. package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +1 -3
  76. package/Tests/Server/Utils/AI/Insights/InsightStoreHardening.test.ts +1 -3
  77. package/Tests/Server/Utils/AI/Insights/InsightTriage.test.ts +1 -1
  78. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +5 -5
  79. package/Tests/Server/Utils/Alert/AlertPrivacyFilter.test.ts +187 -0
  80. package/Tests/Server/Utils/AlertEpisode/AlertEpisodePrivacyFilter.test.ts +170 -0
  81. package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +125 -0
  82. package/Tests/Server/Utils/Incident/IncidentPrivacyFilter.test.ts +264 -0
  83. package/Tests/Server/Utils/IncidentEpisode/IncidentEpisodePrivacyFilter.test.ts +175 -0
  84. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +181 -0
  85. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +1 -1
  86. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +2 -2
  87. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelsList.test.ts +341 -0
  88. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +2175 -0
  89. package/Tests/Server/Utils/Workspace/SlackChannelsList.test.ts +585 -0
  90. package/Tests/Types/Billing/MeteredPlan.test.ts +46 -0
  91. package/Tests/Types/JSONFunctions.test.ts +103 -1
  92. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +219 -1
  93. package/Tests/UI/Components/BulkUpdateForm.test.tsx +174 -0
  94. package/Tests/UI/Components/CardModelDetailEdit.test.tsx +315 -0
  95. package/Tests/UI/Components/ModelDetailSelect.test.tsx +229 -0
  96. package/Tests/UI/Components/ModelTable/BaseModelTableBulkSelectAll.test.tsx +1258 -0
  97. package/Tests/UI/Components/ModelTable/BaseModelTableColumnCustomization.test.tsx +679 -0
  98. package/Tests/UI/Components/ModelTable/ColumnCustomizationModal.test.tsx +572 -0
  99. package/Tests/UI/Components/ModelTable/ColumnPreference.test.ts +1678 -0
  100. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +1094 -0
  101. package/Tests/UI/Components/ModelTableExportFromColumns.test.ts +153 -0
  102. package/Tests/UI/Components/MonacoLoader.test.ts +67 -0
  103. package/Tests/UI/Components/MonacoRuntime.test.ts +121 -0
  104. package/Tests/UI/Components/TableBulkCsvExport.test.tsx +212 -3
  105. package/Tests/UI/Utils/Project.test.ts +809 -0
  106. package/Tests/UI/Utils/TableColumnsToCsv.test.ts +360 -0
  107. package/Tests/Utils/Alerts/AlertMetricType.test.ts +143 -0
  108. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +219 -0
  109. package/Tests/Utils/Incident/IncidentMetricType.test.ts +174 -0
  110. package/Tests/Utils/Monitor/MonitorProbeSelectionUtil.test.ts +145 -0
  111. package/Tests/Utils/StatusPage/ResourceUptime.test.ts +263 -0
  112. package/Tests/Utils/UserPreferences.test.ts +563 -0
  113. package/Types/JSONFunctions.ts +7 -2
  114. package/Types/Kubernetes/KubernetesCostIngest.ts +37 -1
  115. package/Types/Workspace/NotificationRules/BaseNotificationRule.ts +4 -0
  116. package/Types/Workspace/NotificationRules/NotificationRuleCondition.ts +25 -10
  117. package/Types/Workspace/WorkspaceMessagePayload.ts +1 -0
  118. package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +63 -8
  119. package/UI/Components/CodeEditor/CodeEditor.tsx +3 -0
  120. package/UI/Components/CodeEditor/MonacoLoader.ts +36 -0
  121. package/UI/Components/Forms/ModelForm.tsx +41 -18
  122. package/UI/Components/MasterPage/MasterPage.tsx +65 -0
  123. package/UI/Components/ModelDetail/CardModelDetail.tsx +23 -1
  124. package/UI/Components/ModelDetail/ModelDetail.tsx +46 -1
  125. package/UI/Components/ModelTable/BaseModelTable.tsx +687 -34
  126. package/UI/Components/ModelTable/Column.ts +29 -0
  127. package/UI/Components/ModelTable/ColumnCustomizationModal.tsx +420 -0
  128. package/UI/Components/ModelTable/ColumnPreference.ts +482 -0
  129. package/UI/Components/ModelTable/CustomFieldColumns.tsx +326 -0
  130. package/UI/Components/ModelTable/ExportFromColumns.ts +57 -0
  131. package/UI/Components/ModelTable/TableView.tsx +24 -2
  132. package/UI/Components/ModelTable/useCustomFieldColumns.ts +150 -0
  133. package/UI/Components/Navbar/NavBar.tsx +66 -1
  134. package/UI/Components/SideMenu/SideMenu.tsx +24 -4
  135. package/UI/Components/Table/Table.tsx +66 -14
  136. package/UI/Components/Table/TableRow.tsx +180 -175
  137. package/UI/Components/Table/Types/Column.ts +20 -0
  138. package/UI/Utils/Project.ts +214 -18
  139. package/UI/Utils/TableColumnsToCsv.ts +86 -6
  140. package/UI/esbuild-config.js +34 -0
  141. package/Utils/Monitor/MonitorProbeSelectionUtil.ts +72 -0
  142. package/Utils/UserPreferences.ts +53 -0
  143. package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js +86 -0
  144. package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js.map +1 -1
  145. package/build/dist/Models/DatabaseModels/DashboardDomain.js +1 -0
  146. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  147. package/build/dist/Models/DatabaseModels/Incident.js +2 -0
  148. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  149. package/build/dist/Models/DatabaseModels/IncidentEpisode.js +1 -0
  150. package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
  151. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js +1 -0
  152. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js.map +1 -1
  153. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js +1 -0
  154. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js.map +1 -1
  155. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js +1 -0
  156. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js.map +1 -1
  157. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js +1 -0
  158. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js.map +1 -1
  159. package/build/dist/Models/DatabaseModels/Monitor.js +1 -0
  160. package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
  161. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +1 -0
  162. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  163. package/build/dist/Models/DatabaseModels/NetworkSite.js +1 -0
  164. package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -1
  165. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +1 -0
  166. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -1
  167. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +1 -0
  168. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  169. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +2 -0
  170. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  171. package/build/dist/Models/DatabaseModels/Probe.js +13 -13
  172. package/build/dist/Models/DatabaseModels/Probe.js.map +1 -1
  173. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -0
  174. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  175. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js +1 -0
  176. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js.map +1 -1
  177. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js +1 -0
  178. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js.map +1 -1
  179. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -0
  180. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  181. package/build/dist/Models/DatabaseModels/StatusPage.js +1 -0
  182. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  183. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +1 -0
  184. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  185. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +1 -0
  186. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  187. package/build/dist/Models/DatabaseModels/TableView.js +40 -0
  188. package/build/dist/Models/DatabaseModels/TableView.js.map +1 -1
  189. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js +1 -0
  190. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js.map +1 -1
  191. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  192. package/build/dist/Server/API/BaseAPI.js +12 -1
  193. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  194. package/build/dist/Server/API/CommonAPI.js +21 -0
  195. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  196. package/build/dist/Server/API/MicrosoftTeamsAPI.js +72 -2
  197. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  198. package/build/dist/Server/API/ProbeAPI.js +2 -0
  199. package/build/dist/Server/API/ProbeAPI.js.map +1 -1
  200. package/build/dist/Server/API/SlackAPI.js +44 -0
  201. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  202. package/build/dist/Server/API/StatusPageAPI.js +98 -152
  203. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  204. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js +36 -0
  205. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js.map +1 -0
  206. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js +26 -0
  207. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js.map +1 -0
  208. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js +254 -0
  209. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js.map +1 -0
  210. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.js +12 -0
  211. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.js.map +1 -0
  212. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  213. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  214. package/build/dist/Server/Services/DatabaseService.js +10 -2
  215. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  216. package/build/dist/Server/Services/MonitorGroupService.js +118 -0
  217. package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
  218. package/build/dist/Server/Services/MonitorService.js +198 -24
  219. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  220. package/build/dist/Server/Services/ProbeService.js +238 -9
  221. package/build/dist/Server/Services/ProbeService.js.map +1 -1
  222. package/build/dist/Server/Services/StatusPageService.js +46 -39
  223. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  224. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +178 -3
  225. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  226. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +80 -0
  227. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -0
  228. package/build/dist/Server/Utils/Monitor/MonitorResource.js +67 -29
  229. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  230. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +31 -14
  231. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  232. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +117 -0
  233. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -0
  234. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +549 -7
  235. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  236. package/build/dist/Types/JSONFunctions.js +8 -3
  237. package/build/dist/Types/JSONFunctions.js.map +1 -1
  238. package/build/dist/Types/Kubernetes/KubernetesCostIngest.js.map +1 -1
  239. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js +17 -7
  240. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js.map +1 -1
  241. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +37 -11
  242. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
  243. package/build/dist/UI/Components/CodeEditor/CodeEditor.js +2 -0
  244. package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
  245. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js +34 -0
  246. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js.map +1 -0
  247. package/build/dist/UI/Components/Forms/ModelForm.js +37 -17
  248. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  249. package/build/dist/UI/Components/MasterPage/MasterPage.js +50 -1
  250. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  251. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +22 -1
  252. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
  253. package/build/dist/UI/Components/ModelDetail/ModelDetail.js +31 -1
  254. package/build/dist/UI/Components/ModelDetail/ModelDetail.js.map +1 -1
  255. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +472 -27
  256. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  257. package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js +189 -0
  258. package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js.map +1 -0
  259. package/build/dist/UI/Components/ModelTable/ColumnPreference.js +258 -0
  260. package/build/dist/UI/Components/ModelTable/ColumnPreference.js.map +1 -0
  261. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +168 -0
  262. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -0
  263. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js +30 -0
  264. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js.map +1 -0
  265. package/build/dist/UI/Components/ModelTable/TableView.js +13 -2
  266. package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
  267. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +84 -0
  268. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -0
  269. package/build/dist/UI/Components/Navbar/NavBar.js +39 -1
  270. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  271. package/build/dist/UI/Components/SideMenu/SideMenu.js +15 -5
  272. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  273. package/build/dist/UI/Components/Table/Table.js +46 -13
  274. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  275. package/build/dist/UI/Components/Table/TableRow.js +11 -6
  276. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  277. package/build/dist/UI/Utils/Project.js +161 -15
  278. package/build/dist/UI/Utils/Project.js.map +1 -1
  279. package/build/dist/UI/Utils/TableColumnsToCsv.js +67 -6
  280. package/build/dist/UI/Utils/TableColumnsToCsv.js.map +1 -1
  281. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js +52 -0
  282. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js.map +1 -0
  283. package/build/dist/Utils/UserPreferences.js +33 -0
  284. package/build/dist/Utils/UserPreferences.js.map +1 -1
  285. package/package.json +4 -3
@@ -0,0 +1,1094 @@
1
+ import Column from "../../../../UI/Components/ModelTable/Column";
2
+ import Columns from "../../../../UI/Components/ModelTable/Columns";
3
+ import {
4
+ CustomFieldDefinition,
5
+ CustomFieldsColumnKey,
6
+ getCustomFieldColumns,
7
+ getCustomFieldDefinitions,
8
+ getCustomFieldValue,
9
+ parseDropdownOptions,
10
+ renderCustomFieldValue,
11
+ } from "../../../../UI/Components/ModelTable/CustomFieldColumns";
12
+ import FieldType from "../../../../UI/Components/Types/FieldType";
13
+ import Monitor from "../../../../Models/DatabaseModels/Monitor";
14
+ import MonitorCustomField from "../../../../Models/DatabaseModels/MonitorCustomField";
15
+ import CustomFieldType from "../../../../Types/CustomField/CustomFieldType";
16
+ import { JSONObject } from "../../../../Types/JSON";
17
+ import "@testing-library/jest-dom/extend-expect";
18
+ import { cleanup, render } from "@testing-library/react";
19
+ import React from "react";
20
+ import { afterEach, describe, expect, test } from "@jest/globals";
21
+
22
+ /*
23
+ * Custom field *definitions* live in a per-resource table; the *values* live in
24
+ * one `customFields` jsonb column on the resource. This module turns the former
25
+ * into table columns that read the latter, and the exact shape it produces is
26
+ * load-bearing in several places that will not complain loudly when it is
27
+ * wrong - the table either throws on select, sends garbage to TypeORM, or
28
+ * silently renders every cell blank. Each shape rule below is pinned with a
29
+ * note about what breaks if it changes.
30
+ */
31
+
32
+ const textDefinition: CustomFieldDefinition = {
33
+ name: "Owner",
34
+ customFieldType: CustomFieldType.Text,
35
+ };
36
+
37
+ const booleanDefinition: CustomFieldDefinition = {
38
+ name: "Paging",
39
+ customFieldType: CustomFieldType.Boolean,
40
+ };
41
+
42
+ const dropdownDefinition: CustomFieldDefinition = {
43
+ name: "Severity",
44
+ customFieldType: CustomFieldType.Dropdown,
45
+ dropdownOptions: "Low\nHigh",
46
+ };
47
+
48
+ const multiSelectDefinition: CustomFieldDefinition = {
49
+ name: "Teams",
50
+ customFieldType: CustomFieldType.MultiSelectDropdown,
51
+ dropdownOptions: "Infra\nApps",
52
+ };
53
+
54
+ type RenderValueFunction = (data: {
55
+ value: unknown;
56
+ definition: CustomFieldDefinition;
57
+ noValueMessage?: string | undefined;
58
+ }) => HTMLElement;
59
+
60
+ const renderValue: RenderValueFunction = (data: {
61
+ value: unknown;
62
+ definition: CustomFieldDefinition;
63
+ noValueMessage?: string | undefined;
64
+ }): HTMLElement => {
65
+ const { container } = render(<>{renderCustomFieldValue(data)}</>);
66
+
67
+ return container;
68
+ };
69
+
70
+ /*
71
+ * Every badge is the same markup - an indigo pill with a dot - so counting the
72
+ * pills is how we tell "one badge holding a comma list" apart from "one badge
73
+ * per value".
74
+ */
75
+ type GetBadgeLabelsFunction = (container: HTMLElement) => Array<string>;
76
+
77
+ const getBadgeLabels: GetBadgeLabelsFunction = (
78
+ container: HTMLElement,
79
+ ): Array<string> => {
80
+ return Array.from(container.querySelectorAll("span.bg-indigo-50")).map(
81
+ (element: Element): string => {
82
+ return (element.textContent || "").trim();
83
+ },
84
+ );
85
+ };
86
+
87
+ type GetPlaceholderFunction = (container: HTMLElement) => Element | null;
88
+
89
+ const getPlaceholder: GetPlaceholderFunction = (
90
+ container: HTMLElement,
91
+ ): Element | null => {
92
+ return container.querySelector("span.text-gray-400");
93
+ };
94
+
95
+ type ColumnAtFunction = (
96
+ columns: Columns<Monitor>,
97
+ index: number,
98
+ ) => Column<Monitor>;
99
+
100
+ const columnAt: ColumnAtFunction = (
101
+ columns: Columns<Monitor>,
102
+ index: number,
103
+ ): Column<Monitor> => {
104
+ const column: Column<Monitor> | undefined = columns[index];
105
+
106
+ if (!column) {
107
+ throw new Error(`Expected a generated column at index ${index}`);
108
+ }
109
+
110
+ return column;
111
+ };
112
+
113
+ type ExportValueOfFunction = (column: Column<Monitor>, item: Monitor) => string;
114
+
115
+ const exportValueOf: ExportValueOfFunction = (
116
+ column: Column<Monitor>,
117
+ item: Monitor,
118
+ ): string => {
119
+ if (!column.getExportValue) {
120
+ throw new Error(`Column "${column.title}" has no getExportValue`);
121
+ }
122
+
123
+ return column.getExportValue(item);
124
+ };
125
+
126
+ type RenderColumnFunction = (
127
+ column: Column<Monitor>,
128
+ item: Monitor,
129
+ ) => HTMLElement;
130
+
131
+ const renderColumn: RenderColumnFunction = (
132
+ column: Column<Monitor>,
133
+ item: Monitor,
134
+ ): HTMLElement => {
135
+ if (!column.getElement) {
136
+ throw new Error(`Column "${column.title}" has no getElement`);
137
+ }
138
+
139
+ const { container } = render(<>{column.getElement(item)}</>);
140
+
141
+ return container;
142
+ };
143
+
144
+ type MonitorWithFieldsFunction = (customFields: JSONObject) => Monitor;
145
+
146
+ const monitorWithFields: MonitorWithFieldsFunction = (
147
+ customFields: JSONObject,
148
+ ): Monitor => {
149
+ const monitor: Monitor = new Monitor();
150
+ monitor.customFields = customFields;
151
+
152
+ return monitor;
153
+ };
154
+
155
+ afterEach(() => {
156
+ cleanup();
157
+ });
158
+
159
+ describe("CustomFieldColumns.parseDropdownOptions", () => {
160
+ test("splits the stored blob into one option per line", () => {
161
+ /*
162
+ * The settings page writes dropdown options as a textarea blob, so the
163
+ * newline is the only separator there is - a comma is a legal character
164
+ * inside an option label.
165
+ */
166
+ expect(parseDropdownOptions("Low\nMedium\nHigh")).toEqual([
167
+ "Low",
168
+ "Medium",
169
+ "High",
170
+ ]);
171
+ });
172
+
173
+ test("trims each option", () => {
174
+ expect(parseDropdownOptions(" Low \n\tHigh\t")).toEqual(["Low", "High"]);
175
+ });
176
+
177
+ test("drops blank and whitespace-only lines", () => {
178
+ // A trailing newline in the textarea must not become an empty option.
179
+ expect(parseDropdownOptions("Low\n\n \nHigh\n")).toEqual(["Low", "High"]);
180
+ });
181
+
182
+ test("returns an empty list for a blob that is only whitespace", () => {
183
+ expect(parseDropdownOptions("\n \n\t\n")).toEqual([]);
184
+ });
185
+
186
+ test("returns the single option when the blob has no newline at all", () => {
187
+ expect(parseDropdownOptions("Low")).toEqual(["Low"]);
188
+ });
189
+
190
+ test("returns an empty list for empty, null, undefined and non-string input", () => {
191
+ // The column is nullable and the API is free to hand back anything.
192
+ expect(parseDropdownOptions("")).toEqual([]);
193
+ expect(parseDropdownOptions(null)).toEqual([]);
194
+ expect(parseDropdownOptions(undefined)).toEqual([]);
195
+ expect(parseDropdownOptions(42 as unknown as string)).toEqual([]);
196
+ expect(parseDropdownOptions({} as unknown as string)).toEqual([]);
197
+ expect(parseDropdownOptions([] as unknown as string)).toEqual([]);
198
+ });
199
+ });
200
+
201
+ describe("CustomFieldColumns.getCustomFieldDefinitions", () => {
202
+ test("normalises a plain JSON object off the definitions endpoint", () => {
203
+ expect(
204
+ getCustomFieldDefinitions([
205
+ {
206
+ name: "Severity",
207
+ description: "How bad it is",
208
+ customFieldType: CustomFieldType.Dropdown,
209
+ dropdownOptions: "Low\nHigh",
210
+ },
211
+ ]),
212
+ ).toEqual([
213
+ {
214
+ name: "Severity",
215
+ description: "How bad it is",
216
+ customFieldType: CustomFieldType.Dropdown,
217
+ dropdownOptions: "Low\nHigh",
218
+ },
219
+ ]);
220
+ });
221
+
222
+ test("normalises a hydrated BaseModel the same way", () => {
223
+ /*
224
+ * ModelAPI hands back model instances, not plain JSON, and the hook feeds
225
+ * them in unchanged.
226
+ */
227
+ const model: MonitorCustomField = new MonitorCustomField();
228
+ model.name = "Severity";
229
+ model.description = "How bad it is";
230
+ model.customFieldType = CustomFieldType.Dropdown;
231
+ model.dropdownOptions = "Low\nHigh";
232
+
233
+ expect(getCustomFieldDefinitions([model])).toEqual([
234
+ {
235
+ name: "Severity",
236
+ description: "How bad it is",
237
+ customFieldType: CustomFieldType.Dropdown,
238
+ dropdownOptions: "Low\nHigh",
239
+ },
240
+ ]);
241
+ });
242
+
243
+ test("leaves the optional properties undefined when they are absent", () => {
244
+ expect(getCustomFieldDefinitions([{ name: "Owner" }])).toEqual([
245
+ {
246
+ name: "Owner",
247
+ description: undefined,
248
+ customFieldType: undefined,
249
+ dropdownOptions: undefined,
250
+ },
251
+ ]);
252
+ });
253
+
254
+ test("drops non-string description, type and options rather than passing them through", () => {
255
+ /*
256
+ * A number sitting in `description` would end up as a column tooltip, and
257
+ * a non-string type would silently miss every renderer branch.
258
+ */
259
+ expect(
260
+ getCustomFieldDefinitions([
261
+ {
262
+ name: "Owner",
263
+ description: 42,
264
+ customFieldType: 7,
265
+ dropdownOptions: ["Low", "High"],
266
+ },
267
+ ]),
268
+ ).toEqual([
269
+ {
270
+ name: "Owner",
271
+ description: undefined,
272
+ customFieldType: undefined,
273
+ dropdownOptions: undefined,
274
+ },
275
+ ]);
276
+ });
277
+
278
+ test("skips an entry with no name", () => {
279
+ // The name is the jsonb key; without one there is nothing to read.
280
+ expect(getCustomFieldDefinitions([{ description: "orphan" }])).toEqual([]);
281
+ });
282
+
283
+ test("skips an entry whose name is not a string", () => {
284
+ expect(
285
+ getCustomFieldDefinitions([
286
+ { name: 42 },
287
+ { name: null },
288
+ { name: { first: "Severity" } },
289
+ { name: ["Severity"] },
290
+ ]),
291
+ ).toEqual([]);
292
+ });
293
+
294
+ test("skips an entry whose name is empty or whitespace-only", () => {
295
+ expect(
296
+ getCustomFieldDefinitions([
297
+ { name: "" },
298
+ { name: " " },
299
+ { name: "\n" },
300
+ ]),
301
+ ).toEqual([]);
302
+ });
303
+
304
+ test("keeps the good entries alongside the skipped ones", () => {
305
+ expect(
306
+ getCustomFieldDefinitions([
307
+ { name: "Owner" },
308
+ { name: " " },
309
+ { description: "no name here" },
310
+ { name: "Severity" },
311
+ ]).map((definition: CustomFieldDefinition): string => {
312
+ return definition.name;
313
+ }),
314
+ ).toEqual(["Owner", "Severity"]);
315
+ });
316
+
317
+ test("trims the name", () => {
318
+ /*
319
+ * The trimmed name becomes the column id and the jsonb key lookup, so a
320
+ * stray space would produce a column that never matches a value.
321
+ */
322
+ expect(
323
+ getCustomFieldDefinitions([{ name: " Severity " }])[0]?.name,
324
+ ).toEqual("Severity");
325
+ });
326
+
327
+ test("de-duplicates by name, keeping the first entry", () => {
328
+ /*
329
+ * Two definitions with the same name would generate two columns with the
330
+ * same id, and the preference layer keys on that id.
331
+ */
332
+ expect(
333
+ getCustomFieldDefinitions([
334
+ { name: "Severity", description: "first" },
335
+ { name: "Severity", description: "second" },
336
+ ]),
337
+ ).toEqual([
338
+ {
339
+ name: "Severity",
340
+ description: "first",
341
+ customFieldType: undefined,
342
+ dropdownOptions: undefined,
343
+ },
344
+ ]);
345
+ });
346
+
347
+ test("de-duplicates after trimming, not before", () => {
348
+ expect(
349
+ getCustomFieldDefinitions([{ name: "Severity" }, { name: " Severity " }]),
350
+ ).toHaveLength(1);
351
+ });
352
+
353
+ test("sorts by name", () => {
354
+ /*
355
+ * This is the load-bearing one. The definition models carry no ordering
356
+ * column and the list endpoint is queried with an empty sort, so Postgres
357
+ * may hand back a different order on every request. Without a
358
+ * deterministic order here the viewer's saved column layout would appear
359
+ * to shuffle itself between page loads.
360
+ */
361
+ expect(
362
+ getCustomFieldDefinitions([
363
+ { name: "Zone" },
364
+ { name: "Alpha" },
365
+ { name: "Middle" },
366
+ ]).map((definition: CustomFieldDefinition): string => {
367
+ return definition.name;
368
+ }),
369
+ ).toEqual(["Alpha", "Middle", "Zone"]);
370
+ });
371
+
372
+ test("sorts case-insensitively the way a human reads the picker", () => {
373
+ // localeCompare, not a raw code point compare: "apple" sorts before "Banana".
374
+ expect(
375
+ getCustomFieldDefinitions([
376
+ { name: "Banana" },
377
+ { name: "apple" },
378
+ { name: "Cherry" },
379
+ ]).map((definition: CustomFieldDefinition): string => {
380
+ return definition.name;
381
+ }),
382
+ ).toEqual(["apple", "Banana", "Cherry"]);
383
+ });
384
+
385
+ test("sorts on the trimmed name", () => {
386
+ expect(
387
+ getCustomFieldDefinitions([{ name: " Zone" }, { name: "Alpha " }]).map(
388
+ (definition: CustomFieldDefinition): string => {
389
+ return definition.name;
390
+ },
391
+ ),
392
+ ).toEqual(["Alpha", "Zone"]);
393
+ });
394
+
395
+ test("returns an empty list for an empty or missing input", () => {
396
+ expect(getCustomFieldDefinitions([])).toEqual([]);
397
+ expect(
398
+ getCustomFieldDefinitions(undefined as unknown as Array<JSONObject>),
399
+ ).toEqual([]);
400
+ expect(
401
+ getCustomFieldDefinitions(null as unknown as Array<JSONObject>),
402
+ ).toEqual([]);
403
+ });
404
+
405
+ test("survives null and undefined entries inside the list", () => {
406
+ expect(
407
+ getCustomFieldDefinitions([
408
+ null as unknown as JSONObject,
409
+ undefined as unknown as JSONObject,
410
+ { name: "Owner" },
411
+ ]),
412
+ ).toHaveLength(1);
413
+ });
414
+ });
415
+
416
+ describe("CustomFieldColumns.getCustomFieldValue", () => {
417
+ test("reads the named key out of the customFields object", () => {
418
+ expect(
419
+ getCustomFieldValue({ customFields: { Severity: "High" } }, "Severity"),
420
+ ).toEqual("High");
421
+ });
422
+
423
+ test("reads a value off a hydrated model", () => {
424
+ expect(
425
+ getCustomFieldValue(monitorWithFields({ Owner: "SRE" }), "Owner"),
426
+ ).toEqual("SRE");
427
+ });
428
+
429
+ test("preserves the raw value type", () => {
430
+ /*
431
+ * The renderer branches on the value's type, so this must not stringify
432
+ * on the way out.
433
+ */
434
+ expect(
435
+ getCustomFieldValue({ customFields: { Paging: false } }, "Paging"),
436
+ ).toBe(false);
437
+ expect(getCustomFieldValue({ customFields: { Count: 0 } }, "Count")).toBe(
438
+ 0,
439
+ );
440
+ expect(
441
+ getCustomFieldValue({ customFields: { Teams: ["Infra"] } }, "Teams"),
442
+ ).toEqual(["Infra"]);
443
+ });
444
+
445
+ test("returns undefined when the row carries no customFields", () => {
446
+ /*
447
+ * The column is only selected when at least one definition exists, and a
448
+ * row that has never been edited has a null jsonb column.
449
+ */
450
+ expect(getCustomFieldValue({}, "Severity")).toBeUndefined();
451
+ expect(
452
+ getCustomFieldValue({ customFields: null }, "Severity"),
453
+ ).toBeUndefined();
454
+ expect(
455
+ getCustomFieldValue({ customFields: undefined }, "Severity"),
456
+ ).toBeUndefined();
457
+ expect(getCustomFieldValue(new Monitor(), "Severity")).toBeUndefined();
458
+ });
459
+
460
+ test("returns undefined when customFields is not an object", () => {
461
+ expect(
462
+ getCustomFieldValue({ customFields: "High" }, "Severity"),
463
+ ).toBeUndefined();
464
+ expect(
465
+ getCustomFieldValue({ customFields: 42 }, "Severity"),
466
+ ).toBeUndefined();
467
+ expect(
468
+ getCustomFieldValue({ customFields: true }, "Severity"),
469
+ ).toBeUndefined();
470
+ });
471
+
472
+ test("returns undefined when the key is absent", () => {
473
+ // A field added after the row was last saved.
474
+ expect(
475
+ getCustomFieldValue({ customFields: { Owner: "SRE" } }, "Severity"),
476
+ ).toBeUndefined();
477
+ });
478
+
479
+ test("returns undefined for a missing or non-object row", () => {
480
+ expect(getCustomFieldValue(undefined, "Severity")).toBeUndefined();
481
+ expect(getCustomFieldValue(null, "Severity")).toBeUndefined();
482
+ });
483
+ });
484
+
485
+ describe("CustomFieldColumns.getCustomFieldColumns", () => {
486
+ test("generates one column per definition, in the order given", () => {
487
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
488
+ definitions: [textDefinition, dropdownDefinition],
489
+ });
490
+
491
+ expect(columns).toHaveLength(2);
492
+ expect(
493
+ columns.map((column: Column<Monitor>): string => {
494
+ return column.title;
495
+ }),
496
+ ).toEqual(["Owner", "Severity"]);
497
+ });
498
+
499
+ test("declares the real model column as its field", () => {
500
+ /*
501
+ * A synthetic `field: { "customFields.Severity": true }` would fail the
502
+ * model's column check inside getSelectFromColumns and throw the whole
503
+ * table away, so the declared field has to be exactly the jsonb column.
504
+ */
505
+ const column: Column<Monitor> = columnAt(
506
+ getCustomFieldColumns<Monitor>({ definitions: [dropdownDefinition] }),
507
+ 0,
508
+ );
509
+
510
+ expect(column.field).toEqual({ customFields: true });
511
+ expect(Object.keys(column.field as JSONObject)).toEqual([
512
+ CustomFieldsColumnKey,
513
+ ]);
514
+ });
515
+
516
+ test("puts the definition name in selectedProperty", () => {
517
+ /*
518
+ * BaseModelTable joins field + selectedProperty into the column key
519
+ * "customFields.<name>"; without selectedProperty every custom field
520
+ * column would collapse onto the same key.
521
+ */
522
+ const column: Column<Monitor> = columnAt(
523
+ getCustomFieldColumns<Monitor>({ definitions: [dropdownDefinition] }),
524
+ 0,
525
+ );
526
+
527
+ expect(column.selectedProperty).toEqual("Severity");
528
+ });
529
+
530
+ test("always disables sorting", () => {
531
+ /*
532
+ * Nothing downstream validates `sort` before it reaches TypeORM, so a
533
+ * clickable header here would send an unknown property into the query
534
+ * builder. Postgres can only order by a jsonb key through an explicit ->>
535
+ * expression, which the sort path has no concept of.
536
+ */
537
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
538
+ definitions: [
539
+ textDefinition,
540
+ booleanDefinition,
541
+ dropdownDefinition,
542
+ multiSelectDefinition,
543
+ ],
544
+ });
545
+
546
+ for (const column of columns) {
547
+ expect(column.disableSort).toBe(true);
548
+ }
549
+ });
550
+
551
+ test("gives the column a stable id derived from the field name", () => {
552
+ /*
553
+ * The id is what the saved preference stores. Deriving it from the title
554
+ * (as untagged columns do) would be the same string today but would drift
555
+ * the moment anything about the title changes.
556
+ */
557
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
558
+ definitions: [dropdownDefinition, multiSelectDefinition],
559
+ });
560
+
561
+ expect(
562
+ columns.map((column: Column<Monitor>): string | undefined => {
563
+ return column.id;
564
+ }),
565
+ ).toEqual(["customFields.Severity", "customFields.Teams"]);
566
+ });
567
+
568
+ test("produces the same id for the same definition every time", () => {
569
+ const first: Columns<Monitor> = getCustomFieldColumns<Monitor>({
570
+ definitions: [dropdownDefinition],
571
+ });
572
+ const second: Columns<Monitor> = getCustomFieldColumns<Monitor>({
573
+ definitions: [dropdownDefinition],
574
+ isHiddenByDefault: false,
575
+ });
576
+
577
+ expect(columnAt(first, 0).id).toEqual(columnAt(second, 0).id);
578
+ });
579
+
580
+ test("hides custom field columns by default", () => {
581
+ /*
582
+ * A project is free to define a dozen custom fields, and turning every one
583
+ * on by default would push the columns the table was designed around off
584
+ * the side of the screen the first time anyone opens it.
585
+ */
586
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
587
+ definitions: [textDefinition, dropdownDefinition],
588
+ });
589
+
590
+ for (const column of columns) {
591
+ expect(column.isHiddenByDefault).toBe(true);
592
+ }
593
+ });
594
+
595
+ test("stays hidden when isHiddenByDefault is passed explicitly or as undefined", () => {
596
+ expect(
597
+ columnAt(
598
+ getCustomFieldColumns<Monitor>({
599
+ definitions: [textDefinition],
600
+ isHiddenByDefault: true,
601
+ }),
602
+ 0,
603
+ ).isHiddenByDefault,
604
+ ).toBe(true);
605
+
606
+ expect(
607
+ columnAt(
608
+ getCustomFieldColumns<Monitor>({
609
+ definitions: [textDefinition],
610
+ isHiddenByDefault: undefined,
611
+ }),
612
+ 0,
613
+ ).isHiddenByDefault,
614
+ ).toBe(true);
615
+ });
616
+
617
+ test("can be asked to show the columns instead", () => {
618
+ // Only an explicit `false` opts in - a caller that wants them visible.
619
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
620
+ definitions: [textDefinition, dropdownDefinition],
621
+ isHiddenByDefault: false,
622
+ });
623
+
624
+ for (const column of columns) {
625
+ expect(column.isHiddenByDefault).toBe(false);
626
+ }
627
+ });
628
+
629
+ test("takes the title and description from the definition", () => {
630
+ const column: Column<Monitor> = columnAt(
631
+ getCustomFieldColumns<Monitor>({
632
+ definitions: [
633
+ {
634
+ name: "Severity",
635
+ description: "How bad it is",
636
+ customFieldType: CustomFieldType.Dropdown,
637
+ },
638
+ ],
639
+ }),
640
+ 0,
641
+ );
642
+
643
+ expect(column.title).toEqual("Severity");
644
+ expect(column.description).toEqual("How bad it is");
645
+ });
646
+
647
+ test("leaves the description undefined when the definition has none", () => {
648
+ expect(
649
+ columnAt(
650
+ getCustomFieldColumns<Monitor>({ definitions: [textDefinition] }),
651
+ 0,
652
+ ).description,
653
+ ).toBeUndefined();
654
+ });
655
+
656
+ test("declares itself as an Element column", () => {
657
+ expect(
658
+ columnAt(
659
+ getCustomFieldColumns<Monitor>({ definitions: [textDefinition] }),
660
+ 0,
661
+ ).type,
662
+ ).toEqual(FieldType.Element);
663
+ });
664
+
665
+ test("gives every column a getElement", () => {
666
+ /*
667
+ * The typed cell renderers index item[column.key] directly, so with a
668
+ * dotted key like "customFields.Severity" they would look for a literal
669
+ * property of that name and find nothing. Only getElement can reach into
670
+ * the jsonb object.
671
+ */
672
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
673
+ definitions: [
674
+ textDefinition,
675
+ booleanDefinition,
676
+ dropdownDefinition,
677
+ multiSelectDefinition,
678
+ ],
679
+ });
680
+
681
+ for (const column of columns) {
682
+ expect(typeof column.getElement).toEqual("function");
683
+ }
684
+ });
685
+
686
+ test("exports a plain string for a text value", () => {
687
+ /*
688
+ * The CSV export walks the same dotted key and would come back empty
689
+ * without an explicit getExportValue.
690
+ */
691
+ const column: Column<Monitor> = columnAt(
692
+ getCustomFieldColumns<Monitor>({ definitions: [textDefinition] }),
693
+ 0,
694
+ );
695
+
696
+ expect(exportValueOf(column, monitorWithFields({ Owner: "SRE" }))).toEqual(
697
+ "SRE",
698
+ );
699
+ });
700
+
701
+ test("exports non-string scalars through String()", () => {
702
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
703
+ definitions: [booleanDefinition, { name: "Count" }],
704
+ });
705
+
706
+ /*
707
+ * A zero and a false have to survive the export: the "" fallback is only
708
+ * for undefined and null, so these go through String() like any other
709
+ * scalar.
710
+ */
711
+ expect(
712
+ exportValueOf(columnAt(columns, 0), monitorWithFields({ Paging: false })),
713
+ ).toEqual("false");
714
+ expect(
715
+ exportValueOf(columnAt(columns, 1), monitorWithFields({ Count: 0 })),
716
+ ).toEqual("0");
717
+ });
718
+
719
+ test("joins an array value for the CSV cell", () => {
720
+ const column: Column<Monitor> = columnAt(
721
+ getCustomFieldColumns<Monitor>({ definitions: [multiSelectDefinition] }),
722
+ 0,
723
+ );
724
+
725
+ expect(
726
+ exportValueOf(column, monitorWithFields({ Teams: ["Infra", "Apps"] })),
727
+ ).toEqual("Infra, Apps");
728
+ });
729
+
730
+ test("JSON-stringifies an object value for the CSV cell", () => {
731
+ const column: Column<Monitor> = columnAt(
732
+ getCustomFieldColumns<Monitor>({ definitions: [textDefinition] }),
733
+ 0,
734
+ );
735
+
736
+ expect(
737
+ exportValueOf(column, monitorWithFields({ Owner: { team: "SRE" } })),
738
+ ).toEqual('{"team":"SRE"}');
739
+ });
740
+
741
+ test("exports an empty string when the value is missing", () => {
742
+ /*
743
+ * An empty cell, not the literal "undefined", which is what String()
744
+ * would have produced.
745
+ */
746
+ const column: Column<Monitor> = columnAt(
747
+ getCustomFieldColumns<Monitor>({ definitions: [textDefinition] }),
748
+ 0,
749
+ );
750
+
751
+ expect(exportValueOf(column, new Monitor())).toEqual("");
752
+ expect(exportValueOf(column, monitorWithFields({}))).toEqual("");
753
+ expect(exportValueOf(column, monitorWithFields({ Owner: null }))).toEqual(
754
+ "",
755
+ );
756
+ });
757
+
758
+ test("returns no columns for an empty or missing definition list", () => {
759
+ /*
760
+ * A resource with no custom fields must contribute nothing at all, or the
761
+ * picker grows an empty section.
762
+ */
763
+ expect(getCustomFieldColumns<Monitor>({ definitions: [] })).toEqual([]);
764
+ expect(
765
+ getCustomFieldColumns<Monitor>({
766
+ definitions: undefined as unknown as Array<CustomFieldDefinition>,
767
+ }),
768
+ ).toEqual([]);
769
+ });
770
+ });
771
+
772
+ describe("CustomFieldColumns.renderCustomFieldValue", () => {
773
+ test("renders a text value as-is", () => {
774
+ expect(
775
+ renderValue({ value: "SRE", definition: textDefinition }).textContent,
776
+ ).toEqual("SRE");
777
+ });
778
+
779
+ test("renders a number value", () => {
780
+ expect(
781
+ renderValue({
782
+ value: 42,
783
+ definition: { name: "Count", customFieldType: CustomFieldType.Number },
784
+ }).textContent,
785
+ ).toEqual("42");
786
+ });
787
+
788
+ test("renders a zero rather than treating it as missing", () => {
789
+ // 0 is a real answer; only undefined, null and "" are missing.
790
+ const container: HTMLElement = renderValue({
791
+ value: 0,
792
+ definition: { name: "Count", customFieldType: CustomFieldType.Number },
793
+ });
794
+
795
+ expect(container.textContent).toEqual("0");
796
+ expect(getPlaceholder(container)).toBeNull();
797
+ });
798
+
799
+ test("renders a false boolean as 'No', not as the empty placeholder", () => {
800
+ /*
801
+ * The whole reason the boolean branch sits above the empty check: `false`
802
+ * is a real answer to a yes/no field, and rendering it as "-" would read
803
+ * as "nobody has filled this in".
804
+ */
805
+ const container: HTMLElement = renderValue({
806
+ value: false,
807
+ definition: booleanDefinition,
808
+ });
809
+
810
+ expect(container.textContent).toEqual("No");
811
+ expect(getPlaceholder(container)).toBeNull();
812
+ });
813
+
814
+ test("renders a true boolean as 'Yes'", () => {
815
+ expect(
816
+ renderValue({ value: true, definition: booleanDefinition }).textContent,
817
+ ).toEqual("Yes");
818
+ });
819
+
820
+ test("renders the string forms of a boolean", () => {
821
+ /*
822
+ * jsonb round-trips through a form that posts strings, so both shapes are
823
+ * in the wild.
824
+ */
825
+ expect(
826
+ renderValue({ value: "true", definition: booleanDefinition }).textContent,
827
+ ).toEqual("Yes");
828
+ expect(
829
+ renderValue({ value: "false", definition: booleanDefinition })
830
+ .textContent,
831
+ ).toEqual("No");
832
+ });
833
+
834
+ test("treats anything else on a boolean field as 'No'", () => {
835
+ expect(
836
+ renderValue({ value: "maybe", definition: booleanDefinition })
837
+ .textContent,
838
+ ).toEqual("No");
839
+ expect(
840
+ renderValue({ value: 1, definition: booleanDefinition }).textContent,
841
+ ).toEqual("No");
842
+ });
843
+
844
+ test("styles Yes and No differently", () => {
845
+ expect(
846
+ renderValue({
847
+ value: true,
848
+ definition: booleanDefinition,
849
+ }).querySelector("span.bg-green-50"),
850
+ ).not.toBeNull();
851
+ expect(
852
+ renderValue({
853
+ value: false,
854
+ definition: booleanDefinition,
855
+ }).querySelector("span.bg-gray-50"),
856
+ ).not.toBeNull();
857
+ });
858
+
859
+ test("still shows the placeholder for a boolean nobody answered", () => {
860
+ // Missing is missing - the boolean branch only claims real values.
861
+ expect(
862
+ getPlaceholder(
863
+ renderValue({ value: undefined, definition: booleanDefinition }),
864
+ ),
865
+ ).not.toBeNull();
866
+ });
867
+
868
+ test("renders a dropdown value as a single badge", () => {
869
+ const container: HTMLElement = renderValue({
870
+ value: "High",
871
+ definition: dropdownDefinition,
872
+ });
873
+
874
+ expect(getBadgeLabels(container)).toEqual(["High"]);
875
+ });
876
+
877
+ test("renders one badge per multi-select value", () => {
878
+ /*
879
+ * One badge each rather than a single comma-joined badge: the table reads
880
+ * as a set of tags, which is what a multi-select is.
881
+ */
882
+ const container: HTMLElement = renderValue({
883
+ value: ["Infra", "Apps"],
884
+ definition: multiSelectDefinition,
885
+ });
886
+
887
+ expect(getBadgeLabels(container)).toEqual(["Infra", "Apps"]);
888
+ });
889
+
890
+ test("renders a bare string on a multi-select field as one badge", () => {
891
+ /*
892
+ * Tolerant on the way out: a value saved while the field was a
893
+ * single-select is still a bare string in the jsonb after someone switches
894
+ * the field type, and it must not render as one badge per character.
895
+ */
896
+ const container: HTMLElement = renderValue({
897
+ value: "Infra",
898
+ definition: multiSelectDefinition,
899
+ });
900
+
901
+ expect(getBadgeLabels(container)).toEqual(["Infra"]);
902
+ });
903
+
904
+ test("drops empty entries out of a multi-select array", () => {
905
+ const container: HTMLElement = renderValue({
906
+ value: ["Infra", "", null, undefined, "Apps"],
907
+ definition: multiSelectDefinition,
908
+ });
909
+
910
+ expect(getBadgeLabels(container)).toEqual(["Infra", "Apps"]);
911
+ });
912
+
913
+ test("stringifies non-string multi-select entries", () => {
914
+ expect(
915
+ getBadgeLabels(
916
+ renderValue({ value: [1, 2], definition: multiSelectDefinition }),
917
+ ),
918
+ ).toEqual(["1", "2"]);
919
+ });
920
+
921
+ test("renders the placeholder for an empty multi-select array", () => {
922
+ /*
923
+ * An empty array is not caught by the `value === ""` check above, so the
924
+ * multi-select branch has to fall back to the placeholder itself.
925
+ */
926
+ const container: HTMLElement = renderValue({
927
+ value: [],
928
+ definition: multiSelectDefinition,
929
+ });
930
+
931
+ expect(getBadgeLabels(container)).toEqual([]);
932
+ expect(getPlaceholder(container)).not.toBeNull();
933
+ expect(container.textContent).toEqual("-");
934
+ });
935
+
936
+ test("renders the placeholder when every multi-select entry is empty", () => {
937
+ const container: HTMLElement = renderValue({
938
+ value: ["", null],
939
+ definition: multiSelectDefinition,
940
+ });
941
+
942
+ expect(getPlaceholder(container)).not.toBeNull();
943
+ });
944
+
945
+ test("renders the placeholder for undefined, null and an empty string", () => {
946
+ for (const value of [undefined, null, ""]) {
947
+ const container: HTMLElement = renderValue({
948
+ value: value,
949
+ definition: textDefinition,
950
+ });
951
+
952
+ expect(container.textContent).toEqual("-");
953
+ expect(getPlaceholder(container)).not.toBeNull();
954
+ }
955
+ });
956
+
957
+ test("uses the column's own noValueMessage when it has one", () => {
958
+ expect(
959
+ renderValue({
960
+ value: undefined,
961
+ definition: textDefinition,
962
+ noValueMessage: "Not set",
963
+ }).textContent,
964
+ ).toEqual("Not set");
965
+ });
966
+
967
+ test("joins an array value on a plain text field", () => {
968
+ /*
969
+ * The field type changed the other way round - an old multi-select value
970
+ * on a field that is now Text still has to read as something.
971
+ */
972
+ expect(
973
+ renderValue({ value: ["Infra", "Apps"], definition: textDefinition })
974
+ .textContent,
975
+ ).toEqual("Infra, Apps");
976
+ });
977
+
978
+ test("JSON-stringifies an object value", () => {
979
+ // Better than React's "[object Object]" or an outright render crash.
980
+ expect(
981
+ renderValue({ value: { team: "SRE" }, definition: textDefinition })
982
+ .textContent,
983
+ ).toEqual('{"team":"SRE"}');
984
+ });
985
+
986
+ test("falls through to the text path when the definition has no type", () => {
987
+ /*
988
+ * customFieldType is nullable on the definition models, so an untyped
989
+ * field has to render rather than blank out.
990
+ */
991
+ const definition: CustomFieldDefinition = { name: "Notes" };
992
+
993
+ expect(
994
+ renderValue({ value: "hello", definition: definition }).textContent,
995
+ ).toEqual("hello");
996
+ expect(
997
+ renderValue({ value: 7, definition: definition }).textContent,
998
+ ).toEqual("7");
999
+ expect(
1000
+ renderValue({ value: true, definition: definition }).textContent,
1001
+ ).toEqual("true");
1002
+ expect(
1003
+ getPlaceholder(renderValue({ value: undefined, definition: definition })),
1004
+ ).not.toBeNull();
1005
+ });
1006
+
1007
+ test("renders a boolean-looking string on an untyped field as the string", () => {
1008
+ /*
1009
+ * Without a Boolean type there is no yes/no to render - the value is just
1010
+ * text, and pretending otherwise would invent a meaning the field does not
1011
+ * have.
1012
+ */
1013
+ const container: HTMLElement = renderValue({
1014
+ value: "false",
1015
+ definition: { name: "Notes" },
1016
+ });
1017
+
1018
+ expect(container.textContent).toEqual("false");
1019
+ expect(container.querySelector("span.bg-gray-50")).toBeNull();
1020
+ });
1021
+ });
1022
+
1023
+ describe("CustomFieldColumns generated getElement", () => {
1024
+ test("reads the value out of the row's customFields and renders it", () => {
1025
+ const column: Column<Monitor> = columnAt(
1026
+ getCustomFieldColumns<Monitor>({ definitions: [textDefinition] }),
1027
+ 0,
1028
+ );
1029
+
1030
+ expect(
1031
+ renderColumn(column, monitorWithFields({ Owner: "SRE" })).textContent,
1032
+ ).toEqual("SRE");
1033
+ });
1034
+
1035
+ test("renders the placeholder for a row with no customFields at all", () => {
1036
+ const column: Column<Monitor> = columnAt(
1037
+ getCustomFieldColumns<Monitor>({ definitions: [textDefinition] }),
1038
+ 0,
1039
+ );
1040
+
1041
+ expect(getPlaceholder(renderColumn(column, new Monitor()))).not.toBeNull();
1042
+ });
1043
+
1044
+ test("renders 'No' for a false boolean straight off the row", () => {
1045
+ /*
1046
+ * The end-to-end version of the boolean rule: a monitor that was
1047
+ * explicitly marked as not paging must not read as unanswered in the
1048
+ * table.
1049
+ */
1050
+ const column: Column<Monitor> = columnAt(
1051
+ getCustomFieldColumns<Monitor>({ definitions: [booleanDefinition] }),
1052
+ 0,
1053
+ );
1054
+
1055
+ expect(
1056
+ renderColumn(column, monitorWithFields({ Paging: false })).textContent,
1057
+ ).toEqual("No");
1058
+ });
1059
+
1060
+ test("renders one badge per multi-select value straight off the row", () => {
1061
+ const column: Column<Monitor> = columnAt(
1062
+ getCustomFieldColumns<Monitor>({ definitions: [multiSelectDefinition] }),
1063
+ 0,
1064
+ );
1065
+
1066
+ expect(
1067
+ getBadgeLabels(
1068
+ renderColumn(column, monitorWithFields({ Teams: ["Infra", "Apps"] })),
1069
+ ),
1070
+ ).toEqual(["Infra", "Apps"]);
1071
+ });
1072
+
1073
+ test("each column reads only its own key", () => {
1074
+ /*
1075
+ * Every generated column shares the same `field`, so the only thing
1076
+ * keeping them apart is the name closed over by getElement.
1077
+ */
1078
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
1079
+ definitions: [dropdownDefinition, textDefinition],
1080
+ });
1081
+ const monitor: Monitor = monitorWithFields({
1082
+ Owner: "SRE",
1083
+ Severity: "High",
1084
+ });
1085
+
1086
+ // Sorted input is not required here; definitions map straight through.
1087
+ expect(renderColumn(columnAt(columns, 0), monitor).textContent).toEqual(
1088
+ "High",
1089
+ );
1090
+ expect(renderColumn(columnAt(columns, 1), monitor).textContent).toEqual(
1091
+ "SRE",
1092
+ );
1093
+ });
1094
+ });