@oneuptime/common 12.0.7 → 12.0.8

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 (186) hide show
  1. package/Models/DatabaseModels/TelemetryEntity.ts +45 -0
  2. package/Models/DatabaseModels/TelemetryEntityRelationship.ts +26 -0
  3. package/Models/DatabaseModels/WorkflowLog.ts +38 -0
  4. package/Server/API/UserAPI.ts +16 -1
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.ts +41 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.ts +17 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  8. package/Server/Middleware/MasterAdminAuthorization.ts +11 -6
  9. package/Server/Services/TelemetryEntityRelationshipService.ts +3 -0
  10. package/Server/Services/TelemetryEntityService.ts +114 -1
  11. package/Server/Types/Workflow/Components/API/Utils.ts +44 -1
  12. package/Server/Types/Workflow/TriggerCode.ts +12 -0
  13. package/Server/Types/Workflow/Workflow.ts +5 -0
  14. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +689 -0
  15. package/Server/Utils/VM/VMAPI.ts +56 -8
  16. package/Tests/App/Dashboard/MonitorCriteriaAttributeFilter.test.tsx +574 -0
  17. package/Tests/Server/API/UserProjectsAPI.test.ts +478 -4
  18. package/Tests/Server/Services/TelemetryEntityManualCreate.test.ts +248 -0
  19. package/Tests/Server/Types/Workflow/Components/ApiComponentHeaders.test.ts +192 -0
  20. package/Tests/Server/Utils/AnalyticsDatabase/QuerySettingsHelper.test.ts +152 -0
  21. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +322 -0
  22. package/Tests/Server/Utils/VM/VMAPISubstitution.test.ts +243 -0
  23. package/Tests/Types/SerializableObjectDictionaryImportCycle.test.ts +197 -0
  24. package/Tests/Types/Telemetry/EntityTypeGroups.test.ts +140 -0
  25. package/Tests/Types/Workflow/StepTrace.test.ts +235 -0
  26. package/Tests/Types/Workflow/TemplateSyntax.test.ts +379 -0
  27. package/Tests/Types/Workflow/Templates.test.ts +403 -0
  28. package/Tests/UI/Components/CustomTimeRangeModal.test.tsx +459 -0
  29. package/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx +477 -0
  30. package/Tests/UI/Components/Forms/AnchoredFieldPopupKeyboard.test.tsx +382 -0
  31. package/Tests/UI/Components/Forms/ColorPickerPicking.test.tsx +569 -0
  32. package/Tests/UI/Components/Forms/ValidationJSON.test.ts +241 -0
  33. package/Tests/UI/Components/LogTimeRangePicker.test.tsx +42 -0
  34. package/Tests/UI/Components/ModalPortalDismissal.test.tsx +90 -0
  35. package/Tests/UI/Components/TelemetryTimeRangePicker.test.tsx +49 -7
  36. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +325 -0
  37. package/Tests/UI/Components/Workflow/GraphLint.test.ts +821 -0
  38. package/Tests/UI/Components/Workflow/ModelColumnEditor.test.ts +287 -0
  39. package/Tests/UI/Components/Workflow/ModelColumnEditorServerContract.test.ts +185 -0
  40. package/Tests/UI/Components/Workflow/ModelSchema.test.ts +174 -0
  41. package/Tests/UI/Components/Workflow/RunStatusWatcher.test.ts +152 -0
  42. package/Tests/UI/Components/Workflow/Utils.test.ts +192 -0
  43. package/Tests/UI/Monitor/MonitorStepCriteriaView.test.tsx +432 -0
  44. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +514 -0
  45. package/Tests/Utils/Monitor/NetworkTopologyUtil.test.ts +240 -0
  46. package/Tests/Utils/Schema/AnalyticsModelSchema.test.ts +469 -0
  47. package/Tests/Utils/Schema/ModelSchema.test.ts +268 -0
  48. package/Tests/Utils/Telemetry/EntityKeyNonTelemetry.test.ts +193 -0
  49. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +31 -0
  50. package/Types/SerializableObjectDictionary.ts +133 -39
  51. package/Types/Telemetry/EntitySource.ts +40 -0
  52. package/Types/Telemetry/EntityType.ts +27 -0
  53. package/Types/Telemetry/EntityTypeGroups.ts +65 -0
  54. package/Types/Workflow/Component.ts +29 -0
  55. package/Types/Workflow/Components/API.ts +35 -0
  56. package/Types/Workflow/Components/BaseModel.ts +7 -3
  57. package/Types/Workflow/StepTrace.ts +181 -0
  58. package/Types/Workflow/TemplateSyntax.ts +499 -0
  59. package/Types/Workflow/Templates.ts +316 -0
  60. package/UI/Components/Date/CustomTimeRangeModal.tsx +278 -0
  61. package/UI/Components/Date/TimeRangePickerDropdown.tsx +250 -0
  62. package/UI/Components/Dictionary/Dictionary.tsx +23 -6
  63. package/UI/Components/Forms/Fields/ColorPicker.tsx +66 -10
  64. package/UI/Components/Forms/Fields/IconPicker.tsx +48 -10
  65. package/UI/Components/Forms/Types/Field.ts +7 -0
  66. package/UI/Components/Forms/Validation.ts +63 -1
  67. package/UI/Components/Input/Input.tsx +31 -3
  68. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +11 -216
  69. package/UI/Components/Modal/Modal.tsx +37 -4
  70. package/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.tsx +11 -210
  71. package/UI/Components/Workflow/ArgumentsForm.tsx +194 -7
  72. package/UI/Components/Workflow/Component.tsx +28 -26
  73. package/UI/Components/Workflow/ComponentSettingsModal.tsx +69 -8
  74. package/UI/Components/Workflow/GraphLint.ts +591 -0
  75. package/UI/Components/Workflow/ModelColumnEditor.tsx +396 -0
  76. package/UI/Components/Workflow/ModelSchema.ts +196 -0
  77. package/UI/Components/Workflow/RunForm.tsx +41 -7
  78. package/UI/Components/Workflow/RunStatusWatcher.ts +122 -0
  79. package/UI/Components/Workflow/StepTraceViewer.tsx +186 -0
  80. package/UI/Components/Workflow/Utils.ts +93 -1
  81. package/UI/Components/Workflow/Workflow.tsx +77 -4
  82. package/UI/Types/LayeredDismissal.ts +31 -0
  83. package/UI/Types/UseAnchoredFieldPopup.ts +99 -0
  84. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +533 -0
  85. package/Utils/Monitor/NetworkTopologyUtil.ts +45 -7
  86. package/Utils/Telemetry/EntityKey.ts +66 -0
  87. package/build/dist/Models/DatabaseModels/TelemetryEntity.js +47 -0
  88. package/build/dist/Models/DatabaseModels/TelemetryEntity.js.map +1 -1
  89. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js +27 -0
  90. package/build/dist/Models/DatabaseModels/TelemetryEntityRelationship.js.map +1 -1
  91. package/build/dist/Models/DatabaseModels/WorkflowLog.js +39 -0
  92. package/build/dist/Models/DatabaseModels/WorkflowLog.js.map +1 -1
  93. package/build/dist/Server/API/UserAPI.js +16 -1
  94. package/build/dist/Server/API/UserAPI.js.map +1 -1
  95. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js +20 -0
  96. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786551733814-MigrationName.js.map +1 -0
  97. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js +12 -0
  98. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786559879134-AddWorkflowLogStepTrace.js.map +1 -0
  99. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  100. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  101. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +11 -6
  102. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  103. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js +3 -0
  104. package/build/dist/Server/Services/TelemetryEntityRelationshipService.js.map +1 -1
  105. package/build/dist/Server/Services/TelemetryEntityService.js +91 -1
  106. package/build/dist/Server/Services/TelemetryEntityService.js.map +1 -1
  107. package/build/dist/Server/Types/Workflow/Components/API/Utils.js +28 -0
  108. package/build/dist/Server/Types/Workflow/Components/API/Utils.js.map +1 -1
  109. package/build/dist/Server/Types/Workflow/TriggerCode.js.map +1 -1
  110. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +507 -0
  111. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -0
  112. package/build/dist/Server/Utils/VM/VMAPI.js +51 -4
  113. package/build/dist/Server/Utils/VM/VMAPI.js.map +1 -1
  114. package/build/dist/Types/SerializableObjectDictionary.js +133 -39
  115. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  116. package/build/dist/Types/Telemetry/EntitySource.js +39 -0
  117. package/build/dist/Types/Telemetry/EntitySource.js.map +1 -0
  118. package/build/dist/Types/Telemetry/EntityType.js +27 -0
  119. package/build/dist/Types/Telemetry/EntityType.js.map +1 -1
  120. package/build/dist/Types/Telemetry/EntityTypeGroups.js +57 -0
  121. package/build/dist/Types/Telemetry/EntityTypeGroups.js.map +1 -0
  122. package/build/dist/Types/Workflow/Component.js +21 -0
  123. package/build/dist/Types/Workflow/Component.js.map +1 -1
  124. package/build/dist/Types/Workflow/Components/API.js +35 -0
  125. package/build/dist/Types/Workflow/Components/API.js.map +1 -1
  126. package/build/dist/Types/Workflow/Components/BaseModel.js +7 -3
  127. package/build/dist/Types/Workflow/Components/BaseModel.js.map +1 -1
  128. package/build/dist/Types/Workflow/StepTrace.js +104 -0
  129. package/build/dist/Types/Workflow/StepTrace.js.map +1 -0
  130. package/build/dist/Types/Workflow/TemplateSyntax.js +326 -0
  131. package/build/dist/Types/Workflow/TemplateSyntax.js.map +1 -0
  132. package/build/dist/Types/Workflow/Templates.js +218 -0
  133. package/build/dist/Types/Workflow/Templates.js.map +1 -0
  134. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js +126 -0
  135. package/build/dist/UI/Components/Date/CustomTimeRangeModal.js.map +1 -0
  136. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +123 -0
  137. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -0
  138. package/build/dist/UI/Components/Dictionary/Dictionary.js +22 -6
  139. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  140. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js +52 -13
  141. package/build/dist/UI/Components/Forms/Fields/ColorPicker.js.map +1 -1
  142. package/build/dist/UI/Components/Forms/Fields/IconPicker.js +28 -12
  143. package/build/dist/UI/Components/Forms/Fields/IconPicker.js.map +1 -1
  144. package/build/dist/UI/Components/Forms/Validation.js +44 -0
  145. package/build/dist/UI/Components/Forms/Validation.js.map +1 -1
  146. package/build/dist/UI/Components/Input/Input.js +12 -4
  147. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  148. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +5 -108
  149. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  150. package/build/dist/UI/Components/Modal/Modal.js +31 -5
  151. package/build/dist/UI/Components/Modal/Modal.js.map +1 -1
  152. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js +5 -105
  153. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryTimeRangePicker.js.map +1 -1
  154. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +140 -11
  155. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  156. package/build/dist/UI/Components/Workflow/Component.js +20 -19
  157. package/build/dist/UI/Components/Workflow/Component.js.map +1 -1
  158. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js +31 -3
  159. package/build/dist/UI/Components/Workflow/ComponentSettingsModal.js.map +1 -1
  160. package/build/dist/UI/Components/Workflow/GraphLint.js +396 -0
  161. package/build/dist/UI/Components/Workflow/GraphLint.js.map +1 -0
  162. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +225 -0
  163. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -0
  164. package/build/dist/UI/Components/Workflow/ModelSchema.js +118 -0
  165. package/build/dist/UI/Components/Workflow/ModelSchema.js.map +1 -0
  166. package/build/dist/UI/Components/Workflow/RunForm.js +22 -6
  167. package/build/dist/UI/Components/Workflow/RunForm.js.map +1 -1
  168. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js +76 -0
  169. package/build/dist/UI/Components/Workflow/RunStatusWatcher.js.map +1 -0
  170. package/build/dist/UI/Components/Workflow/StepTraceViewer.js +76 -0
  171. package/build/dist/UI/Components/Workflow/StepTraceViewer.js.map +1 -0
  172. package/build/dist/UI/Components/Workflow/Utils.js +73 -1
  173. package/build/dist/UI/Components/Workflow/Utils.js.map +1 -1
  174. package/build/dist/UI/Components/Workflow/Workflow.js +51 -6
  175. package/build/dist/UI/Components/Workflow/Workflow.js.map +1 -1
  176. package/build/dist/UI/Types/LayeredDismissal.js +21 -0
  177. package/build/dist/UI/Types/LayeredDismissal.js.map +1 -0
  178. package/build/dist/UI/Types/UseAnchoredFieldPopup.js +74 -1
  179. package/build/dist/UI/Types/UseAnchoredFieldPopup.js.map +1 -1
  180. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +386 -0
  181. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -0
  182. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +35 -6
  183. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  184. package/build/dist/Utils/Telemetry/EntityKey.js +52 -0
  185. package/build/dist/Utils/Telemetry/EntityKey.js.map +1 -1
  186. package/package.json +1 -1
@@ -34,6 +34,20 @@ export default class VMUtil {
34
34
  }
35
35
  }
36
36
 
37
+ /*
38
+ * When we stringified the value ourselves just above, every placeholder in
39
+ * the text we are about to substitute into sits inside a JSON string
40
+ * literal — that is what JSON.stringify did to it. A resolved value
41
+ * carrying a quote or a newline therefore has to be escaped, or the
42
+ * JSON.parse at the bottom of this method fails and the caller silently
43
+ * receives a corrupted string where it asked for an object.
44
+ *
45
+ * This is separate from the caller's isJSON flag, which describes text the
46
+ * caller wrote and where a placeholder may legitimately stand in for a bare
47
+ * JSON value rather than sit inside a string.
48
+ */
49
+ const shouldEscapeForJSON: boolean = Boolean(isJSON) || didStringify;
50
+
37
51
  if (
38
52
  typeof valueToReplaceInPlace === "string" &&
39
53
  valueToReplaceInPlace.toString().includes("{{") &&
@@ -45,7 +59,7 @@ export default class VMUtil {
45
59
  valueToReplaceInPlaceCopy = VMUtil.expandEachLoops(
46
60
  storageMap,
47
61
  valueToReplaceInPlaceCopy,
48
- isJSON,
62
+ shouldEscapeForJSON,
49
63
  );
50
64
 
51
65
  const variablesInArgument: Array<string> = [];
@@ -83,11 +97,21 @@ export default class VMUtil {
83
97
  if (valueToReplaceInPlaceCopy.trim() === "{{" + variable + "}}") {
84
98
  valueToReplaceInPlaceCopy = valueToReplaceInPlace;
85
99
  } else {
100
+ const replacement: string = shouldEscapeForJSON
101
+ ? VMUtil.serializeValueForJSON(valueToReplaceInPlace)
102
+ : `${valueToReplaceInPlace}`;
103
+
104
+ /*
105
+ * Function form, not the string form. String.replace treats $&, $1,
106
+ * $` and $' in the REPLACEMENT as substitution patterns, so a
107
+ * resolved value of "50$" or "a$&b" rewrote itself using the matched
108
+ * text. A function replacement is taken literally.
109
+ */
86
110
  valueToReplaceInPlaceCopy = valueToReplaceInPlaceCopy.replace(
87
111
  "{{" + variable + "}}",
88
- isJSON
89
- ? VMUtil.serializeValueForJSON(valueToReplaceInPlace)
90
- : `${valueToReplaceInPlace}`,
112
+ () => {
113
+ return replacement;
114
+ },
91
115
  );
92
116
  }
93
117
  }
@@ -294,10 +318,14 @@ export default class VMUtil {
294
318
  replacement = `${foundValue}`;
295
319
  }
296
320
 
297
- body = body.replace(
298
- "{{" + variable + "}}",
299
- isJSON ? VMUtil.serializeValueForJSON(replacement) : replacement,
300
- );
321
+ const substitution: string = isJSON
322
+ ? VMUtil.serializeValueForJSON(replacement)
323
+ : replacement;
324
+
325
+ // Function form — see the note on the same call in replaceValueInPlace.
326
+ body = body.replace("{{" + variable + "}}", () => {
327
+ return substitution;
328
+ });
301
329
  }
302
330
 
303
331
  return body;
@@ -313,6 +341,15 @@ export default class VMUtil {
313
341
  value = JSON.stringify(value);
314
342
  } else {
315
343
  value = value
344
+ /*
345
+ * Backslash first, and only first. It was missing entirely, so a value
346
+ * like a Windows path or a regex ("C:\Users", "\d+") produced an
347
+ * invalid escape sequence in the surrounding document and the
348
+ * JSON.parse that followed threw. Escaping it after the others would
349
+ * instead double-escape the backslashes they just introduced.
350
+ */
351
+ .split("\\")
352
+ .join("\\\\")
316
353
  .split("\t")
317
354
  .join("\\t")
318
355
  .split("\n")
@@ -355,6 +392,17 @@ export default class VMUtil {
355
392
  if (indexString !== "last") {
356
393
  index = parseInt(indexString);
357
394
  } else {
395
+ /*
396
+ * `current[arrayKey].length` was read unguarded, so `items[last]`
397
+ * against a key that is missing (or is not an array) threw a
398
+ * TypeError out of deepFind and killed the entire run — the one
399
+ * resolution failure in here that was not silent. Fall through to the
400
+ * undefined return that every other miss produces.
401
+ */
402
+ if (!Array.isArray(current[arrayKey])) {
403
+ return undefined;
404
+ }
405
+
358
406
  index = current[arrayKey].length - 1;
359
407
  }
360
408
 
@@ -0,0 +1,574 @@
1
+ /*
2
+ * The main entry, not "/extend-expect": the latter no longer ships type
3
+ * declarations, so every jest-dom matcher in this file fails to typecheck and
4
+ * the whole suite is skipped before a single assertion runs.
5
+ */
6
+ import "@testing-library/jest-dom";
7
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
8
+ import React, { ReactElement } from "react";
9
+ import { afterEach, describe, expect, jest, test } from "@jest/globals";
10
+
11
+ /*
12
+ * Both previews render a live telemetry viewer that lists logs/spans over the
13
+ * API. Nothing in this file is about the preview, and mounting the real one
14
+ * would only add network to a test that is about clicks.
15
+ */
16
+ jest.mock(
17
+ "../../../../App/FeatureSet/Dashboard/src/Components/Monitor/LogMonitor/LogMonitorPreview",
18
+ () => {
19
+ return {
20
+ __esModule: true,
21
+ default: (): ReactElement => {
22
+ return <div data-testid="logs-preview" />;
23
+ },
24
+ };
25
+ },
26
+ );
27
+
28
+ jest.mock(
29
+ "../../../../App/FeatureSet/Dashboard/src/Components/Monitor/TraceMonitor/TraceMonitorPreview",
30
+ () => {
31
+ return {
32
+ __esModule: true,
33
+ default: (): ReactElement => {
34
+ return <div data-testid="spans-preview" />;
35
+ },
36
+ };
37
+ },
38
+ );
39
+
40
+ import LogMonitorStepForm from "../../../../App/FeatureSet/Dashboard/src/Components/Form/Monitor/LogMonitor/LogMonitorStepFrom";
41
+ import TraceMonitorStepForm from "../../../../App/FeatureSet/Dashboard/src/Components/Form/Monitor/TraceMonitor/TraceMonitorStepForm";
42
+ import MonitorStepLogMonitor, {
43
+ MonitorStepLogMonitorUtil,
44
+ } from "../../../Types/Monitor/MonitorStepLogMonitor";
45
+ import MonitorStepTraceMonitor, {
46
+ MonitorStepTraceMonitorUtil,
47
+ } from "../../../Types/Monitor/MonitorStepTraceMonitor";
48
+ import Modal from "../../../UI/Components/Modal/Modal";
49
+ import BasicForm from "../../../UI/Components/Forms/BasicForm";
50
+ import FormFieldSchemaType from "../../../UI/Components/Forms/Types/FormFieldSchemaType";
51
+
52
+ /*
53
+ * Reported as: "when I try to filter the criteria by attributes it just closes
54
+ * the window". Monitor → Criteria → Edit Monitoring Criteria → Show Advanced
55
+ * Options → Add Filter by Attributes → click the Key box → pick a key off the
56
+ * suggestion list, and the whole Edit Monitor dialog disappears, taking the
57
+ * criteria the user was editing with it.
58
+ *
59
+ * Two separate defects reached that screen, and both are pinned here:
60
+ *
61
+ * 1. The suggestion list is portalled to document.body so the modal's scroll
62
+ * container cannot clip it, and React dispatches synthetic events through
63
+ * the React tree rather than the DOM tree. Picking an option therefore
64
+ * bubbled into Modal's backdrop handler, which decided "outside" by asking
65
+ * whether the panel DOM-contains the target — a portalled option never is.
66
+ * Fixed in Modal (#3133); the mechanism is covered in
67
+ * Common/Tests/UI/Components/ModalPortalDismissal.test.tsx. What was
68
+ * missing was coverage of the surface the user actually reported, which is
69
+ * this one: the criteria editor, where the autocomplete sits at the bottom
70
+ * of a nested form inside a scrolled modal body.
71
+ *
72
+ * 2. Clearing a Number-typed attribute value deleted the array member rather
73
+ * than emptying it, which dropped the row and then crashed the form on the
74
+ * next render. Fixed in Dictionary; the row-level tests are in
75
+ * Common/Tests/UI/Components/DictionaryAttributeFilterRow.test.tsx.
76
+ *
77
+ * The harness runs the real chain from the modal down: Modal → the outer
78
+ * BasicForm → the step form → its own nested BasicForm → FormField → Dictionary
79
+ * → AutocompleteTextInput. Faking any layer of that would lose the thing that
80
+ * made this a bug — the portal boundary between the option and the panel.
81
+ *
82
+ * Every pick fires mousedown *and* click. Modal only dismisses on a press that
83
+ * both starts and ends outside the panel, so a click-only test passes with the
84
+ * bug fully present.
85
+ */
86
+
87
+ const ATTRIBUTE_KEYS: Array<string> = [
88
+ "{OriginalFormat}",
89
+ "Action",
90
+ "ActionId",
91
+ "ActionName",
92
+ "ActionType",
93
+ "AlreadyCancelled",
94
+ ];
95
+
96
+ interface Recorder {
97
+ closeCount: number;
98
+ latestLogMonitor: MonitorStepLogMonitor | null;
99
+ latestTraceMonitor: MonitorStepTraceMonitor | null;
100
+ }
101
+
102
+ type NewRecorderFunction = () => Recorder;
103
+
104
+ const newRecorder: NewRecorderFunction = (): Recorder => {
105
+ return { closeCount: 0, latestLogMonitor: null, latestTraceMonitor: null };
106
+ };
107
+
108
+ /*
109
+ * Mirrors the criteria editor: CardModelDetail puts the step form inside a
110
+ * ModelFormModal, so the step form's own BasicForm is nested inside the modal
111
+ * form. That nesting is load-bearing here — it is the shape the user was in.
112
+ */
113
+ const LogHarness: React.FunctionComponent<{ recorder: Recorder }> = (props: {
114
+ recorder: Recorder;
115
+ }): ReactElement => {
116
+ const [logMonitor, setLogMonitor] = React.useState<MonitorStepLogMonitor>(
117
+ MonitorStepLogMonitorUtil.getDefault(),
118
+ );
119
+
120
+ return (
121
+ <Modal
122
+ title="Edit Monitor"
123
+ onClose={() => {
124
+ props.recorder.closeCount++;
125
+ }}
126
+ onSubmit={() => {}}
127
+ >
128
+ <BasicForm
129
+ id="monitor-form"
130
+ hideSubmitButton={true}
131
+ initialValues={{}}
132
+ onSubmit={() => {}}
133
+ fields={[
134
+ {
135
+ field: { monitorSteps: true },
136
+ title: "Monitor Details",
137
+ fieldType: FormFieldSchemaType.CustomComponent,
138
+ getCustomElement: () => {
139
+ return (
140
+ <LogMonitorStepForm
141
+ monitorStepLogMonitor={logMonitor}
142
+ onMonitorStepLogMonitorChanged={(
143
+ value: MonitorStepLogMonitor,
144
+ ) => {
145
+ props.recorder.latestLogMonitor = value;
146
+ setLogMonitor(value);
147
+ }}
148
+ attributeKeys={ATTRIBUTE_KEYS}
149
+ telemetryServices={[]}
150
+ telemetryEntities={[]}
151
+ />
152
+ );
153
+ },
154
+ },
155
+ ]}
156
+ />
157
+ </Modal>
158
+ );
159
+ };
160
+
161
+ const TraceHarness: React.FunctionComponent<{ recorder: Recorder }> = (props: {
162
+ recorder: Recorder;
163
+ }): ReactElement => {
164
+ const [traceMonitor, setTraceMonitor] =
165
+ React.useState<MonitorStepTraceMonitor>(
166
+ MonitorStepTraceMonitorUtil.getDefault(),
167
+ );
168
+
169
+ return (
170
+ <Modal
171
+ title="Edit Monitor"
172
+ onClose={() => {
173
+ props.recorder.closeCount++;
174
+ }}
175
+ onSubmit={() => {}}
176
+ >
177
+ <BasicForm
178
+ id="monitor-form"
179
+ hideSubmitButton={true}
180
+ initialValues={{}}
181
+ onSubmit={() => {}}
182
+ fields={[
183
+ {
184
+ field: { monitorSteps: true },
185
+ title: "Monitor Details",
186
+ fieldType: FormFieldSchemaType.CustomComponent,
187
+ getCustomElement: () => {
188
+ return (
189
+ <TraceMonitorStepForm
190
+ monitorStepTraceMonitor={traceMonitor}
191
+ onMonitorStepTraceMonitorChanged={(
192
+ value: MonitorStepTraceMonitor,
193
+ ) => {
194
+ props.recorder.latestTraceMonitor = value;
195
+ setTraceMonitor(value);
196
+ }}
197
+ attributeKeys={ATTRIBUTE_KEYS}
198
+ telemetryServices={[]}
199
+ telemetryEntities={[]}
200
+ />
201
+ );
202
+ },
203
+ },
204
+ ]}
205
+ />
206
+ </Modal>
207
+ );
208
+ };
209
+
210
+ /*
211
+ * AutocompleteTextInput and react-select both report role="combobox". The
212
+ * listbox each autocomplete owns is the only stable way to tell them apart.
213
+ */
214
+ type AutocompleteInputsFunction = () => Array<HTMLInputElement>;
215
+
216
+ const autocompleteInputs: AutocompleteInputsFunction =
217
+ (): Array<HTMLInputElement> => {
218
+ return Array.from(
219
+ document.querySelectorAll<HTMLInputElement>(
220
+ 'input[aria-controls^="autocomplete-suggestions-"]',
221
+ ),
222
+ );
223
+ };
224
+
225
+ type KeyInputFunction = () => HTMLInputElement;
226
+
227
+ const keyInput: KeyInputFunction = (): HTMLInputElement => {
228
+ return autocompleteInputs().filter((input: HTMLInputElement) => {
229
+ return input.getAttribute("placeholder") !== "Value";
230
+ })[0]!;
231
+ };
232
+
233
+ type ValueInputFunction = () => HTMLInputElement;
234
+
235
+ const valueInput: ValueInputFunction = (): HTMLInputElement => {
236
+ return autocompleteInputs().filter((input: HTMLInputElement) => {
237
+ return input.getAttribute("placeholder") === "Value";
238
+ })[0]!;
239
+ };
240
+
241
+ // A real pointer press is mousedown-then-click; Modal watches both ends.
242
+ type PressFunction = (element: HTMLElement) => void;
243
+
244
+ const press: PressFunction = (element: HTMLElement): void => {
245
+ fireEvent.mouseDown(element);
246
+ fireEvent.click(element);
247
+ };
248
+
249
+ /*
250
+ * The layer a user actually presses to dismiss is the one the panel is centred
251
+ * in; the tinted sheet behind it is aria-hidden and inert.
252
+ */
253
+ type BackdropLayerFunction = () => HTMLElement;
254
+
255
+ const backdropLayer: BackdropLayerFunction = (): HTMLElement => {
256
+ return screen.getByTestId("modal").parentElement!;
257
+ };
258
+
259
+ // Walks the video: open the advanced section, then add one attribute filter.
260
+ type OpenAttributeFilterFunction = () => void;
261
+
262
+ const openAttributeFilter: OpenAttributeFilterFunction = (): void => {
263
+ const showAdvanced: HTMLElement | null = screen.queryByText(
264
+ "Show Advanced Options",
265
+ );
266
+
267
+ if (showAdvanced) {
268
+ fireEvent.click(showAdvanced);
269
+ }
270
+
271
+ fireEvent.click(screen.getByText("Add Filter by Attributes"));
272
+ };
273
+
274
+ type RenderLogHarnessFunction = () => Recorder;
275
+
276
+ const renderLogHarness: RenderLogHarnessFunction = (): Recorder => {
277
+ const recorder: Recorder = newRecorder();
278
+ render(<LogHarness recorder={recorder} />);
279
+ openAttributeFilter();
280
+ return recorder;
281
+ };
282
+
283
+ describe("Monitoring criteria — Filter by Attributes", () => {
284
+ afterEach(() => {
285
+ cleanup();
286
+ });
287
+
288
+ describe("the reported bug: picking a key must not close the modal", () => {
289
+ test("the suggestion list is portalled outside the modal panel", () => {
290
+ renderLogHarness();
291
+
292
+ fireEvent.focus(keyInput());
293
+
294
+ /*
295
+ * The precondition that made this a bug at all. If the menu ever stops
296
+ * being portalled, the Modal contract below is no longer what protects
297
+ * this screen — and this test says so before the others start passing
298
+ * for the wrong reason.
299
+ */
300
+ expect(
301
+ screen.getByTestId("modal").contains(screen.getByText("ActionName")),
302
+ ).toBe(false);
303
+ });
304
+
305
+ test("picking a suggested key leaves the modal open", () => {
306
+ const recorder: Recorder = renderLogHarness();
307
+
308
+ fireEvent.focus(keyInput());
309
+ press(screen.getByText("ActionName"));
310
+
311
+ expect(recorder.closeCount).toBe(0);
312
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
313
+ });
314
+
315
+ test("picking a suggested key fills the row in", () => {
316
+ renderLogHarness();
317
+
318
+ fireEvent.focus(keyInput());
319
+ press(screen.getByText("ActionName"));
320
+
321
+ expect(keyInput().value).toBe("ActionName");
322
+ });
323
+
324
+ test("the whole form the user filled in survives the pick", () => {
325
+ const recorder: Recorder = renderLogHarness();
326
+
327
+ fireEvent.focus(keyInput());
328
+ press(screen.getByText("ActionName"));
329
+
330
+ // Everything the modal was showing is still on screen.
331
+ expect(screen.getByText("Filter by Attributes")).toBeInTheDocument();
332
+ expect(screen.getByText("Log Severity")).toBeInTheDocument();
333
+ expect(recorder.closeCount).toBe(0);
334
+ });
335
+
336
+ test("the picked attribute reaches the monitor step", () => {
337
+ const recorder: Recorder = renderLogHarness();
338
+
339
+ fireEvent.focus(keyInput());
340
+ press(screen.getByText("ActionName"));
341
+ fireEvent.change(valueInput(), { target: { value: "checkout" } });
342
+
343
+ expect(recorder.latestLogMonitor?.attributes).toEqual({
344
+ ActionName: "checkout",
345
+ });
346
+ expect(recorder.closeCount).toBe(0);
347
+ });
348
+
349
+ test("picking a second key on a second row also leaves the modal open", () => {
350
+ const recorder: Recorder = renderLogHarness();
351
+
352
+ fireEvent.focus(keyInput());
353
+ press(screen.getByText("ActionName"));
354
+
355
+ fireEvent.click(screen.getByText("Add Filter by Attributes"));
356
+
357
+ const secondKeyInput: HTMLInputElement = autocompleteInputs().filter(
358
+ (input: HTMLInputElement) => {
359
+ return (
360
+ input.getAttribute("placeholder") !== "Value" && input.value === ""
361
+ );
362
+ },
363
+ )[0]!;
364
+
365
+ fireEvent.focus(secondKeyInput);
366
+ press(screen.getByText("ActionType"));
367
+
368
+ expect(recorder.closeCount).toBe(0);
369
+ expect(recorder.latestLogMonitor?.attributes).toEqual({
370
+ ActionName: "",
371
+ ActionType: "",
372
+ });
373
+ });
374
+
375
+ test("the same pick on the Traces criteria form leaves the modal open", () => {
376
+ const recorder: Recorder = newRecorder();
377
+ render(<TraceHarness recorder={recorder} />);
378
+ openAttributeFilter();
379
+
380
+ fireEvent.focus(keyInput());
381
+ press(screen.getByText("ActionName"));
382
+
383
+ expect(recorder.closeCount).toBe(0);
384
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
385
+ expect(keyInput().value).toBe("ActionName");
386
+ });
387
+ });
388
+
389
+ describe("the rest of the row, from inside the modal", () => {
390
+ test("choosing a key with the keyboard leaves the modal open", () => {
391
+ const recorder: Recorder = renderLogHarness();
392
+
393
+ fireEvent.focus(keyInput());
394
+ fireEvent.keyDown(keyInput(), { key: "ArrowDown" });
395
+ fireEvent.keyDown(keyInput(), { key: "Enter" });
396
+
397
+ expect(recorder.closeCount).toBe(0);
398
+ expect(keyInput().value).toBe("{OriginalFormat}");
399
+ });
400
+
401
+ test("Enter with nothing highlighted does not close the modal", () => {
402
+ const recorder: Recorder = renderLogHarness();
403
+
404
+ fireEvent.focus(keyInput());
405
+ fireEvent.keyDown(keyInput(), { key: "Enter" });
406
+
407
+ expect(recorder.closeCount).toBe(0);
408
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
409
+ });
410
+
411
+ test("Escape closes the suggestion list, not the modal", () => {
412
+ const recorder: Recorder = renderLogHarness();
413
+
414
+ fireEvent.focus(keyInput());
415
+ expect(document.querySelectorAll('[role="listbox"]')).toHaveLength(1);
416
+
417
+ fireEvent.keyDown(keyInput(), { key: "Escape" });
418
+
419
+ expect(document.querySelectorAll('[role="listbox"]')).toHaveLength(0);
420
+ expect(recorder.closeCount).toBe(0);
421
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
422
+ });
423
+
424
+ test("a second Escape, with the list closed, does close the modal", () => {
425
+ const recorder: Recorder = renderLogHarness();
426
+
427
+ fireEvent.focus(keyInput());
428
+ fireEvent.keyDown(keyInput(), { key: "Escape" });
429
+ fireEvent.keyDown(document, { key: "Escape" });
430
+
431
+ expect(recorder.closeCount).toBe(1);
432
+ });
433
+
434
+ test("typing a value keeps the modal open", () => {
435
+ const recorder: Recorder = renderLogHarness();
436
+
437
+ fireEvent.focus(keyInput());
438
+ press(screen.getByText("ActionName"));
439
+ fireEvent.change(valueInput(), { target: { value: "checkout" } });
440
+
441
+ expect(recorder.closeCount).toBe(0);
442
+ expect(valueInput().value).toBe("checkout");
443
+ });
444
+
445
+ test("deleting the row keeps the modal open", () => {
446
+ const recorder: Recorder = renderLogHarness();
447
+
448
+ fireEvent.focus(keyInput());
449
+ press(screen.getByText("ActionName"));
450
+
451
+ press(screen.getByTestId("delete-ActionName"));
452
+
453
+ expect(recorder.closeCount).toBe(0);
454
+ expect(autocompleteInputs()).toHaveLength(0);
455
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
456
+ });
457
+ });
458
+
459
+ /*
460
+ * The second defect, seen from the screen it broke. A Number-typed row that
461
+ * had its value cleared used to vanish, and the next "Add Filter by
462
+ * Attributes" click rendered an `undefined` row and threw — which, inside a
463
+ * modal, is another way for the window to just go away.
464
+ */
465
+ describe("a Number-typed attribute value", () => {
466
+ type SwitchToNumberFunction = () => void;
467
+
468
+ const switchToNumber: SwitchToNumberFunction = (): void => {
469
+ /*
470
+ * The step form has dropdowns of its own above the filter list, so the
471
+ * type select has to be found relative to the row rather than by a
472
+ * position in the whole modal. Within a row the comboboxes run: key
473
+ * autocomplete, operator select, type select, value autocomplete.
474
+ */
475
+ const row: HTMLElement = keyInput().closest(
476
+ "div.flex.items-start",
477
+ ) as HTMLElement;
478
+ const typeSelect: HTMLElement =
479
+ row.querySelectorAll<HTMLElement>('[role="combobox"]')[2]!;
480
+
481
+ fireEvent.keyDown(typeSelect, { key: "ArrowDown" });
482
+ press(screen.getByText("Number"));
483
+ };
484
+
485
+ test("clearing it keeps the row and the modal", () => {
486
+ const recorder: Recorder = renderLogHarness();
487
+
488
+ fireEvent.focus(keyInput());
489
+ press(screen.getByText("ActionId"));
490
+ switchToNumber();
491
+
492
+ const numberInput: HTMLInputElement = document.querySelector(
493
+ 'input[type="number"]',
494
+ ) as HTMLInputElement;
495
+ fireEvent.change(numberInput, { target: { value: "5" } });
496
+ expect(recorder.latestLogMonitor?.attributes).toEqual({ ActionId: 5 });
497
+
498
+ fireEvent.change(numberInput, { target: { value: "" } });
499
+
500
+ expect(keyInput().value).toBe("ActionId");
501
+ expect(recorder.latestLogMonitor?.attributes).toEqual({});
502
+ expect(recorder.closeCount).toBe(0);
503
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
504
+ });
505
+
506
+ test("adding another row after clearing it does not blow the modal up", () => {
507
+ const recorder: Recorder = renderLogHarness();
508
+
509
+ fireEvent.focus(keyInput());
510
+ press(screen.getByText("ActionId"));
511
+ switchToNumber();
512
+
513
+ const numberInput: HTMLInputElement = document.querySelector(
514
+ 'input[type="number"]',
515
+ ) as HTMLInputElement;
516
+ fireEvent.change(numberInput, { target: { value: "5" } });
517
+ fireEvent.change(numberInput, { target: { value: "" } });
518
+
519
+ // The click that used to throw on the very next render.
520
+ fireEvent.click(screen.getByText("Add Filter by Attributes"));
521
+
522
+ expect(recorder.closeCount).toBe(0);
523
+ expect(screen.getByTestId("modal")).toBeInTheDocument();
524
+ expect(screen.getByText("Filter by Attributes")).toBeInTheDocument();
525
+ });
526
+ });
527
+
528
+ /*
529
+ * The Modal fix narrows what counts as a backdrop press, so the far more
530
+ * common behaviour next to it has to be pinned just as hard on this screen —
531
+ * a criteria modal nobody can dismiss would be a worse bug than the one it
532
+ * replaced.
533
+ */
534
+ describe("the modal is still dismissable the ordinary way", () => {
535
+ test("a press on the backdrop closes it", () => {
536
+ const recorder: Recorder = renderLogHarness();
537
+
538
+ press(backdropLayer());
539
+
540
+ expect(recorder.closeCount).toBe(1);
541
+ });
542
+
543
+ test("a press on the backdrop still closes it after a key was picked", () => {
544
+ const recorder: Recorder = renderLogHarness();
545
+
546
+ fireEvent.focus(keyInput());
547
+ press(screen.getByText("ActionName"));
548
+ expect(recorder.closeCount).toBe(0);
549
+
550
+ press(backdropLayer());
551
+
552
+ expect(recorder.closeCount).toBe(1);
553
+ });
554
+
555
+ test("the close button still closes it while the suggestion list is open", () => {
556
+ const recorder: Recorder = renderLogHarness();
557
+
558
+ fireEvent.focus(keyInput());
559
+ fireEvent.click(screen.getByTestId("close-button"));
560
+
561
+ expect(recorder.closeCount).toBe(1);
562
+ });
563
+
564
+ test("Cancel still closes it", () => {
565
+ const recorder: Recorder = renderLogHarness();
566
+
567
+ fireEvent.focus(keyInput());
568
+ press(screen.getByText("ActionName"));
569
+ fireEvent.click(screen.getByTestId("modal-footer-close-button"));
570
+
571
+ expect(recorder.closeCount).toBe(1);
572
+ });
573
+ });
574
+ });