Przeglądaj źródła

feat: refactor date-and-time-picker to use custom dayjs utility and add timezone support (#15101)

Wu Tianwei 1 miesiąc temu
rodzic
commit
5be8fbab56

+ 1 - 1
web/app/components/base/date-and-time-picker/calendar/item.tsx

@@ -1,7 +1,7 @@
 import React, { type FC } from 'react'
 import type { CalendarItemProps } from '../types'
 import cn from '@/utils/classnames'
-import dayjs from 'dayjs'
+import dayjs from '../utils/dayjs'
 
 const Item: FC<CalendarItemProps> = ({
   day,

+ 4 - 4
web/app/components/base/date-and-time-picker/date-picker/header.tsx

@@ -23,16 +23,16 @@ const Header: FC<DatePickerHeaderProps> = ({
         </button>
       </div>
       <button
-        onClick={onClickNextMonth}
+        onClick={onClickPrevMonth}
         className='p-1.5 hover:bg-state-base-hover rounded-lg'
       >
-        <RiArrowDownSLine className='w-[18px] h-[18px] text-text-secondary' />
+        <RiArrowUpSLine className='w-[18px] h-[18px] text-text-secondary' />
       </button>
       <button
-        onClick={onClickPrevMonth}
+        onClick={onClickNextMonth}
         className='p-1.5 hover:bg-state-base-hover rounded-lg'
       >
-        <RiArrowUpSLine className='w-[18px] h-[18px] text-text-secondary' />
+        <RiArrowDownSLine className='w-[18px] h-[18px] text-text-secondary' />
       </button>
     </div>
   )

+ 50 - 23
web/app/components/base/date-and-time-picker/date-picker/index.tsx

@@ -1,10 +1,16 @@
 import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import dayjs, { type Dayjs } from 'dayjs'
 import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react'
 import cn from '@/utils/classnames'
 import type { DatePickerProps, Period } from '../types'
 import { ViewType } from '../types'
-import { cloneTime, getDaysInMonth, getHourIn12Hour } from '../utils'
+import type { Dayjs } from 'dayjs'
+import dayjs, {
+  clearMonthMapCache,
+  cloneTime,
+  getDateWithTimezone,
+  getDaysInMonth,
+  getHourIn12Hour,
+} from '../utils/dayjs'
 import {
   PortalToFollowElem,
   PortalToFollowElemContent,
@@ -22,6 +28,7 @@ import { useTranslation } from 'react-i18next'
 
 const DatePicker = ({
   value,
+  timezone,
   onChange,
   onClear,
   placeholder,
@@ -32,12 +39,15 @@ const DatePicker = ({
   const [isOpen, setIsOpen] = useState(false)
   const [view, setView] = useState(ViewType.date)
   const containerRef = useRef<HTMLDivElement>(null)
+  const isInitial = useRef(true)
+  const inputValue = useRef(value ? value.tz(timezone) : undefined).current
+  const defaultValue = useRef(getDateWithTimezone({ timezone })).current
 
-  const [currentDate, setCurrentDate] = useState(value || dayjs())
-  const [selectedDate, setSelectedDate] = useState(value)
+  const [currentDate, setCurrentDate] = useState(inputValue || defaultValue)
+  const [selectedDate, setSelectedDate] = useState(inputValue)
 
-  const [selectedMonth, setSelectedMonth] = useState((value || dayjs()).month())
-  const [selectedYear, setSelectedYear] = useState((value || dayjs()).year())
+  const [selectedMonth, setSelectedMonth] = useState((inputValue || defaultValue).month())
+  const [selectedYear, setSelectedYear] = useState((inputValue || defaultValue).year())
 
   useEffect(() => {
     const handleClickOutside = (event: MouseEvent) => {
@@ -50,6 +60,25 @@ const DatePicker = ({
     return () => document.removeEventListener('mousedown', handleClickOutside)
   }, [])
 
+  useEffect(() => {
+    if (isInitial.current) {
+      isInitial.current = false
+      return
+    }
+    clearMonthMapCache()
+    if (value) {
+      const newValue = getDateWithTimezone({ date: value, timezone })
+      setCurrentDate(newValue)
+      setSelectedDate(newValue)
+      onChange(newValue)
+    }
+    else {
+      setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
+      setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [timezone])
+
   const handleClickTrigger = (e: React.MouseEvent) => {
     e.stopPropagation()
     if (isOpen) {
@@ -58,15 +87,15 @@ const DatePicker = ({
     }
     setView(ViewType.date)
     setIsOpen(true)
+    if (value) {
+      setCurrentDate(value)
+      setSelectedDate(value)
+    }
   }
 
   const handleClear = (e: React.MouseEvent) => {
-    const newDate = dayjs()
     e.stopPropagation()
     setSelectedDate(undefined)
-    setCurrentDate(prev => prev || newDate)
-    setSelectedMonth(prev => prev || newDate.month())
-    setSelectedYear(prev => prev || newDate.year())
     if (!isOpen)
       onClear()
   }
@@ -84,13 +113,13 @@ const DatePicker = ({
   }, [currentDate])
 
   const handleDateSelect = useCallback((day: Dayjs) => {
-    const newDate = cloneTime(day, selectedDate || dayjs())
+    const newDate = cloneTime(day, selectedDate || getDateWithTimezone({ timezone }))
     setCurrentDate(newDate)
     setSelectedDate(newDate)
-  }, [selectedDate])
+  }, [selectedDate, timezone])
 
   const handleSelectCurrentDate = () => {
-    const newDate = dayjs()
+    const newDate = getDateWithTimezone({ timezone })
     setCurrentDate(newDate)
     setSelectedDate(newDate)
     onChange(newDate)
@@ -119,19 +148,19 @@ const DatePicker = ({
   }
 
   const handleSelectHour = useCallback((hour: string) => {
-    const selectedTime = selectedDate || dayjs()
+    const selectedTime = selectedDate || getDateWithTimezone({ timezone })
     handleTimeSelect(hour, selectedTime.minute().toString().padStart(2, '0'), selectedTime.format('A') as Period)
-  }, [selectedDate])
+  }, [selectedDate, timezone])
 
   const handleSelectMinute = useCallback((minute: string) => {
-    const selectedTime = selectedDate || dayjs()
+    const selectedTime = selectedDate || getDateWithTimezone({ timezone })
     handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), minute, selectedTime.format('A') as Period)
-  }, [selectedDate])
+  }, [selectedDate, timezone])
 
   const handleSelectPeriod = useCallback((period: Period) => {
-    const selectedTime = selectedDate || dayjs()
+    const selectedTime = selectedDate || getDateWithTimezone({ timezone })
     handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), selectedTime.minute().toString().padStart(2, '0'), period)
-  }, [selectedDate])
+  }, [selectedDate, timezone])
 
   const handleOpenYearMonthPicker = () => {
     setSelectedMonth(currentDate.month())
@@ -156,15 +185,13 @@ const DatePicker = ({
   }, [])
 
   const handleYearMonthConfirm = () => {
-    setCurrentDate((prev) => {
-      return prev ? prev.clone().month(selectedMonth).year(selectedYear) : dayjs().month(selectedMonth).year(selectedYear)
-    })
+    setCurrentDate(prev => prev.clone().month(selectedMonth).year(selectedYear))
     setView(ViewType.date)
   }
 
   const timeFormat = needTimePicker ? 'MMMM D, YYYY hh:mm A' : 'MMMM D, YYYY'
   const displayValue = value?.format(timeFormat) || ''
-  const displayTime = (selectedDate || dayjs().startOf('day')).format('hh:mm A')
+  const displayTime = selectedDate?.format('hh:mm A') || '--:-- --'
   const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
 
   return (

+ 1 - 1
web/app/components/base/date-and-time-picker/hooks.ts

@@ -1,4 +1,4 @@
-import dayjs from 'dayjs'
+import dayjs from './utils/dayjs'
 import { Period } from './types'
 import { useTranslation } from 'react-i18next'
 

+ 24 - 5
web/app/components/base/date-and-time-picker/time-picker/index.tsx

@@ -1,7 +1,6 @@
 import React, { useCallback, useEffect, useRef, useState } from 'react'
-import dayjs from 'dayjs'
 import type { Period, TimePickerProps } from '../types'
-import { cloneTime, getHourIn12Hour } from '../utils'
+import dayjs, { cloneTime, getDateWithTimezone, getHourIn12Hour } from '../utils/dayjs'
 import {
   PortalToFollowElem,
   PortalToFollowElemContent,
@@ -16,6 +15,7 @@ import cn from '@/utils/classnames'
 
 const TimePicker = ({
   value,
+  timezone,
   placeholder,
   onChange,
   onClear,
@@ -24,7 +24,8 @@ const TimePicker = ({
   const { t } = useTranslation()
   const [isOpen, setIsOpen] = useState(false)
   const containerRef = useRef<HTMLDivElement>(null)
-  const [selectedTime, setSelectedTime] = useState(value)
+  const isInitial = useRef(true)
+  const [selectedTime, setSelectedTime] = useState(value ? getDateWithTimezone({ timezone, date: value }) : undefined)
 
   useEffect(() => {
     const handleClickOutside = (event: MouseEvent) => {
@@ -35,6 +36,22 @@ const TimePicker = ({
     return () => document.removeEventListener('mousedown', handleClickOutside)
   }, [])
 
+  useEffect(() => {
+    if (isInitial.current) {
+      isInitial.current = false
+      return
+    }
+    if (value) {
+      const newValue = getDateWithTimezone({ date: value, timezone })
+      setSelectedTime(newValue)
+      onChange(newValue)
+    }
+    else {
+      setSelectedTime(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [timezone])
+
   const handleClickTrigger = (e: React.MouseEvent) => {
     e.stopPropagation()
     if (isOpen) {
@@ -42,6 +59,8 @@ const TimePicker = ({
       return
     }
     setIsOpen(true)
+    if (value)
+      setSelectedTime(value)
   }
 
   const handleClear = (e: React.MouseEvent) => {
@@ -74,11 +93,11 @@ const TimePicker = ({
   }, [selectedTime])
 
   const handleSelectCurrentTime = useCallback(() => {
-    const newDate = dayjs()
+    const newDate = getDateWithTimezone({ timezone })
     setSelectedTime(newDate)
     onChange(newDate)
     setIsOpen(false)
-  }, [onChange])
+  }, [onChange, timezone])
 
   const handleConfirm = useCallback(() => {
     onChange(selectedTime)

+ 2 - 0
web/app/components/base/date-and-time-picker/types.ts

@@ -21,6 +21,7 @@ type TriggerProps = {
 
 export type DatePickerProps = {
   value: Dayjs | undefined
+  timezone?: string
   placeholder?: string
   needTimePicker?: boolean
   onChange: (date: Dayjs | undefined) => void
@@ -46,6 +47,7 @@ export type DatePickerFooterProps = {
 
 export type TimePickerProps = {
   value: Dayjs | undefined
+  timezone?: string
   placeholder?: string
   onChange: (date: Dayjs | undefined) => void
   onClear: () => void

+ 18 - 2
web/app/components/base/date-and-time-picker/utils.ts → web/app/components/base/date-and-time-picker/utils/dayjs.ts

@@ -1,5 +1,12 @@
-import type { Dayjs } from 'dayjs'
-import type { Day } from './types'
+import dayjs, { type Dayjs } from 'dayjs'
+import type { Day } from '../types'
+import utc from 'dayjs/plugin/utc'
+import timezone from 'dayjs/plugin/timezone'
+
+dayjs.extend(utc)
+dayjs.extend(timezone)
+
+export default dayjs
 
 const monthMaps: Record<string, Day[]> = {}
 
@@ -58,7 +65,16 @@ export const getDaysInMonth = (currentDate: Dayjs) => {
   return days
 }
 
+export const clearMonthMapCache = () => {
+  for (const key in monthMaps)
+    delete monthMaps[key]
+}
+
 export const getHourIn12Hour = (date: Dayjs) => {
   const hour = date.hour()
   return hour === 0 ? 12 : hour >= 12 ? hour - 12 : hour
 }
+
+export const getDateWithTimezone = (props: { date?: Dayjs, timezone?: string }) => {
+  return props.date ? dayjs.tz(props.date, props.timezone) : dayjs().tz(props.timezone)
+}