@oneuptime/common 10.4.17 → 10.5.0

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 (318) hide show
  1. package/Models/AnalyticsModels/ExceptionInstance.ts +24 -0
  2. package/Models/AnalyticsModels/Log.ts +16 -0
  3. package/Models/AnalyticsModels/Metric.ts +31 -0
  4. package/Models/AnalyticsModels/MonitorLog.ts +5 -0
  5. package/Models/AnalyticsModels/Profile.ts +25 -0
  6. package/Models/AnalyticsModels/ProfileSample.ts +20 -0
  7. package/Models/AnalyticsModels/Span.ts +23 -0
  8. package/Models/DatabaseModels/AlertEpisodeMember.ts +2 -0
  9. package/Models/DatabaseModels/AlertGroupingRule.ts +0 -38
  10. package/Models/DatabaseModels/AlertLabelRule.ts +152 -0
  11. package/Models/DatabaseModels/AlertOwnerRule.ts +114 -0
  12. package/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.ts +7 -0
  13. package/Models/DatabaseModels/IncidentEpisodeMember.ts +2 -0
  14. package/Models/DatabaseModels/IncidentGroupingRule.ts +0 -38
  15. package/Models/DatabaseModels/IncidentLabelRule.ts +114 -0
  16. package/Models/DatabaseModels/IncidentMember.ts +2 -0
  17. package/Models/DatabaseModels/IncidentOwnerRule.ts +114 -0
  18. package/Models/DatabaseModels/IncidentSla.ts +2 -0
  19. package/Models/DatabaseModels/IncidentTemplate.ts +224 -0
  20. package/Models/DatabaseModels/Index.ts +2 -2
  21. package/Models/DatabaseModels/MetricPipelineRule.ts +2 -0
  22. package/Models/DatabaseModels/MonitorProbe.ts +2 -0
  23. package/Models/DatabaseModels/MonitorTest.ts +2 -0
  24. package/Models/DatabaseModels/OnCallDutyPolicyEscalationRule.ts +2 -0
  25. package/Models/DatabaseModels/OnCallDutyPolicyEscalationRuleSchedule.ts +2 -0
  26. package/Models/DatabaseModels/OnCallDutyPolicyEscalationRuleTeam.ts +2 -0
  27. package/Models/DatabaseModels/OnCallDutyPolicyEscalationRuleUser.ts +2 -0
  28. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +2 -0
  29. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLogTimeline.ts +2 -0
  30. package/Models/DatabaseModels/OnCallDutyPolicyTimeLog.ts +2 -0
  31. package/Models/DatabaseModels/OnCallDutyPolicyUserOverride.ts +2 -0
  32. package/Models/DatabaseModels/ProjectOidc.ts +4 -0
  33. package/Models/DatabaseModels/ProjectSCIM.ts +4 -0
  34. package/Models/DatabaseModels/ProjectSso.ts +4 -0
  35. package/Models/DatabaseModels/ScheduledMaintenance.ts +220 -0
  36. package/Models/DatabaseModels/ScheduledMaintenanceLabelRule.ts +152 -0
  37. package/Models/DatabaseModels/ScheduledMaintenanceOwnerRule.ts +152 -0
  38. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +224 -0
  39. package/Models/DatabaseModels/StatusPageOidc.ts +6 -0
  40. package/Models/DatabaseModels/StatusPageSCIM.ts +4 -0
  41. package/Models/DatabaseModels/StatusPageSCIMLog.ts +2 -0
  42. package/Models/DatabaseModels/StatusPageSso.ts +6 -0
  43. package/Models/DatabaseModels/Team.ts +41 -0
  44. package/Models/DatabaseModels/TeamComplianceSetting.ts +4 -0
  45. package/Models/DatabaseModels/{ServiceMonitor.ts → TeamCustomField.ts} +95 -200
  46. package/Models/DatabaseModels/TelemetryException.ts +2 -0
  47. package/Models/DatabaseModels/UserOnCallLog.ts +2 -0
  48. package/Models/DatabaseModels/UserOnCallLogTimeline.ts +2 -0
  49. package/Models/DatabaseModels/WorkflowLog.ts +2 -0
  50. package/Models/DatabaseModels/WorkflowVariable.ts +2 -0
  51. package/Server/EnvironmentConfig.ts +3 -0
  52. package/Server/Infrastructure/Postgres/SchemaMigrations/1779392865146-AddAgentVersionToKubernetesDockerHost.ts +1 -1
  53. package/Server/Infrastructure/Postgres/SchemaMigrations/1779653508434-AddLabelInheritanceAndScheduledMaintenanceResources.ts +160 -0
  54. package/Server/Infrastructure/Postgres/SchemaMigrations/1779708719656-AddAffectedResourcesToTemplates.ts +197 -0
  55. package/Server/Infrastructure/Postgres/SchemaMigrations/1779739410559-MigrationName.ts +36 -0
  56. package/Server/Infrastructure/Postgres/SchemaMigrations/1779742211961-AttachServiceToScheduledMaintenanceTemplatesAndLabelRules.ts +128 -0
  57. package/Server/Infrastructure/Postgres/SchemaMigrations/1779790539196-MigrationName.ts +53 -0
  58. package/Server/Infrastructure/Postgres/SchemaMigrations/1779823516881-ExpandOwnerRuleInheritFlags.ts +73 -0
  59. package/Server/Infrastructure/Postgres/SchemaMigrations/1779827700000-RenameStatusPageZhToZhCN.ts +62 -0
  60. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +14 -0
  61. package/Server/Middleware/TelemetryIngestionDisabled.ts +32 -0
  62. package/Server/Services/AlertGroupingEngineService.ts +0 -29
  63. package/Server/Services/AlertLabelRuleEngineService.ts +129 -0
  64. package/Server/Services/AlertOwnerRuleEngineService.ts +205 -1
  65. package/Server/Services/IncidentGroupingEngineService.ts +0 -37
  66. package/Server/Services/IncidentLabelRuleEngineService.ts +83 -0
  67. package/Server/Services/IncidentOwnerRuleEngineService.ts +208 -10
  68. package/Server/Services/IncidentService.ts +139 -1
  69. package/Server/Services/Index.ts +0 -2
  70. package/Server/Services/MonitorProbeService.ts +56 -0
  71. package/Server/Services/MonitorService.ts +55 -0
  72. package/Server/Services/ProjectService.ts +17 -8
  73. package/Server/Services/ScheduledMaintenanceLabelRuleEngineService.ts +129 -0
  74. package/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.ts +289 -7
  75. package/Server/Services/StatusPageService.ts +30 -0
  76. package/Server/Services/TeamCustomFieldService.ts +9 -0
  77. package/Server/Types/AnalyticsDatabase/ModelPermission.ts +226 -28
  78. package/Server/Types/Database/Permissions/EditionPermission.ts +46 -0
  79. package/Server/Types/Database/Permissions/TablePermission.ts +8 -1
  80. package/Server/Utils/Monitor/MonitorAlert.ts +35 -0
  81. package/Server/Utils/Monitor/MonitorIncident.ts +244 -34
  82. package/Tests/Server/Middleware/UserAuthorization.test.ts +11 -19
  83. package/Tests/Types/Permission.test.ts +129 -1
  84. package/Types/Accounts/AccountsLanguage.ts +10 -1
  85. package/Types/AdminDashboard/AdminDashboardLanguage.ts +10 -1
  86. package/Types/BaseDatabase/TableEditionAccessControl.ts +3 -0
  87. package/Types/Dashboard/DashboardLanguage.ts +10 -1
  88. package/Types/Database/AccessControl/TableEditionAccessControl.ts +8 -0
  89. package/Types/Date.ts +1 -1
  90. package/Types/Docs/DocsLanguage.ts +10 -1
  91. package/Types/Permission.ts +87 -54
  92. package/Types/StatusPage/StatusPageLanguage.ts +10 -1
  93. package/UI/Components/Charts/Area/AreaChart.tsx +1 -1
  94. package/UI/Components/Charts/Bar/BarChart.tsx +1 -1
  95. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +5 -1
  96. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +1 -1
  97. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +11 -1
  98. package/UI/Components/Charts/Line/LineChart.tsx +1 -1
  99. package/UI/Components/Charts/Utils/XAxis.ts +21 -48
  100. package/UI/Components/EntityDropdown/EntityDropdown.tsx +1808 -0
  101. package/UI/Components/Forms/Fields/FormField.tsx +69 -29
  102. package/UI/Components/Link/Link.tsx +13 -1
  103. package/UI/Components/ModelDetail/ModelDetail.tsx +20 -19
  104. package/UI/Components/ModelTable/BaseModelTable.tsx +5 -0
  105. package/UI/Utils/User.ts +16 -0
  106. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +39 -2
  107. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  108. package/build/dist/Models/AnalyticsModels/Log.js +16 -0
  109. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  110. package/build/dist/Models/AnalyticsModels/Metric.js +31 -0
  111. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  112. package/build/dist/Models/AnalyticsModels/MonitorLog.js +5 -0
  113. package/build/dist/Models/AnalyticsModels/MonitorLog.js.map +1 -1
  114. package/build/dist/Models/AnalyticsModels/Profile.js +40 -2
  115. package/build/dist/Models/AnalyticsModels/Profile.js.map +1 -1
  116. package/build/dist/Models/AnalyticsModels/ProfileSample.js +35 -2
  117. package/build/dist/Models/AnalyticsModels/ProfileSample.js.map +1 -1
  118. package/build/dist/Models/AnalyticsModels/Span.js +23 -0
  119. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  120. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js +2 -0
  121. package/build/dist/Models/DatabaseModels/AlertEpisodeMember.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/AlertGroupingRule.js +0 -39
  123. package/build/dist/Models/DatabaseModels/AlertGroupingRule.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/AlertLabelRule.js +156 -0
  125. package/build/dist/Models/DatabaseModels/AlertLabelRule.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/AlertOwnerRule.js +117 -0
  127. package/build/dist/Models/DatabaseModels/AlertOwnerRule.js.map +1 -1
  128. package/build/dist/Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel.js.map +1 -1
  129. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js +2 -0
  130. package/build/dist/Models/DatabaseModels/IncidentEpisodeMember.js.map +1 -1
  131. package/build/dist/Models/DatabaseModels/IncidentGroupingRule.js +0 -39
  132. package/build/dist/Models/DatabaseModels/IncidentGroupingRule.js.map +1 -1
  133. package/build/dist/Models/DatabaseModels/IncidentLabelRule.js +117 -0
  134. package/build/dist/Models/DatabaseModels/IncidentLabelRule.js.map +1 -1
  135. package/build/dist/Models/DatabaseModels/IncidentMember.js +2 -0
  136. package/build/dist/Models/DatabaseModels/IncidentMember.js.map +1 -1
  137. package/build/dist/Models/DatabaseModels/IncidentOwnerRule.js +117 -0
  138. package/build/dist/Models/DatabaseModels/IncidentOwnerRule.js.map +1 -1
  139. package/build/dist/Models/DatabaseModels/IncidentSla.js +2 -0
  140. package/build/dist/Models/DatabaseModels/IncidentSla.js.map +1 -1
  141. package/build/dist/Models/DatabaseModels/IncidentTemplate.js +216 -0
  142. package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
  143. package/build/dist/Models/DatabaseModels/Index.js +2 -2
  144. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  145. package/build/dist/Models/DatabaseModels/MetricPipelineRule.js +2 -0
  146. package/build/dist/Models/DatabaseModels/MetricPipelineRule.js.map +1 -1
  147. package/build/dist/Models/DatabaseModels/MonitorProbe.js +2 -0
  148. package/build/dist/Models/DatabaseModels/MonitorProbe.js.map +1 -1
  149. package/build/dist/Models/DatabaseModels/MonitorTest.js +2 -0
  150. package/build/dist/Models/DatabaseModels/MonitorTest.js.map +1 -1
  151. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyEscalationRule.js +2 -0
  152. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyEscalationRule.js.map +1 -1
  153. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyEscalationRuleSchedule.js +2 -0
  154. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyEscalationRuleSchedule.js.map +1 -1
  155. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyEscalationRuleTeam.js +2 -0
  156. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyEscalationRuleTeam.js.map +1 -1
  157. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyEscalationRuleUser.js +2 -0
  158. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyEscalationRuleUser.js.map +1 -1
  159. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +2 -0
  160. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  161. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLogTimeline.js +2 -0
  162. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLogTimeline.js.map +1 -1
  163. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js +2 -0
  164. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyTimeLog.js.map +1 -1
  165. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js +2 -0
  166. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyUserOverride.js.map +1 -1
  167. package/build/dist/Models/DatabaseModels/ProjectOidc.js +4 -0
  168. package/build/dist/Models/DatabaseModels/ProjectOidc.js.map +1 -1
  169. package/build/dist/Models/DatabaseModels/ProjectSCIM.js +4 -0
  170. package/build/dist/Models/DatabaseModels/ProjectSCIM.js.map +1 -1
  171. package/build/dist/Models/DatabaseModels/ProjectSso.js +4 -0
  172. package/build/dist/Models/DatabaseModels/ProjectSso.js.map +1 -1
  173. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +216 -0
  174. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  175. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceLabelRule.js +156 -0
  176. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceLabelRule.js.map +1 -1
  177. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceOwnerRule.js +156 -0
  178. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceOwnerRule.js.map +1 -1
  179. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +216 -0
  180. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  181. package/build/dist/Models/DatabaseModels/StatusPageOidc.js +6 -0
  182. package/build/dist/Models/DatabaseModels/StatusPageOidc.js.map +1 -1
  183. package/build/dist/Models/DatabaseModels/StatusPageSCIM.js +4 -0
  184. package/build/dist/Models/DatabaseModels/StatusPageSCIM.js.map +1 -1
  185. package/build/dist/Models/DatabaseModels/StatusPageSCIMLog.js +2 -0
  186. package/build/dist/Models/DatabaseModels/StatusPageSCIMLog.js.map +1 -1
  187. package/build/dist/Models/DatabaseModels/StatusPageSso.js +6 -0
  188. package/build/dist/Models/DatabaseModels/StatusPageSso.js.map +1 -1
  189. package/build/dist/Models/DatabaseModels/Team.js +42 -0
  190. package/build/dist/Models/DatabaseModels/Team.js.map +1 -1
  191. package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js +4 -0
  192. package/build/dist/Models/DatabaseModels/TeamComplianceSetting.js.map +1 -1
  193. package/build/dist/Models/DatabaseModels/{ServiceMonitor.js → TeamCustomField.js} +108 -209
  194. package/build/dist/Models/DatabaseModels/TeamCustomField.js.map +1 -0
  195. package/build/dist/Models/DatabaseModels/TelemetryException.js +2 -0
  196. package/build/dist/Models/DatabaseModels/TelemetryException.js.map +1 -1
  197. package/build/dist/Models/DatabaseModels/UserOnCallLog.js +2 -0
  198. package/build/dist/Models/DatabaseModels/UserOnCallLog.js.map +1 -1
  199. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +2 -0
  200. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
  201. package/build/dist/Models/DatabaseModels/WorkflowLog.js +2 -0
  202. package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
  203. package/build/dist/Models/DatabaseModels/WorkflowVariable.js +2 -0
  204. package/build/dist/Models/DatabaseModels/WorkflowVariable.js.map +1 -1
  205. package/build/dist/Server/EnvironmentConfig.js +1 -0
  206. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  207. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779392865146-AddAgentVersionToKubernetesDockerHost.js.map +1 -1
  208. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779653508434-AddLabelInheritanceAndScheduledMaintenanceResources.js +60 -0
  209. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779653508434-AddLabelInheritanceAndScheduledMaintenanceResources.js.map +1 -0
  210. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779708719656-AddAffectedResourcesToTemplates.js +74 -0
  211. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779708719656-AddAffectedResourcesToTemplates.js.map +1 -0
  212. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779739410559-MigrationName.js +19 -0
  213. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779739410559-MigrationName.js.map +1 -0
  214. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779742211961-AttachServiceToScheduledMaintenanceTemplatesAndLabelRules.js +50 -0
  215. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779742211961-AttachServiceToScheduledMaintenanceTemplatesAndLabelRules.js.map +1 -0
  216. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779790539196-MigrationName.js +26 -0
  217. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779790539196-MigrationName.js.map +1 -0
  218. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779823516881-ExpandOwnerRuleInheritFlags.js +30 -0
  219. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779823516881-ExpandOwnerRuleInheritFlags.js.map +1 -0
  220. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779827700000-RenameStatusPageZhToZhCN.js +50 -0
  221. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779827700000-RenameStatusPageZhToZhCN.js.map +1 -0
  222. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +14 -0
  223. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  224. package/build/dist/Server/Middleware/TelemetryIngestionDisabled.js +22 -0
  225. package/build/dist/Server/Middleware/TelemetryIngestionDisabled.js.map +1 -0
  226. package/build/dist/Server/Services/AlertGroupingEngineService.js +0 -25
  227. package/build/dist/Server/Services/AlertGroupingEngineService.js.map +1 -1
  228. package/build/dist/Server/Services/AlertLabelRuleEngineService.js +117 -3
  229. package/build/dist/Server/Services/AlertLabelRuleEngineService.js.map +1 -1
  230. package/build/dist/Server/Services/AlertOwnerRuleEngineService.js +175 -5
  231. package/build/dist/Server/Services/AlertOwnerRuleEngineService.js.map +1 -1
  232. package/build/dist/Server/Services/IncidentGroupingEngineService.js +0 -31
  233. package/build/dist/Server/Services/IncidentGroupingEngineService.js.map +1 -1
  234. package/build/dist/Server/Services/IncidentLabelRuleEngineService.js +76 -3
  235. package/build/dist/Server/Services/IncidentLabelRuleEngineService.js.map +1 -1
  236. package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js +176 -14
  237. package/build/dist/Server/Services/IncidentOwnerRuleEngineService.js.map +1 -1
  238. package/build/dist/Server/Services/IncidentService.js +104 -1
  239. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  240. package/build/dist/Server/Services/Index.js +0 -2
  241. package/build/dist/Server/Services/Index.js.map +1 -1
  242. package/build/dist/Server/Services/MonitorProbeService.js +46 -0
  243. package/build/dist/Server/Services/MonitorProbeService.js.map +1 -1
  244. package/build/dist/Server/Services/MonitorService.js +40 -0
  245. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  246. package/build/dist/Server/Services/ProjectService.js +17 -8
  247. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  248. package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js +117 -3
  249. package/build/dist/Server/Services/ScheduledMaintenanceLabelRuleEngineService.js.map +1 -1
  250. package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js +245 -10
  251. package/build/dist/Server/Services/ScheduledMaintenanceOwnerRuleEngineService.js.map +1 -1
  252. package/build/dist/Server/Services/StatusPageService.js +24 -0
  253. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  254. package/build/dist/Server/Services/TeamCustomFieldService.js +9 -0
  255. package/build/dist/Server/Services/TeamCustomFieldService.js.map +1 -0
  256. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js +166 -26
  257. package/build/dist/Server/Types/AnalyticsDatabase/ModelPermission.js.map +1 -1
  258. package/build/dist/Server/Types/Database/Permissions/EditionPermission.js +45 -0
  259. package/build/dist/Server/Types/Database/Permissions/EditionPermission.js.map +1 -0
  260. package/build/dist/Server/Types/Database/Permissions/TablePermission.js +7 -1
  261. package/build/dist/Server/Types/Database/Permissions/TablePermission.js.map +1 -1
  262. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +30 -0
  263. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  264. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +200 -31
  265. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  266. package/build/dist/Tests/Server/Middleware/UserAuthorization.test.js +8 -15
  267. package/build/dist/Tests/Server/Middleware/UserAuthorization.test.js.map +1 -1
  268. package/build/dist/Tests/Types/Permission.test.js +90 -1
  269. package/build/dist/Tests/Types/Permission.test.js.map +1 -1
  270. package/build/dist/Types/Accounts/AccountsLanguage.js +10 -1
  271. package/build/dist/Types/Accounts/AccountsLanguage.js.map +1 -1
  272. package/build/dist/Types/AdminDashboard/AdminDashboardLanguage.js +10 -1
  273. package/build/dist/Types/AdminDashboard/AdminDashboardLanguage.js.map +1 -1
  274. package/build/dist/Types/BaseDatabase/TableEditionAccessControl.js +2 -0
  275. package/build/dist/Types/BaseDatabase/TableEditionAccessControl.js.map +1 -0
  276. package/build/dist/Types/Dashboard/DashboardLanguage.js +10 -1
  277. package/build/dist/Types/Dashboard/DashboardLanguage.js.map +1 -1
  278. package/build/dist/Types/Database/AccessControl/TableEditionAccessControl.js +6 -0
  279. package/build/dist/Types/Database/AccessControl/TableEditionAccessControl.js.map +1 -0
  280. package/build/dist/Types/Date.js +1 -1
  281. package/build/dist/Types/Date.js.map +1 -1
  282. package/build/dist/Types/Docs/DocsLanguage.js +10 -1
  283. package/build/dist/Types/Docs/DocsLanguage.js.map +1 -1
  284. package/build/dist/Types/Permission.js +80 -44
  285. package/build/dist/Types/Permission.js.map +1 -1
  286. package/build/dist/Types/StatusPage/StatusPageLanguage.js +10 -1
  287. package/build/dist/Types/StatusPage/StatusPageLanguage.js.map +1 -1
  288. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  289. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  290. package/build/dist/UI/Components/Charts/Bar/BarChart.js +1 -1
  291. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  292. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +5 -1
  293. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  294. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +1 -1
  295. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  296. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +11 -1
  297. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  298. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  299. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  300. package/build/dist/UI/Components/Charts/Utils/XAxis.js +21 -47
  301. package/build/dist/UI/Components/Charts/Utils/XAxis.js.map +1 -1
  302. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js +1125 -0
  303. package/build/dist/UI/Components/EntityDropdown/EntityDropdown.js.map +1 -0
  304. package/build/dist/UI/Components/Forms/Fields/FormField.js +28 -10
  305. package/build/dist/UI/Components/Forms/Fields/FormField.js.map +1 -1
  306. package/build/dist/UI/Components/Link/Link.js +11 -2
  307. package/build/dist/UI/Components/Link/Link.js.map +1 -1
  308. package/build/dist/UI/Components/ModelDetail/ModelDetail.js +20 -18
  309. package/build/dist/UI/Components/ModelDetail/ModelDetail.js.map +1 -1
  310. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +4 -0
  311. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  312. package/build/dist/UI/Utils/User.js +13 -0
  313. package/build/dist/UI/Utils/User.js.map +1 -1
  314. package/package.json +1 -1
  315. package/Server/Services/ServiceMonitorService.ts +0 -57
  316. package/build/dist/Models/DatabaseModels/ServiceMonitor.js.map +0 -1
  317. package/build/dist/Server/Services/ServiceMonitorService.js +0 -56
  318. package/build/dist/Server/Services/ServiceMonitorService.js.map +0 -1
@@ -0,0 +1,1808 @@
1
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
2
+ import Label from "../../../Models/DatabaseModels/Label";
3
+ import Includes from "../../../Types/BaseDatabase/Includes";
4
+ import Query from "../../../Types/BaseDatabase/Query";
5
+ import Search from "../../../Types/BaseDatabase/Search";
6
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
7
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
8
+ import IconProp from "../../../Types/Icon/IconProp";
9
+ import ObjectID from "../../../Types/ObjectID";
10
+ import ModelAPI, { ListResult } from "../../Utils/ModelAPI/ModelAPI";
11
+ import Icon from "../Icon/Icon";
12
+ import {
13
+ DropdownOption,
14
+ DropdownOptionGroup,
15
+ DropdownOptionLabel,
16
+ DropdownValue,
17
+ } from "../Dropdown/Dropdown";
18
+ import React, {
19
+ FunctionComponent,
20
+ ReactElement,
21
+ useCallback,
22
+ useEffect,
23
+ useMemo,
24
+ useRef,
25
+ useState,
26
+ } from "react";
27
+
28
+ /*
29
+ * EntityDropdown is the generalized successor to react-select-based Dropdown.
30
+ * It accepts the same prop shape so callers can drop it in, but adds:
31
+ *
32
+ * - A built-in chip / popover / search UI (no react-select dependency).
33
+ * - Server-side lazy search when `modelType` is provided — only the first
34
+ * page of options needs to live in component memory at any time.
35
+ * - A "Labels" tab in the popover for multi-select on entities that have
36
+ * a `labels` M2M to Label, mirroring the AffectedResourcesPicker UX.
37
+ * Click a label to bulk-select every entity tagged with it.
38
+ *
39
+ * For static-option dropdowns (enum-like fields, no modelType), the popover
40
+ * filters client-side and the Labels tab is hidden. Single-select hides
41
+ * the Labels tab too — bulk-adding into a single slot makes no sense.
42
+ *
43
+ * The prop surface intentionally mirrors `Dropdown` for the simplest possible
44
+ * form-field swap. Internally we normalize everything to `Array<DropdownOption>`
45
+ * (length 1 for single-select) and treat the chips as the source of truth so
46
+ * we don't have to re-resolve IDs to labels on every render.
47
+ */
48
+
49
+ /*
50
+ * Same permissive shape as react-select-based Dropdown. Callers may pass
51
+ * either raw values (string / number / ObjectID / arrays thereof) or
52
+ * resolved DropdownOption(s). We normalize internally so consumers don't
53
+ * need to think about it — particularly the form layer, which stores
54
+ * raw IDs on Formik state.
55
+ */
56
+ export type EntityDropdownValue =
57
+ | DropdownValue
58
+ | Array<DropdownValue>
59
+ | DropdownOption
60
+ | Array<DropdownOption>
61
+ | ObjectID
62
+ | Array<ObjectID>
63
+ | null
64
+ | undefined;
65
+
66
+ export interface EntityDropdownProps {
67
+ /*
68
+ * Static options (enum-like). Used as initial cache when modelType is set;
69
+ * sole source of options when it isn't.
70
+ */
71
+ options?: Array<DropdownOption | DropdownOptionGroup> | undefined;
72
+
73
+ // Drop-in compatibility with `Dropdown`.
74
+ initialValue?: EntityDropdownValue | undefined;
75
+ value?: EntityDropdownValue | undefined;
76
+ onChange?:
77
+ | ((value: DropdownValue | Array<DropdownValue> | null) => void)
78
+ | undefined;
79
+ onFocus?: (() => void) | undefined;
80
+ onBlur?: (() => void) | undefined;
81
+ placeholder?: string | undefined;
82
+ className?: string | undefined;
83
+ isMultiSelect?: boolean | undefined;
84
+ tabIndex?: number | undefined;
85
+ error?: string | undefined;
86
+ id?: string | undefined;
87
+ dataTestId?: string | undefined;
88
+ ariaLabel?: string | undefined;
89
+ disabled?: boolean | undefined;
90
+
91
+ /*
92
+ * Entity backing. When set, the popover fetches options server-side and
93
+ * (for multi-select on labeled entities) exposes a Labels tab.
94
+ */
95
+ modelType?: { new (): BaseModel } | undefined;
96
+ labelField?: string | undefined;
97
+ valueField?: string | undefined;
98
+ colorField?: string | undefined;
99
+ /*
100
+ * Override the auto-detection — explicitly hide the Labels tab even on a
101
+ * labeled entity, or force-show it.
102
+ */
103
+ enableLabelsTab?: boolean | undefined;
104
+ }
105
+
106
+ const SEARCH_DEBOUNCE_MS: number = 250;
107
+ const SEARCH_PAGE_SIZE: number = 50;
108
+ const LABEL_PREVIEW_LIMIT: number = 50;
109
+
110
+ const flattenOptions: (
111
+ options: Array<DropdownOption | DropdownOptionGroup> | undefined,
112
+ ) => Array<DropdownOption> = (
113
+ options: Array<DropdownOption | DropdownOptionGroup> | undefined,
114
+ ): Array<DropdownOption> => {
115
+ if (!options) {
116
+ return [];
117
+ }
118
+ const flat: Array<DropdownOption> = [];
119
+ for (const item of options) {
120
+ if (
121
+ item &&
122
+ typeof item === "object" &&
123
+ "options" in item &&
124
+ Array.isArray((item as DropdownOptionGroup).options)
125
+ ) {
126
+ for (const sub of (item as DropdownOptionGroup).options) {
127
+ flat.push(sub);
128
+ }
129
+ } else {
130
+ flat.push(item as DropdownOption);
131
+ }
132
+ }
133
+ return flat;
134
+ };
135
+
136
+ /*
137
+ * Pull a string key out of any incoming DropdownValue (string | number |
138
+ * boolean | ObjectID-stringified). All comparisons + map keys use the
139
+ * string form to dodge mismatches between numeric and string IDs.
140
+ */
141
+ const valueKey: (v: DropdownValue) => string = (v: DropdownValue): string => {
142
+ if (typeof v === "string") {
143
+ return v;
144
+ }
145
+ return String(v);
146
+ };
147
+
148
+ /*
149
+ * Normalize the prop value down to a list of string keys regardless of
150
+ * the shape callers pass. Mirrors react-select-Dropdown's value coercion
151
+ * so the form layer can keep storing whichever shape it has today.
152
+ */
153
+ const valueToKeys: (v: EntityDropdownValue) => Array<string> = (
154
+ v: EntityDropdownValue,
155
+ ): Array<string> => {
156
+ if (v === undefined || v === null) {
157
+ return [];
158
+ }
159
+ if (typeof v === "string") {
160
+ return v === "" ? [] : [v];
161
+ }
162
+ if (typeof v === "number" || typeof v === "boolean") {
163
+ return [String(v)];
164
+ }
165
+ if (v instanceof ObjectID) {
166
+ return [v.toString()];
167
+ }
168
+ if (Array.isArray(v)) {
169
+ const keys: Array<string> = [];
170
+ for (const item of v) {
171
+ if (item === undefined || item === null) {
172
+ continue;
173
+ }
174
+ if (item instanceof ObjectID) {
175
+ keys.push(item.toString());
176
+ continue;
177
+ }
178
+ if (typeof item === "string") {
179
+ if (item !== "") {
180
+ keys.push(item);
181
+ }
182
+ continue;
183
+ }
184
+ if (typeof item === "number" || typeof item === "boolean") {
185
+ keys.push(String(item));
186
+ continue;
187
+ }
188
+ if (typeof item === "object" && "value" in item) {
189
+ keys.push(valueKey((item as DropdownOption).value));
190
+ continue;
191
+ }
192
+ }
193
+ return keys;
194
+ }
195
+ if (typeof v === "object" && v && "value" in v) {
196
+ return [valueKey((v as DropdownOption).value)];
197
+ }
198
+ return [];
199
+ };
200
+
201
+ /*
202
+ * Best-effort runtime detection of a `labels` ManyToMany. The convention
203
+ * across OneUptime models is a property literally named `labels` typed
204
+ * `Array<Label>` (44 such models at last count), so a property-existence
205
+ * probe is the cheapest reliable check we can do without yanking column
206
+ * metadata. Callers can override via `enableLabelsTab` if heuristics fail.
207
+ */
208
+ const detectLabelsField: (
209
+ ModelType: { new (): BaseModel } | undefined,
210
+ ) => boolean = (ModelType: { new (): BaseModel } | undefined): boolean => {
211
+ if (!ModelType) {
212
+ return false;
213
+ }
214
+ try {
215
+ const instance: BaseModel = new ModelType();
216
+ /*
217
+ * OneUptime models declare `public labels?: Array<Label> = undefined;`
218
+ * so the property is present on the instance (initialized to undefined)
219
+ * and the `in` check is reliable. We add the column-metadata probe
220
+ * underneath as a belt-and-suspenders fallback in case a future model
221
+ * declares the field differently.
222
+ */
223
+ if ("labels" in (instance as unknown as Record<string, unknown>)) {
224
+ return true;
225
+ }
226
+ type WithColumnLookup = {
227
+ getTableColumnMetadata?: (name: string) => unknown;
228
+ getTableColumns?: () => { columns?: Array<string> };
229
+ };
230
+ const metaProbe: WithColumnLookup = instance as unknown as WithColumnLookup;
231
+ if (
232
+ typeof metaProbe.getTableColumnMetadata === "function" &&
233
+ metaProbe.getTableColumnMetadata("labels")
234
+ ) {
235
+ return true;
236
+ }
237
+ if (typeof metaProbe.getTableColumns === "function") {
238
+ const cols: { columns?: Array<string> } | undefined =
239
+ metaProbe.getTableColumns();
240
+ if (cols?.columns?.includes("labels")) {
241
+ return true;
242
+ }
243
+ }
244
+ return false;
245
+ } catch {
246
+ return false;
247
+ }
248
+ };
249
+
250
+ const EntityDropdown: FunctionComponent<EntityDropdownProps> = (
251
+ props: EntityDropdownProps,
252
+ ): ReactElement => {
253
+ const isMulti: boolean = Boolean(props.isMultiSelect);
254
+ const modelType: { new (): BaseModel } | undefined = props.modelType;
255
+ const labelField: string = props.labelField || "name";
256
+ const valueField: string = props.valueField || "_id";
257
+ /*
258
+ * Color column auto-detection mirrors ModelForm.fetchDropdownOptions —
259
+ * BaseModel exposes getFirstColorColumn() which returns the first column
260
+ * decorated as a color (Severity.color, Status.color, etc.). When set,
261
+ * we include it in the server-side SELECT so our supplemental searches
262
+ * carry the same color metadata as the form's pre-fetch.
263
+ */
264
+ const detectedColorField: string | undefined = useMemo(() => {
265
+ if (!modelType) {
266
+ return undefined;
267
+ }
268
+ try {
269
+ type WithColorProbe = {
270
+ getFirstColorColumn?: () => string | null | undefined;
271
+ };
272
+ const probe: WithColorProbe =
273
+ new modelType() as unknown as WithColorProbe;
274
+ if (typeof probe.getFirstColorColumn === "function") {
275
+ return probe.getFirstColorColumn() || undefined;
276
+ }
277
+ } catch {
278
+ // Model can't be instantiated for inspection; fall back to no color.
279
+ }
280
+ return undefined;
281
+ }, [modelType]);
282
+ const colorField: string | undefined = props.colorField || detectedColorField;
283
+ const hasLabelsAutoDetected: boolean = useMemo(() => {
284
+ return detectLabelsField(modelType);
285
+ }, [modelType]);
286
+ /*
287
+ * The Labels tab is only meaningful on multi-select against a labeled
288
+ * entity. enableLabelsTab is an opt-out override (or explicit opt-in for
289
+ * cases where the heuristic misses).
290
+ */
291
+ const labelsTabEnabled: boolean =
292
+ isMulti &&
293
+ (props.enableLabelsTab !== undefined
294
+ ? props.enableLabelsTab
295
+ : hasLabelsAutoDetected) &&
296
+ Boolean(modelType);
297
+
298
+ /*
299
+ * optionsCache is the single source of truth for which DropdownOption goes
300
+ * with which value. It's seeded with the props.options the form pre-fetched
301
+ * and grows as the user types (server-side search) or as we resolve
302
+ * previously-selected values that weren't in the first page.
303
+ */
304
+ const initialFlat: Array<DropdownOption> = useMemo(() => {
305
+ return flattenOptions(props.options);
306
+ }, [props.options]);
307
+
308
+ const optionsCacheRef: React.MutableRefObject<Map<string, DropdownOption>> =
309
+ useRef<Map<string, DropdownOption>>(new Map());
310
+
311
+ /*
312
+ * Seed the cache *during render* rather than in a useEffect. A pure-effect
313
+ * seed only fires after first paint, which means the first render's
314
+ * `selectedOptions` lookup misses, the chip falls back to "label = raw
315
+ * UUID", and the user sees an ID flash by before the next render swaps
316
+ * in the real label. Mutating a ref during render is safe because we
317
+ * don't read derived state from it within the same render — we read it
318
+ * via `selectedOptions` below, whose useMemo deps already include the
319
+ * inputs that change the cache.
320
+ */
321
+ for (const opt of initialFlat) {
322
+ optionsCacheRef.current.set(valueKey(opt.value), opt);
323
+ }
324
+
325
+ /*
326
+ * selectedKeys is the source of truth for what the user has picked. We
327
+ * never mirror it from props beyond the initial sync — props.value updates
328
+ * are honored through the effect below so saved-state restores work.
329
+ */
330
+ const externalKeys: Array<string> = useMemo(() => {
331
+ /*
332
+ * value takes precedence over initialValue (matching react-select
333
+ * controlled-component semantics). We intentionally treat `undefined`
334
+ * as "fall back to initialValue" and any other value as authoritative
335
+ * so callers can clear the field by passing null.
336
+ */
337
+ const source: EntityDropdownValue =
338
+ props.value !== undefined ? props.value : props.initialValue;
339
+ return valueToKeys(source);
340
+ }, [props.value, props.initialValue]);
341
+
342
+ /*
343
+ * Also harvest DropdownOption envelopes from props.value / initialValue,
344
+ * so a parent that hands us a full option (rather than a raw ID) gets
345
+ * its label cached immediately — no resolve round-trip needed.
346
+ */
347
+ const inboundOptions: Array<DropdownOption> = useMemo(() => {
348
+ const collected: Array<DropdownOption> = [];
349
+ const candidates: Array<EntityDropdownValue> = [
350
+ props.value,
351
+ props.initialValue,
352
+ ];
353
+ for (const candidate of candidates) {
354
+ if (!candidate) {
355
+ continue;
356
+ }
357
+ if (Array.isArray(candidate)) {
358
+ for (const item of candidate) {
359
+ if (
360
+ item &&
361
+ typeof item === "object" &&
362
+ !(item instanceof ObjectID) &&
363
+ "value" in item &&
364
+ "label" in item
365
+ ) {
366
+ collected.push(item as DropdownOption);
367
+ }
368
+ }
369
+ continue;
370
+ }
371
+ if (
372
+ typeof candidate === "object" &&
373
+ !(candidate instanceof ObjectID) &&
374
+ "value" in candidate &&
375
+ "label" in candidate
376
+ ) {
377
+ collected.push(candidate as DropdownOption);
378
+ }
379
+ }
380
+ return collected;
381
+ }, [props.value, props.initialValue]);
382
+
383
+ /*
384
+ * Same reasoning as initialFlat — seed at render time so the first paint
385
+ * has the labels.
386
+ */
387
+ for (const opt of inboundOptions) {
388
+ optionsCacheRef.current.set(valueKey(opt.value), opt);
389
+ }
390
+
391
+ const [selectedKeys, setSelectedKeys] = useState<Array<string>>(externalKeys);
392
+
393
+ /*
394
+ * Mirror prop value back into local state when the parent rewrites it
395
+ * (e.g. form reset, saved-view restore). We skip the round-trip if the
396
+ * arrays are identical to avoid render thrash.
397
+ */
398
+ useEffect(() => {
399
+ const same: boolean =
400
+ externalKeys.length === selectedKeys.length &&
401
+ externalKeys.every((k: string, i: number) => {
402
+ return k === selectedKeys[i];
403
+ });
404
+ if (same) {
405
+ return;
406
+ }
407
+ setSelectedKeys(externalKeys);
408
+ // Also stash any new options that came in via props into the cache.
409
+ for (const opt of initialFlat) {
410
+ optionsCacheRef.current.set(valueKey(opt.value), opt);
411
+ }
412
+ }, [externalKeys.join("|")]);
413
+
414
+ const [searchQuery, setSearchQuery] = useState<string>("");
415
+ const [searchResults, setSearchResults] = useState<Array<DropdownOption>>([]);
416
+ const [isLoading, setIsLoading] = useState<boolean>(false);
417
+ const [isOpen, setIsOpen] = useState<boolean>(false);
418
+ const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
419
+ const [activeTab, setActiveTab] = useState<"options" | "labels">("options");
420
+
421
+ // Labels tab state.
422
+ const [allLabels, setAllLabels] = useState<Array<Label>>([]);
423
+ const [isLoadingLabels, setIsLoadingLabels] = useState<boolean>(false);
424
+ const [labelsLoaded, setLabelsLoaded] = useState<boolean>(false);
425
+ const [selectedLabelIds, setSelectedLabelIds] = useState<Array<string>>([]);
426
+ const [isApplyingLabels, setIsApplyingLabels] = useState<boolean>(false);
427
+ const [labelError, setLabelError] = useState<string>("");
428
+ const [expandedLabelIds, setExpandedLabelIds] = useState<Set<string>>(
429
+ new Set(),
430
+ );
431
+ const [resourcesByLabel, setResourcesByLabel] = useState<
432
+ Record<string, Array<DropdownOption>>
433
+ >({});
434
+ const [loadingLabelIds, setLoadingLabelIds] = useState<Set<string>>(
435
+ new Set(),
436
+ );
437
+ const [labelLoadErrors, setLabelLoadErrors] = useState<
438
+ Record<string, string>
439
+ >({});
440
+
441
+ const containerRef: React.MutableRefObject<HTMLDivElement | null> =
442
+ useRef<HTMLDivElement | null>(null);
443
+ const inputRef: React.MutableRefObject<HTMLInputElement | null> =
444
+ useRef<HTMLInputElement | null>(null);
445
+ const debounceRef: React.MutableRefObject<number | null> = useRef<
446
+ number | null
447
+ >(null);
448
+ const searchSeqRef: React.MutableRefObject<number> = useRef<number>(0);
449
+
450
+ // Click-outside closes the popover.
451
+ useEffect(() => {
452
+ const handle: (event: MouseEvent) => void = (event: MouseEvent): void => {
453
+ if (
454
+ containerRef.current &&
455
+ event.target instanceof Node &&
456
+ !containerRef.current.contains(event.target)
457
+ ) {
458
+ setIsOpen(false);
459
+ }
460
+ };
461
+ document.addEventListener("mousedown", handle);
462
+ return () => {
463
+ document.removeEventListener("mousedown", handle);
464
+ };
465
+ }, []);
466
+
467
+ useEffect(() => {
468
+ return () => {
469
+ if (debounceRef.current !== null) {
470
+ window.clearTimeout(debounceRef.current);
471
+ }
472
+ };
473
+ }, []);
474
+
475
+ /*
476
+ * BaseModel -> DropdownOption. Pulls label/value (and optional color)
477
+ * via the configured field names. We don't try to be clever about nested
478
+ * objects — the form pre-fetcher follows the same flat convention.
479
+ */
480
+ const modelToOption: (item: BaseModel) => DropdownOption | null = useCallback(
481
+ (item: BaseModel): DropdownOption | null => {
482
+ const raw: Record<string, unknown> = item as unknown as Record<
483
+ string,
484
+ unknown
485
+ >;
486
+ const valueRaw: unknown = raw[valueField] ?? item._id;
487
+ const labelRaw: unknown = raw[labelField];
488
+ if (valueRaw === undefined || valueRaw === null) {
489
+ return null;
490
+ }
491
+ const label: string =
492
+ typeof labelRaw === "string"
493
+ ? labelRaw
494
+ : labelRaw &&
495
+ typeof (labelRaw as { toString?: () => string }).toString ===
496
+ "function"
497
+ ? (labelRaw as { toString: () => string }).toString()
498
+ : "";
499
+ const valueStr: string =
500
+ typeof valueRaw === "string"
501
+ ? valueRaw
502
+ : (valueRaw as { toString: () => string }).toString();
503
+ const option: DropdownOption = {
504
+ value: valueStr,
505
+ label: label || valueStr,
506
+ };
507
+ if (colorField) {
508
+ const colorRaw: unknown = raw[colorField];
509
+ if (colorRaw) {
510
+ option.color = colorRaw as NonNullable<DropdownOption["color"]>;
511
+ }
512
+ }
513
+ return option;
514
+ },
515
+ [labelField, valueField, colorField],
516
+ );
517
+
518
+ /*
519
+ * Server-side search. Runs when the popover is open on the options tab.
520
+ * Static dropdowns (no modelType) skip the API and filter the seeded
521
+ * options client-side instead.
522
+ */
523
+ useEffect(() => {
524
+ if (debounceRef.current !== null) {
525
+ window.clearTimeout(debounceRef.current);
526
+ }
527
+ if (!isOpen || activeTab !== "options" || !modelType) {
528
+ return;
529
+ }
530
+ const trimmed: string = searchQuery.trim();
531
+ const mySeq: number = ++searchSeqRef.current;
532
+ setIsLoading(true);
533
+ debounceRef.current = window.setTimeout(
534
+ async () => {
535
+ try {
536
+ const query: Query<BaseModel> = {} as Query<BaseModel>;
537
+ if (trimmed.length > 0) {
538
+ (query as Record<string, unknown>)[labelField] = new Search(
539
+ trimmed,
540
+ );
541
+ }
542
+ const baseSelect: Record<string, true> = {
543
+ _id: true,
544
+ [labelField]: true,
545
+ } as Record<string, true>;
546
+ if (colorField) {
547
+ baseSelect[colorField] = true;
548
+ }
549
+ const result: ListResult<BaseModel> =
550
+ await ModelAPI.getList<BaseModel>({
551
+ modelType: modelType,
552
+ query: query,
553
+ limit: SEARCH_PAGE_SIZE,
554
+ skip: 0,
555
+ select: baseSelect as never,
556
+ sort: { [labelField]: SortOrder.Ascending } as never,
557
+ });
558
+ if (mySeq !== searchSeqRef.current) {
559
+ return; // stale
560
+ }
561
+ const options: Array<DropdownOption> = [];
562
+ for (const item of result.data) {
563
+ const opt: DropdownOption | null = modelToOption(item);
564
+ if (!opt) {
565
+ continue;
566
+ }
567
+ options.push(opt);
568
+ optionsCacheRef.current.set(valueKey(opt.value), opt);
569
+ }
570
+ setSearchResults(options);
571
+ } catch {
572
+ if (mySeq === searchSeqRef.current) {
573
+ setSearchResults([]);
574
+ }
575
+ } finally {
576
+ if (mySeq === searchSeqRef.current) {
577
+ setIsLoading(false);
578
+ }
579
+ }
580
+ },
581
+ trimmed === "" ? 0 : SEARCH_DEBOUNCE_MS,
582
+ );
583
+ }, [
584
+ searchQuery,
585
+ isOpen,
586
+ activeTab,
587
+ modelType,
588
+ labelField,
589
+ colorField,
590
+ modelToOption,
591
+ ]);
592
+
593
+ /*
594
+ * Resolve previously-selected values whose labels we don't yet have. Fires
595
+ * once on mount and whenever externalKeys grows beyond what's in cache —
596
+ * keeps the chips readable even when the form was saved with selections
597
+ * that aren't on the dropdown's first page.
598
+ */
599
+ useEffect(() => {
600
+ if (!modelType) {
601
+ return;
602
+ }
603
+ const missing: Array<string> = selectedKeys.filter(
604
+ (key: string): boolean => {
605
+ return !optionsCacheRef.current.has(key);
606
+ },
607
+ );
608
+ if (missing.length === 0) {
609
+ return;
610
+ }
611
+ let cancelled: boolean = false;
612
+ const resolve: () => Promise<void> = async (): Promise<void> => {
613
+ try {
614
+ const baseSelect: Record<string, true> = {
615
+ _id: true,
616
+ [labelField]: true,
617
+ } as Record<string, true>;
618
+ if (colorField) {
619
+ baseSelect[colorField] = true;
620
+ }
621
+ const result: ListResult<BaseModel> = await ModelAPI.getList<BaseModel>(
622
+ {
623
+ modelType: modelType,
624
+ query: {
625
+ _id: new Includes(missing),
626
+ } as Query<BaseModel>,
627
+ limit: missing.length,
628
+ skip: 0,
629
+ select: baseSelect as never,
630
+ sort: {},
631
+ },
632
+ );
633
+ if (cancelled) {
634
+ return;
635
+ }
636
+ for (const item of result.data) {
637
+ const opt: DropdownOption | null = modelToOption(item);
638
+ if (opt) {
639
+ optionsCacheRef.current.set(valueKey(opt.value), opt);
640
+ }
641
+ }
642
+ // Force a re-render so chips pick up the resolved labels.
643
+ setSelectedKeys((prev: Array<string>): Array<string> => {
644
+ return [...prev];
645
+ });
646
+ } catch {
647
+ // Leave the unresolved chips as raw IDs.
648
+ }
649
+ };
650
+ void resolve();
651
+ return () => {
652
+ cancelled = true;
653
+ };
654
+ }, [
655
+ selectedKeys.join("|"),
656
+ modelType,
657
+ labelField,
658
+ colorField,
659
+ modelToOption,
660
+ ]);
661
+
662
+ /*
663
+ * Lazy-load the project's labels the first time the user clicks the
664
+ * Labels tab — same gating as AffectedResourcesPicker. DON'T add
665
+ * isLoadingLabels to the deps; setting it inside the effect would
666
+ * self-cancel the in-flight request.
667
+ */
668
+ useEffect(() => {
669
+ if (!labelsTabEnabled || activeTab !== "labels" || labelsLoaded) {
670
+ return;
671
+ }
672
+ let cancelled: boolean = false;
673
+ const loadLabels: () => Promise<void> = async (): Promise<void> => {
674
+ setIsLoadingLabels(true);
675
+ setLabelError("");
676
+ try {
677
+ const result: ListResult<Label> = await ModelAPI.getList<Label>({
678
+ modelType: Label,
679
+ query: {} as Query<Label>,
680
+ limit: LIMIT_PER_PROJECT,
681
+ skip: 0,
682
+ select: { _id: true, name: true, color: true } as never,
683
+ sort: { name: SortOrder.Ascending } as never,
684
+ });
685
+ if (cancelled) {
686
+ return;
687
+ }
688
+ setAllLabels(result.data || []);
689
+ setLabelsLoaded(true);
690
+ } catch {
691
+ if (cancelled) {
692
+ return;
693
+ }
694
+ setLabelError(
695
+ "Failed to load labels. You may not have permission to read labels.",
696
+ );
697
+ } finally {
698
+ if (!cancelled) {
699
+ setIsLoadingLabels(false);
700
+ }
701
+ }
702
+ };
703
+ void loadLabels();
704
+ return () => {
705
+ cancelled = true;
706
+ };
707
+ }, [labelsTabEnabled, activeTab, labelsLoaded]);
708
+
709
+ const filteredLabels: Array<Label> = useMemo(() => {
710
+ const q: string = searchQuery.trim().toLowerCase();
711
+ if (q === "") {
712
+ return allLabels;
713
+ }
714
+ return allLabels.filter((label: Label): boolean => {
715
+ const name: string = (label.name || "").toLowerCase();
716
+ return name.includes(q);
717
+ });
718
+ }, [allLabels, searchQuery]);
719
+
720
+ /*
721
+ * Available results = initialFlat (the form's pre-fetched set, which has
722
+ * the color/labels metadata we want to preserve) UNIONed with searchResults
723
+ * (the supplemental server-side hits for big lists), filtered by the
724
+ * current query. Even for entity-backed dropdowns we keep initialFlat in
725
+ * the mix so colors and any other rich fields the pre-fetch carried
726
+ * survive — our supplemental fetch only requests the minimum SELECT.
727
+ */
728
+ const optionsList: Array<DropdownOption> = useMemo(() => {
729
+ const q: string = searchQuery.trim().toLowerCase();
730
+ const matches: (opt: DropdownOption) => boolean = (
731
+ opt: DropdownOption,
732
+ ): boolean => {
733
+ if (q === "") {
734
+ return true;
735
+ }
736
+ return opt.label.toLowerCase().includes(q);
737
+ };
738
+ const seen: Set<string> = new Set();
739
+ const out: Array<DropdownOption> = [];
740
+ for (const opt of initialFlat) {
741
+ const key: string = valueKey(opt.value);
742
+ if (seen.has(key) || !matches(opt)) {
743
+ continue;
744
+ }
745
+ seen.add(key);
746
+ out.push(opt);
747
+ }
748
+ if (modelType) {
749
+ for (const opt of searchResults) {
750
+ const key: string = valueKey(opt.value);
751
+ if (seen.has(key) || !matches(opt)) {
752
+ continue;
753
+ }
754
+ seen.add(key);
755
+ out.push(opt);
756
+ }
757
+ }
758
+ return out;
759
+ }, [modelType, searchResults, searchQuery, initialFlat]);
760
+
761
+ /*
762
+ * Filter out the currently-selected key(s) ONLY in multi-select. For
763
+ * single-select the user needs to see the current value too so they can
764
+ * compare and switch — react-select keeps the selected option visible
765
+ * (just highlights it), so we match that.
766
+ */
767
+ const availableOptions: Array<DropdownOption> = useMemo(() => {
768
+ if (!isMulti) {
769
+ return optionsList;
770
+ }
771
+ const selectedSet: Set<string> = new Set(selectedKeys);
772
+ return optionsList.filter((opt: DropdownOption): boolean => {
773
+ return !selectedSet.has(valueKey(opt.value));
774
+ });
775
+ }, [optionsList, selectedKeys, isMulti]);
776
+
777
+ // Clamp the keyboard cursor when the visible list shrinks.
778
+ useEffect(() => {
779
+ const len: number =
780
+ activeTab === "labels" ? filteredLabels.length : availableOptions.length;
781
+ if (highlightedIndex >= len) {
782
+ setHighlightedIndex(len - 1);
783
+ }
784
+ }, [availableOptions, filteredLabels, highlightedIndex, activeTab]);
785
+
786
+ const notify: (next: Array<string>) => void = useCallback(
787
+ (next: Array<string>): void => {
788
+ if (!props.onChange) {
789
+ return;
790
+ }
791
+ if (isMulti) {
792
+ props.onChange(next as Array<DropdownValue>);
793
+ return;
794
+ }
795
+ props.onChange(next.length > 0 ? next[0]! : null);
796
+ },
797
+ [isMulti, props.onChange],
798
+ );
799
+
800
+ const addOption: (opt: DropdownOption) => void = (
801
+ opt: DropdownOption,
802
+ ): void => {
803
+ const key: string = valueKey(opt.value);
804
+ optionsCacheRef.current.set(key, opt);
805
+ if (isMulti) {
806
+ if (selectedKeys.includes(key)) {
807
+ return;
808
+ }
809
+ const next: Array<string> = [...selectedKeys, key];
810
+ setSelectedKeys(next);
811
+ notify(next);
812
+ setSearchQuery("");
813
+ inputRef.current?.focus();
814
+ return;
815
+ }
816
+ setSelectedKeys([key]);
817
+ notify([key]);
818
+ setSearchQuery("");
819
+ setIsOpen(false);
820
+ };
821
+
822
+ const removeKey: (key: string) => void = (key: string): void => {
823
+ const next: Array<string> = selectedKeys.filter((k: string): boolean => {
824
+ return k !== key;
825
+ });
826
+ setSelectedKeys(next);
827
+ notify(next);
828
+ };
829
+
830
+ const clearAll: () => void = (): void => {
831
+ setSelectedKeys([]);
832
+ notify([]);
833
+ };
834
+
835
+ /*
836
+ * Bulk-add via labels: fetch every entity tagged with any selected label
837
+ * and merge into the current selection. Caps generous (LIMIT_PER_PROJECT)
838
+ * since this is an intentional bulk action, not a typeahead.
839
+ */
840
+ const applyLabelSelection: () => Promise<void> = async (): Promise<void> => {
841
+ if (!modelType || selectedLabelIds.length === 0) {
842
+ return;
843
+ }
844
+ setIsApplyingLabels(true);
845
+ setLabelError("");
846
+ try {
847
+ const baseSelect: Record<string, true> = {
848
+ _id: true,
849
+ [labelField]: true,
850
+ } as Record<string, true>;
851
+ if (colorField) {
852
+ baseSelect[colorField] = true;
853
+ }
854
+ const result: ListResult<BaseModel> = await ModelAPI.getList<BaseModel>({
855
+ modelType: modelType,
856
+ query: {
857
+ labels: new Includes(selectedLabelIds),
858
+ } as Query<BaseModel>,
859
+ limit: LIMIT_PER_PROJECT,
860
+ skip: 0,
861
+ select: baseSelect as never,
862
+ sort: { [labelField]: SortOrder.Ascending } as never,
863
+ });
864
+ const existing: Set<string> = new Set(selectedKeys);
865
+ const additions: Array<string> = [];
866
+ for (const item of result.data) {
867
+ const opt: DropdownOption | null = modelToOption(item);
868
+ if (!opt) {
869
+ continue;
870
+ }
871
+ const key: string = valueKey(opt.value);
872
+ optionsCacheRef.current.set(key, opt);
873
+ if (!existing.has(key)) {
874
+ existing.add(key);
875
+ additions.push(key);
876
+ }
877
+ }
878
+ if (additions.length === 0) {
879
+ setLabelError(
880
+ "No new entries matched the selected labels (or you don't have read access).",
881
+ );
882
+ return;
883
+ }
884
+ const next: Array<string> = [...selectedKeys, ...additions];
885
+ setSelectedKeys(next);
886
+ notify(next);
887
+ setSelectedLabelIds([]);
888
+ setSearchQuery("");
889
+ setActiveTab("options");
890
+ setIsOpen(false);
891
+ } catch {
892
+ setLabelError("Failed to fetch entries for the selected labels.");
893
+ } finally {
894
+ setIsApplyingLabels(false);
895
+ }
896
+ };
897
+
898
+ const toggleLabelId: (id: string) => void = (id: string): void => {
899
+ setSelectedLabelIds((prev: Array<string>): Array<string> => {
900
+ if (prev.includes(id)) {
901
+ return prev.filter((x: string): boolean => {
902
+ return x !== id;
903
+ });
904
+ }
905
+ return [...prev, id];
906
+ });
907
+ };
908
+
909
+ const fetchLabelPreview: (labelId: string) => Promise<void> = async (
910
+ labelId: string,
911
+ ): Promise<void> => {
912
+ if (!modelType) {
913
+ return;
914
+ }
915
+ setLoadingLabelIds((prev: Set<string>): Set<string> => {
916
+ const next: Set<string> = new Set(prev);
917
+ next.add(labelId);
918
+ return next;
919
+ });
920
+ setLabelLoadErrors(
921
+ (prev: Record<string, string>): Record<string, string> => {
922
+ const next: Record<string, string> = { ...prev };
923
+ delete next[labelId];
924
+ return next;
925
+ },
926
+ );
927
+ try {
928
+ const baseSelect: Record<string, true> = {
929
+ _id: true,
930
+ [labelField]: true,
931
+ } as Record<string, true>;
932
+ if (colorField) {
933
+ baseSelect[colorField] = true;
934
+ }
935
+ const result: ListResult<BaseModel> = await ModelAPI.getList<BaseModel>({
936
+ modelType: modelType,
937
+ query: {
938
+ labels: new Includes([labelId]),
939
+ } as Query<BaseModel>,
940
+ limit: LABEL_PREVIEW_LIMIT,
941
+ skip: 0,
942
+ select: baseSelect as never,
943
+ sort: { [labelField]: SortOrder.Ascending } as never,
944
+ });
945
+ const items: Array<DropdownOption> = [];
946
+ for (const item of result.data) {
947
+ const opt: DropdownOption | null = modelToOption(item);
948
+ if (opt) {
949
+ items.push(opt);
950
+ optionsCacheRef.current.set(valueKey(opt.value), opt);
951
+ }
952
+ }
953
+ setResourcesByLabel(
954
+ (
955
+ prev: Record<string, Array<DropdownOption>>,
956
+ ): Record<string, Array<DropdownOption>> => {
957
+ return { ...prev, [labelId]: items };
958
+ },
959
+ );
960
+ } catch {
961
+ setLabelLoadErrors(
962
+ (prev: Record<string, string>): Record<string, string> => {
963
+ return { ...prev, [labelId]: "Failed to load entries." };
964
+ },
965
+ );
966
+ } finally {
967
+ setLoadingLabelIds((prev: Set<string>): Set<string> => {
968
+ const next: Set<string> = new Set(prev);
969
+ next.delete(labelId);
970
+ return next;
971
+ });
972
+ }
973
+ };
974
+
975
+ const toggleLabelExpansion: (labelId: string) => void = (
976
+ labelId: string,
977
+ ): void => {
978
+ setExpandedLabelIds((prev: Set<string>): Set<string> => {
979
+ const next: Set<string> = new Set(prev);
980
+ if (next.has(labelId)) {
981
+ next.delete(labelId);
982
+ } else {
983
+ next.add(labelId);
984
+ if (resourcesByLabel[labelId] === undefined) {
985
+ void fetchLabelPreview(labelId);
986
+ }
987
+ }
988
+ return next;
989
+ });
990
+ };
991
+
992
+ const selectedOptions: Array<DropdownOption> = useMemo(() => {
993
+ return selectedKeys.map((key: string): DropdownOption => {
994
+ const cached: DropdownOption | undefined =
995
+ optionsCacheRef.current.get(key);
996
+ if (cached) {
997
+ return cached;
998
+ }
999
+ return { value: key, label: key };
1000
+ });
1001
+ /*
1002
+ * Cache is mutated during render based on initialFlat / inboundOptions
1003
+ * (see seed loops above), so include them here to make the dependency
1004
+ * graph honest — when the parent hands us new options, this memo
1005
+ * recomputes against the freshly-seeded cache.
1006
+ */
1007
+ }, [selectedKeys, initialFlat, inboundOptions]);
1008
+
1009
+ /*
1010
+ * Visual label color extracted from option.color (which can be either a
1011
+ * Color instance or a string). Used to render a small dot next to each
1012
+ * row that has one.
1013
+ */
1014
+ const optionColorString: (opt: DropdownOption) => string | undefined = (
1015
+ opt: DropdownOption,
1016
+ ): string | undefined => {
1017
+ const c: unknown = opt.color;
1018
+ if (!c) {
1019
+ return undefined;
1020
+ }
1021
+ if (typeof c === "string") {
1022
+ return c;
1023
+ }
1024
+ if (typeof (c as { toString?: () => string }).toString === "function") {
1025
+ return (c as { toString: () => string }).toString();
1026
+ }
1027
+ return undefined;
1028
+ };
1029
+
1030
+ const labelColorString: (label: Label) => string | undefined = (
1031
+ label: Label,
1032
+ ): string | undefined => {
1033
+ const c: unknown = label.color;
1034
+ if (!c) {
1035
+ return undefined;
1036
+ }
1037
+ if (typeof c === "string") {
1038
+ return c;
1039
+ }
1040
+ if (typeof (c as { toString?: () => string }).toString === "function") {
1041
+ return (c as { toString: () => string }).toString();
1042
+ }
1043
+ return undefined;
1044
+ };
1045
+
1046
+ /*
1047
+ * Render the *current selection* as either a chip row (multi) or the
1048
+ * single value in-place when the popover is closed (single). When the
1049
+ * popover is open the input takes over for typing.
1050
+ */
1051
+ const placeholderText: string = props.placeholder || "Select...";
1052
+ const showSingleSelectedText: boolean =
1053
+ !isMulti && !isOpen && selectedOptions.length > 0;
1054
+
1055
+ return (
1056
+ <div
1057
+ ref={containerRef}
1058
+ id={props.id}
1059
+ className={props.className || "relative mt-2 mb-1 w-full"}
1060
+ >
1061
+ {isMulti && selectedOptions.length > 0 && (
1062
+ <div className="mb-2 flex flex-wrap gap-1.5">
1063
+ {selectedOptions.map((opt: DropdownOption): ReactElement => {
1064
+ const key: string = valueKey(opt.value);
1065
+ const colorStr: string | undefined = optionColorString(opt);
1066
+ return (
1067
+ <span
1068
+ key={key}
1069
+ className="inline-flex items-center gap-1.5 rounded-md border border-indigo-100 bg-indigo-50 px-2 py-1 text-xs font-medium text-indigo-900"
1070
+ >
1071
+ {colorStr && (
1072
+ <span
1073
+ aria-hidden="true"
1074
+ className="inline-block h-2 w-2 rounded-full"
1075
+ style={{ backgroundColor: colorStr }}
1076
+ />
1077
+ )}
1078
+ <span className="max-w-[14rem] truncate">{opt.label}</span>
1079
+ {!props.disabled && (
1080
+ <button
1081
+ type="button"
1082
+ aria-label={`Remove ${opt.label}`}
1083
+ onClick={(): void => {
1084
+ removeKey(key);
1085
+ }}
1086
+ className="ml-0.5 rounded-full text-indigo-400 transition-colors hover:bg-indigo-100 hover:text-indigo-700 focus:outline-none focus:ring-1 focus:ring-indigo-500"
1087
+ >
1088
+ <svg
1089
+ className="h-3 w-3"
1090
+ viewBox="0 0 20 20"
1091
+ fill="currentColor"
1092
+ aria-hidden="true"
1093
+ >
1094
+ <path
1095
+ fillRule="evenodd"
1096
+ d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
1097
+ clipRule="evenodd"
1098
+ />
1099
+ </svg>
1100
+ </button>
1101
+ )}
1102
+ </span>
1103
+ );
1104
+ })}
1105
+ </div>
1106
+ )}
1107
+
1108
+ <div className="relative">
1109
+ {/*
1110
+ * Single-select shows the resolved label in-place when closed so the
1111
+ * chrome looks like a static value field. Click anywhere on the
1112
+ * container to start typing.
1113
+ */}
1114
+ {showSingleSelectedText && (
1115
+ <button
1116
+ type="button"
1117
+ disabled={props.disabled}
1118
+ onClick={(): void => {
1119
+ if (props.disabled) {
1120
+ return;
1121
+ }
1122
+ setIsOpen(true);
1123
+ inputRef.current?.focus();
1124
+ }}
1125
+ onFocus={() => {
1126
+ props.onFocus?.();
1127
+ }}
1128
+ className={`flex w-full items-center justify-between rounded-lg border bg-white px-3 py-2 text-left text-sm shadow-sm transition-colors ${
1129
+ props.error
1130
+ ? "border-red-400"
1131
+ : "border-gray-300 hover:border-indigo-300"
1132
+ } ${
1133
+ props.disabled
1134
+ ? "cursor-not-allowed bg-gray-100 text-gray-400"
1135
+ : ""
1136
+ }`}
1137
+ >
1138
+ <span className="flex items-center gap-2">
1139
+ {(() => {
1140
+ const colorStr: string | undefined = optionColorString(
1141
+ selectedOptions[0]!,
1142
+ );
1143
+ if (!colorStr) {
1144
+ return null;
1145
+ }
1146
+ return (
1147
+ <span
1148
+ aria-hidden="true"
1149
+ className="inline-block h-2.5 w-2.5 rounded-full border border-gray-200"
1150
+ style={{ backgroundColor: colorStr }}
1151
+ />
1152
+ );
1153
+ })()}
1154
+ <span className="font-medium text-gray-900">
1155
+ {selectedOptions[0]!.label}
1156
+ </span>
1157
+ </span>
1158
+ <div className="flex items-center gap-1 text-gray-400">
1159
+ {!props.disabled && (
1160
+ <button
1161
+ type="button"
1162
+ aria-label="Clear selection"
1163
+ onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
1164
+ e.stopPropagation();
1165
+ clearAll();
1166
+ }}
1167
+ className="rounded p-0.5 hover:bg-gray-100 hover:text-red-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
1168
+ >
1169
+ <svg
1170
+ className="h-3.5 w-3.5"
1171
+ viewBox="0 0 20 20"
1172
+ fill="currentColor"
1173
+ aria-hidden="true"
1174
+ >
1175
+ <path
1176
+ fillRule="evenodd"
1177
+ d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
1178
+ clipRule="evenodd"
1179
+ />
1180
+ </svg>
1181
+ </button>
1182
+ )}
1183
+ <Icon icon={IconProp.ChevronDown} className="h-4 w-4" />
1184
+ </div>
1185
+ </button>
1186
+ )}
1187
+
1188
+ {!showSingleSelectedText && (
1189
+ <div
1190
+ className={`relative rounded-lg border bg-white shadow-sm transition-colors ${
1191
+ props.error
1192
+ ? "border-red-400 ring-2 ring-red-100"
1193
+ : isOpen
1194
+ ? "border-indigo-400 ring-2 ring-indigo-100"
1195
+ : "border-gray-300 hover:border-indigo-300"
1196
+ } ${props.disabled ? "bg-gray-100" : ""}`}
1197
+ >
1198
+ <input
1199
+ ref={inputRef}
1200
+ type="text"
1201
+ value={searchQuery}
1202
+ disabled={props.disabled}
1203
+ tabIndex={props.tabIndex}
1204
+ aria-autocomplete="list"
1205
+ aria-expanded={isOpen}
1206
+ aria-label={props.ariaLabel}
1207
+ aria-invalid={props.error ? true : undefined}
1208
+ data-testid={props.dataTestId}
1209
+ role="combobox"
1210
+ placeholder={
1211
+ isMulti && selectedOptions.length > 0
1212
+ ? "Search to add more..."
1213
+ : placeholderText
1214
+ }
1215
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
1216
+ setSearchQuery(event.target.value);
1217
+ setIsOpen(true);
1218
+ setHighlightedIndex(-1);
1219
+ }}
1220
+ onFocus={() => {
1221
+ setIsOpen(true);
1222
+ props.onFocus?.();
1223
+ }}
1224
+ onBlur={() => {
1225
+ props.onBlur?.();
1226
+ }}
1227
+ onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
1228
+ const activeLen: number =
1229
+ activeTab === "labels"
1230
+ ? filteredLabels.length
1231
+ : availableOptions.length;
1232
+ if (event.key === "ArrowDown") {
1233
+ if (activeLen === 0) {
1234
+ return;
1235
+ }
1236
+ event.preventDefault();
1237
+ setIsOpen(true);
1238
+ setHighlightedIndex((prev: number): number => {
1239
+ const next: number = prev + 1;
1240
+ return next >= activeLen ? 0 : next;
1241
+ });
1242
+ return;
1243
+ }
1244
+ if (event.key === "ArrowUp") {
1245
+ if (activeLen === 0) {
1246
+ return;
1247
+ }
1248
+ event.preventDefault();
1249
+ setIsOpen(true);
1250
+ setHighlightedIndex((prev: number): number => {
1251
+ if (prev <= 0) {
1252
+ return activeLen - 1;
1253
+ }
1254
+ return prev - 1;
1255
+ });
1256
+ return;
1257
+ }
1258
+ if (event.key === "Enter") {
1259
+ if (highlightedIndex < 0 || highlightedIndex >= activeLen) {
1260
+ return;
1261
+ }
1262
+ event.preventDefault();
1263
+ if (activeTab === "labels") {
1264
+ const label: Label | undefined =
1265
+ filteredLabels[highlightedIndex];
1266
+ const labelId: string = label?._id ? String(label._id) : "";
1267
+ if (labelId) {
1268
+ toggleLabelId(labelId);
1269
+ }
1270
+ return;
1271
+ }
1272
+ const opt: DropdownOption | undefined =
1273
+ availableOptions[highlightedIndex];
1274
+ if (opt) {
1275
+ addOption(opt);
1276
+ setHighlightedIndex(-1);
1277
+ }
1278
+ return;
1279
+ }
1280
+ if (event.key === "Escape") {
1281
+ setIsOpen(false);
1282
+ setHighlightedIndex(-1);
1283
+ return;
1284
+ }
1285
+ if (
1286
+ event.key === "Backspace" &&
1287
+ searchQuery === "" &&
1288
+ isMulti &&
1289
+ selectedKeys.length > 0 &&
1290
+ activeTab === "options"
1291
+ ) {
1292
+ event.preventDefault();
1293
+ removeKey(selectedKeys[selectedKeys.length - 1] as string);
1294
+ }
1295
+ }}
1296
+ className="block w-full rounded-lg bg-transparent px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:text-gray-500"
1297
+ />
1298
+ {!isMulti && selectedKeys.length > 0 && !props.disabled && (
1299
+ <button
1300
+ type="button"
1301
+ aria-label="Clear selection"
1302
+ onClick={(): void => {
1303
+ clearAll();
1304
+ setSearchQuery("");
1305
+ inputRef.current?.focus();
1306
+ }}
1307
+ className="absolute inset-y-0 right-2 my-auto flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors hover:bg-gray-100 hover:text-red-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
1308
+ >
1309
+ <svg
1310
+ className="h-3.5 w-3.5"
1311
+ viewBox="0 0 20 20"
1312
+ fill="currentColor"
1313
+ aria-hidden="true"
1314
+ >
1315
+ <path
1316
+ fillRule="evenodd"
1317
+ d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
1318
+ clipRule="evenodd"
1319
+ />
1320
+ </svg>
1321
+ </button>
1322
+ )}
1323
+ </div>
1324
+ )}
1325
+ </div>
1326
+
1327
+ {isOpen && !props.disabled && (
1328
+ <div
1329
+ className="absolute z-20 mt-1 flex max-h-96 w-full flex-col overflow-hidden rounded-md border border-gray-200 bg-white text-sm shadow-lg"
1330
+ role="listbox"
1331
+ >
1332
+ {labelsTabEnabled && (
1333
+ <div className="flex flex-shrink-0 items-center gap-1 border-b border-gray-100 bg-gray-50 px-1.5 py-1">
1334
+ <button
1335
+ type="button"
1336
+ role="tab"
1337
+ aria-selected={activeTab === "options"}
1338
+ onMouseDown={(
1339
+ event: React.MouseEvent<HTMLButtonElement>,
1340
+ ): void => {
1341
+ event.preventDefault();
1342
+ }}
1343
+ onClick={(): void => {
1344
+ setActiveTab("options");
1345
+ setHighlightedIndex(-1);
1346
+ }}
1347
+ className={`rounded px-2.5 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
1348
+ activeTab === "options"
1349
+ ? "bg-white text-indigo-700 shadow-sm ring-1 ring-gray-200"
1350
+ : "text-gray-600 hover:bg-white/60 hover:text-gray-800"
1351
+ }`}
1352
+ >
1353
+ Results
1354
+ </button>
1355
+ <button
1356
+ type="button"
1357
+ role="tab"
1358
+ aria-selected={activeTab === "labels"}
1359
+ onMouseDown={(
1360
+ event: React.MouseEvent<HTMLButtonElement>,
1361
+ ): void => {
1362
+ event.preventDefault();
1363
+ }}
1364
+ onClick={(): void => {
1365
+ setActiveTab("labels");
1366
+ setHighlightedIndex(-1);
1367
+ }}
1368
+ className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
1369
+ activeTab === "labels"
1370
+ ? "bg-white text-indigo-700 shadow-sm ring-1 ring-gray-200"
1371
+ : "text-gray-600 hover:bg-white/60 hover:text-gray-800"
1372
+ }`}
1373
+ >
1374
+ <Icon icon={IconProp.Tag} className="h-3.5 w-3.5" />
1375
+ Labels
1376
+ {selectedLabelIds.length > 0 && (
1377
+ <span className="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-indigo-100 px-1 text-[10px] font-semibold text-indigo-700">
1378
+ {selectedLabelIds.length}
1379
+ </span>
1380
+ )}
1381
+ </button>
1382
+ <span className="ml-auto pr-1 text-[11px] text-gray-400">
1383
+ {activeTab === "options"
1384
+ ? "Pick individually"
1385
+ : "Bulk-add by tag"}
1386
+ </span>
1387
+ </div>
1388
+ )}
1389
+
1390
+ {activeTab === "options" && (
1391
+ <div className="flex-1 overflow-auto py-1">
1392
+ {isLoading && (
1393
+ <div className="flex items-center px-3 py-2 text-gray-500">
1394
+ <svg
1395
+ className="animate-spin -ml-0.5 mr-2 h-4 w-4 text-indigo-500"
1396
+ xmlns="http://www.w3.org/2000/svg"
1397
+ fill="none"
1398
+ viewBox="0 0 24 24"
1399
+ aria-hidden="true"
1400
+ >
1401
+ <circle
1402
+ className="opacity-25"
1403
+ cx="12"
1404
+ cy="12"
1405
+ r="10"
1406
+ stroke="currentColor"
1407
+ strokeWidth="4"
1408
+ ></circle>
1409
+ <path
1410
+ className="opacity-75"
1411
+ fill="currentColor"
1412
+ d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
1413
+ ></path>
1414
+ </svg>
1415
+ <span>Searching...</span>
1416
+ </div>
1417
+ )}
1418
+ {!isLoading && availableOptions.length === 0 && (
1419
+ <div className="px-3 py-2 text-gray-500">
1420
+ {searchQuery.trim() === ""
1421
+ ? "No options."
1422
+ : "No matching entries."}
1423
+ </div>
1424
+ )}
1425
+ {!isLoading &&
1426
+ availableOptions.map(
1427
+ (opt: DropdownOption, idx: number): ReactElement => {
1428
+ const key: string = valueKey(opt.value);
1429
+ const isHighlighted: boolean = idx === highlightedIndex;
1430
+ const isCurrentSelection: boolean =
1431
+ !isMulti && selectedKeys[0] === key;
1432
+ const colorStr: string | undefined = optionColorString(opt);
1433
+ return (
1434
+ <button
1435
+ key={key}
1436
+ type="button"
1437
+ role="option"
1438
+ aria-selected={isCurrentSelection || isHighlighted}
1439
+ onMouseEnter={(): void => {
1440
+ setHighlightedIndex(idx);
1441
+ }}
1442
+ onMouseDown={(
1443
+ event: React.MouseEvent<HTMLButtonElement>,
1444
+ ): void => {
1445
+ event.preventDefault();
1446
+ }}
1447
+ onClick={(): void => {
1448
+ addOption(opt);
1449
+ }}
1450
+ className={`flex w-full items-center gap-2 px-3 py-2 text-left ${
1451
+ isHighlighted
1452
+ ? "bg-indigo-600 text-white"
1453
+ : isCurrentSelection
1454
+ ? "bg-indigo-50 text-indigo-900"
1455
+ : "text-gray-700 hover:bg-indigo-50"
1456
+ }`}
1457
+ >
1458
+ {colorStr && (
1459
+ <span
1460
+ aria-hidden="true"
1461
+ className="inline-block h-2.5 w-2.5 flex-shrink-0 rounded-full border border-gray-200"
1462
+ style={{ backgroundColor: colorStr }}
1463
+ />
1464
+ )}
1465
+ <span className="truncate">{opt.label}</span>
1466
+ {opt.description && (
1467
+ <span
1468
+ className={`ml-auto truncate text-xs ${
1469
+ isHighlighted
1470
+ ? "text-indigo-100"
1471
+ : "text-gray-500"
1472
+ }`}
1473
+ >
1474
+ {opt.description}
1475
+ </span>
1476
+ )}
1477
+ {/*
1478
+ * Trailing check for the current single-select value
1479
+ * — same visual cue react-select uses to flag the
1480
+ * active option without taking it out of the list.
1481
+ */}
1482
+ {isCurrentSelection && (
1483
+ <svg
1484
+ className={`ml-auto h-4 w-4 flex-shrink-0 ${
1485
+ isHighlighted ? "text-white" : "text-indigo-600"
1486
+ }`}
1487
+ viewBox="0 0 20 20"
1488
+ fill="currentColor"
1489
+ aria-hidden="true"
1490
+ >
1491
+ <path
1492
+ fillRule="evenodd"
1493
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
1494
+ clipRule="evenodd"
1495
+ />
1496
+ </svg>
1497
+ )}
1498
+ </button>
1499
+ );
1500
+ },
1501
+ )}
1502
+ </div>
1503
+ )}
1504
+
1505
+ {activeTab === "labels" && (
1506
+ <div className="flex-1 overflow-auto py-1">
1507
+ {isLoadingLabels && (
1508
+ <div className="flex items-center px-3 py-2 text-gray-500">
1509
+ <svg
1510
+ className="animate-spin -ml-0.5 mr-2 h-4 w-4 text-indigo-500"
1511
+ xmlns="http://www.w3.org/2000/svg"
1512
+ fill="none"
1513
+ viewBox="0 0 24 24"
1514
+ aria-hidden="true"
1515
+ >
1516
+ <circle
1517
+ className="opacity-25"
1518
+ cx="12"
1519
+ cy="12"
1520
+ r="10"
1521
+ stroke="currentColor"
1522
+ strokeWidth="4"
1523
+ ></circle>
1524
+ <path
1525
+ className="opacity-75"
1526
+ fill="currentColor"
1527
+ d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
1528
+ ></path>
1529
+ </svg>
1530
+ <span>Loading labels...</span>
1531
+ </div>
1532
+ )}
1533
+ {!isLoadingLabels && labelError !== "" && (
1534
+ <div className="px-3 py-2 text-red-600">{labelError}</div>
1535
+ )}
1536
+ {!isLoadingLabels &&
1537
+ labelError === "" &&
1538
+ labelsLoaded &&
1539
+ allLabels.length === 0 && (
1540
+ <div className="px-3 py-2 text-gray-500">
1541
+ No labels found in this project. Create labels first to use
1542
+ this shortcut.
1543
+ </div>
1544
+ )}
1545
+ {!isLoadingLabels &&
1546
+ labelError === "" &&
1547
+ labelsLoaded &&
1548
+ allLabels.length > 0 &&
1549
+ filteredLabels.length === 0 && (
1550
+ <div className="px-3 py-2 text-gray-500">
1551
+ No labels match &ldquo;{searchQuery.trim()}&rdquo;.
1552
+ </div>
1553
+ )}
1554
+ {!isLoadingLabels &&
1555
+ filteredLabels.map(
1556
+ (label: Label, idx: number): ReactElement => {
1557
+ const labelId: string = label._id ? String(label._id) : "";
1558
+ if (!labelId) {
1559
+ return <span key={`empty-${idx}`} />;
1560
+ }
1561
+ const isChecked: boolean =
1562
+ selectedLabelIds.includes(labelId);
1563
+ const isHighlighted: boolean = idx === highlightedIndex;
1564
+ const isExpanded: boolean = expandedLabelIds.has(labelId);
1565
+ const isLoadingPreview: boolean =
1566
+ loadingLabelIds.has(labelId);
1567
+ const preview: Array<DropdownOption> | undefined =
1568
+ resourcesByLabel[labelId];
1569
+ const previewError: string | undefined =
1570
+ labelLoadErrors[labelId];
1571
+ const colorStr: string | undefined =
1572
+ labelColorString(label);
1573
+ return (
1574
+ <div
1575
+ key={labelId}
1576
+ onMouseEnter={(): void => {
1577
+ setHighlightedIndex(idx);
1578
+ }}
1579
+ className={`border-b border-gray-100 last:border-b-0 ${
1580
+ isHighlighted ? "bg-indigo-50" : ""
1581
+ }`}
1582
+ >
1583
+ <div className="flex w-full items-center gap-2 px-2 py-1.5">
1584
+ <button
1585
+ type="button"
1586
+ aria-label={
1587
+ isExpanded ? "Collapse entries" : "Expand entries"
1588
+ }
1589
+ aria-expanded={isExpanded}
1590
+ onMouseDown={(
1591
+ event: React.MouseEvent<HTMLButtonElement>,
1592
+ ): void => {
1593
+ event.preventDefault();
1594
+ }}
1595
+ onClick={(): void => {
1596
+ toggleLabelExpansion(labelId);
1597
+ }}
1598
+ className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-gray-400 hover:bg-gray-200 hover:text-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-500"
1599
+ >
1600
+ <Icon
1601
+ icon={IconProp.ChevronRight}
1602
+ className={`h-3.5 w-3.5 transition-transform duration-150 ${
1603
+ isExpanded ? "rotate-90" : ""
1604
+ }`}
1605
+ />
1606
+ </button>
1607
+ <button
1608
+ type="button"
1609
+ role="option"
1610
+ aria-selected={isChecked}
1611
+ onMouseDown={(
1612
+ event: React.MouseEvent<HTMLButtonElement>,
1613
+ ): void => {
1614
+ event.preventDefault();
1615
+ }}
1616
+ onClick={(): void => {
1617
+ toggleLabelId(labelId);
1618
+ }}
1619
+ className={`flex flex-1 items-center gap-2 rounded px-1 py-1 text-left ${
1620
+ isHighlighted
1621
+ ? "text-gray-900"
1622
+ : "text-gray-700 hover:bg-indigo-100/50"
1623
+ }`}
1624
+ >
1625
+ <span
1626
+ aria-hidden="true"
1627
+ className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border ${
1628
+ isChecked
1629
+ ? "border-indigo-600 bg-indigo-600 text-white"
1630
+ : "border-gray-300 bg-white"
1631
+ }`}
1632
+ >
1633
+ {isChecked && (
1634
+ <svg
1635
+ className="h-3 w-3"
1636
+ viewBox="0 0 20 20"
1637
+ fill="currentColor"
1638
+ >
1639
+ <path
1640
+ fillRule="evenodd"
1641
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
1642
+ clipRule="evenodd"
1643
+ />
1644
+ </svg>
1645
+ )}
1646
+ </span>
1647
+ {colorStr && (
1648
+ <span
1649
+ className="inline-block h-3 w-3 flex-shrink-0 rounded-full"
1650
+ style={{ backgroundColor: colorStr }}
1651
+ aria-hidden="true"
1652
+ />
1653
+ )}
1654
+ <span className="truncate">
1655
+ {label.name || "Unnamed Label"}
1656
+ </span>
1657
+ </button>
1658
+ {preview !== undefined && (
1659
+ <span className="flex-shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-medium text-gray-600">
1660
+ {preview.length}
1661
+ {preview.length >= LABEL_PREVIEW_LIMIT ? "+" : ""}
1662
+ </span>
1663
+ )}
1664
+ </div>
1665
+ {isExpanded && (
1666
+ <div className="border-t border-gray-100 bg-gray-50 px-3 py-2 pl-10">
1667
+ {isLoadingPreview ? (
1668
+ <div className="flex items-center gap-2 py-1 text-xs text-gray-500">
1669
+ <svg
1670
+ className="h-3.5 w-3.5 animate-spin text-indigo-500"
1671
+ xmlns="http://www.w3.org/2000/svg"
1672
+ fill="none"
1673
+ viewBox="0 0 24 24"
1674
+ aria-hidden="true"
1675
+ >
1676
+ <circle
1677
+ className="opacity-25"
1678
+ cx="12"
1679
+ cy="12"
1680
+ r="10"
1681
+ stroke="currentColor"
1682
+ strokeWidth="4"
1683
+ />
1684
+ <path
1685
+ className="opacity-75"
1686
+ fill="currentColor"
1687
+ d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
1688
+ />
1689
+ </svg>
1690
+ <span>Loading entries...</span>
1691
+ </div>
1692
+ ) : previewError ? (
1693
+ <div className="py-1 text-xs text-red-600">
1694
+ {previewError}
1695
+ </div>
1696
+ ) : !preview || preview.length === 0 ? (
1697
+ <div className="py-1 text-xs italic text-gray-500">
1698
+ No entries tagged with this label.
1699
+ </div>
1700
+ ) : (
1701
+ <div className="flex flex-wrap gap-1">
1702
+ {preview.map(
1703
+ (item: DropdownOption): ReactElement => {
1704
+ return (
1705
+ <span
1706
+ key={valueKey(item.value)}
1707
+ className="inline-flex items-center gap-1 rounded border border-gray-200 bg-white px-1.5 py-0.5 text-[11px] text-gray-700"
1708
+ >
1709
+ <span className="max-w-[10rem] truncate">
1710
+ {item.label}
1711
+ </span>
1712
+ </span>
1713
+ );
1714
+ },
1715
+ )}
1716
+ </div>
1717
+ )}
1718
+ </div>
1719
+ )}
1720
+ </div>
1721
+ );
1722
+ },
1723
+ )}
1724
+ </div>
1725
+ )}
1726
+
1727
+ {activeTab === "labels" && selectedLabelIds.length > 0 && (
1728
+ <div className="flex flex-shrink-0 items-center justify-between gap-2 border-t border-gray-100 bg-gray-50 px-2 py-1.5">
1729
+ <button
1730
+ type="button"
1731
+ onMouseDown={(
1732
+ event: React.MouseEvent<HTMLButtonElement>,
1733
+ ): void => {
1734
+ event.preventDefault();
1735
+ }}
1736
+ onClick={(): void => {
1737
+ setSelectedLabelIds([]);
1738
+ }}
1739
+ disabled={isApplyingLabels}
1740
+ className="rounded px-2 py-1 text-xs font-medium text-gray-600 hover:bg-white hover:text-gray-800 focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:opacity-50"
1741
+ >
1742
+ Clear
1743
+ </button>
1744
+ <button
1745
+ type="button"
1746
+ onMouseDown={(
1747
+ event: React.MouseEvent<HTMLButtonElement>,
1748
+ ): void => {
1749
+ event.preventDefault();
1750
+ }}
1751
+ onClick={(): void => {
1752
+ void applyLabelSelection();
1753
+ }}
1754
+ disabled={isApplyingLabels}
1755
+ className="inline-flex items-center gap-1.5 rounded-md bg-indigo-600 px-3 py-1 text-xs font-semibold text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 disabled:opacity-60"
1756
+ >
1757
+ {isApplyingLabels && (
1758
+ <svg
1759
+ className="h-3.5 w-3.5 animate-spin"
1760
+ xmlns="http://www.w3.org/2000/svg"
1761
+ fill="none"
1762
+ viewBox="0 0 24 24"
1763
+ aria-hidden="true"
1764
+ >
1765
+ <circle
1766
+ className="opacity-25"
1767
+ cx="12"
1768
+ cy="12"
1769
+ r="10"
1770
+ stroke="currentColor"
1771
+ strokeWidth="4"
1772
+ />
1773
+ <path
1774
+ className="opacity-75"
1775
+ fill="currentColor"
1776
+ d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
1777
+ />
1778
+ </svg>
1779
+ )}
1780
+ {isApplyingLabels
1781
+ ? "Adding..."
1782
+ : `Add entries from ${selectedLabelIds.length} label${
1783
+ selectedLabelIds.length === 1 ? "" : "s"
1784
+ }`}
1785
+ </button>
1786
+ </div>
1787
+ )}
1788
+ </div>
1789
+ )}
1790
+
1791
+ {props.error && (
1792
+ <p className="mt-1 text-sm text-red-400" role="alert">
1793
+ {props.error}
1794
+ </p>
1795
+ )}
1796
+ </div>
1797
+ );
1798
+ };
1799
+
1800
+ // Re-export the option types from Dropdown so callers don't need both imports.
1801
+ export type {
1802
+ DropdownOption,
1803
+ DropdownOptionGroup,
1804
+ DropdownOptionLabel,
1805
+ DropdownValue,
1806
+ };
1807
+
1808
+ export default EntityDropdown;