@oneuptime/common 12.0.19 → 12.0.21

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 (124) hide show
  1. package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
  2. package/Models/AnalyticsModels/Index.ts +2 -0
  3. package/Server/API/EnterpriseLicenseAPI.ts +94 -19
  4. package/Server/EnvironmentConfig.ts +15 -5
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
  7. package/Server/Services/ChangeEventService.ts +11 -0
  8. package/Server/Services/EnterpriseLicenseService.ts +3 -3
  9. package/Server/Services/Index.ts +2 -0
  10. package/Server/Services/ProjectService.ts +104 -0
  11. package/Server/Utils/Attribution.ts +11 -11
  12. package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
  13. package/Server/Utils/Response.ts +2 -2
  14. package/Server/Utils/StartServer.ts +2 -2
  15. package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
  16. package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
  17. package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
  18. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
  19. package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
  20. package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
  21. package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
  22. package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
  23. package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
  24. package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
  25. package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
  26. package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
  27. package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
  28. package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
  29. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
  30. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
  31. package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
  32. package/Tests/Server/Utils/Attribution.test.ts +4 -5
  33. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
  34. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
  35. package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
  36. package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
  37. package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
  38. package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
  39. package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
  40. package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
  41. package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
  42. package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
  43. package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
  44. package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
  45. package/Types/Analytics/RevenueEvent.ts +1 -0
  46. package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
  47. package/Types/Marketing/Attribution.ts +9 -15
  48. package/Types/Marketing/MarketingEvent.ts +19 -12
  49. package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
  50. package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
  51. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
  52. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
  53. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
  54. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
  55. package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
  56. package/UI/Components/Charts/Line/LineChart.tsx +23 -1
  57. package/UI/Components/Detail/Detail.tsx +18 -1
  58. package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
  59. package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
  60. package/Utils/Telemetry/CrossSignalScope.ts +19 -10
  61. package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
  62. package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
  63. package/build/dist/Models/AnalyticsModels/Index.js +2 -0
  64. package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
  65. package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
  66. package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
  67. package/build/dist/Server/EnvironmentConfig.js +14 -5
  68. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  69. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
  70. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
  71. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
  72. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  73. package/build/dist/Server/Services/ChangeEventService.js +9 -0
  74. package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
  75. package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
  76. package/build/dist/Server/Services/Index.js +2 -0
  77. package/build/dist/Server/Services/Index.js.map +1 -1
  78. package/build/dist/Server/Services/ProjectService.js +88 -0
  79. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  80. package/build/dist/Server/Utils/Attribution.js +6 -6
  81. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
  82. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
  83. package/build/dist/Server/Utils/Response.js +2 -2
  84. package/build/dist/Server/Utils/Response.js.map +1 -1
  85. package/build/dist/Server/Utils/StartServer.js +2 -2
  86. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  87. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
  88. package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
  89. package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
  90. package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
  91. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
  92. package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
  93. package/build/dist/Types/Marketing/Attribution.js +9 -15
  94. package/build/dist/Types/Marketing/Attribution.js.map +1 -1
  95. package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
  96. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
  97. package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
  98. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  99. package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
  100. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  101. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
  102. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  103. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
  104. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  105. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
  106. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  107. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
  108. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  109. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
  110. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
  111. package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
  112. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  113. package/build/dist/UI/Components/Detail/Detail.js +17 -1
  114. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  115. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
  116. package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
  117. package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
  118. package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
  119. package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
  120. package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
  121. package/package.json +1 -1
  122. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
  123. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
@@ -0,0 +1,549 @@
1
+ import Validation from "../../../../UI/Components/Forms/Validation";
2
+ import Field from "../../../../UI/Components/Forms/Types/Field";
3
+ import Fields from "../../../../UI/Components/Forms/Types/Fields";
4
+ import FormFieldSchemaType from "../../../../UI/Components/Forms/Types/FormFieldSchemaType";
5
+ import FormValues from "../../../../UI/Components/Forms/Types/FormValues";
6
+ import Dictionary from "../../../../Types/Dictionary";
7
+ import { JSONObject } from "../../../../Types/JSON";
8
+ import { describe, expect, test } from "@jest/globals";
9
+
10
+ /*
11
+ * Step scoping is the contract that makes a wizard's "Next" button a gate.
12
+ *
13
+ * BasicForm reuses one validator for every step and hands it the id of the
14
+ * step being submitted; Validation.validate then skips every field that lives
15
+ * somewhere else. That is what lets a field-level error render inline, under
16
+ * its own input, on the step the operator is actually looking at — instead of
17
+ * as one combined banner at the end (issue #3377, the Network Device Discovery
18
+ * Scan wizard).
19
+ *
20
+ * Nothing tested this branch before. ValidationJSON.test.ts is the only other
21
+ * direct test of Validation.validate and it never passes currentFormStepId at
22
+ * all, so the whole step filter — and every consequence of it, including the
23
+ * ones below that are deliberately surprising — was unpinned.
24
+ *
25
+ * Several tests here assert CURRENT behaviour that is not obviously desirable
26
+ * (customValidation skipped for absent keys; customValidation overwriting a
27
+ * built-in message; onValidate output not being step-scoped). They are here
28
+ * precisely because production code depends on those semantics today, so a
29
+ * change to them should be a deliberate, visible decision rather than a
30
+ * silent one.
31
+ */
32
+
33
+ interface WizardEntity extends JSONObject {
34
+ target?: string | undefined;
35
+ probe?: string | undefined;
36
+ community?: string | undefined;
37
+ interval?: number | undefined;
38
+ repeat?: boolean | undefined;
39
+ }
40
+
41
+ const STEP_ONE: string = "scan-target";
42
+ const STEP_TWO: string = "snmp";
43
+ const STEP_THREE: string = "schedule";
44
+
45
+ type MakeFieldFunction = (
46
+ name: string,
47
+ overrides?: Partial<Field<WizardEntity>>,
48
+ ) => Field<WizardEntity>;
49
+
50
+ const makeField: MakeFieldFunction = (
51
+ name: string,
52
+ overrides?: Partial<Field<WizardEntity>>,
53
+ ): Field<WizardEntity> => {
54
+ return {
55
+ name: name,
56
+ title: name,
57
+ field: { [name]: true },
58
+ fieldType: FormFieldSchemaType.Text,
59
+ ...overrides,
60
+ } as Field<WizardEntity>;
61
+ };
62
+
63
+ type RunValidateFunction = (args: {
64
+ formFields: Fields<WizardEntity>;
65
+ values: FormValues<WizardEntity>;
66
+ currentFormStepId?: string | null | undefined;
67
+ onValidate?: ((values: FormValues<WizardEntity>) => JSONObject) | undefined;
68
+ }) => Dictionary<string>;
69
+
70
+ const runValidate: RunValidateFunction = (args: {
71
+ formFields: Fields<WizardEntity>;
72
+ values: FormValues<WizardEntity>;
73
+ currentFormStepId?: string | null | undefined;
74
+ onValidate?: ((values: FormValues<WizardEntity>) => JSONObject) | undefined;
75
+ }): Dictionary<string> => {
76
+ return Validation.validate<WizardEntity>({
77
+ formFields: args.formFields,
78
+ values: args.values,
79
+ onValidate: args.onValidate,
80
+ currentFormStepId: args.currentFormStepId,
81
+ });
82
+ };
83
+
84
+ /*
85
+ * A three-step wizard shaped like the Discovery one: a validated text field on
86
+ * step 1, an optional field on step 2, and a numeric field on step 3.
87
+ */
88
+ const WIZARD_FIELDS: Fields<WizardEntity> = [
89
+ makeField("target", {
90
+ stepId: STEP_ONE,
91
+ required: true,
92
+ customValidation: (values: FormValues<WizardEntity>): string | null => {
93
+ const value: string = (values.target || "").toString().trim();
94
+
95
+ if (!value) {
96
+ return null;
97
+ }
98
+
99
+ return value.includes("/") ? null : `"${value}" is not a subnet.`;
100
+ },
101
+ }),
102
+ makeField("probe", { stepId: STEP_ONE, required: true }),
103
+ makeField("community", { stepId: STEP_TWO, required: false }),
104
+ makeField("interval", {
105
+ stepId: STEP_THREE,
106
+ required: true,
107
+ fieldType: FormFieldSchemaType.Number,
108
+ validation: { minValue: 15 },
109
+ }),
110
+ ];
111
+
112
+ describe("Validation.validate — a field is judged on its own step", () => {
113
+ test("an invalid value on the current step is reported", () => {
114
+ const errors: Dictionary<string> = runValidate({
115
+ formFields: WIZARD_FIELDS,
116
+ values: { target: "9876543210", probe: "probe-1" },
117
+ currentFormStepId: STEP_ONE,
118
+ });
119
+
120
+ expect(errors["target"]).toBe('"9876543210" is not a subnet.');
121
+ });
122
+
123
+ test("a valid value on the current step clears the step", () => {
124
+ const errors: Dictionary<string> = runValidate({
125
+ formFields: WIZARD_FIELDS,
126
+ values: { target: "192.168.1.0/24", probe: "probe-1" },
127
+ currentFormStepId: STEP_ONE,
128
+ });
129
+
130
+ expect(errors).toEqual({});
131
+ });
132
+
133
+ test("a required field left empty on the current step is reported", () => {
134
+ const errors: Dictionary<string> = runValidate({
135
+ formFields: WIZARD_FIELDS,
136
+ values: { target: "192.168.1.0/24" },
137
+ currentFormStepId: STEP_ONE,
138
+ });
139
+
140
+ expect(errors["probe"]).toBe("probe is required.");
141
+ });
142
+
143
+ test("validation.minValue is applied on its own step", () => {
144
+ const errors: Dictionary<string> = runValidate({
145
+ formFields: WIZARD_FIELDS,
146
+ values: { interval: 5, repeat: true },
147
+ currentFormStepId: STEP_THREE,
148
+ });
149
+
150
+ expect(errors["interval"]).toContain("should not be less than 15");
151
+ });
152
+ });
153
+
154
+ describe("Validation.validate — other steps are not judged", () => {
155
+ /*
156
+ * The half of the contract that keeps the button honest in the other
157
+ * direction: an error the operator cannot see must never block the step they
158
+ * are on. There is only one render path for a field error — the field
159
+ * itself — so an error keyed to a field on another step would make Next a
160
+ * dead button with nothing on screen to explain it.
161
+ */
162
+ test("a broken value on a LATER step does not block the current one", () => {
163
+ const errors: Dictionary<string> = runValidate({
164
+ formFields: WIZARD_FIELDS,
165
+ values: { target: "192.168.1.0/24", probe: "probe-1", interval: 1 },
166
+ currentFormStepId: STEP_ONE,
167
+ });
168
+
169
+ expect(errors).toEqual({});
170
+ });
171
+
172
+ test("a broken value on an EARLIER step does not block the current one", () => {
173
+ const errors: Dictionary<string> = runValidate({
174
+ formFields: WIZARD_FIELDS,
175
+ values: { target: "not-a-subnet", interval: 60, repeat: true },
176
+ currentFormStepId: STEP_THREE,
177
+ });
178
+
179
+ expect(errors).toEqual({});
180
+ });
181
+
182
+ test("a required field on another step is not demanded early", () => {
183
+ const errors: Dictionary<string> = runValidate({
184
+ formFields: WIZARD_FIELDS,
185
+ values: {},
186
+ currentFormStepId: STEP_TWO,
187
+ });
188
+
189
+ // Both step-one fields are required and both are missing.
190
+ expect(errors).toEqual({});
191
+ });
192
+
193
+ test("each step reports only its own field", () => {
194
+ const values: FormValues<WizardEntity> = {
195
+ target: "not-a-subnet",
196
+ probe: "probe-1",
197
+ interval: 1,
198
+ repeat: true,
199
+ };
200
+
201
+ expect(
202
+ Object.keys(
203
+ runValidate({
204
+ formFields: WIZARD_FIELDS,
205
+ values: values,
206
+ currentFormStepId: STEP_ONE,
207
+ }),
208
+ ),
209
+ ).toEqual(["target"]);
210
+
211
+ expect(
212
+ Object.keys(
213
+ runValidate({
214
+ formFields: WIZARD_FIELDS,
215
+ values: values,
216
+ currentFormStepId: STEP_THREE,
217
+ }),
218
+ ),
219
+ ).toEqual(["interval"]);
220
+ });
221
+ });
222
+
223
+ describe("Validation.validate — a form with no steps judges everything", () => {
224
+ test.each([
225
+ ["currentFormStepId omitted", undefined],
226
+ ["currentFormStepId null", null],
227
+ ["currentFormStepId empty string", ""],
228
+ ])(
229
+ "%s validates every field regardless of stepId",
230
+ (_label: string, currentFormStepId: string | null | undefined) => {
231
+ const errors: Dictionary<string> = runValidate({
232
+ formFields: WIZARD_FIELDS,
233
+ values: { target: "not-a-subnet", interval: 1 },
234
+ currentFormStepId: currentFormStepId,
235
+ });
236
+
237
+ expect(errors["target"]).toBe('"not-a-subnet" is not a subnet.');
238
+ expect(errors["probe"]).toBe("probe is required.");
239
+ expect(errors["interval"]).toContain("should not be less than 15");
240
+ },
241
+ );
242
+ });
243
+
244
+ describe("Validation.validate — a field with no stepId at all", () => {
245
+ /*
246
+ * Current behaviour, pinned because it is a trap rather than a design: once
247
+ * currentFormStepId is set, `field.stepId !== currentFormStepId` is true for
248
+ * an undefined stepId on EVERY step, so the field is validated on none of
249
+ * them. BasicForm's render filter uses the same comparison, so the field is
250
+ * also drawn on no step — the two agree, and the field is simply inert.
251
+ * setAllTouched does NOT agree (it additionally requires a truthy stepId, so
252
+ * the field is marked touched everywhere), which is harmless only because
253
+ * nothing ever produces an error for it.
254
+ *
255
+ * The practical consequence: in a stepped form, a field that forgets its
256
+ * stepId silently stops being validated. That is why
257
+ * App/Tests/Dashboard/NetworkFormStepsInvariants.test.ts exists, and why the
258
+ * shared field helpers stamp every field they return.
259
+ */
260
+ test("is skipped on every step once the form is stepped", () => {
261
+ const orphan: Fields<WizardEntity> = [
262
+ makeField("target", { required: true }),
263
+ ];
264
+
265
+ for (const stepId of [STEP_ONE, STEP_TWO, STEP_THREE]) {
266
+ expect(
267
+ runValidate({
268
+ formFields: orphan,
269
+ values: {},
270
+ currentFormStepId: stepId,
271
+ }),
272
+ ).toEqual({});
273
+ }
274
+ });
275
+
276
+ test("is validated normally when the form has no steps", () => {
277
+ const orphan: Fields<WizardEntity> = [
278
+ makeField("target", { required: true }),
279
+ ];
280
+
281
+ expect(
282
+ runValidate({ formFields: orphan, values: {}, currentFormStepId: null }),
283
+ ).toEqual({ target: "target is required." });
284
+ });
285
+ });
286
+
287
+ describe("Validation.validate — showIf short-circuits before every rule", () => {
288
+ test("a hidden field is not required, even on its own step", () => {
289
+ const fields: Fields<WizardEntity> = [
290
+ makeField("interval", {
291
+ stepId: STEP_THREE,
292
+ required: true,
293
+ showIf: (values: FormValues<WizardEntity>): boolean => {
294
+ return Boolean(values.repeat);
295
+ },
296
+ }),
297
+ ];
298
+
299
+ expect(
300
+ runValidate({
301
+ formFields: fields,
302
+ values: { repeat: false },
303
+ currentFormStepId: STEP_THREE,
304
+ }),
305
+ ).toEqual({});
306
+ });
307
+
308
+ test("a hidden field's customValidation does not run", () => {
309
+ const fields: Fields<WizardEntity> = [
310
+ makeField("interval", {
311
+ stepId: STEP_THREE,
312
+ showIf: (): boolean => {
313
+ return false;
314
+ },
315
+ customValidation: (): string | null => {
316
+ return "should never be seen";
317
+ },
318
+ }),
319
+ ];
320
+
321
+ expect(
322
+ runValidate({
323
+ formFields: fields,
324
+ values: { interval: 1 },
325
+ currentFormStepId: STEP_THREE,
326
+ }),
327
+ ).toEqual({});
328
+ });
329
+
330
+ test("the same field is validated once it is revealed", () => {
331
+ const fields: Fields<WizardEntity> = [
332
+ makeField("interval", {
333
+ stepId: STEP_THREE,
334
+ required: true,
335
+ showIf: (values: FormValues<WizardEntity>): boolean => {
336
+ return Boolean(values.repeat);
337
+ },
338
+ }),
339
+ ];
340
+
341
+ expect(
342
+ runValidate({
343
+ formFields: fields,
344
+ values: { repeat: true },
345
+ currentFormStepId: STEP_THREE,
346
+ }),
347
+ ).toEqual({ interval: "interval is required." });
348
+ });
349
+ });
350
+
351
+ describe("Validation.validate — customValidation semantics a wizard depends on", () => {
352
+ /*
353
+ * Pinned because App/FeatureSet/Dashboard/src/Components/Billing/PayAsYouGo.tsx
354
+ * relies on it (and works around it with getDefaultValue), and because it is
355
+ * the reason the Discovery validators short-circuit on empty instead of
356
+ * returning the parser's own "is required" text.
357
+ */
358
+ test("does not run for a field whose key is absent from the values", () => {
359
+ const fields: Fields<WizardEntity> = [
360
+ makeField("target", {
361
+ stepId: STEP_ONE,
362
+ required: true,
363
+ customValidation: (): string | null => {
364
+ return "custom message";
365
+ },
366
+ }),
367
+ ];
368
+
369
+ expect(
370
+ runValidate({
371
+ formFields: fields,
372
+ values: {},
373
+ currentFormStepId: STEP_ONE,
374
+ }),
375
+ ).toEqual({ target: "target is required." });
376
+ });
377
+
378
+ test("does run once the key exists, even when the value is empty", () => {
379
+ const fields: Fields<WizardEntity> = [
380
+ makeField("target", {
381
+ stepId: STEP_ONE,
382
+ required: true,
383
+ customValidation: (): string | null => {
384
+ return "custom message";
385
+ },
386
+ }),
387
+ ];
388
+
389
+ expect(
390
+ runValidate({
391
+ formFields: fields,
392
+ values: { target: "" },
393
+ currentFormStepId: STEP_ONE,
394
+ }),
395
+ ).toEqual({ target: "custom message" });
396
+ });
397
+
398
+ test("runs last, so it overwrites a built-in message for the same field", () => {
399
+ /*
400
+ * Which is why a validator that speaks up for empty input replaces the
401
+ * short "X is required." with its own, longer sentence. Returning null for
402
+ * empty is not politeness — it is how the required message survives.
403
+ */
404
+ const fields: Fields<WizardEntity> = [
405
+ makeField("target", {
406
+ stepId: STEP_ONE,
407
+ required: true,
408
+ validation: { minLength: 20 },
409
+ customValidation: (): string | null => {
410
+ return "custom wins";
411
+ },
412
+ }),
413
+ ];
414
+
415
+ expect(
416
+ runValidate({
417
+ formFields: fields,
418
+ values: { target: "short" },
419
+ currentFormStepId: STEP_ONE,
420
+ }),
421
+ ).toEqual({ target: "custom wins" });
422
+ });
423
+
424
+ test("returning null leaves the built-in message in place", () => {
425
+ const fields: Fields<WizardEntity> = [
426
+ makeField("target", {
427
+ stepId: STEP_ONE,
428
+ required: true,
429
+ validation: { minLength: 20 },
430
+ customValidation: (): string | null => {
431
+ return null;
432
+ },
433
+ }),
434
+ ];
435
+
436
+ expect(
437
+ runValidate({
438
+ formFields: fields,
439
+ values: { target: "short" },
440
+ currentFormStepId: STEP_ONE,
441
+ })["target"],
442
+ ).toContain("cannot be less than 20 characters");
443
+ });
444
+
445
+ test("is handed the whole form's values, not just its own field", () => {
446
+ let seen: FormValues<WizardEntity> | null = null;
447
+
448
+ const fields: Fields<WizardEntity> = [
449
+ makeField("interval", {
450
+ stepId: STEP_THREE,
451
+ customValidation: (values: FormValues<WizardEntity>): string | null => {
452
+ seen = values;
453
+ return null;
454
+ },
455
+ }),
456
+ ];
457
+
458
+ runValidate({
459
+ formFields: fields,
460
+ values: { interval: 60, repeat: true, target: "192.168.1.0/24" },
461
+ currentFormStepId: STEP_THREE,
462
+ });
463
+
464
+ /*
465
+ * Cross-step values are readable, which is how a toggle on another step
466
+ * can switch a validator off.
467
+ */
468
+ expect(seen).toEqual({
469
+ interval: 60,
470
+ repeat: true,
471
+ target: "192.168.1.0/24",
472
+ });
473
+ });
474
+
475
+ test("a customValidation on another step's field never runs", () => {
476
+ let calls: number = 0;
477
+
478
+ const fields: Fields<WizardEntity> = [
479
+ makeField("target", {
480
+ stepId: STEP_ONE,
481
+ customValidation: (): string | null => {
482
+ calls++;
483
+ return "nope";
484
+ },
485
+ }),
486
+ ];
487
+
488
+ runValidate({
489
+ formFields: fields,
490
+ values: { target: "anything" },
491
+ currentFormStepId: STEP_TWO,
492
+ });
493
+
494
+ expect(calls).toBe(0);
495
+ });
496
+ });
497
+
498
+ describe("Validation.validate — onValidate output is NOT step-scoped", () => {
499
+ /*
500
+ * Current behaviour, pinned deliberately. The per-field loop is filtered by
501
+ * step; the form-level onValidate result is spread over the top of it
502
+ * unfiltered. A key naming a field on another step therefore blocks the
503
+ * button with nothing on screen to explain it.
504
+ *
505
+ * Unreachable today — ModelTable/CardModelDetail expose formSteps but no
506
+ * onValidate, and the single production onValidate in the repo is on a
507
+ * stepless form — so this is a latent trap rather than a live bug. Anyone
508
+ * adding an onValidate to a wizard should see this test fail their
509
+ * assumptions before their users do.
510
+ */
511
+ test("its keys survive the step filter", () => {
512
+ const errors: Dictionary<string> = runValidate({
513
+ formFields: WIZARD_FIELDS,
514
+ values: { target: "192.168.1.0/24", probe: "probe-1" },
515
+ currentFormStepId: STEP_ONE,
516
+ onValidate: (): JSONObject => {
517
+ return { interval: "an error about a field three steps away" };
518
+ },
519
+ });
520
+
521
+ expect(errors["interval"]).toBe("an error about a field three steps away");
522
+ });
523
+
524
+ test("its keys win over a per-field message for the same field", () => {
525
+ const errors: Dictionary<string> = runValidate({
526
+ formFields: WIZARD_FIELDS,
527
+ values: { target: "9876543210", probe: "probe-1" },
528
+ currentFormStepId: STEP_ONE,
529
+ onValidate: (): JSONObject => {
530
+ return { target: "form-level message" };
531
+ },
532
+ });
533
+
534
+ expect(errors["target"]).toBe("form-level message");
535
+ });
536
+
537
+ test("an empty result adds nothing", () => {
538
+ const errors: Dictionary<string> = runValidate({
539
+ formFields: WIZARD_FIELDS,
540
+ values: { target: "192.168.1.0/24", probe: "probe-1" },
541
+ currentFormStepId: STEP_ONE,
542
+ onValidate: (): JSONObject => {
543
+ return {};
544
+ },
545
+ });
546
+
547
+ expect(errors).toEqual({});
548
+ });
549
+ });