Skip to content
Open
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
2 changes: 1 addition & 1 deletion time/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func Validate(value string) error {
func ParseTime(now time.Time, value string, startOf bool, weekday time.Weekday) (time.Time, error) {
parse, err := time.Parse(time.RFC3339, value)
Copy link
Member

Choose a reason for hiding this comment

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

So, just to clarify, next step is to apply the normalizeRangeDateFormat calls to ui/src/common/RelativeDateTimeSelector.tsx:18 instead of closer to where the api gets called as a refactor.
And to also change how the date is displayed in addition to how the date is sent back to the parent component?

First of I think it would be okay if we only support dates and not date times when specifying the range on the dashboard page. I think this is enough and it removes the need from the user to specify a time. When the "end" range then uses endOfDay of the given date.

I imaging that the impl of the RelativeDateTimeSelector can look like this. If the parsing was unsuccessful, we just forward the invalid value to setValue, if successful, parseRelativeTime returns a normalized time which is either the raw relative time (e.g. now-1d, or the normalized date-time (parsed from local date and formatted to RFC3339).

As input we always get the formatted RFC3339 date, so we parse the value input of the component as RFC3339.

    const parsed = parseRelativeTime(value, type, moment(), 'YYYY-MM-DDTHH:mm:ssZ');
    return (
        <TextField
            fullWidth
            style={style}
            // .localized is similar to .normalized but the opposite.
            // if it's a absolute date, do date.format('l), otherwise return the raw relative date expression
            value={parsed.localized} 
            disabled={disabled}
            InputProps={{disableUnderline}}
            onChange={(e) => {
                const newValue = e.target.value;
                const result = parseRelativeTime(value, type, moment(), 'l');
                setValue(result.success ? result.normalized : newValue, result.success);
            }}
            error={!parsed.success}
            helperText={
                small ? undefined : !parsed.success ? (
                    <Typography color={'secondary'} variant={'caption'}>
                        {parsed.error}
                    </Typography>
                ) : (
                    <Typography variant={'caption'}>{parsed.preview.format('llll')}</Typography>
                )
            }
            label={label}
        />
    );

Copy link
Contributor Author

@zumoshi zumoshi Jun 3, 2025

Choose a reason for hiding this comment

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

So, basically merge the functionality of normalize/friendly methods I made into parseRelativeTime and get rid of them? + allowing omitting time and entering only date

Copy link
Member

Choose a reason for hiding this comment

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

Yes

if err == nil {
return parse, nil
return model.Time(parse).OmitTimeZone(), nil
}

return timemath.Parse(now, value, startOf, weekday)
Expand Down
13 changes: 6 additions & 7 deletions ui/src/common/RelativeDateTimeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface RelativeDateTimeSelectorProps {
}

export const RelativeDateTimeSelector: React.FC<RelativeDateTimeSelectorProps> = ({
value,
value: apiValue,
onChange: setValue,
type,
style,
Expand All @@ -28,18 +28,17 @@ export const RelativeDateTimeSelector: React.FC<RelativeDateTimeSelectorProps> =
const [errVisible, setErrVisible] = React.useState(false);
const [error, setError] = React.useState('');
const {start, stop} = useTimeout(() => setErrVisible(true), 200);
const parsed = parseRelativeTime(apiValue, type);

const parsed = parseRelativeTime(value, type);
return (
<TextField
fullWidth
style={style}
value={value}
value={parsed.success ? parsed.localized : apiValue}
disabled={disabled}
InputProps={{disableUnderline}}
onChange={(e) => {
const newValue = e.target.value;
const result = parseRelativeTime(newValue, type);
const result = parseRelativeTime(e.target.value, type);
setErrVisible(false);
stop();
if (!result.success) {
Expand All @@ -48,7 +47,7 @@ export const RelativeDateTimeSelector: React.FC<RelativeDateTimeSelectorProps> =
} else {
setError('');
}
setValue(newValue, result.success);
setValue(result.success ? result.normalized : e.target.value, result.success);
}}
error={error !== ''}
helperText={
Expand All @@ -59,7 +58,7 @@ export const RelativeDateTimeSelector: React.FC<RelativeDateTimeSelectorProps> =
{error}
</Typography>
) : (
<Typography variant={'caption'}>{!parsed.success ? '...' : parsed.value.format('llll')}</Typography>
<Typography variant={'caption'}>{!parsed.success ? '...' : parsed.preview.format('llll')}</Typography>
)
}
label={label}
Expand Down
199 changes: 178 additions & 21 deletions ui/src/utils/time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ moment.updateLocale('en', {
});

it('should test for valid date', () => {
expect(isValidDate('2017-05-05')).toBe(false);
expect(isValidDate('2017-05-05T15:23')).toBe(false);
expect(isValidDate('2017-05-05 15:23')).toBe(true);
expect(isValidDate('2017-05-05', 'YYYY-MM-DD HH:mm')).toBe(false);
expect(isValidDate('2017-05-05T15:23', 'YYYY-MM-DD HH:mm')).toBe(false);
expect(isValidDate('2017-05-05 15:23', 'YYYY-MM-DD HH:mm')).toBe(true);

expect(isValidDate('2019-10-20T15:55:00Z')).toBe(true);
expect(isValidDate('2017-05-05 15:23')).toBe(false);
});

// 2018-10-15 Monday
Expand All @@ -21,22 +24,176 @@ it('should test for valid date', () => {
// 2019-10-14 Monday
// 2019-10-21 Monday

it('should parse', () => {
expectSuccess(parseRelativeTime('now-1d', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-19 15:55:00');
expectSuccess(parseRelativeTime('now-120s', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-20 15:53:15');
expectSuccess(parseRelativeTime('now-1d-1h', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-19 14:55:00');
expectSuccess(parseRelativeTime('now/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-14 00:00:00');
expectSuccess(parseRelativeTime('now/w', 'endOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-20 23:59:59');
expectSuccess(parseRelativeTime('now-1w/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-10-07 00:00:00');
expectSuccess(parseRelativeTime('now-1y+1w/w', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2018-10-22 00:00:00');
expectSuccess(parseRelativeTime('now/d+5h', 'startOf', moment('2019-10-20T15:55:00'))).toEqual('2019-10-20 05:00:00');
expectSuccess(parseRelativeTime('now/y', 'startOf', moment('2019-10-20T15:55:15'))).toEqual('2019-01-01 00:00:00');
});

const expectSuccess = (value: ReturnType<typeof parseRelativeTime>) => {
if (value.success) {
return expect(value.value.format('YYYY-MM-DD HH:mm:ss'));
it.each([
{
value: 'now-1d',
divide: 'startOf',
now: moment('2019-10-20T15:55:00'),
expected: '2019-10-19 15:55:00',
localized: 'now-1d',
normalized: 'now-1d',
shouldParse: true,
},
{
value: 'now-120s',
divide: 'startOf',
now: moment('2019-10-20T15:55:15'),
expected: '2019-10-20 15:53:15',
localized: 'now-120s',
normalized: 'now-120s',
shouldParse: true,
},
{
value: 'now-1d-1h',
divide: 'startOf',
now: moment('2019-10-20T15:55:00'),
expected: '2019-10-19 14:55:00',
localized: 'now-1d-1h',
normalized: 'now-1d-1h',
shouldParse: true,
},
{
value: 'now/w',
divide: 'startOf',
now: moment('2019-10-20T15:55:15'),
expected: '2019-10-14 00:00:00',
localized: 'now/w',
normalized: 'now/w',
shouldParse: true,
},
{
value: 'now/w',
divide: 'endOf',
now: moment('2019-10-20T15:55:15'),
expected: '2019-10-20 23:59:59',
localized: 'now/w',
normalized: 'now/w',
shouldParse: true,
},
{
value: 'now-1w/w',
divide: 'startOf',
now: moment('2019-10-20T15:55:15'),
expected: '2019-10-07 00:00:00',
localized: 'now-1w/w',
normalized: 'now-1w/w',
shouldParse: true,
},
{
value: 'now-1y+1w/w',
divide: 'startOf',
now: moment('2019-10-20T15:55:15'),
expected: '2018-10-22 00:00:00',
localized: 'now-1y+1w/w',
normalized: 'now-1y+1w/w',
shouldParse: true,
},
{
value: 'now/d+5h',
divide: 'startOf',
now: moment('2019-10-20T15:55:00'),
expected: '2019-10-20 05:00:00',
localized: 'now/d+5h',
normalized: 'now/d+5h',
shouldParse: true,
},
{
value: 'now/y',
divide: 'startOf',
now: moment('2019-10-20T15:55:15'),
expected: '2019-01-01 00:00:00',
localized: 'now/y',
normalized: 'now/y',
shouldParse: true,
},
{
value: '2025-01-01 10:10',
divide: 'startOf',
expected: '2025-01-01 10:10:00',
localized: '2025-01-01 10:10',
normalized: moment('2025-01-01 10:10')
.utc()
.format(),
shouldParse: true,
},
{
value: '2025-01-01 10:10',
divide: 'endOf',
expected: '2025-01-01 10:10:00',
localized: '2025-01-01 10:10',
normalized: moment('2025-01-01 10:10')
.utc()
.format(),
shouldParse: true,
},
{
value: '2025-01-02',
divide: 'startOf',
expected: '2025-01-02 00:00:00',
localized: '2025-01-02',
normalized: moment('2025-01-02 00:00')
.utc()
.format(),
shouldParse: true,
},
{
value: '2025-01-02',
divide: 'endOf',
expected: '2025-01-02 23:59:59',
localized: '2025-01-02',
normalized: moment('2025-01-02 23:59:59')
.utc()
.format(),
shouldParse: true,
},
{
value: moment('2025-01-02 10:00:00').format(),
divide: 'startOf',
expected: '2025-01-02 10:00:00',
localized: '2025-01-02 10:00',
normalized: moment('2025-01-02 10:00:00')
.utc()
.format(),
shouldParse: true,
},
{
value: moment('2025-01-02 00:00:00').format(),
divide: 'startOf',
expected: '2025-01-02 00:00:00',
localized: '2025-01-02',
normalized: moment('2025-01-02 00:00:00')
.utc()
.format(),
shouldParse: true,
},
{
value: moment('2025-01-02 23:59:59')
.utc()
.format(),
divide: 'endOf',
expected: '2025-01-02 23:59:59',
localized: '2025-01-02',
normalized: moment('2025-01-02 23:59:59')
.utc()
.format(),
shouldParse: true,
},
{
value: 'invalid',
divide: 'endOf',
expected: "Expected valid date (e.g. 2020-01-01 16:30) or 'now' at index 0",
localized: 'invalid',
normalized: undefined,
shouldParse: false,
},
])('should parse', ({value, divide, now, expected, normalized, localized, shouldParse}) => {
const result = parseRelativeTime(value, divide as 'startOf' | 'endOf', now);
expect(result.success).toBe(shouldParse);
if (result.success) {
expect(result.preview.format('YYYY-MM-DD HH:mm:ss')).toEqual(expected);
expect(result.normalized).toEqual(normalized);
expect(result.localized).toEqual(localized);
} else {
expect(result.error).toEqual(expected);
}
expect(value.error).toEqual('no error');
return expect('');
};
});
46 changes: 37 additions & 9 deletions ui/src/utils/time.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import moment from 'moment';
import moment from 'moment-timezone';

interface Success {
success: true;
value: moment.Moment;
preview: moment.Moment;
localized: string;
normalized: string;
}

interface Failure {
Expand Down Expand Up @@ -32,9 +34,35 @@ enum Unit {
Second = 's',
}

// tslint:disable-next-line:cyclomatic-complexity mccabe-complexity
export const parseRelativeTime = (value: string, divide: 'endOf' | 'startOf', nowDate = moment()): Success | Failure => {
if (isValidDate(value)) {
return success(asDate(value));
for (const format of ['YYYY-MM-DD HH:mm', 'YYYY-MM-DD', 'YYYY-MM-DD[T]HH:mm:ssZ']) {
Copy link
Member

@jmattheis jmattheis Jun 25, 2025

Choose a reason for hiding this comment

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

I think this should only support L and L LT from https://momentjs.com/docs/?%2Fdisplaying%2Fformat%2F#/displaying/ so that the date can be formatted in the user defined date locale. YYYY-MM-DD can be supported as a fallback. L or L LT should be used below when passing it as localized to the success function.

FYI: I'll be unable to properly review this until end of next week.

if (isValidDate(value, format)) {
const parsed = asDate(value, format);
if (divide === 'endOf' && format === 'YYYY-MM-DD') {
parsed.endOf('day');
}

if (format === 'YYYY-MM-DD[T]HH:mm:ssZ') {
const localDate = parsed.clone().local();
if (
(divide === 'startOf' && parsed.isSame(localDate.startOf('day'), 'second')) ||
(divide === 'endOf' && parsed.isSame(localDate.endOf('day'), 'second'))
) {
value = asDate(value).format('YYYY-MM-DD');
} else {
value = asDate(value).format('YYYY-MM-DD HH:mm');
}
}
return success(
parsed,
value,
parsed
.clone()
.utc()
.format()
);
}
}

if (value.substr(0, 3) === 'now') {
Expand Down Expand Up @@ -105,18 +133,18 @@ export const parseRelativeTime = (value: string, divide: 'endOf' | 'startOf', no
if (expectNext === Type.Value) {
return failure('Expected number at the end but got nothing');
}
return success(time);
return success(time, value, value);
}

if (value.indexOf('now') !== -1) {
return failure("'now' must be at the start");
}

return failure("Expected valid date or 'now' at index 0");
return failure("Expected valid date (e.g. 2020-01-01 16:30) or 'now' at index 0");
};

export const success = (value: moment.Moment): Success => {
return {success: true, value};
export const success = (value: moment.Moment, localized: string, normalized: string): Success => {
return {success: true, preview: value, normalized, localized};
};
export const failure = (error: string): Failure => {
return {success: false, error};
Expand All @@ -133,7 +161,7 @@ export const isValidDate = (value: string, format?: string) => {
return asDate(value, format).isValid();
};

export const asDate = (value: string, format = 'YYYY-MM-DD HH:mm') => {
export const asDate = (value: string, format = 'YYYY-MM-DD[T]HH:mm:ssZ') => {
return moment(value, format, true);
};
export const isSameDate = (from: moment.Moment, to?: moment.Moment): boolean => {
Expand Down
Loading