@oneuptime/common 12.0.31 → 12.0.32

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 (188) hide show
  1. package/Models/DatabaseModels/AlertState.ts +0 -2
  2. package/Models/DatabaseModels/Domain.ts +1 -1
  3. package/Models/DatabaseModels/Incident.ts +1 -1
  4. package/Models/DatabaseModels/IncidentTemplate.ts +1 -1
  5. package/Models/DatabaseModels/Index.ts +2 -0
  6. package/Models/DatabaseModels/MetricType.ts +0 -2
  7. package/Models/DatabaseModels/MonitorStatusTimeline.ts +0 -2
  8. package/Models/DatabaseModels/MonitorTest.ts +0 -2
  9. package/Models/DatabaseModels/NetworkDevice.ts +121 -2
  10. package/Models/DatabaseModels/NetworkDeviceOidTemplate.ts +3 -1
  11. package/Models/DatabaseModels/NetworkDeviceRole.ts +648 -0
  12. package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -1
  13. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -1
  14. package/Models/DatabaseModels/StatusPageAnnouncement.ts +0 -2
  15. package/Models/DatabaseModels/StatusPageAnnouncementTemplate.ts +0 -2
  16. package/Models/DatabaseModels/StatusPagePrivateUser.ts +0 -2
  17. package/Models/DatabaseModels/StatusPageResource.ts +0 -2
  18. package/Models/DatabaseModels/StatusPageSubscriber.ts +0 -2
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.ts +72 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  21. package/Server/Services/DatabaseService.ts +39 -9
  22. package/Server/Services/Index.ts +2 -0
  23. package/Server/Services/NetworkDeviceRoleService.ts +90 -0
  24. package/Server/Services/ProjectService.ts +85 -0
  25. package/Server/Services/UserNotificationSettingService.ts +0 -6
  26. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +31 -1
  27. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +35 -8
  28. package/Tests/App/Dashboard/DashboardCommandPalette.test.tsx +107 -3
  29. package/Tests/App/Dashboard/DashboardWideChartZoom.test.tsx +401 -0
  30. package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +318 -0
  31. package/Tests/App/Dashboard/NetworkDeviceBulkDelete.test.tsx +357 -0
  32. package/Tests/App/Dashboard/NetworkSideMenu.test.tsx +590 -0
  33. package/Tests/App/Dashboard/SideMenuHarness.tsx +11 -3
  34. package/Tests/Models/DatabaseModels/ModelRegistryInvariants.test.ts +115 -41
  35. package/Tests/Server/Infrastructure/Postgres/NetworkDeviceRoleTableMigration.test.ts +476 -0
  36. package/Tests/Server/Services/DatabaseServiceGenerateSlug.test.ts +215 -0
  37. package/Tests/Server/Services/NetworkDeviceRoleModel.test.ts +672 -0
  38. package/Tests/Server/Services/NetworkDeviceRuleEngines.test.ts +1 -1
  39. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +67 -4
  40. package/Tests/Server/Services/ScheduledMaintenanceSubscriberEndTime.test.ts +369 -0
  41. package/Tests/Server/Services/UserNotificationSettingMonitorDefaults.test.ts +248 -0
  42. package/Tests/Server/Services/UserNotificationSettingWorkspaceChannels.test.ts +43 -2
  43. package/Tests/Server/Utils/BasicCron.test.ts +283 -0
  44. package/Tests/Server/Utils/IncomingCallPhoneNumber.test.ts +174 -0
  45. package/Tests/Types/NetworkDevice/DefaultNetworkDeviceRole.test.ts +417 -0
  46. package/Tests/UI/Components/Charts/ChartClickDoubleClickDisambiguation.test.tsx +221 -0
  47. package/Tests/UI/Components/Charts/ChartDoubleClickReset.test.tsx +220 -0
  48. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +751 -0
  49. package/Tests/UI/Components/ModelTable/BaseModelTableColumnCustomization.test.tsx +475 -43
  50. package/Tests/UI/Components/ModelTable/ColumnPreference.test.ts +89 -0
  51. package/Tests/UI/Components/NavBarCommandKGating.test.tsx +103 -8
  52. package/Tests/UI/Components/SideMenuItem.test.tsx +30 -0
  53. package/Tests/UI/Utils/TableColumnsToCsv.test.ts +30 -0
  54. package/Tests/UI/Utils/UseDashboardTimeRangeZoom.test.tsx +187 -0
  55. package/Tests/Utils/Dashboard/DashboardResourceListSharedArgs.test.ts +302 -0
  56. package/Tests/Utils/Dashboard/DashboardTimeRangeZoom.test.ts +242 -0
  57. package/Tests/Utils/Monitor/NetworkDeviceRoleCatalog.test.ts +981 -0
  58. package/Tests/Utils/Monitor/NetworkTopologyRoleCatalog.test.ts +865 -0
  59. package/Tests/Utils/NetworkDevice/DeviceRoleKeyUtil.test.ts +538 -0
  60. package/Tests/Utils/Slo/SloEvaluation.test.ts +171 -0
  61. package/Tests/Utils/Slug.test.ts +61 -1
  62. package/Tests/jest.setup.ts +19 -0
  63. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +61 -0
  64. package/Types/NetworkDevice/DefaultNetworkDeviceRole.ts +145 -0
  65. package/Types/Permission.ts +45 -0
  66. package/UI/Components/Charts/Area/AreaChart.tsx +7 -0
  67. package/UI/Components/Charts/Bar/BarChart.tsx +6 -0
  68. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +9 -1
  69. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +97 -35
  70. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +14 -0
  71. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +97 -35
  72. package/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.ts +13 -0
  73. package/UI/Components/Charts/Line/LineChart.tsx +7 -0
  74. package/UI/Components/ModelTable/BaseModelTable.tsx +50 -15
  75. package/UI/Components/Navbar/NavBar.tsx +62 -13
  76. package/UI/Components/Navbar/NavBarMenuModal.tsx +7 -2
  77. package/UI/Components/SideMenu/CountModelSideMenuItem.tsx +2 -0
  78. package/UI/Components/SideMenu/SideMenu.tsx +12 -2
  79. package/UI/Components/SideMenu/SideMenuItem.tsx +4 -1
  80. package/UI/Components/SideMenu/SideMenuSection.tsx +14 -2
  81. package/UI/Utils/UseDashboardTimeRangeZoom.ts +77 -0
  82. package/Utils/Dashboard/DashboardTimeRangeZoom.ts +118 -0
  83. package/Utils/Monitor/NetworkDeviceRoleCatalog.ts +232 -0
  84. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +2 -1
  85. package/Utils/Monitor/NetworkTopologyUtil.ts +116 -33
  86. package/Utils/NetworkDevice/DeviceRoleKeyUtil.ts +137 -0
  87. package/Utils/Rules/RulePatternMatchUtil.ts +1 -1
  88. package/Utils/Slug.ts +34 -1
  89. package/build/dist/Models/DatabaseModels/AlertState.js +0 -2
  90. package/build/dist/Models/DatabaseModels/AlertState.js.map +1 -1
  91. package/build/dist/Models/DatabaseModels/Domain.js +1 -1
  92. package/build/dist/Models/DatabaseModels/Domain.js.map +1 -1
  93. package/build/dist/Models/DatabaseModels/Incident.js +1 -1
  94. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  95. package/build/dist/Models/DatabaseModels/IncidentTemplate.js +1 -1
  96. package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
  97. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/MetricType.js +0 -2
  100. package/build/dist/Models/DatabaseModels/MetricType.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +0 -2
  102. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  103. package/build/dist/Models/DatabaseModels/MonitorTest.js +0 -2
  104. package/build/dist/Models/DatabaseModels/MonitorTest.js.map +1 -1
  105. package/build/dist/Models/DatabaseModels/NetworkDevice.js +124 -3
  106. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  107. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js +3 -1
  108. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js.map +1 -1
  109. package/build/dist/Models/DatabaseModels/NetworkDeviceRole.js +677 -0
  110. package/build/dist/Models/DatabaseModels/NetworkDeviceRole.js.map +1 -0
  111. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -1
  112. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -1
  114. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +0 -2
  116. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/StatusPageAnnouncementTemplate.js +0 -2
  118. package/build/dist/Models/DatabaseModels/StatusPageAnnouncementTemplate.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +0 -2
  120. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPageResource.js +0 -2
  122. package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
  123. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js +0 -2
  124. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.js +39 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.js.map +1 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  129. package/build/dist/Server/Services/DatabaseService.js +31 -5
  130. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  131. package/build/dist/Server/Services/Index.js +2 -0
  132. package/build/dist/Server/Services/Index.js.map +1 -1
  133. package/build/dist/Server/Services/NetworkDeviceRoleService.js +70 -0
  134. package/build/dist/Server/Services/NetworkDeviceRoleService.js.map +1 -0
  135. package/build/dist/Server/Services/ProjectService.js +65 -0
  136. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  137. package/build/dist/Server/Services/UserNotificationSettingService.js +0 -1
  138. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  139. package/build/dist/Types/NetworkDevice/DefaultNetworkDeviceRole.js +105 -0
  140. package/build/dist/Types/NetworkDevice/DefaultNetworkDeviceRole.js.map +1 -0
  141. package/build/dist/Types/Permission.js +40 -0
  142. package/build/dist/Types/Permission.js.map +1 -1
  143. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  144. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  145. package/build/dist/UI/Components/Charts/Bar/BarChart.js +1 -1
  146. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  147. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +6 -1
  148. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  149. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +79 -31
  150. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  151. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +7 -1
  152. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  153. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +79 -31
  154. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  155. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js +14 -0
  156. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js.map +1 -0
  157. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  158. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  159. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +39 -13
  160. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  161. package/build/dist/UI/Components/Navbar/NavBar.js +44 -9
  162. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  163. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
  164. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  165. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js +1 -1
  166. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js.map +1 -1
  167. package/build/dist/UI/Components/SideMenu/SideMenu.js +9 -6
  168. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  169. package/build/dist/UI/Components/SideMenu/SideMenuItem.js +1 -1
  170. package/build/dist/UI/Components/SideMenu/SideMenuItem.js.map +1 -1
  171. package/build/dist/UI/Components/SideMenu/SideMenuSection.js +7 -2
  172. package/build/dist/UI/Components/SideMenu/SideMenuSection.js.map +1 -1
  173. package/build/dist/UI/Utils/UseDashboardTimeRangeZoom.js +42 -0
  174. package/build/dist/UI/Utils/UseDashboardTimeRangeZoom.js.map +1 -0
  175. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js +74 -0
  176. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js.map +1 -0
  177. package/build/dist/Utils/Monitor/NetworkDeviceRoleCatalog.js +126 -0
  178. package/build/dist/Utils/Monitor/NetworkDeviceRoleCatalog.js.map +1 -0
  179. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +2 -1
  180. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  181. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +67 -32
  182. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  183. package/build/dist/Utils/NetworkDevice/DeviceRoleKeyUtil.js +114 -0
  184. package/build/dist/Utils/NetworkDevice/DeviceRoleKeyUtil.js.map +1 -0
  185. package/build/dist/Utils/Rules/RulePatternMatchUtil.js +1 -1
  186. package/build/dist/Utils/Slug.js +31 -1
  187. package/build/dist/Utils/Slug.js.map +1 -1
  188. package/package.json +1 -1
@@ -80,11 +80,17 @@ jest.mock("../../../UI/Utils/User", () => {
80
80
  type CapturedTableProps = {
81
81
  columns?: Array<CapturedColumn> | undefined;
82
82
  query?: Record<string, unknown> | undefined;
83
+ userPreferencesKey?: string | undefined;
84
+ disableColumnCustomization?: boolean | undefined;
83
85
  };
84
86
 
85
87
  type CapturedColumn = {
86
88
  title: string;
87
89
  field?: Record<string, unknown> | undefined;
90
+ isHiddenByDefault?: boolean | undefined;
91
+ isNotCustomizable?: boolean | undefined;
92
+ hideOnMobile?: boolean | undefined;
93
+ disableCsvExport?: boolean | undefined;
88
94
  getElement?: ((item: Monitor) => ReactElement) | undefined;
89
95
  getExportValue?: ((item: Monitor) => string) | undefined;
90
96
  };
@@ -196,6 +202,16 @@ import {
196
202
  MONITOR_TEMPLATE_FACET_QUERY_FIELD,
197
203
  } from "../../../../App/FeatureSet/Dashboard/src/Components/Monitor/MonitorFacets";
198
204
  import { ResourceFacet } from "../../../../App/FeatureSet/Dashboard/src/Components/ResourceOwners/ResourceFacet";
205
+ import Columns from "../../../UI/Components/ModelTable/Columns";
206
+ import {
207
+ ColumnPreference,
208
+ CustomizableColumn,
209
+ applyColumnPreference,
210
+ getColumnBaseId,
211
+ getColumnIds,
212
+ getCustomizableColumns,
213
+ } from "../../../UI/Components/ModelTable/ColumnPreference";
214
+ import Column from "../../../UI/Components/ModelTable/Column";
199
215
  import Monitor from "../../../Models/DatabaseModels/Monitor";
200
216
  import MonitorTemplate from "../../../Models/DatabaseModels/MonitorTemplate";
201
217
  import Includes from "../../../Types/BaseDatabase/Includes";
@@ -372,6 +388,235 @@ describe("the Template column on the monitor list", () => {
372
388
  });
373
389
  });
374
390
 
391
+ /*
392
+ * Issue #3491 shipped the column switched on, which put a stripe of "—" down
393
+ * the middle of every monitor list: most monitors are not created from a
394
+ * template, and the ones that are still only need the answer occasionally.
395
+ * It is now off by default and turned on from the Customize Columns picker.
396
+ * "Hidden" and "reachable" have to hold together — a column hidden on a table
397
+ * with no picker is a column nobody can get back — so these run the real
398
+ * ColumnPreference rules over the column set the table actually declares,
399
+ * rather than reading the flag on its own.
400
+ */
401
+ describe("the Template column's place in a first-time viewer's table", () => {
402
+ type DeclaredColumnsFunction = () => Columns<Monitor>;
403
+
404
+ const declaredColumns: DeclaredColumnsFunction = (): Columns<Monitor> => {
405
+ return (capturedTableProps?.columns || []) as unknown as Columns<Monitor>;
406
+ };
407
+
408
+ type TemplateColumnIdFunction = () => string;
409
+
410
+ const templateColumnId: TemplateColumnIdFunction = (): string => {
411
+ const columns: Columns<Monitor> = declaredColumns();
412
+
413
+ const index: number = columns.findIndex((column: { title: string }) => {
414
+ return column.title === "Template";
415
+ });
416
+
417
+ return getColumnIds<Monitor>(columns)[index] as string;
418
+ };
419
+
420
+ type VisibleTitlesFunction = (
421
+ preference: ColumnPreference | null,
422
+ ) => Array<string>;
423
+
424
+ const visibleTitles: VisibleTitlesFunction = (
425
+ preference: ColumnPreference | null,
426
+ ): Array<string> => {
427
+ return applyColumnPreference<Monitor>({
428
+ columns: declaredColumns(),
429
+ preference,
430
+ }).map((column: { title: string }) => {
431
+ return column.title;
432
+ });
433
+ };
434
+
435
+ /*
436
+ * The flag only means something if the table persists a layout: without a
437
+ * `userPreferencesKey` the picker is never rendered (see
438
+ * BaseModelTable.isColumnCustomizationEnabled), so a hidden-by-default
439
+ * column would be a column nobody could ever get back.
440
+ */
441
+ test("is switchable back on, because the table has a column picker", async () => {
442
+ const props: CapturedTableProps = await renderTable({
443
+ projectId: PROJECT_ID,
444
+ });
445
+
446
+ expect(props.userPreferencesKey).toBeTruthy();
447
+ expect(props.disableColumnCustomization).toBeFalsy();
448
+ expect(columnTitled("Template")!.isNotCustomizable).toBeFalsy();
449
+ });
450
+
451
+ test("is not rendered for a viewer with no stored layout", async () => {
452
+ await renderTable({ projectId: PROJECT_ID });
453
+
454
+ const titles: Array<string> = visibleTitles(null);
455
+
456
+ expect(titles).not.toContain("Template");
457
+ // Not because the table came back empty.
458
+ expect(titles).toContain("Name");
459
+ expect(titles).toContain("Monitor Status");
460
+ expect(titles).toContain("Labels");
461
+ });
462
+
463
+ test("is offered by the picker, switched off", async () => {
464
+ await renderTable({ projectId: PROJECT_ID });
465
+
466
+ const entry: CustomizableColumn<Monitor> | undefined =
467
+ getCustomizableColumns<Monitor>({
468
+ columns: declaredColumns(),
469
+ preference: null,
470
+ }).find((candidate: CustomizableColumn<Monitor>) => {
471
+ return candidate.column.title === "Template";
472
+ });
473
+
474
+ expect(entry).toBeDefined();
475
+ expect(entry!.isVisible).toBe(false);
476
+ expect(entry!.isPinned).toBe(false);
477
+ /*
478
+ * Off, not gone: the picker must not offer to remove a column the table
479
+ * ships, because nothing would put it back.
480
+ */
481
+ expect(entry!.isRemovable).toBe(false);
482
+ });
483
+
484
+ test("stays on for a viewer who already switched it on", async () => {
485
+ await renderTable({ projectId: PROJECT_ID });
486
+
487
+ const preference: ColumnPreference = {
488
+ order: [templateColumnId()],
489
+ hidden: [],
490
+ };
491
+
492
+ expect(visibleTitles(preference)).toContain("Template");
493
+ });
494
+
495
+ /*
496
+ * The id a viewer's "switch Template on" is stored under is the column's
497
+ * first field key, and getColumnIds breaks ties on that key with a title
498
+ * slug and then a POSITIONAL suffix. So the day someone adds a second column
499
+ * keyed on `monitorTemplate` - a "Template Version" column is entirely
500
+ * plausible - this column's persisted id changes under everyone who had it
501
+ * on, their stored entry stops matching, and the column falls back to its
502
+ * declared default, which is now off. Nobody would report that as a bug;
503
+ * they would just never see the column again.
504
+ */
505
+ test("has an id no other column competes for, so a stored layout keeps meaning it", async () => {
506
+ await renderTable({ projectId: PROJECT_ID });
507
+
508
+ const columns: Columns<Monitor> = declaredColumns();
509
+ const ids: Array<string> = getColumnIds<Monitor>(columns);
510
+
511
+ const baseIds: Array<string> = columns.map((column: Column<Monitor>) => {
512
+ return getColumnBaseId<Monitor>(column);
513
+ });
514
+
515
+ // Nothing had to be disambiguated, so no id depends on declaration order.
516
+ expect(ids).toEqual(baseIds);
517
+
518
+ expect(
519
+ ids.filter((id: string) => {
520
+ return id === templateColumnId();
521
+ }),
522
+ ).toHaveLength(1);
523
+
524
+ /*
525
+ * Titles are the first tie-breaker, so two columns sharing one would push
526
+ * the tie down to position - and the picker would show the viewer two rows
527
+ * they cannot tell apart either.
528
+ */
529
+ const titles: Array<string> = columns.map((column: Column<Monitor>) => {
530
+ return column.title;
531
+ });
532
+
533
+ expect(new Set(titles).size).toBe(titles.length);
534
+ });
535
+
536
+ /*
537
+ * The upgrade path almost every real viewer takes: they arranged this table
538
+ * before the Template column existed, so their stored `order` simply has no
539
+ * entry for it and it has to stay off rather than appear uninvited. The
540
+ * generic rule is pinned in ColumnPreference's own tests; this pins it
541
+ * against the ids MonitorTable actually declares, so that giving the column
542
+ * an explicit `id` - which those generic tests would not notice - cannot
543
+ * quietly change what an existing layout resolves to.
544
+ */
545
+ test("stays off for a viewer whose layout predates the column", async () => {
546
+ await renderTable({ projectId: PROJECT_ID });
547
+
548
+ const templateId: string = templateColumnId();
549
+
550
+ const preference: ColumnPreference = {
551
+ order: getColumnIds<Monitor>(declaredColumns()).filter((id: string) => {
552
+ return id !== templateId;
553
+ }),
554
+ hidden: [],
555
+ };
556
+
557
+ const titles: Array<string> = visibleTitles(preference);
558
+
559
+ expect(titles).not.toContain("Template");
560
+ // The rest of their arrangement survived, so this is not an empty result.
561
+ expect(titles).toContain("Name");
562
+ expect(titles).toContain("Labels");
563
+ });
564
+
565
+ /*
566
+ * `hideOnMobile` is enforced at render (Table/TableHeader and Table/TableRow
567
+ * drop the column outright) while the picker knows nothing about it, so a
568
+ * column carrying both flags is a dead control on a phone: the viewer ticks
569
+ * it, the shown-columns count goes up, and no column arrives. For a column
570
+ * that is hidden by default that is also a one-way door, since the picker is
571
+ * the only route back. Stated over the whole set so the next author cannot
572
+ * reintroduce the pair on a different column.
573
+ */
574
+ test("no column the picker can switch on is one the phone would throw away", async () => {
575
+ await renderTable({ projectId: PROJECT_ID });
576
+
577
+ const columns: Columns<Monitor> = declaredColumns();
578
+
579
+ /*
580
+ * An invariant over an empty set holds for the wrong reason, and this one
581
+ * is read off captured props - so prove the column it exists for is in
582
+ * there before believing the result.
583
+ */
584
+ expect(
585
+ columns.map((column: Column<Monitor>) => {
586
+ return column.title;
587
+ }),
588
+ ).toContain("Template");
589
+
590
+ const unreachable: Array<string> = columns
591
+ .filter((column: Column<Monitor>) => {
592
+ return (
593
+ Boolean(column.isHiddenByDefault) && Boolean(column.hideOnMobile)
594
+ );
595
+ })
596
+ .map((column: Column<Monitor>) => {
597
+ return column.title;
598
+ });
599
+
600
+ expect(unreachable).toEqual([]);
601
+ });
602
+
603
+ /*
604
+ * The default monitors CSV no longer carries a Template column, and it
605
+ * matters *why*: exports follow what is on screen (BaseModelTable attaches
606
+ * export keys only while walking the columns it is about to render), so the
607
+ * column leaves the CSV purely by being switched off, and comes back the
608
+ * moment the viewer switches it on. Someone chasing "the export lost the
609
+ * template" could reach for `disableCsvExport` or teach the exporter about
610
+ * visibility; both would break the column for the viewers who did switch it
611
+ * on. This records which lever is the real one.
612
+ */
613
+ test("does not opt out of the CSV", async () => {
614
+ await renderTable({ projectId: PROJECT_ID });
615
+
616
+ expect(columnTitled("Template")!.disableCsvExport).toBeFalsy();
617
+ });
618
+ });
619
+
375
620
  describe("the Template chip in the monitor list's filter bar", () => {
376
621
  test("is one of the chips the bar is given", async () => {
377
622
  await renderTable({ projectId: PROJECT_ID });
@@ -503,3 +748,76 @@ describe("the same table scoped to one template", () => {
503
748
  expect(facetKeyed("monitorType")).toBeDefined();
504
749
  });
505
750
  });
751
+
752
+ /*
753
+ * The two tables above are the same component with different column sets, and
754
+ * a saved layout records only the columns the picker was showing. Put those
755
+ * two facts together over one shared storage key and the template page becomes
756
+ * a trap: a viewer who tidies the Linked Monitors table writes a layout with
757
+ * no "monitorTemplate" entry in either `order` or `hidden`, and back on the
758
+ * monitors list a column named in neither list falls back to its declared
759
+ * default - which is now hidden. Their earlier "switch Template on" is gone,
760
+ * with no action that looks like it touched the monitors list.
761
+ *
762
+ * ModelTable is stubbed here, so no Save can be driven; what can be pinned is
763
+ * the precondition, and the precondition is what has to stay false.
764
+ */
765
+ describe("the layout key each monitor table saves under", () => {
766
+ type ColumnIdsOfFunction = (props: CapturedTableProps) => Array<string>;
767
+
768
+ const columnIdsOf: ColumnIdsOfFunction = (
769
+ props: CapturedTableProps,
770
+ ): Array<string> => {
771
+ return getColumnIds<Monitor>(
772
+ (props.columns || []) as unknown as Columns<Monitor>,
773
+ );
774
+ };
775
+
776
+ test("cannot let the template page overwrite the monitor list's layout", async () => {
777
+ const unscoped: CapturedTableProps = await renderTable({
778
+ projectId: PROJECT_ID,
779
+ });
780
+
781
+ const unscopedKey: string | undefined = unscoped.userPreferencesKey;
782
+ const unscopedIds: Array<string> = columnIdsOf(unscoped);
783
+
784
+ /*
785
+ * A second render rather than a rerender: the two pages mount the table
786
+ * independently, and a stale capture would make this test agree with
787
+ * itself.
788
+ */
789
+ cleanup();
790
+ capturedTableProps = null;
791
+
792
+ const scoped: CapturedTableProps = await renderTable({
793
+ projectId: PROJECT_ID,
794
+ monitorTemplateId: TEMPLATE_ID,
795
+ });
796
+
797
+ const scopedKey: string | undefined = scoped.userPreferencesKey;
798
+ const scopedIds: Array<string> = columnIdsOf(scoped);
799
+
800
+ /*
801
+ * The invariant, stated so that either fix satisfies it: keep the two
802
+ * tables on separate keys, or make them offer the same columns. Only
803
+ * "shared key AND different columns" loses a viewer's choice.
804
+ */
805
+ const sharesKey: boolean = unscopedKey === scopedKey;
806
+ const offersSameColumns: boolean =
807
+ [...unscopedIds].sort().join(",") === [...scopedIds].sort().join(",");
808
+
809
+ expect(sharesKey && !offersSameColumns).toBe(false);
810
+
811
+ /*
812
+ * And the arrangement that satisfies it today, recorded so a rename of
813
+ * either key is a deliberate act - changing the unscoped key silently
814
+ * resets every existing viewer's monitor list layout.
815
+ */
816
+ expect(unscopedKey).toBe("monitors-table");
817
+ expect(scopedKey).toBe("monitor-template-monitors-table");
818
+
819
+ // The half of the invariant that is genuinely false, so it is the keys doing the work.
820
+ expect(unscopedIds).toContain("monitorTemplate");
821
+ expect(scopedIds).not.toContain("monitorTemplate");
822
+ });
823
+ });
@@ -0,0 +1,357 @@
1
+ import "@testing-library/jest-dom";
2
+ import { cleanup, render, waitFor } from "@testing-library/react";
3
+ import React, { ReactElement } from "react";
4
+ import { MemoryRouter } from "react-router-dom";
5
+ import {
6
+ afterEach,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ jest,
11
+ test,
12
+ } from "@jest/globals";
13
+
14
+ /*
15
+ * Issue #3559, on the two screens it was reported against.
16
+ *
17
+ * Network -> Devices offered Add/Remove Labels, Set/Clear OID Template,
18
+ * Archive and Export CSV over a selection of 916 devices, and no way to
19
+ * delete any of them. Deleting a batch of stale, duplicated or test devices
20
+ * out of a fleet of 7,489 meant deleting them one at a time.
21
+ *
22
+ * The action itself is ModelTable's - it adds a default bulk "Delete" to any
23
+ * table that already offers bulk actions, for anyone the model lets delete
24
+ * (asserted end to end in BaseModelTableBulkDelete.test.tsx). Two things about
25
+ * these pages have to hold for that to reach the user, and neither is visible
26
+ * from the component:
27
+ *
28
+ * 1. the page keeps handing ModelTable a non-empty `bulkActions` - the
29
+ * default Delete is not injected into a table that offers none, because
30
+ * the row checkboxes only exist for tables that do;
31
+ * 2. the page fills in what the generic confirmation cannot know. Deleting
32
+ * a device takes its interfaces, links, endpoints and auto-created
33
+ * monitor with it, and Archive is the reversible alternative. "This
34
+ * action cannot be undone" on its own does not say that, and at 900 rows
35
+ * the difference is not recoverable.
36
+ *
37
+ * ModelTable is replaced by a prop recorder here: the point is what these two
38
+ * pages hand it, not re-testing the table.
39
+ */
40
+
41
+ jest.mock("react-i18next", () => {
42
+ return {
43
+ useTranslation: () => {
44
+ return {
45
+ t: (key: string): string => {
46
+ return key;
47
+ },
48
+ };
49
+ },
50
+ };
51
+ });
52
+
53
+ jest.mock("../../../UI/Utils/Permission", () => {
54
+ return {
55
+ __esModule: true,
56
+ default: {
57
+ getAllPermissions: () => {
58
+ return [];
59
+ },
60
+ getProjectPermissions: () => {
61
+ return [];
62
+ },
63
+ getGlobalPermissions: () => {
64
+ return [];
65
+ },
66
+ },
67
+ };
68
+ });
69
+
70
+ jest.mock("../../../UI/Utils/User", () => {
71
+ return {
72
+ __esModule: true,
73
+ default: {
74
+ isMasterAdmin: () => {
75
+ return false;
76
+ },
77
+ getUserId: () => {
78
+ return null;
79
+ },
80
+ },
81
+ };
82
+ });
83
+
84
+ type CapturedBulkActions = {
85
+ buttons?: Array<{ title?: string | undefined }> | undefined;
86
+ deleteConfirmationWarning?: string | undefined;
87
+ };
88
+
89
+ type CapturedTableProps = {
90
+ bulkActions?: CapturedBulkActions | undefined;
91
+ isDeleteable?: boolean | undefined;
92
+ cardProps?: { description?: string | undefined } | undefined;
93
+ };
94
+
95
+ let capturedTableProps: CapturedTableProps | null = null;
96
+
97
+ jest.mock("../../../UI/Components/ModelTable/ModelTable", () => {
98
+ return {
99
+ __esModule: true,
100
+ default: (props: CapturedTableProps) => {
101
+ capturedTableProps = props;
102
+ return null;
103
+ },
104
+ };
105
+ });
106
+
107
+ /*
108
+ * The facet bar fetches sites, labels and probes on mount and owns the query
109
+ * the table is given. None of that is what these tests are about.
110
+ */
111
+ jest.mock(
112
+ "../../../../App/FeatureSet/Dashboard/src/Components/ResourceOwners/useResourceOwners",
113
+ () => {
114
+ const actual: Record<string, unknown> = jest.requireActual(
115
+ "../../../../App/FeatureSet/Dashboard/src/Components/ResourceOwners/useResourceOwners",
116
+ ) as Record<string, unknown>;
117
+
118
+ return {
119
+ ...actual,
120
+ __esModule: true,
121
+ default: () => {
122
+ return {
123
+ filterBar: null,
124
+ mergeFiltersIntoQuery: (
125
+ base: Record<string, unknown> | undefined,
126
+ ) => {
127
+ return base || {};
128
+ },
129
+ hasActiveFilters: false,
130
+ facetSelections: {},
131
+ facetOperators: {},
132
+ setFacetSelection: () => {
133
+ // no-op
134
+ },
135
+ clearAllFacets: () => {
136
+ // no-op
137
+ },
138
+ facetSaveState: {},
139
+ restoreFacetState: () => {
140
+ // no-op
141
+ },
142
+ getOwnersForResource: () => {
143
+ return [];
144
+ },
145
+ isLoadingOwners: false,
146
+ onResourcesFetched: () => {
147
+ // no-op
148
+ },
149
+ };
150
+ },
151
+ };
152
+ },
153
+ );
154
+
155
+ jest.mock("../../../UI/Components/BulkUpdate/BulkLabelActions", () => {
156
+ return {
157
+ __esModule: true,
158
+ default: () => {
159
+ return {
160
+ bulkActions: [{ title: "Add Labels" }, { title: "Remove Labels" }],
161
+ modals: null,
162
+ };
163
+ },
164
+ };
165
+ });
166
+
167
+ jest.mock(
168
+ "../../../../App/FeatureSet/Dashboard/src/Components/NetworkDevice/useBulkOidTemplateActions",
169
+ () => {
170
+ return {
171
+ __esModule: true,
172
+ default: () => {
173
+ return {
174
+ bulkActions: [
175
+ { title: "Set OID Collection Template" },
176
+ { title: "Clear OID Collection Template" },
177
+ ],
178
+ modals: null,
179
+ };
180
+ },
181
+ };
182
+ },
183
+ );
184
+
185
+ jest.mock(
186
+ "../../../../App/FeatureSet/Dashboard/src/Components/NetworkDevice/DeviceSummaryCards",
187
+ () => {
188
+ return {
189
+ __esModule: true,
190
+ default: () => {
191
+ return null;
192
+ },
193
+ };
194
+ },
195
+ );
196
+
197
+ jest.mock("../../../../App/FeatureSet/Dashboard/src/Utils/Probe", () => {
198
+ return {
199
+ __esModule: true,
200
+ default: {
201
+ getAllProbes: async () => {
202
+ return [];
203
+ },
204
+ },
205
+ };
206
+ });
207
+
208
+ import NetworkDevicesPage from "../../../../App/FeatureSet/Dashboard/src/Pages/NetworkDevice/Devices";
209
+ import NetworkDeviceArchivedPage from "../../../../App/FeatureSet/Dashboard/src/Pages/NetworkDevice/Archived";
210
+ import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
211
+ import Route from "../../../Types/API/Route";
212
+
213
+ const PAGE_PROPS: PageComponentProps = {
214
+ pageRoute: new Route("/dashboard/network-devices"),
215
+ currentProject: null,
216
+ hasPaymentMethod: true,
217
+ };
218
+
219
+ type RenderPageFunction = (
220
+ page: (props: PageComponentProps) => ReactElement,
221
+ ) => Promise<CapturedTableProps>;
222
+
223
+ const renderPage: RenderPageFunction = async (
224
+ page: (props: PageComponentProps) => ReactElement,
225
+ ): Promise<CapturedTableProps> => {
226
+ const Page: (props: PageComponentProps) => ReactElement = page;
227
+
228
+ render(
229
+ <MemoryRouter>
230
+ <Page {...PAGE_PROPS} />
231
+ </MemoryRouter>,
232
+ );
233
+
234
+ await waitFor(() => {
235
+ expect(capturedTableProps).not.toBeNull();
236
+ });
237
+
238
+ return capturedTableProps!;
239
+ };
240
+
241
+ type ButtonTitlesFunction = (props: CapturedTableProps) => Array<string>;
242
+
243
+ const buttonTitles: ButtonTitlesFunction = (
244
+ props: CapturedTableProps,
245
+ ): Array<string> => {
246
+ return (props.bulkActions?.buttons || []).map(
247
+ (button: { title?: string | undefined }): string => {
248
+ return button.title || "";
249
+ },
250
+ );
251
+ };
252
+
253
+ describe("Network Devices bulk delete wiring", () => {
254
+ beforeEach(() => {
255
+ capturedTableProps = null;
256
+ window.localStorage.clear();
257
+ });
258
+
259
+ afterEach(() => {
260
+ cleanup();
261
+ jest.clearAllMocks();
262
+ });
263
+
264
+ describe("the Devices list", () => {
265
+ test("still offers bulk actions, so the default Delete is added to them", async () => {
266
+ const props: CapturedTableProps = await renderPage(
267
+ NetworkDevicesPage as unknown as (
268
+ props: PageComponentProps,
269
+ ) => ReactElement,
270
+ );
271
+
272
+ expect(buttonTitles(props)).toEqual(
273
+ expect.arrayContaining([
274
+ "Add Labels",
275
+ "Remove Labels",
276
+ "Set OID Collection Template",
277
+ "Archive",
278
+ ]),
279
+ );
280
+ expect(props.bulkActions?.buttons?.length).toBeGreaterThan(0);
281
+ });
282
+
283
+ /*
284
+ * The page deliberately has no per-row Delete. Bulk deletion does not hang
285
+ * off that flag, and this is here so that stays a deliberate pairing
286
+ * rather than looking like an oversight in either direction.
287
+ */
288
+ test("has no per-row Delete button", async () => {
289
+ const props: CapturedTableProps = await renderPage(
290
+ NetworkDevicesPage as unknown as (
291
+ props: PageComponentProps,
292
+ ) => ReactElement,
293
+ );
294
+
295
+ expect(props.isDeleteable).toBe(false);
296
+ });
297
+
298
+ test("warns what leaves with the devices, and points at Archive", async () => {
299
+ const props: CapturedTableProps = await renderPage(
300
+ NetworkDevicesPage as unknown as (
301
+ props: PageComponentProps,
302
+ ) => ReactElement,
303
+ );
304
+
305
+ const warning: string =
306
+ props.bulkActions?.deleteConfirmationWarning || "";
307
+
308
+ expect(warning).toContain("interfaces");
309
+ expect(warning).toContain("links");
310
+ expect(warning).toContain("monitor");
311
+ expect(warning.toLowerCase()).toContain("archive");
312
+ });
313
+ });
314
+
315
+ describe("the Archived devices list", () => {
316
+ test("still offers bulk actions, so the default Delete is added to them", async () => {
317
+ const props: CapturedTableProps = await renderPage(
318
+ NetworkDeviceArchivedPage as unknown as (
319
+ props: PageComponentProps,
320
+ ) => ReactElement,
321
+ );
322
+
323
+ expect(buttonTitles(props)).toEqual(
324
+ expect.arrayContaining(["Unarchive"]),
325
+ );
326
+ });
327
+
328
+ /*
329
+ * Archiving then deleting is the clean-up path the issue describes, so
330
+ * the warning here points back at Unarchive rather than at Archive.
331
+ */
332
+ test("warns what leaves with the devices, and points at Unarchive", async () => {
333
+ const props: CapturedTableProps = await renderPage(
334
+ NetworkDeviceArchivedPage as unknown as (
335
+ props: PageComponentProps,
336
+ ) => ReactElement,
337
+ );
338
+
339
+ const warning: string =
340
+ props.bulkActions?.deleteConfirmationWarning || "";
341
+
342
+ expect(warning).toContain("interfaces");
343
+ expect(warning).toContain("monitor");
344
+ expect(warning.toLowerCase()).toContain("unarchive");
345
+ });
346
+
347
+ test("tells the reader the list is where devices get deleted for good", async () => {
348
+ const props: CapturedTableProps = await renderPage(
349
+ NetworkDeviceArchivedPage as unknown as (
350
+ props: PageComponentProps,
351
+ ) => ReactElement,
352
+ );
353
+
354
+ expect(props.cardProps?.description || "").toContain("delete");
355
+ });
356
+ });
357
+ });