@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.
- package/Tests/Types/API/HostnameAuthorityParsing.test.ts +1 -0
- package/Tests/Types/API/URLMailtoQueryString.test.ts +439 -0
- package/Tests/Types/API/URLQueryString.test.ts +33 -24
- package/Tests/Types/API/URLRouteQuerySeparation.test.ts +491 -0
- package/Tests/UI/Components/LogsHistogramDragTooltip.test.tsx +393 -0
- package/Tests/UI/Components/LogsViewerHistogramZoom.test.tsx +248 -0
- package/Tests/UI/Components/TelemetryHistogramDragTooltip.test.tsx +205 -0
- package/Tests/UI/Components/UseHistogramZoom.test.tsx +262 -0
- package/Types/API/URL.ts +55 -18
- package/UI/Components/Charts/Utils/useHistogramZoom.ts +101 -0
- package/UI/Components/LogsViewer/LogsViewer.tsx +19 -3
- package/UI/Components/LogsViewer/components/LogsHistogram.tsx +52 -1
- package/UI/Components/TelemetryViewer/TelemetryViewer.tsx +18 -2
- package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +52 -1
- package/build/dist/Types/API/URL.js +48 -17
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js +47 -0
- package/build/dist/UI/Components/Charts/Utils/useHistogramZoom.js.map +1 -0
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +15 -3
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +33 -5
- package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js +14 -2
- package/build/dist/UI/Components/TelemetryViewer/TelemetryViewer.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +33 -5
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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;
|