Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: date-picker bug for negative UTC timezones #6096

Merged
merged 8 commits into from
Apr 18, 2023
Merged
31 changes: 9 additions & 22 deletions frontend/src/components/DatePicker/DatePickerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
useMultiStyleConfig,
} from '@chakra-ui/react'
import { format, isValid, parse } from 'date-fns'
import { zonedTimeToUtc } from 'date-fns-tz'

import { ThemeColorScheme } from '~theme/foundations/colours'
import { useIsMobile } from '~hooks/useIsMobile'
Expand Down Expand Up @@ -84,7 +83,6 @@ const useProvideDatePicker = ({
isReadOnly: isReadOnlyProp,
isRequired: isRequiredProp,
isInvalid: isInvalidProp,
timeZone = 'UTC',
locale,
isDateUnavailable,
allowManualInput = true,
Expand Down Expand Up @@ -120,9 +118,9 @@ const useProvideDatePicker = ({
const formatInputValue = useCallback(
(date: Date | null) => {
if (!date || !isValid(date)) return ''
return format(zonedTimeToUtc(date, timeZone), displayFormat, { locale })
return format(date, displayFormat, { locale })
},
[displayFormat, locale, timeZone],
[displayFormat, locale],
)

// What is rendered as a string in the input according to given display format.
Expand All @@ -142,11 +140,7 @@ const useProvideDatePicker = ({

const handleInputBlur: FocusEventHandler<HTMLInputElement> = useCallback(
(e) => {
const date = parse(
internalInputValue,
dateFormat,
zonedTimeToUtc(new Date(), timeZone),
)
const date = parse(internalInputValue, dateFormat, new Date())
// Clear if input is invalid on blur if invalid dates are not allowed.
if (!allowInvalidDates && !isValid(date)) {
setInternalValue(null)
Expand All @@ -161,7 +155,6 @@ const useProvideDatePicker = ({
onBlur,
setInternalInputValue,
setInternalValue,
timeZone,
],
)

Expand All @@ -179,12 +172,11 @@ const useProvideDatePicker = ({

const handleDateChange = useCallback(
(date: Date | null) => {
const zonedDate = date ? zonedTimeToUtc(date, timeZone) : null
if (allowInvalidDates || isValid(zonedDate) || !zonedDate) {
setInternalValue(zonedDate)
if (allowInvalidDates || isValid(date) || !date) {
setInternalValue(date)
}
if (zonedDate) {
setInternalInputValue(format(zonedDate, displayFormat, { locale }))
if (date) {
setInternalInputValue(format(date, displayFormat, { locale }))
} else {
setInternalInputValue('')
}
Expand All @@ -198,23 +190,18 @@ const useProvideDatePicker = ({
locale,
setInternalInputValue,
setInternalValue,
timeZone,
],
)

const handleInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const date = parse(
event.target.value,
dateFormat,
zonedTimeToUtc(new Date(), timeZone),
)
const date = parse(event.target.value, dateFormat, new Date())
setInternalInputValue(event.target.value)
if (isValid(date)) {
setInternalValue(date)
}
},
[dateFormat, setInternalInputValue, setInternalValue, timeZone],
[dateFormat, setInternalInputValue, setInternalValue],
)

const handleInputClick: MouseEventHandler<HTMLInputElement> = useCallback(
Expand Down
6 changes: 0 additions & 6 deletions frontend/src/components/DatePicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,4 @@ export interface DatePickerBaseProps
refocusOnClose?: boolean
/** date-fns's Locale of the date to be applied if provided. */
locale?: Locale
/**
* Time zone of date created.
* Defaults to `'UTC'`.
* Accepts all possible `Intl.Locale.prototype.timeZones` values
*/
timeZone?: string
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
useMultiStyleConfig,
} from '@chakra-ui/react'
import { format, isValid, parse } from 'date-fns'
import { zonedTimeToUtc } from 'date-fns-tz'

import { ThemeColorScheme } from '~theme/foundations/colours'
import { useIsMobile } from '~hooks/useIsMobile'
Expand Down Expand Up @@ -92,7 +91,6 @@ const useProvideDateRangePicker = ({
isReadOnly: isReadOnlyProp,
isRequired: isRequiredProp,
isInvalid: isInvalidProp,
timeZone = 'UTC',
locale,
isDateUnavailable,
allowManualInput = true,
Expand Down Expand Up @@ -129,13 +127,13 @@ const useProvideDateRangePicker = ({
// What is rendered as a string in the start date range input according to given display format.
const [startInputDisplay, setStartInputDisplay] = useState(
startDate && isValid(startDate)
? format(zonedTimeToUtc(startDate, timeZone), displayFormat, { locale })
? format(startDate, displayFormat, { locale })
: '',
)
// What is rendered as a string in the end date range input according to given display format.
const [endInputDisplay, setEndInputDisplay] = useState(
endDate && isValid(endDate)
? format(zonedTimeToUtc(endDate, timeZone), displayFormat, { locale })
? format(endDate, displayFormat, { locale })
: '',
)

Expand All @@ -151,24 +149,18 @@ const useProvideDateRangePicker = ({
) as DateRangeValue

const [nextStart, nextEnd] = sortedRange
const zonedStartDate = nextStart
? zonedTimeToUtc(nextStart, timeZone)
: null
const zonedEndDate = nextEnd ? zonedTimeToUtc(nextEnd, timeZone) : null
if (zonedStartDate) {
if (isValid(zonedStartDate)) {
setStartInputDisplay(
format(zonedStartDate, displayFormat, { locale }),
)
if (nextStart) {
if (isValid(nextStart)) {
setStartInputDisplay(format(nextStart, displayFormat, { locale }))
} else if (!allowInvalidDates) {
setStartInputDisplay('')
}
} else {
setStartInputDisplay('')
}
if (zonedEndDate) {
if (isValid(zonedEndDate)) {
setEndInputDisplay(format(zonedEndDate, displayFormat, { locale }))
if (nextEnd) {
if (isValid(nextEnd)) {
setEndInputDisplay(format(nextEnd, displayFormat, { locale }))
} else if (!allowInvalidDates) {
setEndInputDisplay('')
}
Expand All @@ -177,7 +169,7 @@ const useProvideDateRangePicker = ({
}
setInternalValue(validRange)
},
[allowInvalidDates, displayFormat, locale, setInternalValue, timeZone],
[allowInvalidDates, displayFormat, locale, setInternalValue],
)

const fcProps = useFormControlProps({
Expand Down Expand Up @@ -274,11 +266,8 @@ const useProvideDateRangePicker = ({

const handleCalendarDateChange = useCallback(
(date: DateRangeValue) => {
const zonedDateRange = date.map((d) =>
d ? zonedTimeToUtc(d, timeZone) : null,
) as DateRangeValue
const [nextStartDate, nextEndDate] = zonedDateRange
setInternalValue(zonedDateRange)
const [nextStartDate, nextEndDate] = date
setInternalValue(date)
setStartInputDisplay(
nextStartDate ? format(nextStartDate, displayFormat, { locale }) : '',
)
Expand All @@ -296,7 +285,6 @@ const useProvideDateRangePicker = ({
displayFormat,
locale,
setInternalValue,
timeZone,
],
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { Controller, RegisterOptions } from 'react-hook-form'
import { Box, FormControl, SimpleGrid } from '@chakra-ui/react'
import { isBefore, isEqual, isValid } from 'date-fns'
Expand All @@ -10,7 +10,11 @@ import {
DateValidationOptions,
} from '~shared/types/field'

import { fromUtcToLocalDate, isDateOutOfRange } from '~utils/date'
import {
isDateOutOfRange,
loadDateFromNormalizedDate,
normalizeDateToUtc,
} from '~utils/date'
import { createBaseValidationRules } from '~utils/fieldValidation'
import { DatePicker } from '~components/DatePicker'
import { SingleSelect } from '~components/Dropdown'
Expand Down Expand Up @@ -49,10 +53,10 @@ const transformDateFieldToEditForm = (field: DateFieldBase): EditDateInputs => {
selectedDateValidation:
field.dateValidation.selectedDateValidation ?? ('' as const),
customMaxDate: field.dateValidation.selectedDateValidation
? field.dateValidation.customMaxDate ?? null
? loadDateFromNormalizedDate(field.dateValidation.customMaxDate)
: null,
customMinDate: field.dateValidation.selectedDateValidation
? field.dateValidation.customMinDate ?? null
? loadDateFromNormalizedDate(field.dateValidation.customMinDate)
: null,
}
return {
Expand Down Expand Up @@ -97,6 +101,25 @@ const transformDateEditFormToField = (
}

export const EditDate = ({ field }: EditDateProps): JSX.Element => {
const preSubmitTransform = useCallback(
(inputs: EditDateInputs, output: DateFieldBase): DateFieldBase => {
// normalize time to UTC before saving
return {
...output,
dateValidation: {
...inputs.dateValidation,
customMinDate: normalizeDateToUtc(
inputs.dateValidation.customMinDate,
),
customMaxDate: normalizeDateToUtc(
inputs.dateValidation.customMaxDate,
),
},
} as DateFieldBase
},
[],
)

const {
register,
formState: { errors },
Expand All @@ -111,6 +134,7 @@ export const EditDate = ({ field }: EditDateProps): JSX.Element => {
transform: {
input: transformDateFieldToEditForm,
output: transformDateEditFormToField,
preSubmit: preSubmitTransform,
},
})

Expand Down Expand Up @@ -222,9 +246,7 @@ export const EditDate = ({ field }: EditDateProps): JSX.Element => {
isDateUnavailable={(d) =>
isDateOutOfRange(
d,
fromUtcToLocalDate(
getValues('dateValidation.customMinDate'),
),
getValues('dateValidation.customMinDate'),
)
}
{...field}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/templates/Field/Date/DateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { FormColorTheme } from '~shared/types'
import { DateSelectedValidation } from '~shared/types/field'

import {
fromUtcToLocalDate,
isDateAfterToday,
isDateBeforeToday,
isDateOutOfRange,
loadDateFromNormalizedDate,
} from '~utils/date'
import { createDateValidationRules } from '~utils/fieldValidation'
import { DatePicker } from '~components/DatePicker'
Expand Down Expand Up @@ -53,8 +53,8 @@ export const DateField = ({
// need to convert to local time but with the same date as UTC.
return isDateOutOfRange(
date,
fromUtcToLocalDate(customMinDate),
fromUtcToLocalDate(customMaxDate),
loadDateFromNormalizedDate(customMinDate),
loadDateFromNormalizedDate(customMaxDate),
)
}
default:
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ export const isDateAfterToday = (date: number | Date) => {
return isAfter(date, endOfToday())
}

// Converts UTC time to the same date in local time, ignoring original timezone.
export const fromUtcToLocalDate = (date?: Date | null) => {
export const normalizeDateToUtc = (date: Date | null) => {
if (!date) return date
return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
}

export const loadDateFromNormalizedDate = (date: Date | null) => {
if (!date) return date
return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/utils/fieldValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ import {
import { VerifiableFieldBase } from '~features/verifiable-fields/types'

import {
fromUtcToLocalDate,
isDateAfterToday,
isDateBeforeToday,
isDateOutOfRange,
loadDateFromNormalizedDate,
} from './date'
import { formatNumberToLocaleString } from './stringFormat'

Expand Down Expand Up @@ -434,8 +434,8 @@ export const createDateValidationRules: ValidationRuleFn<DateFieldBase> = (
return (
!isDateOutOfRange(
parseDate(val),
fromUtcToLocalDate(customMinDate),
fromUtcToLocalDate(customMaxDate),
loadDateFromNormalizedDate(customMinDate),
loadDateFromNormalizedDate(customMaxDate),
) || 'Selected date is not within the allowed date range'
)
},
Expand Down
12 changes: 4 additions & 8 deletions src/app/utils/field-validation/validators/dateValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,8 @@ const dateFormatValidator: DateValidator = (response) => {
*/
const pastOnlyValidator: DateValidator = (response) => {
// Today takes two possible values - a min (in makeFutureOnlyValidator) and max (here)
// Add 14 hours here to account for up to UTC + 14 timezone
// This allows validation to pass as long as user is on the correct date (locally)
// Even if they are in a different timezone
const todayMax = moment().utc().add(14, 'hours').startOf('day')
// Dates are converted to use local timezones when loaded by the DateField so no conversion required
const todayMax = moment()
LinHuiqing marked this conversation as resolved.
Show resolved Hide resolved
const { answer } = response
const answerDate = createMomentFromDateString(answer)

Expand All @@ -59,10 +57,8 @@ const pastOnlyValidator: DateValidator = (response) => {
*/
const futureOnlyValidator: DateValidator = (response) => {
// Today takes two possible values - a min (here) and max (in makePastOnlyValidator)
// Subtract 12 hours here to account for up to UTC - 12 timezone
// This allows validation to pass as long as user is on the correct date (locally)
// Even if they are in a different timezone
const todayMin = moment().utc().subtract(12, 'hours').startOf('day')
// Dates are converted to use local timezones when loaded by the DateField so no conversion required
const todayMin = moment()
LinHuiqing marked this conversation as resolved.
Show resolved Hide resolved
const { answer } = response
const answerDate = createMomentFromDateString(answer)

Expand Down