@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
@@ -208,6 +208,19 @@ export interface BulkActionProps<T extends BaseModel | AnalyticsBaseModel> {
208
208
  * cannot be undone - it cannot know what else goes with it.
209
209
  */
210
210
  deleteConfirmationWarning?: string | undefined;
211
+ /**
212
+ * The word the default bulk Delete uses instead of "Delete", for a table
213
+ * whose rows are relationships rather than things - "Unlink" on a table of
214
+ * links, say. When set, the menu item, the confirmation's title and its
215
+ * sentence all use it ("Unlink 3 Alerts", "Are you sure you want to unlink
216
+ * 3 alerts?"), and the sentence no longer claims the action cannot be
217
+ * undone: removing a relationship is undone by adding it again. It is still
218
+ * the table's delete action - it deletes the selected rows, is gated on the
219
+ * model's delete permission, and stops a second Delete being added.
220
+ */
221
+ deleteVerb?: string | undefined;
222
+ /* The menu icon to go with deleteVerb. Defaults to the trash can. */
223
+ deleteIcon?: IconProp | undefined;
211
224
  }
212
225
 
213
226
  /**
@@ -562,6 +575,29 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
562
575
 
563
576
  const model: TBaseModel = new props.modelType();
564
577
 
578
+ /*
579
+ * The header's create button, "<verb> <noun>". Translating the two words
580
+ * separately reads the verb out of context - "Link" comes back as the noun
581
+ * in many languages, and word order differs - so the whole phrase is looked
582
+ * up first ("Link Alert") and the word-by-word join is only the fallback
583
+ * for a phrase with no entry of its own. In English both are the same.
584
+ */
585
+ const getCreateButtonTitle: () => string = (): string => {
586
+ const verb: string = props.createVerb || "Create";
587
+ const noun: string = props.singularName || model.singularName || "";
588
+
589
+ if (noun) {
590
+ const phrase: string = `${verb} ${noun}`;
591
+ const translatedPhrase: string = tx(phrase);
592
+
593
+ if (translatedPhrase && translatedPhrase !== phrase) {
594
+ return translatedPhrase;
595
+ }
596
+ }
597
+
598
+ return `${tx(verb)} ${tx(noun)}`;
599
+ };
600
+
565
601
  /*
566
602
  * How a create / update / delete affordance should be rendered for this
567
603
  * viewer. A user without the permission used to simply not see the button,
@@ -586,8 +622,18 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
586
622
  const getActionGate: GetActionGateFunction = (
587
623
  action: ModelAction,
588
624
  ): ActionGate => {
625
+ /*
626
+ * A table that calls its delete "Unlink" must not say the locked button
627
+ * would delete the row's noun ("permission to delete this Alert"). Keyed
628
+ * on deleteVerb alone: deleteButtonText is also set by tables such as
629
+ * "Remove from Project", which keep the default "delete" sentence.
630
+ */
589
631
  const result: PermissionGateResult = PermissionGate.check(model, action, {
590
632
  singularName: props.singularName || model.singularName || undefined,
633
+ verb:
634
+ action === ModelAction.Delete
635
+ ? props.bulkActions?.deleteVerb?.trim() || undefined
636
+ : undefined,
591
637
  });
592
638
 
593
639
  if (result.isAllowed) {
@@ -2446,9 +2492,7 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
2446
2492
  showAs !== ShowAs.OrderedStatesList
2447
2493
  ) {
2448
2494
  headerbuttons.push({
2449
- title: `${tx(props.createVerb || "Create")} ${tx(
2450
- props.singularName || model.singularName || "",
2451
- )}`,
2495
+ title: getCreateButtonTitle(),
2452
2496
  buttonStyle: ButtonStyleType.NORMAL,
2453
2497
  buttonSize: ButtonSize.Normal,
2454
2498
  className: "",
@@ -2908,10 +2952,16 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
2908
2952
 
2909
2953
  const deleteGate: ActionGate = getActionGate(ModelAction.Delete);
2910
2954
 
2955
+ /* The row action wears the same icon as the bulk one it stands for. */
2956
+ const rowDeleteIcon: IconProp =
2957
+ (props.bulkActions?.deleteVerb?.trim() &&
2958
+ props.bulkActions?.deleteIcon) ||
2959
+ IconProp.Trash;
2960
+
2911
2961
  if (props.isDeleteable && deleteGate.show) {
2912
2962
  actionsSchema.push({
2913
2963
  title: tx(props.deleteButtonText || "Delete"),
2914
- icon: IconProp.Trash,
2964
+ icon: rowDeleteIcon,
2915
2965
  buttonStyleType: ButtonStyleType.DANGER_OUTLINE,
2916
2966
  disabled: deleteGate.disabled,
2917
2967
  tooltip: deleteGate.tooltip,
@@ -3008,28 +3058,43 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3008
3058
 
3009
3059
  const getDeleteBulkAction: GetDeleteBulkActionFunction =
3010
3060
  (): BulkActionButtonSchema<TBaseModel> => {
3061
+ /*
3062
+ * A table that names its own verb ("Unlink") removes relationships, not
3063
+ * things, so its sentence drops "This action cannot be undone" - that
3064
+ * would say the opposite of the warning it usually appends.
3065
+ */
3066
+ const deleteVerb: string | undefined =
3067
+ props.bulkActions?.deleteVerb?.trim() || undefined;
3068
+
3069
+ type GetItemLabelFunction = (items: Array<TBaseModel>) => string;
3070
+
3071
+ const getItemLabel: GetItemLabelFunction = (
3072
+ items: Array<TBaseModel>,
3073
+ ): string => {
3074
+ return items.length === 1
3075
+ ? props.singularName || model.singularName || "item"
3076
+ : props.pluralName || model.pluralName || "items";
3077
+ };
3078
+
3011
3079
  return {
3012
- title: "Delete",
3080
+ title: deleteVerb || "Delete",
3013
3081
  buttonStyleType: ButtonStyleType.DANGER,
3014
- icon: IconProp.Trash,
3082
+ icon: (deleteVerb && props.bulkActions?.deleteIcon) || IconProp.Trash,
3015
3083
  confirmMessage: (items: Array<TBaseModel>) => {
3016
- const itemLabel: string =
3017
- items.length === 1
3018
- ? props.singularName || model.singularName || "item"
3019
- : props.pluralName || model.pluralName || "items";
3084
+ const itemLabel: string = getItemLabel(items);
3020
3085
 
3021
3086
  const warning: string = props.bulkActions?.deleteConfirmationWarning
3022
3087
  ? ` ${props.bulkActions.deleteConfirmationWarning}`
3023
3088
  : "";
3024
3089
 
3090
+ if (deleteVerb) {
3091
+ return `Are you sure you want to ${deleteVerb.toLowerCase()} ${items.length} ${itemLabel.toLowerCase()}?${warning}`;
3092
+ }
3093
+
3025
3094
  return `Are you sure you want to delete ${items.length} ${itemLabel}? This action cannot be undone.${warning}`;
3026
3095
  },
3027
3096
  confirmTitle: (items: Array<TBaseModel>) => {
3028
- const itemLabel: string =
3029
- items.length === 1
3030
- ? props.singularName || model.singularName || "item"
3031
- : props.pluralName || model.pluralName || "items";
3032
- return `Delete ${items.length} ${itemLabel}`;
3097
+ return `${deleteVerb || "Delete"} ${items.length} ${getItemLabel(items)}`;
3033
3098
  },
3034
3099
  confirmButtonStyleType: ButtonStyleType.DANGER,
3035
3100
  onClick: async ({
@@ -3207,7 +3272,19 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3207
3272
  * Table derives the row checkboxes from this array being non-empty,
3208
3273
  * so injecting a locked Delete into an otherwise empty array would
3209
3274
  * grow a selection column onto tables that have never had one.
3275
+ *
3276
+ * A page's own button counts as the delete action when it carries
3277
+ * the title the default one would have: "Delete", or the table's
3278
+ * deleteVerb.
3210
3279
  */
3280
+ const deleteActionTitles: Array<string> = ["Delete"];
3281
+ const bulkDeleteVerb: string | undefined =
3282
+ props.bulkActions?.deleteVerb?.trim() || undefined;
3283
+
3284
+ if (bulkDeleteVerb) {
3285
+ deleteActionTitles.push(bulkDeleteVerb);
3286
+ }
3287
+
3211
3288
  const alreadyHasDeleteAction: boolean = sourceButtons.some(
3212
3289
  (
3213
3290
  action:
@@ -3221,8 +3298,9 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3221
3298
  typeof action === "object" &&
3222
3299
  action !== null &&
3223
3300
  "title" in action &&
3224
- (action as BulkActionButtonSchema<TBaseModel>).title ===
3225
- "Delete"
3301
+ deleteActionTitles.includes(
3302
+ (action as BulkActionButtonSchema<TBaseModel>).title,
3303
+ )
3226
3304
  ) {
3227
3305
  return true;
3228
3306
  }
@@ -63,6 +63,11 @@ export default interface Columns<
63
63
  colSpan?: number | undefined;
64
64
  disableSort?: boolean;
65
65
  description?: string | undefined;
66
+ /*
67
+ * What the column's values mean, in an (i) beside the header (see the
68
+ * Table Column of the same name). Spread through to the table as is.
69
+ */
70
+ headerTooltip?: string | undefined;
66
71
  type: FieldType;
67
72
  tooltipText?: ((item: TEntity) => string) | undefined;
68
73
  actionButtons?: Array<ActionButton>;
@@ -10,6 +10,7 @@ import { VoidFunction } from "../../../Types/FunctionTypes";
10
10
  import GenericObject from "../../../Types/GenericObject";
11
11
  import IconProp from "../../../Types/Icon/IconProp";
12
12
  import useTranslateValue from "../../Utils/Translation";
13
+ import InfoTooltip from "../Tooltip/InfoTooltip";
13
14
  import React, { ReactElement, useEffect, useState } from "react";
14
15
 
15
16
  export interface ComponentProps<T extends GenericObject> {
@@ -157,10 +158,21 @@ const TableHeader: TableHeaderFunction = <T extends GenericObject>(
157
158
  </>
158
159
  );
159
160
 
161
+ /*
162
+ * A header's name is computed from its content, and the (i)
163
+ * button's "About <title>" would join it - every cell in the
164
+ * column would be announced as "Last seen About Last seen". The
165
+ * column is named by its title alone; the (i) keeps its own name.
166
+ */
167
+ const headerLabel: string | undefined = column.headerTooltip?.trim()
168
+ ? translateString(column.title) ?? column.title
169
+ : undefined;
170
+
160
171
  return (
161
172
  <th
162
173
  key={i}
163
174
  scope="col"
175
+ aria-label={headerLabel}
164
176
  aria-sort={ariaSort}
165
177
  className="text-left text-sm font-semibold text-gray-900"
166
178
  >
@@ -172,7 +184,27 @@ const TableHeader: TableHeaderFunction = <T extends GenericObject>(
172
184
  * padding moves onto the button so the clickable area is the
173
185
  * whole cell, exactly as before.
174
186
  */}
175
- {canSort ? (
187
+ {/*
188
+ * A header tooltip is its own button, and a button may not
189
+ * sit inside the sort button - so for those columns the sort
190
+ * button shrinks to its content and the (i) follows it.
191
+ */}
192
+ {canSort && column.headerTooltip?.trim() ? (
193
+ <div className="flex w-full items-center">
194
+ <button
195
+ type="button"
196
+ onClick={sortColumn}
197
+ className="flex py-3 pl-6 pr-1 cursor-pointer text-left font-semibold text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
198
+ >
199
+ {headerContent}
200
+ </button>
201
+ <InfoTooltip
202
+ label={translateString(column.title) ?? column.title}
203
+ text={column.headerTooltip}
204
+ className="mr-6 font-normal"
205
+ />
206
+ </div>
207
+ ) : canSort ? (
176
208
  <button
177
209
  type="button"
178
210
  onClick={sortColumn}
@@ -181,7 +213,20 @@ const TableHeader: TableHeaderFunction = <T extends GenericObject>(
181
213
  {headerContent}
182
214
  </button>
183
215
  ) : (
184
- <div className={contentClassName}>{headerContent}</div>
216
+ <div
217
+ className={
218
+ column.headerTooltip?.trim()
219
+ ? `${contentClassName} items-center`
220
+ : contentClassName
221
+ }
222
+ >
223
+ {headerContent}
224
+ <InfoTooltip
225
+ label={translateString(column.title) ?? column.title}
226
+ text={column.headerTooltip}
227
+ className="ml-1 font-normal"
228
+ />
229
+ </div>
185
230
  )}
186
231
  </th>
187
232
  );
@@ -8,6 +8,13 @@ export default interface Column<T extends GenericObject> {
8
8
  // Stable identity carried over from the ModelTable column, if it had one.
9
9
  id?: string | undefined;
10
10
  description?: string | undefined;
11
+ /*
12
+ * What the column's values mean, in an (i) tooltip beside the header - for
13
+ * a metric column whose title is jargon ("p95", "Restarts"). Separate from
14
+ * `description`, which ModelTable fills from the database column for other
15
+ * purposes and which is never shown in the header.
16
+ */
17
+ headerTooltip?: string | undefined;
11
18
  disableSort?: boolean | undefined;
12
19
  tooltipText?: ((item: T) => string) | undefined;
13
20
  type: FieldType;
@@ -0,0 +1,78 @@
1
+ import React, { FunctionComponent, ReactElement } from "react";
2
+ import IconProp from "../../../Types/Icon/IconProp";
3
+ import Icon from "../Icon/Icon";
4
+ import Tooltip from "./Tooltip";
5
+
6
+ /*
7
+ * A small (i) beside a label that explains it on hover or keyboard focus -
8
+ * "what does p95 mean?" answered where the number is, rather than in the
9
+ * docs. Built for metric tiles and chart headers, but nothing here is
10
+ * metric-specific.
11
+ *
12
+ * The trigger is a real <button> so it is reachable with Tab and announced
13
+ * as a control; Tippy shows the text on focus as well as on hover and links
14
+ * it with aria-describedby. It is lazy: an overview page draws dozens of
15
+ * these, and none of them needs a Tippy instance until someone reaches for
16
+ * it.
17
+ *
18
+ * A click is swallowed, and so are Enter and Space: the (i) can sit inside a
19
+ * card that is itself a link or a button, and asking what a number means
20
+ * should not navigate away from it.
21
+ */
22
+
23
+ export interface ComponentProps {
24
+ // The explanation. Nothing renders when it is empty.
25
+ text?: string | undefined;
26
+ /*
27
+ * What is being explained, e.g. the metric's title. Names the button for
28
+ * screen readers ("About p95 duration") and is never shown.
29
+ */
30
+ label: string;
31
+ className?: string | undefined;
32
+ iconClassName?: string | undefined;
33
+ dataTestId?: string | undefined;
34
+ }
35
+
36
+ const InfoTooltip: FunctionComponent<ComponentProps> = (
37
+ props: ComponentProps,
38
+ ): ReactElement => {
39
+ const text: string = (props.text || "").trim();
40
+
41
+ if (!text) {
42
+ return <></>;
43
+ }
44
+
45
+ return (
46
+ <Tooltip text={text} lazy={true}>
47
+ <button
48
+ type="button"
49
+ aria-label={`About ${props.label}`}
50
+ data-testid={props.dataTestId}
51
+ className={`inline-flex flex-shrink-0 items-center justify-center rounded-full text-gray-400 hover:text-gray-600 focus:outline-none focus-visible:text-gray-600 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 ${
52
+ props.className || ""
53
+ }`}
54
+ onClick={(event: React.MouseEvent<HTMLButtonElement>): void => {
55
+ event.preventDefault();
56
+ event.stopPropagation();
57
+ }}
58
+ /*
59
+ * A clickable card that handles Enter / Space itself (InfoCard, a
60
+ * tile acting as a button) would otherwise hear these keys bubble up
61
+ * from the (i) and activate.
62
+ */
63
+ onKeyDown={(event: React.KeyboardEvent<HTMLButtonElement>): void => {
64
+ if (event.key === "Enter" || event.key === " ") {
65
+ event.stopPropagation();
66
+ }
67
+ }}
68
+ >
69
+ <Icon
70
+ icon={IconProp.InformationCircle}
71
+ className={props.iconClassName || "h-3.5 w-3.5"}
72
+ />
73
+ </button>
74
+ </Tooltip>
75
+ );
76
+ };
77
+
78
+ export default InfoTooltip;
@@ -79,6 +79,13 @@ export interface PermissionGateOptions {
79
79
  * what the user sees on screen ("Monitor" vs "Monitor Template").
80
80
  */
81
81
  singularName?: string | undefined;
82
+ /*
83
+ * The verb to use in the message when the operation is presented as
84
+ * something other than its own name - a table of links whose delete action
85
+ * is labelled "Unlink", say. Only the wording changes: the permissions
86
+ * checked and listed are still the ones for `action`.
87
+ */
88
+ verb?: string | undefined;
82
89
  }
83
90
 
84
91
  /*
@@ -176,15 +183,17 @@ export default class PermissionGate {
176
183
  const singularName: string =
177
184
  options?.singularName || model.singularName || "item";
178
185
 
186
+ const verb: string = (options?.verb?.trim() || action).toLowerCase();
187
+
179
188
  const titles: Array<string> = this.getPermissionTitles(
180
189
  this.getModelPermissions(model, action),
181
190
  );
182
191
 
183
192
  if (titles.length === 0) {
184
- return `You do not have permission to ${action} this ${singularName}.`;
193
+ return `You do not have permission to ${verb} this ${singularName}.`;
185
194
  }
186
195
 
187
- return `You do not have permission to ${action} this ${singularName}. You need one of these permissions: ${titles.join(
196
+ return `You do not have permission to ${verb} this ${singularName}. You need one of these permissions: ${titles.join(
188
197
  ", ",
189
198
  )}.`;
190
199
  }