@oneuptime/common 12.0.26 → 12.0.27

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 (27) hide show
  1. package/Tests/Types/API/HostnameAuthorityParsing.test.ts +1 -0
  2. package/Tests/Types/API/URLMailtoQueryString.test.ts +439 -0
  3. package/Tests/Types/API/URLQueryString.test.ts +33 -24
  4. package/Tests/Types/API/URLRouteQuerySeparation.test.ts +491 -0
  5. package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +393 -0
  6. package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +248 -0
  7. package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +205 -0
  8. package/Tests/UI/Components/UseHistogramZoom.test.tsx +262 -0
  9. package/Types/API/URL.ts +55 -18
  10. package/UI/Components/Charts/Utils/useHistogramZoom.ts +101 -0
  11. package/UI/Components/LogsViewer/LogsViewer.tsx +19 -3
  12. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +52 -1
  13. package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +18 -2
  14. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +52 -1
  15. package/build/dist/Types/API/URL.js +48 -17
  16. package/build/dist/Types/API/URL.js.map +1 -1
  17. package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js +47 -0
  18. package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js.map +1 -0
  19. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +15 -3
  20. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  21. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +33 -5
  22. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  23. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +14 -2
  24. package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
  25. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +33 -5
  26. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  27. package/package.json +1 -1
@@ -0,0 +1,205 @@
1
+ /** @timezone UTC */
2
+
3
+ import {
4
+ HistogramBucket,
5
+ HistogramSeriesOption,
6
+ } from "../../../UI/Components/TelemetryViewer/types";
7
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
8
+ import React from "react";
9
+ import { afterEach, describe, expect, jest, test } from "@jest/globals";
10
+ import getJestMockFunction, { MockFunction } from "../../MockType";
11
+
12
+ /*
13
+ * The traces / exceptions volume chart is the same chart as the log volume
14
+ * one, drawn from a different signal — so it carries the same drag-to-zoom
15
+ * behaviour and needs the same cover. See LogsHistogramDragTooltip.test.tsx
16
+ * for why recharts is stood in for here.
17
+ */
18
+ jest.mock("recharts", () => {
19
+ const react: typeof React = jest.requireActual("react") as typeof React;
20
+
21
+ interface StubRow {
22
+ time: string;
23
+ }
24
+
25
+ interface StubChartProps {
26
+ data: Array<StubRow>;
27
+ children?: React.ReactNode;
28
+ onMouseDown?: (state: { activeLabel: string }) => void;
29
+ onMouseMove?: (state: { activeLabel: string }) => void;
30
+ onMouseUp?: (state: { activeLabel: string }) => void;
31
+ }
32
+
33
+ return {
34
+ __esModule: true,
35
+ ResponsiveContainer: (props: { children: React.ReactNode }) => {
36
+ return react.createElement("div", null, props.children);
37
+ },
38
+ BarChart: (props: StubChartProps) => {
39
+ return react.createElement(
40
+ "div",
41
+ { "data-testid": "bar-chart" },
42
+ props.data.map((row: StubRow) => {
43
+ return react.createElement("div", {
44
+ key: row.time,
45
+ "data-testid": `bucket-${row.time}`,
46
+ onMouseDown: () => {
47
+ props.onMouseDown?.({ activeLabel: row.time });
48
+ },
49
+ onMouseMove: () => {
50
+ props.onMouseMove?.({ activeLabel: row.time });
51
+ },
52
+ onMouseUp: () => {
53
+ props.onMouseUp?.({ activeLabel: row.time });
54
+ },
55
+ });
56
+ }),
57
+ props.children,
58
+ );
59
+ },
60
+ Bar: () => {
61
+ return null;
62
+ },
63
+ XAxis: () => {
64
+ return null;
65
+ },
66
+ YAxis: () => {
67
+ return null;
68
+ },
69
+ Tooltip: (props: { active?: boolean }) => {
70
+ return react.createElement("div", {
71
+ "data-testid": "tooltip",
72
+ "data-active": String(props.active),
73
+ });
74
+ },
75
+ ReferenceArea: (props: { x1?: string; x2?: string }) => {
76
+ return react.createElement("div", {
77
+ "data-testid": "selection-band",
78
+ "data-x1": props.x1,
79
+ "data-x2": props.x2,
80
+ });
81
+ },
82
+ };
83
+ });
84
+
85
+ // Imported after the mock so the chart picks the stand-in up.
86
+ import TelemetryHistogram from "../../../UI/Components/TelemetryViewer/components/TelemetryHistogram";
87
+
88
+ const FIRST_BUCKET: string = "2026-08-05T11:58:00Z";
89
+ const LAST_BUCKET: string = "2026-08-05T12:00:00Z";
90
+
91
+ const SERIES: Array<HistogramSeriesOption> = [
92
+ { key: "ok", label: "OK", color: "#34d399" },
93
+ { key: "error", label: "Error", color: "#f87171" },
94
+ ];
95
+
96
+ const BUCKETS: Array<HistogramBucket> = [
97
+ { time: FIRST_BUCKET, series: "ok", count: 12 },
98
+ { time: "2026-08-05T11:59:00Z", series: "error", count: 4 },
99
+ { time: LAST_BUCKET, series: "ok", count: 9 },
100
+ ];
101
+
102
+ const ZOOM_OUT_HINT: string = "Double-click to zoom out";
103
+
104
+ function bucketAt(time: string): HTMLElement {
105
+ return screen.getByTestId(`bucket-${time}`);
106
+ }
107
+
108
+ function tooltipActiveProp(): string | null {
109
+ return screen.getByTestId("tooltip").getAttribute("data-active");
110
+ }
111
+
112
+ interface RenderedHistogram {
113
+ onTimeRangeSelect: MockFunction;
114
+ onZoomOut: MockFunction;
115
+ }
116
+
117
+ function renderHistogram(
118
+ options: { canZoomOut?: boolean } = {},
119
+ ): RenderedHistogram {
120
+ const onTimeRangeSelect: MockFunction = getJestMockFunction();
121
+ const onZoomOut: MockFunction = getJestMockFunction();
122
+
123
+ render(
124
+ <TelemetryHistogram
125
+ buckets={BUCKETS}
126
+ series={SERIES}
127
+ isLoading={false}
128
+ onTimeRangeSelect={onTimeRangeSelect}
129
+ {...(options.canZoomOut ? { onZoomOut: onZoomOut } : {})}
130
+ />,
131
+ );
132
+
133
+ return { onTimeRangeSelect: onTimeRangeSelect, onZoomOut: onZoomOut };
134
+ }
135
+
136
+ describe("TelemetryHistogram — dragging out a time range", () => {
137
+ afterEach(() => {
138
+ cleanup();
139
+ });
140
+
141
+ test("holds the tooltip shut for the length of the drag", () => {
142
+ renderHistogram();
143
+
144
+ expect(tooltipActiveProp()).toBe("undefined");
145
+
146
+ fireEvent.mouseDown(bucketAt(FIRST_BUCKET));
147
+ fireEvent.mouseMove(bucketAt(LAST_BUCKET));
148
+
149
+ expect(tooltipActiveProp()).toBe("false");
150
+ });
151
+
152
+ test("gives the tooltip back when the reader lets go", () => {
153
+ renderHistogram();
154
+
155
+ fireEvent.mouseDown(bucketAt(FIRST_BUCKET));
156
+ fireEvent.mouseMove(bucketAt(LAST_BUCKET));
157
+ fireEvent.mouseUp(bucketAt(LAST_BUCKET));
158
+
159
+ expect(tooltipActiveProp()).toBe("undefined");
160
+ });
161
+
162
+ test("gives the tooltip back when the pointer is released off the chart", () => {
163
+ renderHistogram();
164
+
165
+ fireEvent.mouseDown(bucketAt(FIRST_BUCKET));
166
+ fireEvent.mouseMove(bucketAt(LAST_BUCKET));
167
+ fireEvent.mouseUp(document.body);
168
+
169
+ expect(tooltipActiveProp()).toBe("undefined");
170
+ expect(screen.queryByTestId("selection-band")).toBeNull();
171
+ });
172
+
173
+ test("reports the dragged window once", () => {
174
+ const { onTimeRangeSelect } = renderHistogram();
175
+
176
+ fireEvent.mouseDown(bucketAt(FIRST_BUCKET));
177
+ fireEvent.mouseMove(bucketAt(LAST_BUCKET));
178
+ fireEvent.mouseUp(bucketAt(LAST_BUCKET));
179
+
180
+ expect(onTimeRangeSelect).toHaveBeenCalledTimes(1);
181
+ const [start, end] = onTimeRangeSelect.mock.calls[0] as [Date, Date];
182
+ expect(start.toISOString()).toBe(new Date(FIRST_BUCKET).toISOString());
183
+ expect(end.toISOString()).toBe(new Date(LAST_BUCKET).toISOString());
184
+ });
185
+
186
+ test("zooms back out on a double-click", () => {
187
+ const { onZoomOut } = renderHistogram({ canZoomOut: true });
188
+
189
+ expect(screen.queryByText(ZOOM_OUT_HINT)).not.toBeNull();
190
+
191
+ fireEvent.doubleClick(screen.getByTestId("bar-chart"));
192
+
193
+ expect(onZoomOut).toHaveBeenCalledTimes(1);
194
+ });
195
+
196
+ test("offers no way out while the chart is on its original window", () => {
197
+ renderHistogram();
198
+
199
+ expect(screen.queryByText(ZOOM_OUT_HINT)).toBeNull();
200
+
201
+ fireEvent.doubleClick(screen.getByTestId("bar-chart"));
202
+
203
+ expect(screen.queryByText(ZOOM_OUT_HINT)).toBeNull();
204
+ });
205
+ });
@@ -0,0 +1,262 @@
1
+ /** @timezone UTC */
2
+
3
+ import useHistogramZoom, {
4
+ HistogramZoomOptions,
5
+ HistogramZoomState,
6
+ } from "../../../UI/Components/Charts/Utils/useHistogramZoom";
7
+ import InBetween from "../../../Types/BaseDatabase/InBetween";
8
+ import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
9
+ import TimeRange from "../../../Types/Time/TimeRange";
10
+ import { act, cleanup, renderHook } from "@testing-library/react";
11
+ import { afterEach, describe, expect, test } from "@jest/globals";
12
+ import getJestMockFunction, { MockFunction } from "../../MockType";
13
+
14
+ const PAST_HOUR: RangeStartAndEndDateTime = { range: TimeRange.PAST_ONE_HOUR };
15
+ const PAST_DAY: RangeStartAndEndDateTime = { range: TimeRange.PAST_ONE_DAY };
16
+
17
+ function customRange(start: string, end: string): RangeStartAndEndDateTime {
18
+ return {
19
+ range: TimeRange.CUSTOM,
20
+ startAndEndDate: new InBetween<Date>(new Date(start), new Date(end)),
21
+ };
22
+ }
23
+
24
+ const DRAGGED_START: Date = new Date("2026-08-05T11:58:00Z");
25
+ const DRAGGED_END: Date = new Date("2026-08-05T12:00:00Z");
26
+
27
+ interface ZoomHarness {
28
+ result: { current: HistogramZoomState };
29
+ rerender: (options: HistogramZoomOptions) => void;
30
+ onTimeRangeSelect: MockFunction;
31
+ onTimeRangeChange: MockFunction;
32
+ /** Drags a range out of the histogram, the way the chart would. */
33
+ dragZoom: () => void;
34
+ /** Double-clicks the chart, the way the chart would. */
35
+ zoomOut: () => void;
36
+ /** Picks a range from the toolbar, the way the picker would. */
37
+ pickTimeRange: (timeRange: RangeStartAndEndDateTime) => void;
38
+ }
39
+
40
+ function renderZoom(
41
+ timeRange: RangeStartAndEndDateTime | undefined = PAST_HOUR,
42
+ ): ZoomHarness {
43
+ const onTimeRangeSelect: MockFunction = getJestMockFunction();
44
+ const onTimeRangeChange: MockFunction = getJestMockFunction();
45
+
46
+ const { result, rerender } = renderHook(
47
+ (options: HistogramZoomOptions) => {
48
+ return useHistogramZoom(options);
49
+ },
50
+ {
51
+ initialProps: {
52
+ timeRange: timeRange,
53
+ onTimeRangeSelect: onTimeRangeSelect,
54
+ onTimeRangeChange: onTimeRangeChange,
55
+ } as HistogramZoomOptions,
56
+ },
57
+ );
58
+
59
+ return {
60
+ result: result,
61
+ rerender: rerender,
62
+ onTimeRangeSelect: onTimeRangeSelect,
63
+ onTimeRangeChange: onTimeRangeChange,
64
+ dragZoom: (): void => {
65
+ act(() => {
66
+ result.current.onTimeRangeSelect?.(DRAGGED_START, DRAGGED_END);
67
+ });
68
+ },
69
+ zoomOut: (): void => {
70
+ act(() => {
71
+ result.current.onZoomOut?.();
72
+ });
73
+ },
74
+ pickTimeRange: (next: RangeStartAndEndDateTime): void => {
75
+ act(() => {
76
+ result.current.onTimeRangeChange?.(next);
77
+ });
78
+ },
79
+ };
80
+ }
81
+
82
+ describe("useHistogramZoom", () => {
83
+ afterEach(() => {
84
+ cleanup();
85
+ });
86
+
87
+ describe("before anything has been zoomed", () => {
88
+ test("offers no way out, because there is nowhere to go back to", () => {
89
+ const { result } = renderZoom();
90
+
91
+ expect(result.current.onZoomOut).toBeUndefined();
92
+ });
93
+
94
+ test("still passes a dragged range straight through to the host", () => {
95
+ const { dragZoom, onTimeRangeSelect } = renderZoom();
96
+
97
+ dragZoom();
98
+
99
+ expect(onTimeRangeSelect).toHaveBeenCalledWith(
100
+ DRAGGED_START,
101
+ DRAGGED_END,
102
+ );
103
+ });
104
+ });
105
+
106
+ describe("after a drag-zoom", () => {
107
+ test("offers a way back out", () => {
108
+ const { dragZoom, result } = renderZoom();
109
+
110
+ dragZoom();
111
+
112
+ expect(result.current.onZoomOut).toBeDefined();
113
+ });
114
+
115
+ test("restores the window the reader started on", () => {
116
+ const { dragZoom, zoomOut, onTimeRangeChange } = renderZoom(PAST_DAY);
117
+
118
+ dragZoom();
119
+ zoomOut();
120
+
121
+ expect(onTimeRangeChange).toHaveBeenCalledTimes(1);
122
+ expect(onTimeRangeChange).toHaveBeenCalledWith(PAST_DAY);
123
+ });
124
+
125
+ test("restores a custom window just as faithfully", () => {
126
+ const original: RangeStartAndEndDateTime = customRange(
127
+ "2026-08-05T00:00:00Z",
128
+ "2026-08-05T23:59:00Z",
129
+ );
130
+ const { dragZoom, zoomOut, onTimeRangeChange } = renderZoom(original);
131
+
132
+ dragZoom();
133
+ zoomOut();
134
+
135
+ expect(onTimeRangeChange).toHaveBeenCalledWith(original);
136
+ });
137
+
138
+ test("takes the way out away again once it has been used", () => {
139
+ const { dragZoom, zoomOut, result } = renderZoom();
140
+
141
+ dragZoom();
142
+ zoomOut();
143
+
144
+ expect(result.current.onZoomOut).toBeUndefined();
145
+ });
146
+ });
147
+
148
+ /*
149
+ * Drilling down twice is normal: an hour to a minute, a minute to ten
150
+ * seconds. One double-click has to return the whole hour, not the minute
151
+ * on the way — "zoom out to the original", as the report puts it.
152
+ */
153
+ describe("after drilling down more than once", () => {
154
+ test("still returns the window the reader started from", () => {
155
+ const harness: ZoomHarness = renderZoom(PAST_DAY);
156
+
157
+ harness.dragZoom();
158
+ // The host applies the dragged window, so the hook sees it next render.
159
+ harness.rerender({
160
+ timeRange: customRange("2026-08-05T11:58:00Z", "2026-08-05T12:00:00Z"),
161
+ onTimeRangeSelect: harness.onTimeRangeSelect,
162
+ onTimeRangeChange: harness.onTimeRangeChange,
163
+ });
164
+ harness.dragZoom();
165
+
166
+ harness.zoomOut();
167
+
168
+ expect(harness.onTimeRangeChange).toHaveBeenCalledTimes(1);
169
+ expect(harness.onTimeRangeChange).toHaveBeenCalledWith(PAST_DAY);
170
+ });
171
+ });
172
+
173
+ /*
174
+ * Picking a range anywhere else is the reader choosing a new starting
175
+ * point. Holding on to the old one would send a later double-click to a
176
+ * window they had already moved on from.
177
+ */
178
+ describe("when the range is changed by other means", () => {
179
+ test("forgets the remembered window", () => {
180
+ const { dragZoom, pickTimeRange, result } = renderZoom(PAST_DAY);
181
+
182
+ dragZoom();
183
+ pickTimeRange(PAST_HOUR);
184
+
185
+ expect(result.current.onZoomOut).toBeUndefined();
186
+ });
187
+
188
+ test("passes the picked range on to the host untouched", () => {
189
+ const { pickTimeRange, onTimeRangeChange } = renderZoom();
190
+
191
+ pickTimeRange(PAST_DAY);
192
+
193
+ expect(onTimeRangeChange).toHaveBeenCalledWith(PAST_DAY);
194
+ });
195
+
196
+ test("remembers the new starting point on the next drag", () => {
197
+ const harness: ZoomHarness = renderZoom(PAST_DAY);
198
+
199
+ harness.dragZoom();
200
+ harness.pickTimeRange(PAST_HOUR);
201
+ harness.rerender({
202
+ timeRange: PAST_HOUR,
203
+ onTimeRangeSelect: harness.onTimeRangeSelect,
204
+ onTimeRangeChange: harness.onTimeRangeChange,
205
+ });
206
+ harness.dragZoom();
207
+ harness.zoomOut();
208
+
209
+ expect(harness.onTimeRangeChange).toHaveBeenLastCalledWith(PAST_HOUR);
210
+ });
211
+ });
212
+
213
+ describe("hosts that wire up only part of this", () => {
214
+ test("a host with no time range gets no zoom-out to offer", () => {
215
+ const onTimeRangeSelect: MockFunction = getJestMockFunction();
216
+ const onTimeRangeChange: MockFunction = getJestMockFunction();
217
+
218
+ const { result } = renderHook(() => {
219
+ return useHistogramZoom({
220
+ onTimeRangeSelect: onTimeRangeSelect,
221
+ onTimeRangeChange: onTimeRangeChange,
222
+ });
223
+ });
224
+
225
+ act(() => {
226
+ result.current.onTimeRangeSelect?.(DRAGGED_START, DRAGGED_END);
227
+ });
228
+
229
+ // The drag still applies; there is simply no window to hand back.
230
+ expect(onTimeRangeSelect).toHaveBeenCalled();
231
+ expect(result.current.onZoomOut).toBeUndefined();
232
+ });
233
+
234
+ test("a host with no select handler leaves the chart unzoomable", () => {
235
+ const { result } = renderHook(() => {
236
+ return useHistogramZoom({ timeRange: PAST_HOUR });
237
+ });
238
+
239
+ expect(result.current.onTimeRangeSelect).toBeUndefined();
240
+ expect(result.current.onZoomOut).toBeUndefined();
241
+ expect(result.current.onTimeRangeChange).toBeUndefined();
242
+ });
243
+
244
+ test("a host that cannot apply a range change offers no zoom-out", () => {
245
+ const onTimeRangeSelect: MockFunction = getJestMockFunction();
246
+
247
+ const { result } = renderHook(() => {
248
+ return useHistogramZoom({
249
+ timeRange: PAST_HOUR,
250
+ onTimeRangeSelect: onTimeRangeSelect,
251
+ });
252
+ });
253
+
254
+ act(() => {
255
+ result.current.onTimeRangeSelect?.(DRAGGED_START, DRAGGED_END);
256
+ });
257
+
258
+ expect(onTimeRangeSelect).toHaveBeenCalled();
259
+ expect(result.current.onZoomOut).toBeUndefined();
260
+ });
261
+ });
262
+ });
package/Types/API/URL.ts CHANGED
@@ -276,22 +276,33 @@ export default class URL extends DatabaseProperty {
276
276
  }
277
277
 
278
278
  let urlString: string = `${this.protocol}${this.hostname || this.email}`;
279
- if (!this.email && !urlString.startsWith("mailto:")) {
280
- if (this.route && this.route.toString().startsWith("/")) {
281
- if (urlString.endsWith("/")) {
282
- urlString = urlString.substring(0, urlString.length - 1);
283
- }
284
- urlString += this.route.toString();
285
- } else {
286
- if (urlString.endsWith("/")) {
287
- urlString = urlString.substring(0, urlString.length - 1);
288
- }
289
- urlString += "/" + this.route.toString();
290
- }
291
279
 
292
- urlString += this.queryStringSuffix();
280
+ /*
281
+ * mailto: has no authority to trim and no route to append, so it skips the
282
+ * branch below — but it CAN carry a query, and "?subject=...&body=..." is
283
+ * the whole point of a prefilled mail link. The suffix used to be appended
284
+ * inside that branch, so skipping the route silently dropped the subject
285
+ * and body too, and the link opened on an empty draft. It returns here
286
+ * with the suffix attached instead, the way the opaque branch above does.
287
+ */
288
+ if (this.email || urlString.startsWith("mailto:")) {
289
+ return urlString + this.queryStringSuffix();
293
290
  }
294
291
 
292
+ if (this.route && this.route.toString().startsWith("/")) {
293
+ if (urlString.endsWith("/")) {
294
+ urlString = urlString.substring(0, urlString.length - 1);
295
+ }
296
+ urlString += this.route.toString();
297
+ } else {
298
+ if (urlString.endsWith("/")) {
299
+ urlString = urlString.substring(0, urlString.length - 1);
300
+ }
301
+ urlString += "/" + this.route.toString();
302
+ }
303
+
304
+ urlString += this.queryStringSuffix();
305
+
295
306
  return urlString;
296
307
  }
297
308
 
@@ -406,6 +417,20 @@ export default class URL extends DatabaseProperty {
406
417
  return new URL(protocol, number, undefined, opaqueQueryString);
407
418
  }
408
419
 
420
+ /*
421
+ * The query and the fragment come off BEFORE the authority and the route
422
+ * are read off the string.
423
+ *
424
+ * Splitting on "/" first meant a "/" inside a QUERY VALUE was read as a
425
+ * path segment whenever the URL had no path of its own:
426
+ * "https://example.com?next=/a/b" parsed to a route of "a/b" and went back
427
+ * out as "https://example.com/a/b?next=/a/b" — a path nobody wrote, on a
428
+ * request the workflow API components dispatch verbatim. The authority was
429
+ * already cut at "?"/"#" and is unchanged by this, so the host stays the
430
+ * one the SSRF check upstream resolved; what moved was the path.
431
+ */
432
+ const beforeQuery: string = url.split("?")[0] || "";
433
+
409
434
  /*
410
435
  * The authority ends at the first "/", "?" or "#". Splitting on "/" alone
411
436
  * left the query and fragment glued to the host for URLs with no path
@@ -413,16 +438,28 @@ export default class URL extends DatabaseProperty {
413
438
  * round-tripped wrong and handed anything that later interpolated the
414
439
  * host a way to smuggle a path.
415
440
  */
416
- const authority: string = (url.split("/")[0] || "").split(/[?#]/)[0] || "";
441
+ const authorityAndPath: string = beforeQuery.split("#")[0] || "";
442
+
443
+ /*
444
+ * A fragment is not a field on this type. It rides on the end of the route
445
+ * — which is where it has always ended up, because the route was cut at
446
+ * "?" and never at "#" — so "…/docs#section" keeps round-tripping instead
447
+ * of losing its "#section" to this change.
448
+ */
449
+ const fragment: string = beforeQuery.substring(authorityAndPath.length);
450
+
451
+ const authority: string = authorityAndPath.split("/")[0] || "";
417
452
 
418
453
  const hostname: Hostname = new Hostname(authority);
419
454
 
420
455
  let route: Route | undefined;
421
456
 
422
- if (url.split("/").length > 1) {
423
- const paths: Array<string> = url.split("/");
424
- paths.shift();
425
- route = new Route(paths.join("/").split("?")[0]);
457
+ const pathSegments: Array<string> = authorityAndPath.split("/");
458
+ pathSegments.shift(); // drop the authority; what is left is the path
459
+ const path: string = pathSegments.join("/");
460
+
461
+ if (path || fragment) {
462
+ route = new Route(path + fragment);
426
463
  }
427
464
 
428
465
  const queryString: string = URL.queryStringOf(url);
@@ -0,0 +1,101 @@
1
+ import { useCallback, useState } from "react";
2
+ import RangeStartAndEndDateTime from "../../../../Types/Time/RangeStartAndEndDateTime";
3
+
4
+ export interface HistogramZoomOptions {
5
+ /** The window the explorer is showing right now. */
6
+ timeRange?: RangeStartAndEndDateTime | undefined;
7
+ /** Applies a range the reader dragged out on the histogram. */
8
+ onTimeRangeSelect?: ((startTime: Date, endTime: Date) => void) | undefined;
9
+ /** Applies a range picked anywhere else (the toolbar picker, a reset). */
10
+ onTimeRangeChange?:
11
+ | ((timeRange: RangeStartAndEndDateTime) => void)
12
+ | undefined;
13
+ }
14
+
15
+ export interface HistogramZoomState {
16
+ /** Hand to the histogram in place of the raw select handler. */
17
+ onTimeRangeSelect: ((startTime: Date, endTime: Date) => void) | undefined;
18
+ /**
19
+ * Hand to the histogram: set only while a drag-zoom is in effect, so the
20
+ * chart can both offer the affordance and act on a double-click.
21
+ */
22
+ onZoomOut: (() => void) | undefined;
23
+ /** Hand to the time range picker in place of the raw change handler. */
24
+ onTimeRangeChange:
25
+ | ((timeRange: RangeStartAndEndDateTime) => void)
26
+ | undefined;
27
+ }
28
+
29
+ export type UseHistogramZoomFunction = (
30
+ options: HistogramZoomOptions,
31
+ ) => HistogramZoomState;
32
+
33
+ /**
34
+ * Remembers the window a reader was on before they dragged a zoom out of the
35
+ * histogram, so a double-click can put them back on it.
36
+ *
37
+ * Only the *first* zoom is remembered: after drilling from "past one hour"
38
+ * into a minute and then into ten seconds, one double-click returns the whole
39
+ * hour rather than making the reader climb back out a level at a time. Any
40
+ * time range picked by other means — the toolbar picker, a saved view — is
41
+ * the reader choosing a new starting point, so it forgets what it held and
42
+ * the zoom-out affordance goes away until the next drag.
43
+ */
44
+ const useHistogramZoom: UseHistogramZoomFunction = (
45
+ options: HistogramZoomOptions,
46
+ ): HistogramZoomState => {
47
+ const [rangeBeforeZoom, setRangeBeforeZoom] =
48
+ useState<RangeStartAndEndDateTime | null>(null);
49
+
50
+ const timeRange: RangeStartAndEndDateTime | undefined = options.timeRange;
51
+ const onTimeRangeSelect:
52
+ | ((startTime: Date, endTime: Date) => void)
53
+ | undefined = options.onTimeRangeSelect;
54
+ const onTimeRangeChange:
55
+ | ((timeRange: RangeStartAndEndDateTime) => void)
56
+ | undefined = options.onTimeRangeChange;
57
+
58
+ const handleTimeRangeSelect: (startTime: Date, endTime: Date) => void =
59
+ useCallback(
60
+ (startTime: Date, endTime: Date): void => {
61
+ if (!onTimeRangeSelect) {
62
+ return;
63
+ }
64
+
65
+ if (timeRange) {
66
+ setRangeBeforeZoom((current: RangeStartAndEndDateTime | null) => {
67
+ return current || timeRange;
68
+ });
69
+ }
70
+
71
+ onTimeRangeSelect(startTime, endTime);
72
+ },
73
+ [onTimeRangeSelect, timeRange],
74
+ );
75
+
76
+ const handleZoomOut: () => void = useCallback((): void => {
77
+ if (!rangeBeforeZoom || !onTimeRangeChange) {
78
+ return;
79
+ }
80
+
81
+ setRangeBeforeZoom(null);
82
+ onTimeRangeChange(rangeBeforeZoom);
83
+ }, [rangeBeforeZoom, onTimeRangeChange]);
84
+
85
+ const handleTimeRangeChange: (timeRange: RangeStartAndEndDateTime) => void =
86
+ useCallback(
87
+ (nextTimeRange: RangeStartAndEndDateTime): void => {
88
+ setRangeBeforeZoom(null);
89
+ onTimeRangeChange?.(nextTimeRange);
90
+ },
91
+ [onTimeRangeChange],
92
+ );
93
+
94
+ return {
95
+ onTimeRangeSelect: onTimeRangeSelect ? handleTimeRangeSelect : undefined,
96
+ onZoomOut: rangeBeforeZoom && onTimeRangeChange ? handleZoomOut : undefined,
97
+ onTimeRangeChange: onTimeRangeChange ? handleTimeRangeChange : undefined,
98
+ };
99
+ };
100
+
101
+ export default useHistogramZoom;