@oneuptime/common 14.0.5 → 14.0.7

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 (192) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import EventStatusPanel, {
2
2
  ComponentProps,
3
+ EventPanelAction,
3
4
  EventStateAction,
4
5
  EventStateItem,
5
6
  } from "../../../../App/FeatureSet/Dashboard/src/Components/EventView/EventStatusPanel";
@@ -19,6 +20,9 @@ import { Black, Red500 } from "../../../Types/BrandColors";
19
20
  import Color from "../../../Types/Color";
20
21
  import IconProp from "../../../Types/Icon/IconProp";
21
22
 
23
+ // React's warning for list items without a (unique) key.
24
+ const KEY_WARNING_PATTERN: RegExp = /unique "key"|same key/;
25
+
22
26
  jest.mock("react-i18next", () => {
23
27
  return {
24
28
  useTranslation: () => {
@@ -542,7 +546,7 @@ describe("EventStatusPanel disabled behavior", () => {
542
546
  });
543
547
 
544
548
  describe("EventStatusPanel action-group layout", () => {
545
- test("labels one responsive wrapping group for every action control", () => {
549
+ test("labels one responsive wrapping group for every action control (compact layout)", () => {
546
550
  renderPanel();
547
551
 
548
552
  const group: HTMLElement = getActionGroup();
@@ -607,11 +611,53 @@ describe("EventStatusPanel header layouts", () => {
607
611
 
608
612
  expect(identifier).toHaveTextContent("INC-42");
609
613
  expect(identifier).toHaveClass("bg-gray-100", "uppercase");
610
- expect(headerRow).toHaveClass("md:items-start", "md:justify-between");
614
+ expect(headerRow).toHaveClass("xl:items-start", "xl:justify-between");
611
615
  expect(headerRow).toContainElement(group);
612
616
  expect(headerRow).not.toContainElement(screen.getByTestId("pill"));
613
617
  });
614
618
 
619
+ /*
620
+ * Beside a side menu, a header that went side by side from md squeezed the
621
+ * title to a few characters and stacked the actions one per row, so the
622
+ * titled header stacks the actions under the title until xl.
623
+ */
624
+ test("stacks the actions under the title until xl, and puts them beside it only from xl", () => {
625
+ renderPanel({ title: "Database connection failures" });
626
+
627
+ const heading: HTMLElement = screen.getByRole("heading", {
628
+ level: 2,
629
+ name: "Database connection failures",
630
+ });
631
+ const headerRow: HTMLElement = (heading.parentElement as HTMLElement)
632
+ .parentElement as HTMLElement;
633
+
634
+ expect(headerRow).toHaveClass(
635
+ "flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between",
636
+ { exact: true },
637
+ );
638
+ expect(headerRow).not.toHaveClass("md:flex-row");
639
+ expect(headerRow).not.toHaveClass("md:items-start");
640
+ expect(headerRow).not.toHaveClass("md:justify-between");
641
+ });
642
+
643
+ test("keeps the titled header's action group full width until xl", () => {
644
+ renderPanel({ title: "Database connection failures" });
645
+
646
+ const group: HTMLElement = getActionGroup();
647
+
648
+ expect(group).toHaveClass(
649
+ "flex w-full flex-wrap items-center justify-end gap-2 xl:w-auto xl:shrink-0",
650
+ { exact: true },
651
+ );
652
+ expect(group).not.toHaveClass("md:w-auto");
653
+ expect(group).toHaveAttribute("aria-label", "Event actions");
654
+ expect(within(group).getAllByRole("button")).toEqual([
655
+ screen.getByRole("button", { name: "Acknowledge" }),
656
+ screen.getByRole("button", { name: "Resolve" }),
657
+ screen.getByRole("button", { name: "More actions" }),
658
+ ]);
659
+ });
660
+
615
661
  test("uses the compact inline metadata layout when no title is supplied", () => {
616
662
  renderPanel();
617
663
 
@@ -628,6 +674,25 @@ describe("EventStatusPanel header layouts", () => {
628
674
  expect(compactRow).toContainElement(screen.getByTestId("pill"));
629
675
  });
630
676
 
677
+ // Only the titled header moved to xl; the compact layout is as it was.
678
+ test("keeps the compact layout side by side from md, with its action group full width until md", () => {
679
+ renderPanel();
680
+
681
+ const group: HTMLElement = getActionGroup();
682
+ const compactRow: HTMLElement = group.parentElement as HTMLElement;
683
+
684
+ expect(compactRow).toHaveClass(
685
+ "flex flex-col gap-3 px-4 py-4 sm:px-5 md:flex-row md:items-center md:justify-between",
686
+ { exact: true },
687
+ );
688
+ expect(compactRow).not.toHaveClass("xl:flex-row");
689
+ expect(group).toHaveClass(
690
+ "flex w-full flex-wrap items-center justify-end gap-2 md:w-auto",
691
+ { exact: true },
692
+ );
693
+ expect(group).not.toHaveClass("xl:w-auto");
694
+ });
695
+
631
696
  test("falls back to black when hydrated current-state color is absent", () => {
632
697
  renderPanel({
633
698
  states: [
@@ -992,3 +1057,734 @@ describe("EventStatusPanel header facts", () => {
992
1057
  expect(getActionGroup()).not.toContainElement(getFacts());
993
1058
  });
994
1059
  });
1060
+
1061
+ /*
1062
+ * Secondary actions are the header's non-state buttons - "Declare Incident"
1063
+ * on an alert is the first. They share the state buttons' row and size but
1064
+ * never their primary look, never reach onActionClick (which would open a
1065
+ * state-change modal for a state that does not exist), never change what the
1066
+ * "More actions" menu offers, and stay on screen when no state action is
1067
+ * left. A locked one explains itself through a hoverable, focusable wrapper,
1068
+ * because a disabled <button> cannot trigger a tooltip.
1069
+ */
1070
+ describe("EventStatusPanel secondary actions", () => {
1071
+ const DECLARE_ID: string = "alert-declare-incident-btn";
1072
+ const DECLARE_LABEL: string = "Declare Incident";
1073
+ const LOCKED_REASON: string =
1074
+ "You do not have permission to create this Incident.";
1075
+
1076
+ type MakeSecondaryActionFunction = (
1077
+ overrides?: Partial<EventPanelAction> | undefined,
1078
+ ) => EventPanelAction;
1079
+
1080
+ const makeSecondaryAction: MakeSecondaryActionFunction = (
1081
+ overrides?: Partial<EventPanelAction> | undefined,
1082
+ ): EventPanelAction => {
1083
+ return {
1084
+ id: DECLARE_ID,
1085
+ label: DECLARE_LABEL,
1086
+ icon: IconProp.Alert,
1087
+ onClick: (): void => {},
1088
+ ...overrides,
1089
+ };
1090
+ };
1091
+
1092
+ type GetButtonFunction = (name: string) => HTMLElement;
1093
+
1094
+ const getButton: GetButtonFunction = (name: string): HTMLElement => {
1095
+ return screen.getByRole("button", { name: name });
1096
+ };
1097
+
1098
+ type IsBeforeFunction = (first: Node, second: Node) => boolean;
1099
+
1100
+ const isBefore: IsBeforeFunction = (first: Node, second: Node): boolean => {
1101
+ return Boolean(
1102
+ first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
1103
+ );
1104
+ };
1105
+
1106
+ afterEach(() => {
1107
+ jest.restoreAllMocks();
1108
+ });
1109
+
1110
+ /*
1111
+ * First in this block on purpose: React reports a missing list key only
1112
+ * once per owner component, so the first render with secondary actions is
1113
+ * the one that would warn.
1114
+ */
1115
+ test("keys each action by its id, so reordering moves rather than relabels them", () => {
1116
+ const consoleErrorSpy: ReturnType<typeof jest.spyOn> = jest.spyOn(
1117
+ console,
1118
+ "error",
1119
+ );
1120
+
1121
+ const first: EventPanelAction = makeSecondaryAction({
1122
+ id: "first-secondary-btn",
1123
+ label: "First",
1124
+ });
1125
+ const second: EventPanelAction = makeSecondaryAction({
1126
+ id: "second-secondary-btn",
1127
+ label: "Second",
1128
+ isDisabled: true,
1129
+ tooltip: LOCKED_REASON,
1130
+ });
1131
+ const third: EventPanelAction = makeSecondaryAction({
1132
+ id: "third-secondary-btn",
1133
+ label: "Third",
1134
+ });
1135
+
1136
+ const baseProps: ComponentProps = {
1137
+ states: defaultStates(),
1138
+ identifier: "ALR-12",
1139
+ currentStateId: "created",
1140
+ actions: defaultActions(),
1141
+ onActionClick: (): void => {},
1142
+ onStateSelect: (): void => {},
1143
+ };
1144
+
1145
+ const { rerender } = render(
1146
+ <EventStatusPanel
1147
+ {...baseProps}
1148
+ secondaryActions={[first, second, third]}
1149
+ />,
1150
+ );
1151
+
1152
+ const firstNode: HTMLElement = getButton("First");
1153
+ const secondWrapper: HTMLElement = screen.getByTestId(
1154
+ "second-secondary-btn-disabled-wrapper",
1155
+ );
1156
+ const thirdNode: HTMLElement = getButton("Third");
1157
+
1158
+ rerender(
1159
+ <EventStatusPanel
1160
+ {...baseProps}
1161
+ secondaryActions={[third, second, first]}
1162
+ />,
1163
+ );
1164
+
1165
+ // Same DOM nodes, now in the new order.
1166
+ expect(getButton("First")).toBe(firstNode);
1167
+ expect(getButton("Third")).toBe(thirdNode);
1168
+ expect(screen.getByTestId("second-secondary-btn-disabled-wrapper")).toBe(
1169
+ secondWrapper,
1170
+ );
1171
+ expect(firstNode).toHaveAttribute("id", "first-secondary-btn");
1172
+ expect(thirdNode).toHaveAttribute("id", "third-secondary-btn");
1173
+ expect(isBefore(thirdNode, secondWrapper)).toBe(true);
1174
+ expect(isBefore(secondWrapper, firstNode)).toBe(true);
1175
+
1176
+ const keyWarnings: Array<unknown> = consoleErrorSpy.mock.calls.filter(
1177
+ (call: Array<unknown>): boolean => {
1178
+ return KEY_WARNING_PATTERN.test(String(call[0]));
1179
+ },
1180
+ );
1181
+
1182
+ expect(keyWarnings).toEqual([]);
1183
+ });
1184
+
1185
+ test("renders after the state buttons and before the More actions trigger", () => {
1186
+ renderPanel({ secondaryActions: [makeSecondaryAction()] });
1187
+
1188
+ const group: HTMLElement = getActionGroup();
1189
+ const acknowledge: HTMLElement = within(group).getByRole("button", {
1190
+ name: "Acknowledge",
1191
+ });
1192
+ const resolve: HTMLElement = within(group).getByRole("button", {
1193
+ name: "Resolve",
1194
+ });
1195
+ const declare: HTMLElement = within(group).getByRole("button", {
1196
+ name: DECLARE_LABEL,
1197
+ });
1198
+ const more: HTMLElement = within(group).getByRole("button", {
1199
+ name: "More actions",
1200
+ });
1201
+
1202
+ expect(within(group).getAllByRole("button")).toEqual([
1203
+ acknowledge,
1204
+ resolve,
1205
+ declare,
1206
+ more,
1207
+ ]);
1208
+ // A direct child of the group, like the state buttons - not nested.
1209
+ expect(declare.parentElement).toBe(group);
1210
+ });
1211
+
1212
+ test("is a native button with its id and label as title", () => {
1213
+ renderPanel({ secondaryActions: [makeSecondaryAction()] });
1214
+
1215
+ const declare: HTMLElement = getButton(DECLARE_LABEL);
1216
+
1217
+ expect(declare.tagName).toBe("BUTTON");
1218
+ expect(declare).toHaveAttribute("type", "button");
1219
+ expect(declare).toHaveAttribute("id", DECLARE_ID);
1220
+ expect(declare).toHaveAttribute("title", DECLARE_LABEL);
1221
+ expect(declare).toBeEnabled();
1222
+ expect(declare).toHaveAttribute("aria-disabled", "false");
1223
+ expect(declare.querySelector("span")).toHaveClass("truncate");
1224
+ expect(declare.querySelector("span")).toHaveTextContent(DECLARE_LABEL);
1225
+ });
1226
+
1227
+ test("uses the state buttons' base classes with the neutral, never the indigo, look", () => {
1228
+ renderPanel({ secondaryActions: [makeSecondaryAction()] });
1229
+
1230
+ const acknowledge: HTMLElement = getButton("Acknowledge");
1231
+ // DANGER renders neutral, so Resolve is the neutral reference.
1232
+ const resolve: HTMLElement = getButton("Resolve");
1233
+ const declare: HTMLElement = getButton(DECLARE_LABEL);
1234
+
1235
+ const primaryVariantClasses: Array<string> = [
1236
+ "border-indigo-600",
1237
+ "bg-indigo-600",
1238
+ "text-white",
1239
+ "hover:border-indigo-700",
1240
+ "hover:bg-indigo-700",
1241
+ ];
1242
+ const baseClasses: Array<string> = acknowledge.className
1243
+ .split(/\s+/)
1244
+ .filter((className: string): boolean => {
1245
+ return (
1246
+ className.length > 0 && !primaryVariantClasses.includes(className)
1247
+ );
1248
+ });
1249
+
1250
+ expect(baseClasses).toEqual(
1251
+ expect.arrayContaining(["inline-flex", "h-9", "min-w-[7rem]"]),
1252
+ );
1253
+ expect(declare).toHaveClass(...baseClasses);
1254
+ expect(declare.className).toBe(resolve.className);
1255
+ expect(declare).toHaveClass(
1256
+ "border-gray-300",
1257
+ "bg-white",
1258
+ "text-gray-700",
1259
+ "hover:bg-gray-50",
1260
+ );
1261
+ expect(declare).not.toHaveClass(
1262
+ "bg-indigo-600",
1263
+ "border-indigo-600",
1264
+ "text-white",
1265
+ "pointer-events-none",
1266
+ "w-full",
1267
+ );
1268
+ expect(declare).not.toHaveAttribute("style");
1269
+
1270
+ // The state action stays the one primary button in the row.
1271
+ const primaryButtons: Array<HTMLElement> = within(getActionGroup())
1272
+ .getAllByRole("button")
1273
+ .filter((button: HTMLElement): boolean => {
1274
+ return button.classList.contains("bg-indigo-600");
1275
+ });
1276
+
1277
+ expect(primaryButtons).toEqual([acknowledge]);
1278
+ });
1279
+
1280
+ test("translates its label like the state labels", () => {
1281
+ renderPanel({
1282
+ secondaryActions: [
1283
+ makeSecondaryAction({ label: "Resolve translation key" }),
1284
+ ],
1285
+ });
1286
+
1287
+ const translated: HTMLElement = getButton("Résoudre");
1288
+
1289
+ expect(translated).toHaveAttribute("id", DECLARE_ID);
1290
+ expect(translated).toHaveAttribute("title", "Résoudre");
1291
+ expect(
1292
+ screen.queryByRole("button", { name: "Resolve translation key" }),
1293
+ ).not.toBeInTheDocument();
1294
+ });
1295
+
1296
+ test("renders its icon at the state buttons' icon size, and none when it has no icon", () => {
1297
+ renderPanel({
1298
+ secondaryActions: [
1299
+ makeSecondaryAction(),
1300
+ makeSecondaryAction({
1301
+ id: "iconless-secondary-btn",
1302
+ label: "Iconless",
1303
+ icon: undefined,
1304
+ }),
1305
+ ],
1306
+ });
1307
+
1308
+ const declareIcon: SVGElement | null =
1309
+ getButton(DECLARE_LABEL).querySelector("svg");
1310
+ const acknowledgeIcon: SVGElement | null =
1311
+ getButton("Acknowledge").querySelector("svg");
1312
+
1313
+ expect(declareIcon).not.toBeNull();
1314
+ expect(declareIcon).toHaveClass("h-4", "w-4", "shrink-0");
1315
+ expect(declareIcon?.innerHTML).not.toEqual(acknowledgeIcon?.innerHTML);
1316
+ expect(getButton("Iconless").querySelector("svg")).toBeNull();
1317
+ });
1318
+
1319
+ test("calls its own onClick exactly once and never the state callbacks", () => {
1320
+ const onDeclare: MockFunction = getJestMockFunction();
1321
+ const rendered: RenderedPanel = renderPanel({
1322
+ secondaryActions: [makeSecondaryAction({ onClick: onDeclare })],
1323
+ });
1324
+
1325
+ fireEvent.click(getButton(DECLARE_LABEL));
1326
+
1327
+ expect(onDeclare).toHaveBeenCalledTimes(1);
1328
+ expect(onDeclare).toHaveBeenCalledWith();
1329
+ expect(rendered.actionClicks).toEqual([]);
1330
+ expect(rendered.stateSelections).toEqual([]);
1331
+ expect(screen.queryByRole("menu")).not.toBeInTheDocument();
1332
+
1333
+ // The state buttons beside it still report their own states only.
1334
+ fireEvent.click(getButton("Acknowledge"));
1335
+
1336
+ expect(rendered.actionClicks).toEqual(["acknowledged"]);
1337
+ expect(onDeclare).toHaveBeenCalledTimes(1);
1338
+ });
1339
+
1340
+ test("never reaches onActionClick even when its id matches a state id", () => {
1341
+ const onClick: MockFunction = getJestMockFunction();
1342
+ const rendered: RenderedPanel = renderPanel({
1343
+ secondaryActions: [
1344
+ makeSecondaryAction({
1345
+ id: "resolved",
1346
+ label: "Look-alike",
1347
+ onClick: onClick,
1348
+ }),
1349
+ ],
1350
+ });
1351
+
1352
+ fireEvent.click(getButton("Look-alike"));
1353
+
1354
+ expect(onClick).toHaveBeenCalledTimes(1);
1355
+ expect(rendered.actionClicks).toEqual([]);
1356
+ });
1357
+
1358
+ test("leaves the More actions menu exactly as it was", () => {
1359
+ renderPanel();
1360
+
1361
+ const withoutSecondary: Array<string> = menuChoiceNames(openMenu());
1362
+
1363
+ cleanup();
1364
+
1365
+ renderPanel({ secondaryActions: [makeSecondaryAction()] });
1366
+
1367
+ const menu: HTMLElement = openMenu();
1368
+
1369
+ expect(withoutSecondary).toEqual(["Investigating"]);
1370
+ expect(menuChoiceNames(menu)).toEqual(withoutSecondary);
1371
+ expect(within(menu).queryByText(DECLARE_LABEL)).not.toBeInTheDocument();
1372
+ });
1373
+
1374
+ test.each(["investigating", "acknowledged", "resolved", "created"])(
1375
+ "does not hide the %s state from the menu when an action shares its id",
1376
+ (sharedId: string) => {
1377
+ renderPanel({
1378
+ actions: [],
1379
+ secondaryActions: [
1380
+ makeSecondaryAction({ id: sharedId, label: "Look-alike" }),
1381
+ ],
1382
+ });
1383
+
1384
+ expect(menuChoiceNames(openMenu())).toEqual([
1385
+ "Acknowledged",
1386
+ "Investigating",
1387
+ "Resolved",
1388
+ ]);
1389
+ },
1390
+ );
1391
+
1392
+ test("reports a menu selection as a state change, not a secondary click", () => {
1393
+ const onDeclare: MockFunction = getJestMockFunction();
1394
+ const rendered: RenderedPanel = renderPanel({
1395
+ secondaryActions: [makeSecondaryAction({ onClick: onDeclare })],
1396
+ });
1397
+
1398
+ fireEvent.click(getMenuChoice(openMenu(), "Investigating"));
1399
+
1400
+ expect(rendered.stateSelections).toEqual(["investigating"]);
1401
+ expect(onDeclare).not.toHaveBeenCalled();
1402
+ });
1403
+
1404
+ test("does not conjure a More actions menu when every other state already has a button", () => {
1405
+ renderPanel({
1406
+ states: [
1407
+ makeState("created", "Created"),
1408
+ makeState("acknowledged", "Acknowledged"),
1409
+ makeState("resolved", "Resolved"),
1410
+ ],
1411
+ secondaryActions: [makeSecondaryAction()],
1412
+ });
1413
+
1414
+ expect(
1415
+ screen.queryByRole("button", { name: "More actions" }),
1416
+ ).not.toBeInTheDocument();
1417
+ expect(within(getActionGroup()).getAllByRole("button")).toEqual([
1418
+ getButton("Acknowledge"),
1419
+ getButton("Resolve"),
1420
+ getButton(DECLARE_LABEL),
1421
+ ]);
1422
+ });
1423
+
1424
+ test("stays on a resolved event, where it is the only button", () => {
1425
+ const onDeclare: MockFunction = getJestMockFunction();
1426
+
1427
+ renderPanel({
1428
+ currentStateId: "resolved",
1429
+ actions: [],
1430
+ secondaryActions: [makeSecondaryAction({ onClick: onDeclare })],
1431
+ });
1432
+
1433
+ const group: HTMLElement = getActionGroup();
1434
+ const declare: HTMLElement = getButton(DECLARE_LABEL);
1435
+
1436
+ expect(within(group).getAllByRole("button")).toEqual([declare]);
1437
+ expect(declare).toBeEnabled();
1438
+
1439
+ fireEvent.click(declare);
1440
+
1441
+ expect(onDeclare).toHaveBeenCalledTimes(1);
1442
+ });
1443
+
1444
+ test("renders even when the panel has no states at all", () => {
1445
+ render(
1446
+ <EventStatusPanel
1447
+ title="Database latency"
1448
+ states={[]}
1449
+ actions={[]}
1450
+ onActionClick={() => {}}
1451
+ secondaryActions={[makeSecondaryAction()]}
1452
+ />,
1453
+ );
1454
+
1455
+ expect(within(getActionGroup()).getAllByRole("button")).toEqual([
1456
+ getButton(DECLARE_LABEL),
1457
+ ]);
1458
+ });
1459
+
1460
+ test.each([
1461
+ ["undefined", undefined],
1462
+ ["an empty list", []],
1463
+ ])(
1464
+ "renders nothing extra when secondaryActions is %s",
1465
+ (_label: string, secondaryActions: Array<EventPanelAction> | undefined) => {
1466
+ renderPanel({ secondaryActions: secondaryActions });
1467
+
1468
+ const group: HTMLElement = getActionGroup();
1469
+
1470
+ expect(within(group).getAllByRole("button")).toEqual([
1471
+ getButton("Acknowledge"),
1472
+ getButton("Resolve"),
1473
+ getMoreActionsTrigger(),
1474
+ ]);
1475
+ expect(document.getElementById(DECLARE_ID)).toBeNull();
1476
+ expect(
1477
+ document.querySelector('[data-testid$="-disabled-wrapper"]'),
1478
+ ).toBeNull();
1479
+ },
1480
+ );
1481
+
1482
+ test("is disabled with the rest of the panel, and a click does nothing", () => {
1483
+ const onDeclare: MockFunction = getJestMockFunction();
1484
+
1485
+ renderPanel({
1486
+ isDisabled: true,
1487
+ secondaryActions: [makeSecondaryAction({ onClick: onDeclare })],
1488
+ });
1489
+
1490
+ const declare: HTMLElement = getButton(DECLARE_LABEL);
1491
+
1492
+ expect(declare).toBeDisabled();
1493
+ expect(declare).toHaveAttribute("aria-disabled", "true");
1494
+ // No reason to show, so no wrapper and the label stays as the title.
1495
+ expect(screen.queryByTestId(`${DECLARE_ID}-disabled-wrapper`)).toBeNull();
1496
+ expect(declare).toHaveAttribute("title", DECLARE_LABEL);
1497
+
1498
+ fireEvent.click(declare);
1499
+
1500
+ expect(onDeclare).not.toHaveBeenCalled();
1501
+ });
1502
+
1503
+ test("gets the tooltip wrapper when the panel is disabled and the action carries a reason", () => {
1504
+ renderPanel({
1505
+ isDisabled: true,
1506
+ secondaryActions: [makeSecondaryAction({ tooltip: LOCKED_REASON })],
1507
+ });
1508
+
1509
+ const wrapper: HTMLElement = screen.getByTestId(
1510
+ `${DECLARE_ID}-disabled-wrapper`,
1511
+ );
1512
+
1513
+ expect(wrapper).toContainElement(getButton(DECLARE_LABEL));
1514
+ expect(getButton(DECLARE_LABEL)).toBeDisabled();
1515
+ });
1516
+
1517
+ test.each([
1518
+ ["no tooltip", undefined],
1519
+ ["an empty tooltip", ""],
1520
+ ])(
1521
+ "a disabled action with %s is disabled in place, with no wrapper",
1522
+ (_label: string, tooltip: string | undefined) => {
1523
+ const onDeclare: MockFunction = getJestMockFunction();
1524
+ const rendered: RenderedPanel = renderPanel({
1525
+ secondaryActions: [
1526
+ makeSecondaryAction({
1527
+ isDisabled: true,
1528
+ tooltip: tooltip,
1529
+ onClick: onDeclare,
1530
+ }),
1531
+ ],
1532
+ });
1533
+
1534
+ const declare: HTMLElement = getButton(DECLARE_LABEL);
1535
+
1536
+ expect(declare).toBeDisabled();
1537
+ expect(declare).toHaveAttribute("aria-disabled", "true");
1538
+ expect(declare).toHaveAttribute("title", DECLARE_LABEL);
1539
+ expect(declare).not.toHaveClass("pointer-events-none", "w-full");
1540
+ expect(declare.parentElement).toBe(getActionGroup());
1541
+ expect(screen.queryByTestId(`${DECLARE_ID}-disabled-wrapper`)).toBeNull();
1542
+
1543
+ fireEvent.click(declare);
1544
+
1545
+ expect(onDeclare).not.toHaveBeenCalled();
1546
+ // Only this action is locked; the state controls still work.
1547
+ expect(getButton("Acknowledge")).toBeEnabled();
1548
+ expect(getButton("Resolve")).toBeEnabled();
1549
+ expect(getMoreActionsTrigger()).toBeEnabled();
1550
+
1551
+ fireEvent.click(getButton("Resolve"));
1552
+
1553
+ expect(rendered.actionClicks).toEqual(["resolved"]);
1554
+ },
1555
+ );
1556
+
1557
+ test("a disabled action with a reason hands the pointer and focus to a wrapper", () => {
1558
+ renderPanel({
1559
+ secondaryActions: [
1560
+ makeSecondaryAction({ isDisabled: true, tooltip: LOCKED_REASON }),
1561
+ ],
1562
+ });
1563
+
1564
+ const group: HTMLElement = getActionGroup();
1565
+ const wrapper: HTMLElement = screen.getByTestId(
1566
+ `${DECLARE_ID}-disabled-wrapper`,
1567
+ );
1568
+ const declare: HTMLElement = getButton(DECLARE_LABEL);
1569
+
1570
+ expect(wrapper.tagName).toBe("SPAN");
1571
+ expect(wrapper).toHaveAttribute("tabindex", "0");
1572
+ expect(wrapper.tabIndex).toBe(0);
1573
+ expect(wrapper).toHaveClass(
1574
+ "inline-flex",
1575
+ "min-w-[7rem]",
1576
+ "max-w-full",
1577
+ "flex-auto",
1578
+ "rounded-md",
1579
+ "sm:max-w-64",
1580
+ "sm:flex-none",
1581
+ );
1582
+ expect(declare.parentElement).toBe(wrapper);
1583
+ expect(wrapper.parentElement).toBe(group);
1584
+
1585
+ // Taken out of hit-testing so the wrapper receives the hover.
1586
+ expect(declare).toHaveClass("pointer-events-none", "w-full");
1587
+ // The tooltip carries the message; a native title would compete with it.
1588
+ expect(declare).not.toHaveAttribute("title");
1589
+ expect(declare).toBeDisabled();
1590
+ expect(declare).toHaveAttribute("aria-disabled", "true");
1591
+ // Still the neutral look.
1592
+ expect(declare).toHaveClass("border-gray-300", "bg-white", "text-gray-700");
1593
+ expect(declare).not.toHaveClass("bg-indigo-600");
1594
+
1595
+ // Still between the state buttons and the menu.
1596
+ expect(isBefore(getButton("Resolve"), wrapper)).toBe(true);
1597
+ expect(isBefore(wrapper, getMoreActionsTrigger())).toBe(true);
1598
+ });
1599
+
1600
+ test("shows the reason when the wrapper is hovered", () => {
1601
+ renderPanel({
1602
+ secondaryActions: [
1603
+ makeSecondaryAction({ isDisabled: true, tooltip: LOCKED_REASON }),
1604
+ ],
1605
+ });
1606
+
1607
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
1608
+
1609
+ fireEvent.mouseEnter(screen.getByTestId(`${DECLARE_ID}-disabled-wrapper`));
1610
+
1611
+ expect(screen.getByRole("tooltip")).toHaveTextContent(LOCKED_REASON);
1612
+ });
1613
+
1614
+ test("shows the reason when the wrapper is focused from the keyboard", () => {
1615
+ renderPanel({
1616
+ secondaryActions: [
1617
+ makeSecondaryAction({ isDisabled: true, tooltip: LOCKED_REASON }),
1618
+ ],
1619
+ });
1620
+
1621
+ const wrapper: HTMLElement = screen.getByTestId(
1622
+ `${DECLARE_ID}-disabled-wrapper`,
1623
+ );
1624
+
1625
+ wrapper.focus();
1626
+
1627
+ expect(document.activeElement).toBe(wrapper);
1628
+
1629
+ fireEvent.focus(wrapper);
1630
+
1631
+ expect(screen.getByRole("tooltip")).toHaveTextContent(LOCKED_REASON);
1632
+ });
1633
+
1634
+ test("translates the reason like the label", () => {
1635
+ renderPanel({
1636
+ secondaryActions: [
1637
+ makeSecondaryAction({
1638
+ isDisabled: true,
1639
+ tooltip: "Resolve translation key",
1640
+ }),
1641
+ ],
1642
+ });
1643
+
1644
+ fireEvent.mouseEnter(screen.getByTestId(`${DECLARE_ID}-disabled-wrapper`));
1645
+
1646
+ expect(screen.getByRole("tooltip")).toHaveTextContent("Résoudre");
1647
+ expect(screen.getByRole("tooltip")).not.toHaveTextContent(
1648
+ "Resolve translation key",
1649
+ );
1650
+ });
1651
+
1652
+ test("a click on a locked action with a reason does nothing", () => {
1653
+ const onDeclare: MockFunction = getJestMockFunction();
1654
+ const rendered: RenderedPanel = renderPanel({
1655
+ secondaryActions: [
1656
+ makeSecondaryAction({
1657
+ isDisabled: true,
1658
+ tooltip: LOCKED_REASON,
1659
+ onClick: onDeclare,
1660
+ }),
1661
+ ],
1662
+ });
1663
+
1664
+ fireEvent.click(screen.getByTestId(`${DECLARE_ID}-disabled-wrapper`));
1665
+ fireEvent.click(getButton(DECLARE_LABEL));
1666
+
1667
+ expect(onDeclare).not.toHaveBeenCalled();
1668
+ expect(rendered.actionClicks).toEqual([]);
1669
+ });
1670
+
1671
+ test("an enabled action ignores its tooltip and gets no wrapper", () => {
1672
+ renderPanel({
1673
+ secondaryActions: [makeSecondaryAction({ tooltip: LOCKED_REASON })],
1674
+ });
1675
+
1676
+ const declare: HTMLElement = getButton(DECLARE_LABEL);
1677
+
1678
+ expect(screen.queryByTestId(`${DECLARE_ID}-disabled-wrapper`)).toBeNull();
1679
+ expect(declare.parentElement).toBe(getActionGroup());
1680
+ expect(declare).toBeEnabled();
1681
+ expect(declare).toHaveAttribute("title", DECLARE_LABEL);
1682
+ expect(declare).not.toHaveClass("pointer-events-none");
1683
+ });
1684
+
1685
+ test.each([
1686
+ ["titled", "Database latency"],
1687
+ ["compact", undefined],
1688
+ ])("works in the %s layout", (_layout: string, title: string | undefined) => {
1689
+ const onDeclare: MockFunction = getJestMockFunction();
1690
+
1691
+ renderPanel({
1692
+ title: title,
1693
+ secondaryActions: [
1694
+ makeSecondaryAction({ onClick: onDeclare }),
1695
+ makeSecondaryAction({
1696
+ id: "locked-secondary-btn",
1697
+ label: "Locked",
1698
+ isDisabled: true,
1699
+ tooltip: LOCKED_REASON,
1700
+ }),
1701
+ ],
1702
+ });
1703
+
1704
+ if (title) {
1705
+ expect(
1706
+ screen.getByRole("heading", { level: 2, name: title }),
1707
+ ).toBeInTheDocument();
1708
+ } else {
1709
+ expect(
1710
+ screen.queryByRole("heading", { level: 2 }),
1711
+ ).not.toBeInTheDocument();
1712
+ }
1713
+
1714
+ const group: HTMLElement = getActionGroup();
1715
+ const declare: HTMLElement = getButton(DECLARE_LABEL);
1716
+ const locked: HTMLElement = getButton("Locked");
1717
+
1718
+ expect(within(group).getAllByRole("button")).toEqual([
1719
+ getButton("Acknowledge"),
1720
+ getButton("Resolve"),
1721
+ declare,
1722
+ locked,
1723
+ getMoreActionsTrigger(),
1724
+ ]);
1725
+ expect(
1726
+ within(group).getByTestId("locked-secondary-btn-disabled-wrapper"),
1727
+ ).toContainElement(locked);
1728
+
1729
+ fireEvent.click(declare);
1730
+
1731
+ expect(onDeclare).toHaveBeenCalledTimes(1);
1732
+ });
1733
+
1734
+ test("keeps several secondary actions in caller order, each with its own handler", () => {
1735
+ const onFirst: MockFunction = getJestMockFunction();
1736
+ const onSecond: MockFunction = getJestMockFunction();
1737
+ const onThird: MockFunction = getJestMockFunction();
1738
+
1739
+ const rendered: RenderedPanel = renderPanel({
1740
+ secondaryActions: [
1741
+ makeSecondaryAction({
1742
+ id: "first-secondary-btn",
1743
+ label: "First",
1744
+ onClick: onFirst,
1745
+ }),
1746
+ makeSecondaryAction({
1747
+ id: "second-secondary-btn",
1748
+ label: "Second",
1749
+ onClick: onSecond,
1750
+ isDisabled: true,
1751
+ tooltip: LOCKED_REASON,
1752
+ }),
1753
+ makeSecondaryAction({
1754
+ id: "third-secondary-btn",
1755
+ label: "Third",
1756
+ onClick: onThird,
1757
+ }),
1758
+ ],
1759
+ });
1760
+
1761
+ const group: HTMLElement = getActionGroup();
1762
+ const first: HTMLElement = getButton("First");
1763
+ const second: HTMLElement = getButton("Second");
1764
+ const third: HTMLElement = getButton("Third");
1765
+
1766
+ expect(within(group).getAllByRole("button")).toEqual([
1767
+ getButton("Acknowledge"),
1768
+ getButton("Resolve"),
1769
+ first,
1770
+ second,
1771
+ third,
1772
+ getMoreActionsTrigger(),
1773
+ ]);
1774
+ expect(first).toHaveAttribute("id", "first-secondary-btn");
1775
+ expect(second).toHaveAttribute("id", "second-secondary-btn");
1776
+ expect(third).toHaveAttribute("id", "third-secondary-btn");
1777
+
1778
+ fireEvent.click(third);
1779
+ fireEvent.click(second);
1780
+ fireEvent.click(first);
1781
+
1782
+ expect(onFirst).toHaveBeenCalledTimes(1);
1783
+ expect(onSecond).not.toHaveBeenCalled();
1784
+ expect(onThird).toHaveBeenCalledTimes(1);
1785
+ expect(onThird.mock.invocationCallOrder[0]!).toBeLessThan(
1786
+ onFirst.mock.invocationCallOrder[0]!,
1787
+ );
1788
+ expect(rendered.actionClicks).toEqual([]);
1789
+ });
1790
+ });