Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {useMemo, useState} from 'react';
import {useEffect, useMemo, useState} from 'react';

import {DateTimePicker} from '../../DateTimePicker';
import {AbsoluteDate, DateTimeRange, RelativeDate, getHistoricalDate} from '../utils';
Expand Down Expand Up @@ -46,6 +46,29 @@ const AbsoluteDatePicker = ({range, onChange}: AbsoluteDatePickerProps): JSX.Ele
: (range.to as AbsoluteDate)
);

useEffect(() => {
setFrom(
range.from.isRelative()
? new AbsoluteDate(
getHistoricalDate({
unit: dateFromInRelative.unit,
value: dateFromInRelative.value,
})
)
: (range.from as AbsoluteDate)
);
setTo(
range.to.isRelative()
? new AbsoluteDate(
getHistoricalDate({
unit: dateToInRelative.unit,
value: dateToInRelative.value,
})
)
: (range.to as AbsoluteDate)
);
}, [dateFromInRelative, dateToInRelative, range.from, range.to]);

return (
<div className="flex flex-col w-[80%] mx-auto">
<div className="flex flex-col justify-center gap-x-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@ import {Button} from '../../Button';
import {
AbsoluteDate,
DateTimeRange,
NOW,
RelativeDate,
UNITS,
UNIT_TYPE,
formatRange,
getHistoricalDate,
getRelativeTimeRangeBetweenDates,
parseInput,
presetRanges,
unitShort,
} from '../utils';

interface RelativeDatePickerProps {
Expand All @@ -31,66 +37,6 @@ interface RelativeDatePickerProps {
toggleRangePickerPanel: () => void;
}

interface UnitsMap {
[key: string]: string;
}

const unitLong: UnitsMap = {
m: UNITS.MINUTE,
h: UNITS.HOUR,
d: UNITS.DAY,
w: UNITS.WEEK,
y: UNITS.YEAR,
};
const unitShort: UnitsMap = {
[UNITS.MINUTE]: 'm',
[UNITS.HOUR]: 'h',
[UNITS.DAY]: 'd',
[UNITS.WEEK]: 'w',
[UNITS.YEAR]: 'y',
};

const presetRanges = [
{value: 1, unit: UNITS.MINUTE},
{value: 5, unit: UNITS.MINUTE},
{value: 15, unit: UNITS.MINUTE},
{value: 30, unit: UNITS.MINUTE},
{value: 1, unit: UNITS.HOUR},
{value: 3, unit: UNITS.HOUR},
{value: 6, unit: UNITS.HOUR},
{value: 12, unit: UNITS.HOUR},
{value: 1, unit: UNITS.DAY},
{value: 2, unit: UNITS.DAY},
{value: 1, unit: UNITS.WEEK},
{value: 2, unit: UNITS.WEEK},
{value: 4, unit: UNITS.WEEK},
{value: 8, unit: UNITS.WEEK},
{value: 16, unit: UNITS.WEEK},
{value: 26, unit: UNITS.WEEK},
{value: 1, unit: UNITS.YEAR},
{value: 2, unit: UNITS.YEAR},
];

const NOW = new RelativeDate(UNITS.MINUTE, 0);

const parseInput = (input: string): {value: number; unit: string} | null => {
// Ensure the input is not too long to mitigate potential DoS attacks
if (input.length > 100) {
return null; // Input is too long
}

const value = parseFloat(input);
const match = input.match(/^(\d+)([mhdw])$/);

// handle parseFloat edge cases and non-valid input
if (Number.isNaN(value) || input.includes('Infinity') || input.includes('e') || match == null) {
return null;
}

const unit = match[2];
return {value, unit: unitLong[unit]};
};

export const RelativeDatePickerForPanel = ({
onChange = () => null,
range,
Expand Down Expand Up @@ -124,29 +70,6 @@ export const RelativeDatePickerForPanel = ({
: (range.to as AbsoluteDate)
);

const getRelativeTimeRangeBetweenDates = (
timeRange: number
): {unit: UNIT_TYPE; value: number} => {
const roundToHundredth = (value: number): number => {
return Number(value.toFixed(2));
};

if (timeRange < 1000 * 60 * 60) {
const timeRangeToMinutes = timeRange / 1000 / 60;
return {unit: UNITS.MINUTE, value: roundToHundredth(timeRangeToMinutes)};
}
if (timeRange < 1000 * 60 * 60 * 24) {
const timeRangeToHours = timeRange / 1000 / 60 / 60;
return {unit: UNITS.HOUR, value: roundToHundredth(timeRangeToHours)};
}
if (timeRange < 1000 * 60 * 60 * 24 * 7) {
const timeRangeToDays = timeRange / 1000 / 60 / 60 / 24;
return {unit: UNITS.DAY, value: roundToHundredth(timeRangeToDays)};
}
const timeRangeToWeeks = timeRange / 1000 / 60 / 60 / 24 / 7;
return {unit: UNITS.WEEK, value: roundToHundredth(timeRangeToWeeks)};
};

const {unit, value} = useMemo(
() => getRelativeTimeRangeBetweenDates(to.getTime().getTime() - from.getTime().getTime()),
[from, to]
Expand All @@ -156,7 +79,7 @@ export const RelativeDatePickerForPanel = ({
// absolute date range is converted to a relative date range and we then use the `onChange` prop to
// update the range in the `RelativeDatePicker` component below.
useEffect(() => {
onChange(new RelativeDate(unit, value), new RelativeDate(unit, 0));
onChange(new RelativeDate(unit, value), NOW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [unit, value]);

Expand Down Expand Up @@ -205,19 +128,20 @@ const RelativeDatePicker = ({

const [validRange, setValidRange] = useState<{
value: number;
unit: string;
unit: UNIT_TYPE;
}>({
value: date.value,
unit: date.unit,
});

useEffect(() => {
setRangeInputString(`${date.value}${unitShort[date.unit]}`);
setRangeInputString(formatRange(date.value, date.unit));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [range]);

useEffect(() => {
setRangeInputString(`${validRange.value}${unitShort[validRange.unit]}`);
const formattedRange = formatRange(validRange.value, validRange.unit);
setRangeInputString(formattedRange);
onChange(new RelativeDate(validRange.unit, validRange.value), NOW);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [validRange]);
Expand Down Expand Up @@ -300,13 +224,10 @@ const RelativeDatePicker = ({

// if parsed input is not valid, set input to the previous valid value
if (parsedInput === null) {
setRangeInputString(`${validRange.value}${unitShort[validRange.unit]}`);
return;
setRangeInputString(formatRange(validRange.value, validRange.unit));
} else {
setValidRange(parsedInput);
}

// if parsed input is valid, set valid range state
const {value, unit} = parsedInput;
setValidRange({value, unit});
}}
onKeyDown={e => {
// if enter key is pressed, blur the input
Expand Down
107 changes: 107 additions & 0 deletions ui/packages/shared/components/src/DateTimeRangePicker/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright 2022 The Parca Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import {describe, expect, it} from 'vitest';

import {UNITS, formatRange, getRelativeTimeRangeBetweenDates, parseInput} from './utils';

describe('parseInput', () => {
it('should parse single unit inputs correctly', () => {
expect(parseInput('30s')).toEqual({value: 30, unit: UNITS.SECOND});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's going to happen for something like 90s vs 1m30s? Are both parsed as 90s?

Suggested change
expect(parseInput('30s')).toEqual({value: 30, unit: UNITS.SECOND});
expect(parseInput('90s')).toEqual({value: 90, unit: UNITS.SECOND});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good questions.

To answer the test question (and maybe the first one) internally, and as a request to the backend they are both processed and parsed as 90s but in the UI component, it will be parsed as 1m30s, and this is because the logic favours the higher unit over the lower ones.

Regarding the suggestion to work with seconds or milliseconds instead of floating-point numbers, the floating point numbers are only being used to format the human readable text that shows up in the RelativeDatepicker component. This allows us to preserve the user's input format when possible. But as mentioned, we still send the timerange in milliseconds to the backend.

If you think there might be advantages to using milliseconds throughout the entire component, I'm open to discussing potential improvements or alternative implementations.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a strong opinion on how it gets represented internally. I was thinking that maybe there was an easier way. Seems like it's already pretty good, so please ignore my suggestion.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to merge this as is too.

expect(parseInput('45m')).toEqual({value: 45, unit: UNITS.MINUTE});
expect(parseInput('2h')).toEqual({value: 2, unit: UNITS.HOUR});
expect(parseInput('3d')).toEqual({value: 3, unit: UNITS.DAY});
expect(parseInput('1w')).toEqual({value: 1, unit: UNITS.WEEK});
expect(parseInput('2y')).toEqual({value: 2, unit: UNITS.YEAR});
});

it('should parse multi-unit inputs correctly', () => {
expect(parseInput('1h30m')).toEqual({value: 1.5, unit: UNITS.HOUR});
expect(parseInput('2d12h')).toEqual({value: 2.5, unit: UNITS.DAY});
expect(parseInput('1w2d')).toEqual({value: 1.2857142857142858, unit: UNITS.WEEK});
});

it('should return null for invalid inputs', () => {
expect(parseInput('')).toBeNull();
expect(parseInput('invalid')).toBeNull();
expect(parseInput('1x')).toBeNull();
});

it('should return null for inputs longer than 100 characters', () => {
const longInput = '1h'.repeat(51); // 102 characters
expect(parseInput(longInput)).toBeNull();
});
});

describe('getRelativeTimeRangeBetweenDates', () => {
it('should return correct unit and value for various time ranges', () => {
expect(getRelativeTimeRangeBetweenDates(30 * 1000)).toEqual({unit: UNITS.MINUTE, value: 0.5});
expect(getRelativeTimeRangeBetweenDates(45 * 60 * 1000)).toEqual({
unit: UNITS.MINUTE,
value: 45,
});
expect(getRelativeTimeRangeBetweenDates(2 * 60 * 60 * 1000)).toEqual({
unit: UNITS.HOUR,
value: 2,
});
expect(getRelativeTimeRangeBetweenDates(3 * 24 * 60 * 60 * 1000)).toEqual({
unit: UNITS.DAY,
value: 3,
});
expect(getRelativeTimeRangeBetweenDates(10 * 24 * 60 * 60 * 1000)).toEqual({
unit: UNITS.WEEK,
value: 1.43,
});
});

it('should round values to two decimal places', () => {
expect(getRelativeTimeRangeBetweenDates(2 * 60 * 60 * 1000 + 5 * 60 * 1000)).toEqual({
unit: UNITS.HOUR,
value: 2.08,
});
});
});

describe('formatRange', () => {
it('should format single unit ranges correctly', () => {
expect(formatRange(30, UNITS.SECOND)).toBe('30s');
expect(formatRange(45, UNITS.MINUTE)).toBe('45m');
expect(formatRange(2, UNITS.HOUR)).toBe('2h');
expect(formatRange(3, UNITS.DAY)).toBe('3d');
expect(formatRange(1, UNITS.WEEK)).toBe('1w');
expect(formatRange(2, UNITS.YEAR)).toBe('2y');
});

it('should preserve original units when possible', () => {
expect(formatRange(25, UNITS.HOUR)).toBe('25h');
expect(formatRange(36, UNITS.HOUR)).toBe('36h');
expect(formatRange(1.5, UNITS.DAY)).toBe('1d12h');
expect(formatRange(1.25, UNITS.HOUR)).toBe('1h15m');
});

it('should handle fractional values correctly', () => {
expect(formatRange(1.75, UNITS.HOUR)).toBe('1h45m');
expect(formatRange(2.5, UNITS.DAY)).toBe('2d12h');
});

it('should return 0s for zero values', () => {
expect(formatRange(0, UNITS.SECOND)).toBe('0s');
expect(formatRange(0, UNITS.HOUR)).toBe('0s');
});

it('should handle edge cases correctly', () => {
expect(formatRange(0.1, UNITS.MINUTE)).toBe('0m6s');
expect(formatRange(1.99, UNITS.DAY)).toBe('1d23h45m');
expect(formatRange(1.001, UNITS.HOUR)).toBe('1h');
});
});
Loading