@oneuptime/common 12.0.10 → 12.0.11

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 (81) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  6. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  7. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  8. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  9. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  10. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  11. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  12. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  13. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  14. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  15. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  16. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  17. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  18. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  19. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  20. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  21. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  22. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  23. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  24. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  25. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  26. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  27. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  28. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  29. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  30. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  31. package/Types/CustomField/CustomFieldType.ts +28 -0
  32. package/Types/Monitor/MonitorType.ts +11 -0
  33. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  34. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  35. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  36. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  37. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  38. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  39. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  40. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  41. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  42. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  43. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  44. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  45. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  46. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  47. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  48. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  49. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  50. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  51. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  52. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  53. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  54. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  55. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  56. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  57. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  58. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  59. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  60. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  61. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  62. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  63. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  64. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  65. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  66. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  67. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  68. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  69. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  70. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  71. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  72. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  73. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  74. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  75. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  76. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  77. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  78. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  79. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  80. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  81. package/package.json +1 -1
@@ -0,0 +1,323 @@
1
+ /**
2
+ * @timezone UTC
3
+ */
4
+ import { describe, expect, test } from "@jest/globals";
5
+
6
+ import ChartDataPoint, {
7
+ CHART_DATA_POINT_DATE_KEY,
8
+ CHART_DATA_POINT_X_AXIS_KEY,
9
+ } from "../../../../UI/Components/Charts/ChartLibrary/Types/ChartDataPoint";
10
+ import DataPointUtil from "../../../../UI/Components/Charts/Utils/DataPoint";
11
+ import DataPoint from "../../../../UI/Components/Charts/Types/DataPoint";
12
+ import SeriesPoints from "../../../../UI/Components/Charts/Types/SeriesPoints";
13
+ import {
14
+ XAxis as ChartXAxis,
15
+ XAxisAggregateType,
16
+ } from "../../../../UI/Components/Charts/Types/XAxis/XAxis";
17
+ import XAxisType from "../../../../UI/Components/Charts/Types/XAxis/XAxisType";
18
+ import YAxis, {
19
+ YAxisPrecision,
20
+ } from "../../../../UI/Components/Charts/Types/YAxis/YAxis";
21
+ import YAxisType from "../../../../UI/Components/Charts/Types/YAxis/YAxisType";
22
+
23
+ /*
24
+ * DataPointUtil produces the rows recharts is handed directly, so the KEY it
25
+ * writes them under is a contract, not an implementation detail: the chart
26
+ * wrappers look up CHART_DATA_POINT_X_AXIS_KEY on every row and draw nothing
27
+ * at all when it is absent.
28
+ *
29
+ * That key used to be whatever the caller put in `xAxis.legend`, which made
30
+ * the contract invisible — and the one caller that passed something else (the
31
+ * SLO dashboard widget, with "") rendered an empty chart. These tests pin the
32
+ * key, and pin that the legend can no longer influence it.
33
+ *
34
+ * Pinned to UTC via the docblock pragma above: the bucket labels come from
35
+ * local-time formatters, so an unpinned run would assert against the
36
+ * machine's zone.
37
+ */
38
+
39
+ const START: Date = new Date("2026-08-10T00:00:00.000Z");
40
+ const END: Date = new Date("2026-08-10T01:00:00.000Z");
41
+
42
+ /** A 1-hour window buckets EVERY_MINUTE, inclusive of both ends. */
43
+ const EXPECTED_BUCKET_COUNT: number = 61;
44
+
45
+ const Y_AXIS: YAxis = {
46
+ legend: "%",
47
+ options: {
48
+ type: YAxisType.Number,
49
+ min: "auto",
50
+ max: 100,
51
+ precision: YAxisPrecision.TwoDecimals,
52
+ formatter: (value: number): string => {
53
+ return `${value}%`;
54
+ },
55
+ },
56
+ };
57
+
58
+ type BuildXAxisFunction = (data?: {
59
+ legend?: string | undefined;
60
+ aggregateType?: XAxisAggregateType | undefined;
61
+ }) => ChartXAxis;
62
+
63
+ const buildXAxis: BuildXAxisFunction = (
64
+ data: {
65
+ legend?: string | undefined;
66
+ aggregateType?: XAxisAggregateType | undefined;
67
+ } = {},
68
+ ): ChartXAxis => {
69
+ return {
70
+ legend: data.legend ?? "Time",
71
+ options: {
72
+ type: XAxisType.Time,
73
+ min: START,
74
+ max: END,
75
+ aggregateType: data.aggregateType ?? XAxisAggregateType.Average,
76
+ },
77
+ };
78
+ };
79
+
80
+ type MinutesAfterStartFunction = (minutes: number) => Date;
81
+
82
+ const minutesAfterStart: MinutesAfterStartFunction = (
83
+ minutes: number,
84
+ ): Date => {
85
+ return new Date(START.getTime() + minutes * 60 * 1000);
86
+ };
87
+
88
+ type GetPointsFunction = (data: {
89
+ seriesPoints: Array<SeriesPoints>;
90
+ legend?: string | undefined;
91
+ aggregateType?: XAxisAggregateType | undefined;
92
+ }) => Array<ChartDataPoint>;
93
+
94
+ const getPoints: GetPointsFunction = (data: {
95
+ seriesPoints: Array<SeriesPoints>;
96
+ legend?: string | undefined;
97
+ aggregateType?: XAxisAggregateType | undefined;
98
+ }): Array<ChartDataPoint> => {
99
+ return DataPointUtil.getChartDataPoints({
100
+ seriesPoints: data.seriesPoints,
101
+ xAxis: buildXAxis({
102
+ legend: data.legend,
103
+ aggregateType: data.aggregateType,
104
+ }),
105
+ yAxis: Y_AXIS,
106
+ });
107
+ };
108
+
109
+ describe("DataPointUtil.getChartDataPoints", () => {
110
+ describe("the x-axis row key", () => {
111
+ test("writes the canonical key on every bucket", (): void => {
112
+ const rows: Array<ChartDataPoint> = getPoints({ seriesPoints: [] });
113
+
114
+ expect(rows.length).toBe(EXPECTED_BUCKET_COUNT);
115
+
116
+ for (const row of rows) {
117
+ expect(typeof row[CHART_DATA_POINT_X_AXIS_KEY]).toBe("string");
118
+ expect(row[CHART_DATA_POINT_X_AXIS_KEY]).not.toBe("");
119
+ }
120
+ });
121
+
122
+ test.each([
123
+ ["an empty legend", ""],
124
+ ["an arbitrary legend", "Timestamp"],
125
+ ["the canonical legend", "Time"],
126
+ ])(
127
+ "keys rows the same way with %s",
128
+ (_label: string, legend: string): void => {
129
+ const rows: Array<ChartDataPoint> = getPoints({
130
+ seriesPoints: [],
131
+ legend: legend,
132
+ });
133
+
134
+ for (const row of rows) {
135
+ expect(row[CHART_DATA_POINT_X_AXIS_KEY]).toBeDefined();
136
+ }
137
+ },
138
+ );
139
+
140
+ test("never leaks the legend into the row as a second key", (): void => {
141
+ const rows: Array<ChartDataPoint> = getPoints({
142
+ seriesPoints: [],
143
+ legend: "Timestamp",
144
+ });
145
+
146
+ for (const row of rows) {
147
+ expect(Object.keys(row).sort()).toEqual(
148
+ [CHART_DATA_POINT_X_AXIS_KEY, CHART_DATA_POINT_DATE_KEY].sort(),
149
+ );
150
+ }
151
+ });
152
+
153
+ test("produces identical rows whatever the legend says", (): void => {
154
+ const series: Array<SeriesPoints> = [
155
+ {
156
+ seriesName: "SLI",
157
+ data: [
158
+ { x: minutesAfterStart(0), y: 99.1 },
159
+ { x: minutesAfterStart(30), y: 99.4 },
160
+ ],
161
+ },
162
+ ];
163
+
164
+ const canonical: Array<ChartDataPoint> = getPoints({
165
+ seriesPoints: series,
166
+ legend: "Time",
167
+ });
168
+ const empty: Array<ChartDataPoint> = getPoints({
169
+ seriesPoints: series,
170
+ legend: "",
171
+ });
172
+
173
+ expect(empty).toEqual(canonical);
174
+ });
175
+ });
176
+
177
+ describe("the hidden raw-date field", () => {
178
+ test("carries each bucket start as epoch ms", (): void => {
179
+ const rows: Array<ChartDataPoint> = getPoints({ seriesPoints: [] });
180
+
181
+ expect(rows[0]![CHART_DATA_POINT_DATE_KEY]).toBe(START.getTime());
182
+ expect(rows[rows.length - 1]![CHART_DATA_POINT_DATE_KEY]).toBe(
183
+ END.getTime(),
184
+ );
185
+ });
186
+
187
+ test("is underscore-prefixed so it can never collide with a series", (): void => {
188
+ expect(CHART_DATA_POINT_DATE_KEY.startsWith("__")).toBe(true);
189
+ expect(CHART_DATA_POINT_DATE_KEY).not.toBe(CHART_DATA_POINT_X_AXIS_KEY);
190
+ });
191
+ });
192
+
193
+ describe("placing series values into buckets", () => {
194
+ test("lands a point on the bucket its timestamp falls in", (): void => {
195
+ const rows: Array<ChartDataPoint> = getPoints({
196
+ seriesPoints: [
197
+ {
198
+ seriesName: "SLI",
199
+ data: [
200
+ { x: minutesAfterStart(0), y: 10 },
201
+ { x: minutesAfterStart(5), y: 20 },
202
+ { x: minutesAfterStart(60), y: 30 },
203
+ ],
204
+ },
205
+ ],
206
+ });
207
+
208
+ expect(rows[0]!["SLI"]).toBe(10);
209
+ expect(rows[5]!["SLI"]).toBe(20);
210
+ expect(rows[60]!["SLI"]).toBe(30);
211
+ // Buckets with no datapoint stay absent rather than becoming 0.
212
+ expect(rows[1]!["SLI"]).toBeUndefined();
213
+ });
214
+
215
+ test("drops points outside the charted window", (): void => {
216
+ const rows: Array<ChartDataPoint> = getPoints({
217
+ seriesPoints: [
218
+ {
219
+ seriesName: "SLI",
220
+ data: [
221
+ { x: minutesAfterStart(-30), y: 1 },
222
+ { x: minutesAfterStart(120), y: 2 },
223
+ { x: minutesAfterStart(10), y: 3 },
224
+ ],
225
+ },
226
+ ],
227
+ });
228
+
229
+ const withValues: Array<ChartDataPoint> = rows.filter(
230
+ (row: ChartDataPoint) => {
231
+ return row["SLI"] !== undefined;
232
+ },
233
+ );
234
+
235
+ expect(withValues.length).toBe(1);
236
+ expect(rows[10]!["SLI"]).toBe(3);
237
+ });
238
+
239
+ test("keeps several series side by side on one row", (): void => {
240
+ const rows: Array<ChartDataPoint> = getPoints({
241
+ seriesPoints: [
242
+ {
243
+ seriesName: "SLI",
244
+ data: [{ x: minutesAfterStart(3), y: 99 }],
245
+ },
246
+ {
247
+ seriesName: "Burn Rate",
248
+ data: [{ x: minutesAfterStart(3), y: 1.5 }],
249
+ },
250
+ ],
251
+ });
252
+
253
+ expect(rows[3]!["SLI"]).toBe(99);
254
+ expect(rows[3]!["Burn Rate"]).toBe(1.5);
255
+ });
256
+ });
257
+
258
+ describe("rolling up several points into one bucket", () => {
259
+ const sameBucket: Array<SeriesPoints> = [
260
+ {
261
+ seriesName: "SLI",
262
+ data: [
263
+ { x: new Date(START.getTime() + 1000), y: 10 },
264
+ { x: new Date(START.getTime() + 2000), y: 20 },
265
+ { x: new Date(START.getTime() + 3000), y: 60 },
266
+ ],
267
+ },
268
+ ];
269
+
270
+ test.each([
271
+ [XAxisAggregateType.Average, 30],
272
+ [XAxisAggregateType.Sum, 90],
273
+ [XAxisAggregateType.Max, 60],
274
+ [XAxisAggregateType.Min, 10],
275
+ ])("%s", (aggregateType: XAxisAggregateType, expected: number): void => {
276
+ const rows: Array<ChartDataPoint> = getPoints({
277
+ seriesPoints: sameBucket,
278
+ aggregateType: aggregateType,
279
+ });
280
+
281
+ expect(rows[0]!["SLI"]).toBe(expected);
282
+ });
283
+
284
+ /*
285
+ * The accumulator is keyed by (series, bucket). A shared accumulator once
286
+ * carried each bucket's running total into the next, turning an
287
+ * "Incidents Over Time" Sum chart into a reverse-cumulative staircase.
288
+ */
289
+ test("resets its accumulator at every bucket boundary", (): void => {
290
+ const rows: Array<ChartDataPoint> = getPoints({
291
+ aggregateType: XAxisAggregateType.Sum,
292
+ seriesPoints: [
293
+ {
294
+ seriesName: "Incidents",
295
+ data: [
296
+ { x: minutesAfterStart(0), y: 41 },
297
+ { x: minutesAfterStart(1), y: 91 },
298
+ { x: minutesAfterStart(2), y: 356 },
299
+ ],
300
+ },
301
+ ],
302
+ });
303
+
304
+ expect(rows[0]!["Incidents"]).toBe(41);
305
+ expect(rows[1]!["Incidents"]).toBe(91);
306
+ expect(rows[2]!["Incidents"]).toBe(356);
307
+ });
308
+ });
309
+
310
+ describe("empty input", () => {
311
+ test("still returns the full bucket grid so the axis can draw", (): void => {
312
+ const rows: Array<ChartDataPoint> = getPoints({
313
+ seriesPoints: [{ seriesName: "SLI", data: [] as Array<DataPoint> }],
314
+ });
315
+
316
+ expect(rows.length).toBe(EXPECTED_BUCKET_COUNT);
317
+
318
+ for (const row of rows) {
319
+ expect(row["SLI"]).toBeUndefined();
320
+ }
321
+ });
322
+ });
323
+ });
@@ -13,11 +13,20 @@ import FieldType from "../../../../UI/Components/Types/FieldType";
13
13
  import Monitor from "../../../../Models/DatabaseModels/Monitor";
14
14
  import MonitorCustomField from "../../../../Models/DatabaseModels/MonitorCustomField";
15
15
  import CustomFieldType from "../../../../Types/CustomField/CustomFieldType";
16
+ import OneUptimeDate from "../../../../Types/Date";
17
+ import Timezone from "../../../../Types/Timezone";
16
18
  import { JSONObject } from "../../../../Types/JSON";
17
19
  import "@testing-library/jest-dom";
18
20
  import { cleanup, render } from "@testing-library/react";
19
21
  import React from "react";
20
- import { afterEach, describe, expect, test } from "@jest/globals";
22
+ import {
23
+ afterAll,
24
+ afterEach,
25
+ beforeAll,
26
+ describe,
27
+ expect,
28
+ test,
29
+ } from "@jest/globals";
21
30
 
22
31
  /*
23
32
  * Custom field *definitions* live in a per-resource table; the *values* live in
@@ -1211,3 +1220,342 @@ describe("CustomFieldColumns generated getElement", () => {
1211
1220
  );
1212
1221
  });
1213
1222
  });
1223
+
1224
+ /*
1225
+ * ---------------------------------------------------------------------------
1226
+ * Date and DateTime custom fields
1227
+ * ---------------------------------------------------------------------------
1228
+ *
1229
+ * A date custom field is kept in the jsonb bag as an ISO-8601 string, because
1230
+ * that is the one representation that is both comparable in a query (ISO-8601
1231
+ * sorts identically as text and as an instant) and unambiguous about which
1232
+ * instant it names. It is also unreadable in a table cell, so the renderer
1233
+ * hands it to the same local formatter the Detail view uses - a row and the
1234
+ * item page it opens then agree about what day something happened.
1235
+ *
1236
+ * Two things here break silently. The `isDateOnly` flag: drop it and a
1237
+ * warranty-expiry column grows a meaningless "00:00" that nobody entered, and
1238
+ * nothing else about the cell changes to give it away. And the export value:
1239
+ * humanise it and every CSV that feeds an external CMDB stops round-tripping,
1240
+ * which is only discovered on the import side, in someone else's system.
1241
+ *
1242
+ * The formatter resolves wall clocks in the viewer's timezone and picks 12- or
1243
+ * 24-hour from the browser locale, so both are pinned below. Without that these
1244
+ * assertions would pass or fail depending on the machine running the suite,
1245
+ * which is the same thing as not asserting anything.
1246
+ */
1247
+
1248
+ const dateDefinition: CustomFieldDefinition = {
1249
+ name: "Warranty Expiry",
1250
+ customFieldType: CustomFieldType.Date,
1251
+ };
1252
+
1253
+ const dateTimeDefinition: CustomFieldDefinition = {
1254
+ name: "Last Audited At",
1255
+ customFieldType: CustomFieldType.DateTime,
1256
+ };
1257
+
1258
+ /*
1259
+ * 14:30 UTC on purpose: a stored midnight would render as the same calendar day
1260
+ * in half the world's zones by luck, so it could not tell a timezone-aware
1261
+ * rendering apart from a naive string slice.
1262
+ */
1263
+ const storedIsoValue: string = "2026-08-17T14:30:00.000Z";
1264
+
1265
+ describe("CustomFieldColumns date custom fields", () => {
1266
+ beforeAll(() => {
1267
+ OneUptimeDate.setUserTimezone(Timezone.UTC);
1268
+
1269
+ /*
1270
+ * getUserPrefers12HourFormat sniffs the browser locale, so on one developer
1271
+ * machine the cell reads "14:30" and on the next "02:30 PM". Pin it rather
1272
+ * than write an assertion loose enough to accept both - a loose assertion
1273
+ * would also accept the time going missing entirely.
1274
+ */
1275
+ jest
1276
+ .spyOn(OneUptimeDate, "getUserPrefers12HourFormat")
1277
+ .mockReturnValue(false);
1278
+ });
1279
+
1280
+ afterEach(() => {
1281
+ // A test that re-pins the zone must not hand it to the next one.
1282
+ OneUptimeDate.setUserTimezone(Timezone.UTC);
1283
+ });
1284
+
1285
+ afterAll(() => {
1286
+ // Never leak either pin into the rest of the suite.
1287
+ OneUptimeDate.setUserTimezone(null);
1288
+ jest.restoreAllMocks();
1289
+ });
1290
+
1291
+ describe("renderCustomFieldValue", () => {
1292
+ test("renders a Date value as a calendar date with no time of day", () => {
1293
+ /*
1294
+ * A Date field is a day, not an instant - a warranty expires on a date.
1295
+ * Rendering the stored midnight-relative time next to it would invent a
1296
+ * precision the person filling the field never entered.
1297
+ */
1298
+ expect(
1299
+ renderValue({ value: storedIsoValue, definition: dateDefinition })
1300
+ .textContent,
1301
+ ).toEqual("Aug 17, 2026");
1302
+ });
1303
+
1304
+ test("renders a Date value without any clock time in it at all", () => {
1305
+ // Belt and braces on the above: no "14:30", and no stray zone suffix.
1306
+ const text: string =
1307
+ renderValue({ value: storedIsoValue, definition: dateDefinition })
1308
+ .textContent || "";
1309
+
1310
+ expect(text).not.toContain(":");
1311
+ expect(text).not.toContain("UTC");
1312
+ });
1313
+
1314
+ test("renders a DateTime value with the time of day and the zone", () => {
1315
+ /*
1316
+ * The other half of the field pair: "last audited at" is an instant, and
1317
+ * without the zone abbreviation a wall clock names a different instant to
1318
+ * every reader.
1319
+ */
1320
+ expect(
1321
+ renderValue({ value: storedIsoValue, definition: dateTimeDefinition })
1322
+ .textContent,
1323
+ ).toEqual("Aug 17 2026, 14:30 UTC");
1324
+ });
1325
+
1326
+ test("renders the very same stored string differently for Date and DateTime", () => {
1327
+ /*
1328
+ * This is the whole isDateOnly flag. Passing the wrong one - or dropping
1329
+ * the argument so both types take the default - still produces a
1330
+ * plausible-looking cell, so nothing but a direct comparison of the two
1331
+ * catches it.
1332
+ */
1333
+ const asDate: string =
1334
+ renderValue({ value: storedIsoValue, definition: dateDefinition })
1335
+ .textContent || "";
1336
+ const asDateTime: string =
1337
+ renderValue({ value: storedIsoValue, definition: dateTimeDefinition })
1338
+ .textContent || "";
1339
+
1340
+ expect(asDate).not.toEqual(asDateTime);
1341
+ expect(asDateTime).toContain("14:30");
1342
+ expect(asDate).not.toContain("14:30");
1343
+ });
1344
+
1345
+ test("resolves the instant in the viewer's timezone, not in UTC as stored", () => {
1346
+ /*
1347
+ * The stored string is UTC; the cell is read by a person. A New York
1348
+ * viewer must see their own 10:30 - and, at the edges of the day, their
1349
+ * own calendar date - or the table disagrees with every other date in
1350
+ * the product.
1351
+ */
1352
+ OneUptimeDate.setUserTimezone(Timezone.AmericaNew_York);
1353
+
1354
+ expect(
1355
+ renderValue({ value: storedIsoValue, definition: dateTimeDefinition })
1356
+ .textContent,
1357
+ ).toEqual("Aug 17 2026, 10:30 EDT");
1358
+ });
1359
+
1360
+ test("reads an offset-form ISO string as the same instant as the Z form", () => {
1361
+ /*
1362
+ * Both spellings are legal ISO-8601 and both are in the wild - the form
1363
+ * writes "Z", an API client may well post "+00:00" - and they name the
1364
+ * same moment, so they have to render the same.
1365
+ */
1366
+ expect(
1367
+ renderValue({
1368
+ value: "2026-08-17T14:30:00+00:00",
1369
+ definition: dateTimeDefinition,
1370
+ }).textContent,
1371
+ ).toEqual("Aug 17 2026, 14:30 UTC");
1372
+ });
1373
+
1374
+ test("renders a date as plain text rather than as a dropdown badge", () => {
1375
+ /*
1376
+ * The date branch has to sit above the dropdown and fall-through text
1377
+ * branches. A date wearing a badge would read as a tag, which is a
1378
+ * different kind of value.
1379
+ */
1380
+ const container: HTMLElement = renderValue({
1381
+ value: storedIsoValue,
1382
+ definition: dateDefinition,
1383
+ });
1384
+
1385
+ expect(getBadgeLabels(container)).toEqual([]);
1386
+ expect(
1387
+ container.querySelector('[data-dropdown-value-badge="true"]'),
1388
+ ).toBeNull();
1389
+ expect(getPlaceholder(container)).toBeNull();
1390
+ });
1391
+
1392
+ test("renders the placeholder for a date nobody has filled in", () => {
1393
+ /*
1394
+ * Not "Invalid date": an unset date is the ordinary state of a field that
1395
+ * was added to the project last week, and a whole column of red herrings
1396
+ * would train people to ignore the one row that really is broken.
1397
+ */
1398
+ for (const definition of [dateDefinition, dateTimeDefinition]) {
1399
+ for (const value of [undefined, null, ""]) {
1400
+ const container: HTMLElement = renderValue({
1401
+ value: value,
1402
+ definition: definition,
1403
+ });
1404
+
1405
+ expect(container.textContent).toEqual("-");
1406
+ expect(getPlaceholder(container)).not.toBeNull();
1407
+ }
1408
+ }
1409
+ });
1410
+
1411
+ test("uses the column's noValueMessage for a missing date", () => {
1412
+ // The empty branch is shared, so a date field inherits the same override.
1413
+ expect(
1414
+ renderValue({
1415
+ value: null,
1416
+ definition: dateTimeDefinition,
1417
+ noValueMessage: "Never audited",
1418
+ }).textContent,
1419
+ ).toEqual("Never audited");
1420
+ });
1421
+
1422
+ test("falls back to the raw stored string when the value will not parse", () => {
1423
+ /*
1424
+ * A value written over the API - or pasted into a CSV import - before the
1425
+ * field was turned into a date is still sitting in the jsonb bag, and the
1426
+ * cell is the only place anyone will ever see it. Showing what is
1427
+ * actually stored is what lets someone work out WHY the row is wrong;
1428
+ * "Invalid date" says only that something is, and looks identical for
1429
+ * every kind of garbage, so the fix (re-import "Q3 2026" as a real date)
1430
+ * is invisible from the table.
1431
+ */
1432
+ expect(
1433
+ renderValue({ value: "not-a-date", definition: dateDefinition })
1434
+ .textContent,
1435
+ ).toEqual("not-a-date");
1436
+ expect(
1437
+ renderValue({ value: "Q3 2026", definition: dateTimeDefinition })
1438
+ .textContent,
1439
+ ).toEqual("Q3 2026");
1440
+ });
1441
+ });
1442
+
1443
+ describe("getCustomFieldColumns", () => {
1444
+ test("still disables sorting on a date column", () => {
1445
+ /*
1446
+ * Being a date makes a jsonb key look more orderable than it is - it is
1447
+ * still reachable only through an explicit ->> expression, which the sort
1448
+ * path has no concept of, so a clickable header would push an unknown
1449
+ * property into the query builder exactly as it would for text.
1450
+ */
1451
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
1452
+ definitions: [dateDefinition, dateTimeDefinition],
1453
+ });
1454
+
1455
+ for (const column of columns) {
1456
+ expect(column.disableSort).toBe(true);
1457
+ }
1458
+ });
1459
+
1460
+ test("still hides a date column by default", () => {
1461
+ // Date fields are no more special than the rest in the column picker.
1462
+ const columns: Columns<Monitor> = getCustomFieldColumns<Monitor>({
1463
+ definitions: [dateDefinition, dateTimeDefinition],
1464
+ });
1465
+
1466
+ for (const column of columns) {
1467
+ expect(column.isHiddenByDefault).toBe(true);
1468
+ }
1469
+ });
1470
+
1471
+ test("exports the raw ISO string for a Date field, not the humanised one", () => {
1472
+ /*
1473
+ * Deliberate: the CSV is fed back into an external CMDB, which wants a
1474
+ * machine-readable timestamp. "Aug 17, 2026" would have to be re-parsed
1475
+ * on the far side by a locale nobody agreed on, and a date-only rendering
1476
+ * has already thrown the time away.
1477
+ */
1478
+ const column: Column<Monitor> = columnAt(
1479
+ getCustomFieldColumns<Monitor>({ definitions: [dateDefinition] }),
1480
+ 0,
1481
+ );
1482
+
1483
+ expect(
1484
+ exportValueOf(
1485
+ column,
1486
+ monitorWithFields({ "Warranty Expiry": storedIsoValue }),
1487
+ ),
1488
+ ).toEqual(storedIsoValue);
1489
+ });
1490
+
1491
+ test("exports the raw ISO string for a DateTime field too", () => {
1492
+ const column: Column<Monitor> = columnAt(
1493
+ getCustomFieldColumns<Monitor>({ definitions: [dateTimeDefinition] }),
1494
+ 0,
1495
+ );
1496
+
1497
+ expect(
1498
+ exportValueOf(
1499
+ column,
1500
+ monitorWithFields({ "Last Audited At": storedIsoValue }),
1501
+ ),
1502
+ ).toEqual(storedIsoValue);
1503
+ });
1504
+
1505
+ test("exports an empty cell for a date nobody has filled in", () => {
1506
+ // Not the literal "undefined", and not today's date either.
1507
+ const column: Column<Monitor> = columnAt(
1508
+ getCustomFieldColumns<Monitor>({ definitions: [dateDefinition] }),
1509
+ 0,
1510
+ );
1511
+
1512
+ expect(exportValueOf(column, new Monitor())).toEqual("");
1513
+ expect(
1514
+ exportValueOf(column, monitorWithFields({ "Warranty Expiry": null })),
1515
+ ).toEqual("");
1516
+ });
1517
+
1518
+ test("renders the humanised date off the row while exporting the ISO one", () => {
1519
+ /*
1520
+ * The end-to-end version of the split: the same column, the same row,
1521
+ * two different strings on purpose. Collapsing them - either way round -
1522
+ * breaks one of the two audiences.
1523
+ */
1524
+ const column: Column<Monitor> = columnAt(
1525
+ getCustomFieldColumns<Monitor>({ definitions: [dateDefinition] }),
1526
+ 0,
1527
+ );
1528
+ const monitor: Monitor = monitorWithFields({
1529
+ "Warranty Expiry": storedIsoValue,
1530
+ });
1531
+
1532
+ expect(renderColumn(column, monitor).textContent).toEqual("Aug 17, 2026");
1533
+ expect(exportValueOf(column, monitor)).toEqual(storedIsoValue);
1534
+ });
1535
+
1536
+ test("renders a DateTime straight off the row", () => {
1537
+ const column: Column<Monitor> = columnAt(
1538
+ getCustomFieldColumns<Monitor>({ definitions: [dateTimeDefinition] }),
1539
+ 0,
1540
+ );
1541
+
1542
+ expect(
1543
+ renderColumn(
1544
+ column,
1545
+ monitorWithFields({ "Last Audited At": storedIsoValue }),
1546
+ ).textContent,
1547
+ ).toEqual("Aug 17 2026, 14:30 UTC");
1548
+ });
1549
+
1550
+ test("renders the placeholder for a row with no date stored", () => {
1551
+ const column: Column<Monitor> = columnAt(
1552
+ getCustomFieldColumns<Monitor>({ definitions: [dateDefinition] }),
1553
+ 0,
1554
+ );
1555
+
1556
+ expect(
1557
+ getPlaceholder(renderColumn(column, new Monitor())),
1558
+ ).not.toBeNull();
1559
+ });
1560
+ });
1561
+ });