@oneuptime/common 14.0.2 → 14.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  2. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  3. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  4. package/Models/DatabaseModels/Index.ts +2 -0
  5. package/Models/DatabaseModels/Project.ts +60 -0
  6. package/Models/DatabaseModels/RumApplication.ts +52 -1
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/IncidentAlertService.ts +1811 -0
  11. package/Server/Services/IncidentService.ts +210 -10
  12. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  13. package/Server/Services/Index.ts +2 -0
  14. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  15. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  16. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  17. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  18. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  19. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  20. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  21. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  22. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  23. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  24. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  25. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  26. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  27. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  28. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  29. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  30. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  31. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  32. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  33. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  34. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  35. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  36. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  37. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  38. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  39. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  40. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  41. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  42. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  43. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  44. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  45. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  46. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  47. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  48. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  49. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  50. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  51. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  52. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  53. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  54. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  55. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  56. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  57. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  58. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  59. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  60. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  61. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  62. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  63. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  64. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  65. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  66. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  67. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  68. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  69. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  70. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  71. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  72. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  73. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  74. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  76. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  77. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  78. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  79. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  80. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  81. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  82. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  83. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  84. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  85. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  86. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  87. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  88. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  89. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  90. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  91. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  92. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  93. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  94. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  95. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  96. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  97. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  98. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  99. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  100. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  101. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  102. package/Types/Incident/IncidentAlertLink.ts +38 -0
  103. package/Types/Permission.ts +48 -0
  104. package/Types/Rum/SessionReplay.ts +25 -6
  105. package/Types/Rum/WebVitals.ts +52 -0
  106. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  107. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  108. package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
  109. package/UI/Components/ModelTable/Column.ts +5 -0
  110. package/UI/Components/Table/TableHeader.tsx +47 -2
  111. package/UI/Components/Table/Types/Column.ts +7 -0
  112. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  113. package/UI/Utils/PermissionGate.ts +11 -2
  114. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  115. package/Utils/Rum/SessionTraceState.ts +212 -0
  116. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  117. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  119. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  120. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  121. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  123. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  125. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  127. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  134. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  135. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  136. package/build/dist/Server/Services/IncidentService.js +164 -12
  137. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  138. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  139. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  140. package/build/dist/Server/Services/Index.js +2 -0
  141. package/build/dist/Server/Services/Index.js.map +1 -1
  142. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  143. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  144. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  145. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  146. package/build/dist/Types/Permission.js +42 -0
  147. package/build/dist/Types/Permission.js.map +1 -1
  148. package/build/dist/Types/Rum/WebVitals.js +25 -0
  149. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  150. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  151. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  152. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  153. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  154. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
  155. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  156. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  157. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  158. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  159. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  160. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  161. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  162. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  163. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  164. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  165. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  166. package/package.json +1 -1
@@ -0,0 +1,242 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ } from "@testing-library/react";
9
+ import React from "react";
10
+ import {
11
+ afterEach,
12
+ beforeEach,
13
+ describe,
14
+ expect,
15
+ jest,
16
+ test,
17
+ } from "@jest/globals";
18
+ import InfoTooltip from "../../../UI/Components/Tooltip/InfoTooltip";
19
+ import getJestMockFunction, { MockFunction } from "../../MockType";
20
+
21
+ /*
22
+ * The (i) that sits beside a metric's title and says what the metric means.
23
+ *
24
+ * Asserting tippy under jsdom (see DisabledButtonTooltip.test.tsx): the
25
+ * popup is portalled to document.body, so it is queried through `screen`;
26
+ * its box never finishes animating, so presence is asserted rather than
27
+ * data-state.
28
+ */
29
+
30
+ async function hover(trigger: HTMLElement): Promise<void> {
31
+ fireEvent.mouseEnter(trigger);
32
+ await act(async () => {
33
+ jest.advanceTimersByTime(200);
34
+ });
35
+ }
36
+
37
+ async function focus(trigger: HTMLElement): Promise<void> {
38
+ act(() => {
39
+ trigger.focus();
40
+ });
41
+ await act(async () => {
42
+ jest.advanceTimersByTime(200);
43
+ });
44
+ }
45
+
46
+ beforeEach(() => {
47
+ jest.useFakeTimers();
48
+ });
49
+
50
+ afterEach(() => {
51
+ cleanup();
52
+ jest.useRealTimers();
53
+ });
54
+
55
+ const P95: string =
56
+ "95% of page loads finished faster than this; the slowest 5% took longer.";
57
+
58
+ describe("InfoTooltip", () => {
59
+ test("renders a button named after what it explains", () => {
60
+ render(<InfoTooltip label="p95 duration" text={P95} />);
61
+
62
+ const button: HTMLElement = screen.getByRole("button", {
63
+ name: "About p95 duration",
64
+ });
65
+
66
+ expect(button).toBeInTheDocument();
67
+ expect(button).toHaveAttribute("type", "button");
68
+ });
69
+
70
+ test("does not create the tooltip until someone reaches for it", () => {
71
+ render(<InfoTooltip label="p95 duration" text={P95} />);
72
+
73
+ expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
74
+ expect(screen.queryByText(P95)).not.toBeInTheDocument();
75
+ });
76
+
77
+ test("shows the explanation on hover", async () => {
78
+ render(<InfoTooltip label="p95 duration" text={P95} />);
79
+
80
+ await hover(screen.getByRole("button", { name: "About p95 duration" }));
81
+
82
+ expect(screen.getByRole("tooltip")).toHaveTextContent(P95);
83
+ });
84
+
85
+ test("shows the explanation on keyboard focus", async () => {
86
+ render(
87
+ <InfoTooltip label="Error rate" text="Share of events that failed." />,
88
+ );
89
+
90
+ await focus(screen.getByRole("button", { name: "About Error rate" }));
91
+
92
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
93
+ "Share of events that failed.",
94
+ );
95
+ });
96
+
97
+ test("links the explanation to the button for assistive technology once shown", async () => {
98
+ render(<InfoTooltip label="p95 duration" text={P95} />);
99
+
100
+ const button: HTMLElement = screen.getByRole("button", {
101
+ name: "About p95 duration",
102
+ });
103
+
104
+ await focus(button);
105
+ await act(async () => {
106
+ jest.advanceTimersByTime(50);
107
+ });
108
+
109
+ const describedBy: string | null = button.getAttribute("aria-describedby");
110
+
111
+ expect(describedBy).toBeTruthy();
112
+ expect(document.getElementById(describedBy as string)).toHaveTextContent(
113
+ P95,
114
+ );
115
+ });
116
+
117
+ test("does not mark the trigger as expandable (it is not a disclosure)", async () => {
118
+ render(<InfoTooltip label="p95 duration" text={P95} />);
119
+
120
+ const button: HTMLElement = screen.getByRole("button", {
121
+ name: "About p95 duration",
122
+ });
123
+
124
+ await hover(button);
125
+
126
+ expect(button).not.toHaveAttribute("aria-expanded");
127
+ });
128
+
129
+ test.each([
130
+ ["undefined", undefined],
131
+ ["empty", ""],
132
+ ["whitespace", " \n "],
133
+ ])(
134
+ "renders nothing when the explanation is %s",
135
+ (_: string, text?: string) => {
136
+ const { container } = render(
137
+ <InfoTooltip label="p95 duration" text={text} />,
138
+ );
139
+
140
+ expect(container).toBeEmptyDOMElement();
141
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
142
+ },
143
+ );
144
+
145
+ test("trims the explanation it shows", async () => {
146
+ render(<InfoTooltip label="Clients" text={" Platforms seen. "} />);
147
+
148
+ await hover(screen.getByRole("button", { name: "About Clients" }));
149
+
150
+ expect(screen.getByRole("tooltip").textContent).toBe("Platforms seen.");
151
+ });
152
+
153
+ test("a click does not bubble, so an (i) inside a link does not navigate", () => {
154
+ const onParentClick: MockFunction = getJestMockFunction();
155
+
156
+ render(
157
+ <div onClick={onParentClick}>
158
+ <InfoTooltip label="Clients" text="Platforms seen." />
159
+ </div>,
160
+ );
161
+
162
+ const notCancelled: boolean = fireEvent.click(
163
+ screen.getByRole("button", { name: "About Clients" }),
164
+ );
165
+
166
+ expect(onParentClick).not.toHaveBeenCalled();
167
+ /* fireEvent returns false when preventDefault() was called. */
168
+ expect(notCancelled).toBe(false);
169
+ });
170
+
171
+ test("a click inside an anchor does not follow the link", () => {
172
+ render(
173
+ <a href="#/somewhere-else" data-testid="card-link">
174
+ <span>Clients</span>
175
+ <InfoTooltip label="Clients" text="Platforms seen." />
176
+ </a>,
177
+ );
178
+
179
+ const notCancelled: boolean = fireEvent.click(
180
+ screen.getByRole("button", { name: "About Clients" }),
181
+ );
182
+
183
+ expect(notCancelled).toBe(false);
184
+ });
185
+
186
+ test("the icon is decorative - the button's name comes from the label alone", () => {
187
+ render(<InfoTooltip label="CPU" text="Processor time in use." />);
188
+
189
+ const button: HTMLElement = screen.getByRole("button", {
190
+ name: "About CPU",
191
+ });
192
+ const svg: SVGElement | null = button.querySelector("svg");
193
+
194
+ expect(svg).not.toBeNull();
195
+ expect(svg).toHaveAttribute("aria-hidden", "true");
196
+ expect(button.querySelector("[role='img']")).toBeNull();
197
+ });
198
+
199
+ test("passes the test id, extra classes and icon size through", () => {
200
+ render(
201
+ <InfoTooltip
202
+ label="CPU"
203
+ text="Processor time in use."
204
+ dataTestId="cpu-help"
205
+ className="ml-1"
206
+ iconClassName="h-4 w-4"
207
+ />,
208
+ );
209
+
210
+ const button: HTMLElement = screen.getByTestId("cpu-help");
211
+
212
+ expect(button).toHaveClass("ml-1");
213
+ expect(button.querySelector("svg")).toHaveClass("h-4", "w-4");
214
+ });
215
+
216
+ test("uses a small icon by default and shows a visible focus ring", () => {
217
+ render(<InfoTooltip label="CPU" text="Processor time in use." />);
218
+
219
+ const button: HTMLElement = screen.getByRole("button", {
220
+ name: "About CPU",
221
+ });
222
+
223
+ expect(button.querySelector("svg")).toHaveClass("h-3.5", "w-3.5");
224
+ expect(button.className).toContain("focus-visible:ring-2");
225
+ });
226
+
227
+ test("two tooltips on one page each show their own text", async () => {
228
+ render(
229
+ <div>
230
+ <InfoTooltip label="p95 duration" text={P95} />
231
+ <InfoTooltip label="Error rate" text="Share of events that failed." />
232
+ </div>,
233
+ );
234
+
235
+ await hover(screen.getByRole("button", { name: "About Error rate" }));
236
+
237
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
238
+ "Share of events that failed.",
239
+ );
240
+ expect(screen.queryByText(P95)).not.toBeInTheDocument();
241
+ });
242
+ });
@@ -101,6 +101,7 @@ jest.mock("../../../../UI/Utils/Translation", () => {
101
101
  import BaseModelTable, {
102
102
  BaseTableCallbacks,
103
103
  ComponentProps as BaseModelTableProps,
104
+ ModalTableBulkDefaultActions,
104
105
  } from "../../../../UI/Components/ModelTable/BaseModelTable";
105
106
  import TableFilterUrlState from "../../../../UI/Utils/TableFilterUrlState";
106
107
  import PermissionGate from "../../../../UI/Utils/PermissionGate";
@@ -108,6 +109,7 @@ import FieldType from "../../../../UI/Components/Types/FieldType";
108
109
  import { ButtonStyleType } from "../../../../UI/Components/Button/Button";
109
110
  import NetworkDevice from "../../../../Models/DatabaseModels/NetworkDevice";
110
111
  import Permission from "../../../../Types/Permission";
112
+ import IconProp from "../../../../Types/Icon/IconProp";
111
113
  import ListResult from "../../../../Types/BaseDatabase/ListResult";
112
114
  import { JSONObject } from "../../../../Types/JSON";
113
115
 
@@ -136,6 +138,8 @@ const DEVICE_DELETE_WARNING: string =
136
138
  type TableOptions = {
137
139
  bulkActionButtons?: Array<unknown> | undefined;
138
140
  deleteConfirmationWarning?: string | undefined;
141
+ deleteVerb?: string | undefined;
142
+ deleteIcon?: IconProp | undefined;
139
143
  deleteItem?: ((item: NetworkDevice) => Promise<void>) | undefined;
140
144
  };
141
145
 
@@ -218,6 +222,8 @@ describe("BaseModelTable bulk Delete", () => {
218
222
  ...(options.deleteConfirmationWarning
219
223
  ? { deleteConfirmationWarning: options.deleteConfirmationWarning }
220
224
  : {}),
225
+ ...(options.deleteVerb ? { deleteVerb: options.deleteVerb } : {}),
226
+ ...(options.deleteIcon ? { deleteIcon: options.deleteIcon } : {}),
221
227
  },
222
228
  } as unknown as BaseModelTableProps<NetworkDevice>;
223
229
  };
@@ -307,10 +313,12 @@ describe("BaseModelTable bulk Delete", () => {
307
313
  });
308
314
  };
309
315
 
310
- type ConfirmFunction = () => Promise<void>;
316
+ type ConfirmFunction = (buttonText?: string | undefined) => Promise<void>;
311
317
 
312
318
  /* Presses the danger button in the confirmation dialog's footer. */
313
- const confirmDelete: ConfirmFunction = async (): Promise<void> => {
319
+ const confirmDelete: ConfirmFunction = async (
320
+ buttonText?: string | undefined,
321
+ ): Promise<void> => {
314
322
  await waitFor(() => {
315
323
  expect(
316
324
  document.querySelector('[data-testid="modal-footer"]'),
@@ -324,7 +332,7 @@ describe("BaseModelTable bulk Delete", () => {
324
332
  const submitButton: HTMLButtonElement | undefined = Array.from(
325
333
  footer.querySelectorAll<HTMLButtonElement>("button"),
326
334
  ).find((button: HTMLButtonElement) => {
327
- return (button.textContent || "").trim() === "Delete";
335
+ return (button.textContent || "").trim() === (buttonText || "Delete");
328
336
  });
329
337
 
330
338
  expect(submitButton).toBeDefined();
@@ -748,4 +756,200 @@ describe("BaseModelTable bulk Delete", () => {
748
756
  expect(deletedIds).toEqual([]);
749
757
  });
750
758
  });
759
+
760
+ /*
761
+ * A table of links (an incident's linked alerts, say) deletes rows that are
762
+ * relationships, not things. "Delete 3 Alerts ... This action cannot be
763
+ * undone" read as if the alerts themselves were going, and contradicted the
764
+ * warning appended after it. Such a table names its own verb; every other
765
+ * table keeps the default wording (pinned above).
766
+ */
767
+ describe("a table that names its own verb", () => {
768
+ const LINK_WARNING: string =
769
+ "Only the links are removed: the devices themselves are not deleted.";
770
+
771
+ test("offers the verb in place of Delete, once", async () => {
772
+ const { container } = renderTable({
773
+ bulkActionButtons: [
774
+ ARCHIVE_ACTION,
775
+ ModalTableBulkDefaultActions.Delete,
776
+ ],
777
+ deleteVerb: "Unlink",
778
+ deleteIcon: IconProp.LinkSlash,
779
+ });
780
+
781
+ await selectRows(container, 1);
782
+ await openBulkMenu();
783
+
784
+ expect(menuItemLabels()).toEqual(
785
+ expect.arrayContaining(["Archive", "Export CSV", "Unlink"]),
786
+ );
787
+ expect(menuItemLabels()).not.toContain("Delete");
788
+ expect(
789
+ menuItemLabels().filter((label: string) => {
790
+ return label === "Unlink";
791
+ }).length,
792
+ ).toBe(1);
793
+ expect(findMenuItem("Unlink")).not.toBeDisabled();
794
+ });
795
+
796
+ /*
797
+ * The verb must still be recognised as the table's delete action when it
798
+ * is added automatically, or a second, default Delete would sit next to
799
+ * it.
800
+ */
801
+ test("uses the verb for the automatically added action too", async () => {
802
+ const { container } = renderTable({
803
+ deleteVerb: "Unlink",
804
+ });
805
+
806
+ await selectRows(container, 1);
807
+ await openBulkMenu();
808
+
809
+ expect(findMenuItem("Unlink")).toBeDefined();
810
+ expect(findMenuItem("Delete")).toBeUndefined();
811
+ });
812
+
813
+ test("does not add the default action beside a page's own button with that verb", async () => {
814
+ const { container } = renderTable({
815
+ bulkActionButtons: [
816
+ ARCHIVE_ACTION,
817
+ {
818
+ title: "Unlink",
819
+ buttonStyleType: ButtonStyleType.DANGER,
820
+ onClick: async (): Promise<void> => {
821
+ return Promise.resolve();
822
+ },
823
+ },
824
+ ],
825
+ deleteVerb: "Unlink",
826
+ });
827
+
828
+ await selectRows(container, 1);
829
+ await openBulkMenu();
830
+
831
+ expect(
832
+ menuItemLabels().filter((label: string) => {
833
+ return label === "Unlink" || label === "Delete";
834
+ }),
835
+ ).toEqual(["Unlink"]);
836
+ });
837
+
838
+ test("asks with the verb, and does not claim it cannot be undone", async () => {
839
+ const { container } = renderTable({
840
+ bulkActionButtons: [
841
+ ARCHIVE_ACTION,
842
+ ModalTableBulkDefaultActions.Delete,
843
+ ],
844
+ deleteVerb: "Unlink",
845
+ deleteConfirmationWarning: LINK_WARNING,
846
+ });
847
+
848
+ await selectRows(container, 2);
849
+ await openBulkMenu();
850
+
851
+ fireEvent.click(findMenuItem("Unlink")!);
852
+
853
+ await waitFor(() => {
854
+ expect(confirmationText()).not.toBe("");
855
+ });
856
+
857
+ expect(confirmationText().trim()).toBe(
858
+ `Are you sure you want to unlink 2 network devices? ${LINK_WARNING}`,
859
+ );
860
+ expect(confirmationText()).not.toContain("cannot be undone");
861
+ expect(screen.getByText("Unlink 2 Network Devices")).toBeInTheDocument();
862
+ expect(screen.queryByText("Delete 2 Network Devices")).toBeNull();
863
+ });
864
+
865
+ test("uses the singular for one row", async () => {
866
+ const { container } = renderTable({
867
+ deleteVerb: "Unlink",
868
+ });
869
+
870
+ await selectRows(container, 1);
871
+ await openBulkMenu();
872
+
873
+ fireEvent.click(findMenuItem("Unlink")!);
874
+
875
+ await waitFor(() => {
876
+ expect(confirmationText()).not.toBe("");
877
+ });
878
+
879
+ expect(confirmationText().trim()).toBe(
880
+ "Are you sure you want to unlink 1 network device?",
881
+ );
882
+ expect(screen.getByText("Unlink 1 Network Device")).toBeInTheDocument();
883
+ });
884
+
885
+ test("still deletes exactly the selected rows once confirmed", async () => {
886
+ const { container } = renderTable({
887
+ deleteVerb: "Unlink",
888
+ });
889
+
890
+ await selectRows(container, 2);
891
+ await openBulkMenu();
892
+
893
+ fireEvent.click(findMenuItem("Unlink")!);
894
+
895
+ expect(deletedIds).toEqual([]);
896
+
897
+ await confirmDelete("Unlink");
898
+
899
+ await waitFor(() => {
900
+ expect(deletedIds.length).toBe(2);
901
+ });
902
+
903
+ expect(deletedIds.sort()).toEqual(["device-1", "device-2"]);
904
+ });
905
+
906
+ test("is still gated on the model's delete permission", async () => {
907
+ permissionsForTest = [Permission.Viewer];
908
+
909
+ const { container } = renderTable({
910
+ deleteVerb: "Unlink",
911
+ });
912
+
913
+ await selectRows(container, 1);
914
+ await openBulkMenu();
915
+
916
+ const unlinkItem: HTMLElement | undefined = findMenuItem("Unlink");
917
+
918
+ expect(unlinkItem).toBeDefined();
919
+ expect(unlinkItem).toBeDisabled();
920
+
921
+ fireEvent.mouseEnter(unlinkItem!.parentElement as HTMLElement);
922
+
923
+ /*
924
+ * The reason follows the verb, but the permission it names is still the
925
+ * delete one the action is gated on.
926
+ */
927
+ const tooltip: HTMLElement = screen.getByRole("tooltip");
928
+
929
+ expect(tooltip).toHaveTextContent(
930
+ "You do not have permission to unlink this Network Device.",
931
+ );
932
+ expect(tooltip).toHaveTextContent("Delete Network Device");
933
+ expect(tooltip).not.toHaveTextContent("delete this Network Device");
934
+ });
935
+
936
+ test("a blank verb keeps the default wording", async () => {
937
+ const { container } = renderTable({
938
+ deleteVerb: " ",
939
+ });
940
+
941
+ await selectRows(container, 2);
942
+ await openBulkMenu();
943
+
944
+ fireEvent.click(findMenuItem("Delete")!);
945
+
946
+ await waitFor(() => {
947
+ expect(confirmationText()).not.toBe("");
948
+ });
949
+
950
+ expect(confirmationText().trim()).toBe(
951
+ "Are you sure you want to delete 2 Network Devices? This action cannot be undone.",
952
+ );
953
+ });
954
+ });
751
955
  });