Skip to content

Commit 077af77

Browse files
committed
refactor(date-picker): streamline date serialization and normalization functions
1 parent ef1f208 commit 077af77

6 files changed

Lines changed: 115 additions & 93 deletions

File tree

dashboard/src/components/common/date-picker.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
1111
import { Input } from '@/components/ui/input'
1212
import { useTranslation } from 'react-i18next'
1313
import { Calendar as PersianCalendar } from '@/components/ui/persian-calendar'
14-
import { formatDateByLocale, formatDateShort, isDateDisabled, isPersianLocaleLanguage } from '@/utils/datePickerUtils'
15-
import { formatOffsetDateTime, parseDateInput, toUnixSeconds } from '@/utils/dateTimeParsing'
14+
import { formatDateByLocale, formatDateShort, isDateDisabled, isPersianLocaleLanguage, serializeDatePickerValue } from '@/utils/datePickerUtils'
15+
import { parseDateInput } from '@/utils/dateTimeParsing'
1616
import { useTheme } from '@/components/common/theme-provider'
1717
import { DATE_PICKER_PREFERENCE_KEY, getDatePickerPreference, type DatePickerPreference } from '@/utils/userPreferenceStorage'
1818
import useDirDetection from '@/hooks/use-dir-detection'
@@ -263,7 +263,7 @@ export function DatePicker({
263263
}
264264

265265
setInternalDate(selectedDate)
266-
const value = useUtcTimestamp ? toUnixSeconds(selectedDate) : formatOffsetDateTime(selectedDate)
266+
const value = serializeDatePickerValue(selectedDate, { useUtcTimestamp })
267267
handleSingleDateChange(selectedDate)
268268
onFieldChange?.(fieldName, value)
269269
setTimeout(() => {
@@ -294,7 +294,7 @@ export function DatePicker({
294294
newDate.setTime(now.getTime())
295295
}
296296

297-
const value = useUtcTimestamp ? toUnixSeconds(newDate) : formatOffsetDateTime(newDate)
297+
const value = serializeDatePickerValue(newDate, { useUtcTimestamp })
298298
setInternalDate(newDate)
299299
handleSingleDateChange(newDate)
300300
onFieldChange?.(fieldName, value)

dashboard/src/components/dialogs/user-modal.tsx

Lines changed: 23 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import {
3636
type UserResponse,
3737
} from '@/service/api'
3838
import { dateUtils, useRelativeExpiryDate } from '@/utils/dateFormatter'
39-
import { formatOffsetDateTime, parseDateInput, toDisplayDate, toUnixSeconds } from '@/utils/dateTimeParsing'
39+
import { normalizeDatePickerValueForSubmit, serializeDatePickerValue, toDatePickerDisplayDate } from '@/utils/datePickerUtils'
40+
import { parseDateInput } from '@/utils/dateTimeParsing'
4041
import { bytesToFormGigabytes, formatBytes, gbToBytes } from '@/utils/formatByte'
4142
import { invalidateUserMetricsQueries, upsertUserInUsersCache } from '@/utils/usersCache'
4243
import { generateWireGuardKeyPair, getWireGuardPublicKey } from '@/utils/wireguard'
@@ -59,8 +60,6 @@ interface UserModalProps {
5960
onSuccessCallback?: (user: UserResponse) => void
6061
}
6162

62-
const isDate = (v: unknown): v is Date => typeof v === 'object' && v !== null && v instanceof Date
63-
6463
// Add template validation schema
6564
const templateUserSchema = z.object({
6665
username: z.string().min(3, 'validation.minLength').max(128, 'validation.maxLength'),
@@ -104,8 +103,7 @@ const ExpiryDateField = ({
104103
const handleDateChange = React.useCallback(
105104
(date: Date | undefined) => {
106105
if (date) {
107-
// Use the same logic as centralized DatePicker
108-
const value = useUtcTimestamp ? toUnixSeconds(date) : formatOffsetDateTime(date)
106+
const value = serializeDatePickerValue(date, { useUtcTimestamp })
109107
field.onChange(value)
110108
handleFieldChange(fieldName, value)
111109
} else {
@@ -395,6 +393,7 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
395393
const onHoldExpireDurationInputRef = React.useRef<string>('')
396394
const nextPlanExpireInputRef = React.useRef<string>('')
397395
const nextPlanDataLimitInputRef = React.useRef<string>('')
396+
const previousStatusRef = React.useRef(status)
398397

399398
const handleModalOpenChange = React.useCallback(
400399
(open: boolean) => {
@@ -459,42 +458,8 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
459458
const onHoldValue = form.watch('on_hold_timeout')
460459
const dataLimitValue = form.watch('data_limit')
461460

462-
let displayDate: Date | null = null
463-
let onHoldDisplayDate: Date | null = null
464-
465-
// Handle various formats of expire value using the same logic as OnlineBadge/OnlineStatus
466-
const parseDateValue = (value: unknown): Date | null => {
467-
if (isDate(value)) {
468-
return value
469-
} else if (typeof value === 'string') {
470-
if (value === '') {
471-
return null
472-
} else {
473-
try {
474-
const trimmedValue = value.trim()
475-
const dayjsDate = parseDateInput(trimmedValue)
476-
if (dayjsDate.isValid()) {
477-
return toDisplayDate(trimmedValue)
478-
}
479-
} catch (error) {
480-
// Ignore invalid values and return null.
481-
}
482-
}
483-
} else if (typeof value === 'number') {
484-
try {
485-
const dayjsDate = parseDateInput(value)
486-
if (dayjsDate.isValid()) {
487-
return toDisplayDate(value)
488-
}
489-
} catch (error) {
490-
// Ignore invalid values and return null.
491-
}
492-
}
493-
return null
494-
}
495-
496-
displayDate = parseDateValue(expireValue)
497-
onHoldDisplayDate = parseDateValue(onHoldValue)
461+
const displayDate = toDatePickerDisplayDate(expireValue)
462+
const onHoldDisplayDate = toDatePickerDisplayDate(onHoldValue)
498463

499464
// Query client for data refetching
500465
const queryClient = useQueryClient()
@@ -633,16 +598,25 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
633598
}, [selectedTemplateId, form, t])
634599

635600
useEffect(() => {
601+
const previousStatus = previousStatusRef.current
602+
636603
if (status === 'on_hold') {
637604
form.setValue('expire', undefined)
638605
form.clearErrors('expire')
639606
} else {
607+
if (previousStatus === 'on_hold') {
608+
form.setValue('expire', undefined)
609+
form.clearErrors('expire')
610+
setExpireCalendarOpen(false)
611+
}
640612
onHoldExpireDurationInputRef.current = ''
641613
form.setValue('on_hold_expire_duration', undefined)
642614
form.clearErrors('on_hold_expire_duration')
643615
form.setValue('on_hold_timeout', undefined)
644616
form.clearErrors('on_hold_timeout')
645617
}
618+
619+
previousStatusRef.current = status
646620
}, [status, form])
647621

648622
useEffect(() => {
@@ -708,29 +682,6 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
708682
}
709683
}, [isDialogOpen, editingUser, hasNextPlanData, nextPlanManuallyDisabled, form, nextPlanUserTemplateId, nextPlanExpire, nextPlanDataLimit, nextPlanAddRemainingTraffic, editingUserData])
710684

711-
// Helper to convert expire field to needed schema using the same logic as other components
712-
function normalizeExpire(expire: Date | string | number | null | undefined, useUtcTimestamp: boolean = false): string | number | undefined {
713-
if (expire === '') return 0
714-
if (expire === undefined || expire === null) return undefined
715-
716-
// For Date objects, convert to appropriate format
717-
if (expire instanceof Date) {
718-
return useUtcTimestamp ? toUnixSeconds(expire) : formatOffsetDateTime(expire)
719-
}
720-
721-
// For strings and numbers, normalize via centralized parser.
722-
try {
723-
const dayjsDate = parseDateInput(expire)
724-
if (dayjsDate.isValid()) {
725-
return useUtcTimestamp ? toUnixSeconds(expire) : formatOffsetDateTime(expire)
726-
}
727-
} catch (error) {
728-
// If dayjs parsing fails, return undefined
729-
}
730-
731-
return undefined
732-
}
733-
734685
// Helper to clear group selection
735686
const clearGroups = () => form.setValue('group_ids', [])
736687
// Helper to clear template selection
@@ -1017,13 +968,14 @@ function UserModal({ isDialogOpen, onOpenChange, form, editingUser, editingUserI
1017968
const preparedValues = {
1018969
...valuesWithoutNextPlan,
1019970
data_limit: typeof values.data_limit === 'string' ? parseFloat(values.data_limit) : values.data_limit,
1020-
on_hold_expire_duration: values.on_hold_expire_duration
1021-
? typeof values.on_hold_expire_duration === 'string'
1022-
? parseFloat(values.on_hold_expire_duration)
1023-
: values.on_hold_expire_duration
1024-
: undefined,
1025-
expire: status === 'on_hold' ? undefined : normalizeExpire(values.expire),
1026-
on_hold_timeout: status === 'on_hold' ? normalizeExpire(values.on_hold_timeout) : undefined,
971+
on_hold_expire_duration:
972+
status === 'on_hold' && values.on_hold_expire_duration
973+
? typeof values.on_hold_expire_duration === 'string'
974+
? parseFloat(values.on_hold_expire_duration)
975+
: values.on_hold_expire_duration
976+
: undefined,
977+
expire: status === 'on_hold' ? undefined : normalizeDatePickerValueForSubmit(values.expire),
978+
on_hold_timeout: status === 'on_hold' ? normalizeDatePickerValueForSubmit(values.on_hold_timeout) : undefined,
1027979
group_ids: Array.isArray(values.group_ids) ? values.group_ids : [],
1028980
status: values.status,
1029981
}

dashboard/src/components/users/action-buttons.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next'
1414
import { toast } from 'sonner'
1515
import { CopyButton } from '@/components/common/copy-button'
1616
import { bytesToFormGigabytes } from '@/utils/formatByte'
17-
import { normalizeExpireForEditForm } from '@/utils/userEditDateUtils'
17+
import { normalizeDatePickerValueForEditForm } from '@/utils/userEditDateUtils'
1818
import SubscriptionModal from '@/components/dialogs/subscription-modal'
1919
import SetOwnerModal from '@/components/dialogs/set-owner-modal'
2020
import UsageModal from '@/components/dialogs/usage-modal'
@@ -193,12 +193,12 @@ const buildUserEditFormValues = (user: UserResponse): UseEditFormValues => ({
193193
username: user.username,
194194
status: user.status === 'active' || user.status === 'on_hold' || user.status === 'disabled' ? (user.status as UseEditFormValues['status']) : 'active',
195195
data_limit: user.data_limit ? bytesToFormGigabytes(Number(user.data_limit)) : 0,
196-
expire: normalizeExpireForEditForm(user.expire),
196+
expire: normalizeDatePickerValueForEditForm(user.expire),
197197
note: user.note || '',
198198
data_limit_reset_strategy: user.data_limit_reset_strategy || undefined,
199199
group_ids: user.group_ids || [],
200200
on_hold_expire_duration: user.on_hold_expire_duration || undefined,
201-
on_hold_timeout: user.on_hold_timeout || undefined,
201+
on_hold_timeout: normalizeDatePickerValueForEditForm(user.on_hold_timeout),
202202
proxy_settings: user.proxy_settings || undefined,
203203
next_plan: user.next_plan
204204
? {
@@ -923,4 +923,3 @@ export const ActionButtonsModalHost: FC = () => {
923923
}
924924

925925
export default ActionButtons
926-

dashboard/src/components/users/users-table.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
setUsersShowSelectionCheckbox,
3030
} from '@/utils/userPreferenceStorage'
3131
import { bytesToFormGigabytes } from '@/utils/formatByte'
32-
import { normalizeExpireForEditForm } from '@/utils/userEditDateUtils'
32+
import { normalizeDatePickerValueForEditForm } from '@/utils/userEditDateUtils'
3333
import { useQueryClient, useMutation } from '@tanstack/react-query'
3434
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3535
import { useForm } from 'react-hook-form'
@@ -223,12 +223,12 @@ const UsersTable = memo(() => {
223223
username: selectedUser?.username,
224224
status: selectedUser?.status === 'active' || selectedUser?.status === 'on_hold' || selectedUser?.status === 'disabled' ? selectedUser?.status : 'active',
225225
data_limit: selectedUser?.data_limit ? bytesToFormGigabytes(Number(selectedUser.data_limit)) : undefined,
226-
expire: normalizeExpireForEditForm(selectedUser?.expire),
226+
expire: normalizeDatePickerValueForEditForm(selectedUser?.expire),
227227
note: selectedUser?.note || '',
228228
data_limit_reset_strategy: selectedUser?.data_limit_reset_strategy || undefined,
229229
group_ids: selectedUser?.group_ids || [],
230230
on_hold_expire_duration: selectedUser?.on_hold_expire_duration || undefined,
231-
on_hold_timeout: selectedUser?.on_hold_timeout || undefined,
231+
on_hold_timeout: normalizeDatePickerValueForEditForm(selectedUser?.on_hold_timeout),
232232
proxy_settings: selectedUser?.proxy_settings || undefined,
233233
next_plan: selectedUser?.next_plan
234234
? {
@@ -247,12 +247,12 @@ const UsersTable = memo(() => {
247247
username: selectedUser.username,
248248
status: selectedUser.status === 'active' || selectedUser.status === 'on_hold' || selectedUser.status === 'disabled' ? selectedUser.status : 'active',
249249
data_limit: selectedUser.data_limit ? bytesToFormGigabytes(Number(selectedUser.data_limit)) : 0,
250-
expire: normalizeExpireForEditForm(selectedUser.expire),
250+
expire: normalizeDatePickerValueForEditForm(selectedUser.expire),
251251
note: selectedUser.note || '',
252252
data_limit_reset_strategy: selectedUser.data_limit_reset_strategy || undefined,
253253
group_ids: selectedUser.group_ids || [],
254254
on_hold_expire_duration: selectedUser.on_hold_expire_duration || undefined,
255-
on_hold_timeout: selectedUser.on_hold_timeout || undefined,
255+
on_hold_timeout: normalizeDatePickerValueForEditForm(selectedUser.on_hold_timeout),
256256
proxy_settings: selectedUser.proxy_settings || undefined,
257257
next_plan: selectedUser.next_plan
258258
? {

dashboard/src/utils/datePickerUtils.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,88 @@
11
import { DateRange } from 'react-day-picker'
22
import { Period } from '@/service/api'
33
import { format } from 'date-fns'
4+
import { type DateInput, formatOffsetDateTime, parseDateInput, toDisplayDate, toLocalOffsetDateTime, toUnixSeconds } from './dateTimeParsing'
5+
6+
type DatePickerValue = Date | string | number | null | undefined
7+
8+
type DatePickerSerializeOptions = {
9+
useUtcTimestamp?: boolean
10+
}
11+
12+
const UTC_SUFFIX_PATTERN = /Z$/i
413

514
/** True when the app language is Persian (e.g. `fa`, `fa-IR` from i18next or the browser). */
615
export const isPersianLocaleLanguage = (language: string | undefined): boolean =>
716
(language ?? '').toLowerCase().startsWith('fa')
817

18+
export const serializeDatePickerValue = (value: DateInput, { useUtcTimestamp = false }: DatePickerSerializeOptions = {}): string | number => {
19+
return useUtcTimestamp ? toUnixSeconds(value) : formatOffsetDateTime(value)
20+
}
21+
22+
export const normalizeDatePickerValueForEditForm = (value: string | number | null | undefined) => {
23+
if (typeof value === 'string' && UTC_SUFFIX_PATTERN.test(value.trim())) {
24+
return toLocalOffsetDateTime(value)
25+
}
26+
27+
return value
28+
}
29+
30+
export const toDatePickerDisplayDate = (value: unknown): Date | null => {
31+
if (value instanceof Date) {
32+
return value
33+
}
34+
35+
if (typeof value === 'string') {
36+
const trimmedValue = value.trim()
37+
if (trimmedValue === '') return null
38+
39+
try {
40+
const parsed = parseDateInput(trimmedValue)
41+
return parsed.isValid() ? toDisplayDate(trimmedValue) : null
42+
} catch {
43+
return null
44+
}
45+
}
46+
47+
if (typeof value === 'number') {
48+
try {
49+
const parsed = parseDateInput(value)
50+
return parsed.isValid() ? toDisplayDate(value) : null
51+
} catch {
52+
return null
53+
}
54+
}
55+
56+
return null
57+
}
58+
59+
export const normalizeDatePickerValueForSubmit = (value: DatePickerValue | '', options: DatePickerSerializeOptions = {}): string | number | undefined => {
60+
if (value === undefined || value === null) return undefined
61+
62+
if (typeof value === 'string') {
63+
const trimmedValue = value.trim()
64+
if (trimmedValue === '') return 0
65+
66+
try {
67+
const parsed = parseDateInput(trimmedValue)
68+
return parsed.isValid() ? serializeDatePickerValue(trimmedValue, options) : undefined
69+
} catch {
70+
return undefined
71+
}
72+
}
73+
74+
if (value instanceof Date || typeof value === 'number') {
75+
try {
76+
const parsed = parseDateInput(value)
77+
return parsed.isValid() ? serializeDatePickerValue(value, options) : undefined
78+
} catch {
79+
return undefined
80+
}
81+
}
82+
83+
return undefined
84+
}
85+
986
/**
1087
* Determines the appropriate period (hour or day) based on the date range
1188
* @param range - The date range to analyze
Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
1-
import { toLocalOffsetDateTime } from './dateTimeParsing'
1+
import { normalizeDatePickerValueForEditForm } from './datePickerUtils'
22

3-
const UTC_SUFFIX_PATTERN = /Z$/i
3+
export { normalizeDatePickerValueForEditForm }
44

5-
export const normalizeExpireForEditForm = (expire: string | number | null | undefined) => {
6-
if (typeof expire === 'string' && UTC_SUFFIX_PATTERN.test(expire.trim())) {
7-
return toLocalOffsetDateTime(expire)
8-
}
9-
10-
return expire
11-
}
5+
export const normalizeExpireForEditForm = normalizeDatePickerValueForEditForm

0 commit comments

Comments
 (0)