From b383be0d437482c454e57891033c7e6f7310d951 Mon Sep 17 00:00:00 2001 From: Linus Pahl Date: Mon, 30 Oct 2023 18:11:51 +0100 Subject: [PATCH] Correctly highlight selected day in date picker. (#16973) * Consider user timezone when displaying indicator for active date in date picker. * Use moment.utc for date time utils to make them predictable. * Fix linter hint * Adding changelog. * Updating tests. * Define time zone provided for calendar in search date picker. * Adding test * Updating prop types. * Fix typo. --- changelog/unreleased/issue-16096.toml | 5 ++ .../src/components/common/DatePicker.test.tsx | 50 +++++++++++++++++++ .../src/components/common/DatePicker.tsx | 49 ++++++++++-------- graylog2-web-interface/src/util/DateTime.ts | 2 +- .../components/WidgetQueryControls.test.tsx | 9 +--- .../time-range-picker/AbsoluteDatePicker.tsx | 27 +++++----- 6 files changed, 99 insertions(+), 43 deletions(-) create mode 100644 changelog/unreleased/issue-16096.toml create mode 100644 graylog2-web-interface/src/components/common/DatePicker.test.tsx diff --git a/changelog/unreleased/issue-16096.toml b/changelog/unreleased/issue-16096.toml new file mode 100644 index 000000000000..3b05e7e74e8a --- /dev/null +++ b/changelog/unreleased/issue-16096.toml @@ -0,0 +1,5 @@ +type = "fixed" +message = "Fix timezone issue with date picker, which resulted in highlighting the wrong selected day." + +issues = ["16096"] +pulls = ["16973"] diff --git a/graylog2-web-interface/src/components/common/DatePicker.test.tsx b/graylog2-web-interface/src/components/common/DatePicker.test.tsx new file mode 100644 index 000000000000..16092ce53ae4 --- /dev/null +++ b/graylog2-web-interface/src/components/common/DatePicker.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React from 'react'; +import { render, screen } from 'wrappedTestingLibrary'; + +import { alice } from 'fixtures/users'; + +import DatePicker from './DatePicker'; + +const mockCurrentUser = alice.toBuilder() + .timezone('Europe/Berlin') + .build(); + +jest.mock('hooks/useCurrentUser', () => () => mockCurrentUser); + +describe('DatePicker', () => { + describe('should consider user time zone when displaying selected date', () => { + it('for beginning of day (date with user tz)', async () => { + render( {}} />); + + expect(await screen.findByText('19')).toHaveAttribute('aria-selected', 'true'); + }); + + it('for end of day (date with user tz)', async () => { + render( {}} />); + + expect(await screen.findByText('19')).toHaveAttribute('aria-selected', 'true'); + }); + + it('for end of day (date with UTC tz)', async () => { + render( {}} />); + + expect(await screen.findByText('20')).toHaveAttribute('aria-selected', 'true'); + }); + }); +}); diff --git a/graylog2-web-interface/src/components/common/DatePicker.tsx b/graylog2-web-interface/src/components/common/DatePicker.tsx index ea6801405e93..0c1843e7b446 100644 --- a/graylog2-web-interface/src/components/common/DatePicker.tsx +++ b/graylog2-web-interface/src/components/common/DatePicker.tsx @@ -15,13 +15,16 @@ * . */ import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useMemo } from 'react'; import type { DayModifiers } from 'react-day-picker'; import DayPicker from 'react-day-picker'; import styled, { css } from 'styled-components'; import 'react-day-picker/lib/style.css'; -import { toDateObject, adjustFormat } from 'util/DateTime'; + +import type { DateTime } from 'util/DateTime'; +import { isValidDate } from 'util/DateTime'; +import useUserDateTime from 'hooks/useUserDateTime'; const StyledDayPicker = styled(DayPicker)(({ theme }) => css` width: 100%; @@ -58,38 +61,39 @@ const StyledDayPicker = styled(DayPicker)(({ theme }) => css` } `); +const useSelectedDate = (date: DateTime | undefined) => { + const { toUserTimezone } = useUserDateTime(); + + if (!isValidDate(date)) { + return undefined; + } + + return toUserTimezone(date); +}; + type Props = { - date?: string | undefined, + date?: DateTime | undefined, onChange: (day: Date, modifiers: DayModifiers, event: React.MouseEvent) => void, - fromDate: Date, - showOutsideDays: boolean, + fromDate?: Date, + showOutsideDays?: boolean, }; const DatePicker = ({ date, fromDate, onChange, showOutsideDays }: Props) => { - let selectedDate; + const { formatTime } = useUserDateTime(); + const selectedDate = useSelectedDate(date); - if (date) { - try { - selectedDate = toDateObject(date); - } catch (e) { - // don't do anything - } - } - - const modifiers = { - selected: (moddedDate) => { + const modifiers = useMemo(() => ({ + selected: (moddedDate: Date) => { if (!selectedDate) { return false; } - const dateTime = toDateObject(adjustFormat(moddedDate, 'complete')); - - return (adjustFormat(selectedDate, 'date') === adjustFormat(dateTime, 'date')); + return formatTime(selectedDate, 'date') === formatTime(moddedDate, 'date'); }, disabled: { before: new Date(fromDate), }, - }; + }), [formatTime, fromDate, selectedDate]); return ( { DatePicker.propTypes = { /** Initial date to select in the date picker. */ - date: PropTypes.string, + date: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + ]), /** * Callback that will be called when user picks a date. It will receive the new selected day, * `react-day-picker`'s modifiers, and the original event as arguments. diff --git a/graylog2-web-interface/src/util/DateTime.ts b/graylog2-web-interface/src/util/DateTime.ts index 1e3432cbd4d0..c86d7793bcef 100644 --- a/graylog2-web-interface/src/util/DateTime.ts +++ b/graylog2-web-interface/src/util/DateTime.ts @@ -68,7 +68,7 @@ const getFormatStringsForDateTimeFormats = (dateTimeFormats: Array, tz = DEFAULT_OUTPUT_TZ) => { const acceptedFormatStrings = getFormatStringsForDateTimeFormats(acceptedFormats); - const dateObject = moment(dateTime, acceptedFormatStrings, true).tz(tz); + const dateObject = moment.utc(dateTime, acceptedFormatStrings, true).tz(tz); const validationInfo = acceptedFormats?.length ? `Expected formats: ${acceptedFormatStrings.join(', ')}.` : undefined; return validateDateTime(dateObject, dateTime, validationInfo); diff --git a/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx b/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx index 2b2ca3e4198d..822266beb6f9 100644 --- a/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx +++ b/graylog2-web-interface/src/views/components/WidgetQueryControls.test.tsx @@ -32,13 +32,6 @@ import WidgetContext from './contexts/WidgetContext'; jest.mock('views/components/searchbar/queryvalidation/QueryValidation', () => mockComponent('QueryValidation')); jest.mock('views/components/searchbar/queryinput/QueryInput', () => ({ value = '' }: { value: string }) => {value}); - -jest.mock('moment', () => { - const mockMoment = jest.requireActual('moment'); - - return Object.assign(() => mockMoment('2019-10-10T12:26:31.146Z'), mockMoment); -}); - jest.mock('views/components/searchbar/queryvalidation/QueryValidation', () => mockComponent('QueryValidation')); jest.mock('views/components/searchbar/queryinput/BasicQueryInput', () => ({ value = '' }: { value: string }) => {value}); jest.mock('views/components/searchbar/queryinput/QueryInput', () => ({ value = '' }: { value: string }) => {value}); @@ -121,7 +114,7 @@ describe('WidgetQueryControls', () => { describe('displays if global override is set', () => { const resetTimeRangeButtonTitle = /reset global override/i; const resetQueryButtonTitle = /reset global filter/i; - const timeRangeOverrideInfo = '2019-10-10 14:26:31.146 - 2019-10-10 14:26:31.146'; + const timeRangeOverrideInfo = '2020-01-01 11:00:00.850 - 2020-01-02 11:00:00.000'; const queryOverrideInfo = globalOverrideWithQuery.query.query_string; it('shows preview of global override time range', async () => { diff --git a/graylog2-web-interface/src/views/components/searchbar/time-range-filter/time-range-picker/AbsoluteDatePicker.tsx b/graylog2-web-interface/src/views/components/searchbar/time-range-filter/time-range-picker/AbsoluteDatePicker.tsx index 3e7ba23ef34c..fca880d8a604 100644 --- a/graylog2-web-interface/src/views/components/searchbar/time-range-filter/time-range-picker/AbsoluteDatePicker.tsx +++ b/graylog2-web-interface/src/views/components/searchbar/time-range-filter/time-range-picker/AbsoluteDatePicker.tsx @@ -17,10 +17,10 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { DateUtils } from 'react-day-picker'; -import moment from 'moment'; import { DatePicker } from 'components/common'; -import { DATE_TIME_FORMATS } from 'util/DateTime'; +import { toUTCFromTz, toDateObject } from 'util/DateTime'; +import useUserDateTime from 'hooks/useUserDateTime'; type Props = { dateTime: string, @@ -29,25 +29,26 @@ type Props = { } const AbsoluteDatePicker = ({ dateTime, onChange, startDate }: Props) => { - const initialDateTime = moment(dateTime).toObject(); + const { userTimezone, formatTime } = useUserDateTime(); + const initialDateTime = toUTCFromTz(dateTime, userTimezone); - const _onDatePicked = (date) => { - if (!!startDate && DateUtils.isDayBefore(date, startDate)) { + const _onDatePicked = (selectedDate: Date) => { + if (!!startDate && DateUtils.isDayBefore(selectedDate, startDate)) { return false; } - const newDate = moment(date).toObject(); + const selectedDateObject = toDateObject(selectedDate); + const newDate = initialDateTime.set({ + year: selectedDateObject.year(), + month: selectedDateObject.month(), + date: selectedDateObject.date(), + }); - return onChange(moment({ - ...initialDateTime, - years: newDate.years, - months: newDate.months, - date: newDate.date, - }).format(DATE_TIME_FORMATS.default)); + return onChange(formatTime(newDate, 'default')); }; return ( - );