Skip to content

Commit 2052525

Browse files
committed
fix: improve UserModal form validation and UI layout
1 parent e0f4b56 commit 2052525

File tree

1 file changed

+114
-68
lines changed

1 file changed

+114
-68
lines changed

dashboard/src/components/dialogs/UserModal.tsx

Lines changed: 114 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -216,62 +216,65 @@ const ExpiryDateField = ({
216216
[now],
217217
)
218218

219+
const dir = useDirDetection()
220+
219221
return (
220222
<FormItem className="flex flex-1 flex-col">
221223
<FormLabel>{label}</FormLabel>
222-
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
223-
<PopoverTrigger asChild>
224-
<FormControl>
225-
<div className="relative w-full">
226-
<Button
227-
dir={'ltr'}
228-
variant={'outline'}
229-
className={cn('!mt-3.5 h-fit w-full text-left font-normal', !field.value && 'text-muted-foreground')}
230-
type="button"
231-
onClick={e => {
232-
e.preventDefault()
233-
e.stopPropagation()
234-
setCalendarOpen(true)
235-
}}
236-
>
237-
{displayDate ? (
238-
usePersianCalendar ? (
239-
// Persian format - display in local time
240-
displayDate.toLocaleDateString('fa-IR', {
241-
year: 'numeric',
242-
month: '2-digit',
243-
day: '2-digit',
244-
}) +
245-
' ' +
246-
displayDate.toLocaleTimeString('fa-IR', {
247-
hour: '2-digit',
248-
minute: '2-digit',
249-
hour12: false,
250-
})
224+
<div className="relative">
225+
<Popover open={calendarOpen} onOpenChange={setCalendarOpen}>
226+
<PopoverTrigger asChild>
227+
<FormControl>
228+
<div className="relative w-full">
229+
<Button
230+
dir={'ltr'}
231+
variant={'outline'}
232+
className={cn('mt-1 h-fit w-full text-left font-normal', !field.value && 'text-muted-foreground')}
233+
type="button"
234+
onClick={e => {
235+
e.preventDefault()
236+
e.stopPropagation()
237+
setCalendarOpen(true)
238+
}}
239+
>
240+
{displayDate ? (
241+
usePersianCalendar ? (
242+
// Persian format - display in local time
243+
displayDate.toLocaleDateString('fa-IR', {
244+
year: 'numeric',
245+
month: '2-digit',
246+
day: '2-digit',
247+
}) +
248+
' ' +
249+
displayDate.toLocaleTimeString('fa-IR', {
250+
hour: '2-digit',
251+
minute: '2-digit',
252+
hour12: false,
253+
})
254+
) : (
255+
// Gregorian format - display in local time
256+
displayDate.toLocaleDateString('sv-SE', {
257+
year: 'numeric',
258+
month: '2-digit',
259+
day: '2-digit',
260+
}) +
261+
' ' +
262+
displayDate.toLocaleTimeString('sv-SE', {
263+
hour: '2-digit',
264+
minute: '2-digit',
265+
hour12: false,
266+
})
267+
)
268+
) : field.value && !isNaN(Number(field.value)) ? (
269+
String(field.value)
251270
) : (
252-
// Gregorian format - display in local time
253-
displayDate.toLocaleDateString('sv-SE', {
254-
year: 'numeric',
255-
month: '2-digit',
256-
day: '2-digit',
257-
}) +
258-
' ' +
259-
displayDate.toLocaleTimeString('sv-SE', {
260-
hour: '2-digit',
261-
minute: '2-digit',
262-
hour12: false,
263-
})
264-
)
265-
) : field.value && !isNaN(Number(field.value)) ? (
266-
String(field.value)
267-
) : (
268-
<span>{t('userDialog.expireDate', { defaultValue: 'Expire date' })}</span>
269-
)}
270-
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
271-
</Button>
272-
</div>
273-
</FormControl>
274-
</PopoverTrigger>
271+
<span>{t('userDialog.expireDate', { defaultValue: 'Expire date' })}</span>
272+
)}
273+
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
274+
</Button>
275+
</div>
276+
</FormControl>
277+
</PopoverTrigger>
275278
<PopoverContent
276279
className="w-auto p-0"
277280
align="start"
@@ -358,14 +361,15 @@ const ExpiryDateField = ({
358361
)}
359362
</div>
360363
</PopoverContent>
361-
</Popover>
362-
{expireInfo && (
363-
<p className={cn(!expireInfo.time && 'hidden', 'text-xs text-muted-foreground')}>
364-
{expireInfo.time !== '0' && expireInfo.time !== '0s'
365-
? t('expires', { time: expireInfo.time, defaultValue: 'Expires in {{time}}' })
366-
: t('expired', { time: expireInfo.time, defaultValue: 'Expired in {{time}}' })}
367-
</p>
368-
)}
364+
</Popover>
365+
{expireInfo && (
366+
<p className={cn("absolute top-full mt-1 text-xs text-muted-foreground whitespace-nowrap", !expireInfo.time && 'hidden',dir ==="rtl" ? 'right-0' : 'left-0')} >
367+
{expireInfo.time !== '0' && expireInfo.time !== '0s'
368+
? t('expires', { time: expireInfo.time, defaultValue: 'Expires in {{time}}' })
369+
: t('expired', { time: expireInfo.time, defaultValue: 'Expired in {{time}}' })}
370+
</p>
371+
)}
372+
</div>
369373
<FormMessage />
370374
</FormItem>
371375
)
@@ -476,7 +480,10 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
476480
{ id: 'groups', label: 'groups', icon: Users },
477481
{ id: 'templates', label: 'templates.title', icon: Layers },
478482
]
479-
const [nextPlanEnabled, setNextPlanEnabled] = useState(!!form.watch('next_plan'))
483+
const [nextPlanEnabled, setNextPlanEnabled] = useState(() => {
484+
const nextPlan = form.watch('next_plan')
485+
return nextPlan !== undefined && nextPlan !== null && Object.keys(nextPlan).length > 0
486+
})
480487
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(null)
481488
const [expireCalendarOpen, setExpireCalendarOpen] = useState(false)
482489
const [onHoldCalendarOpen, setOnHoldCalendarOpen] = useState(false)
@@ -712,12 +719,23 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
712719
useEffect(() => {
713720
if (!nextPlanEnabled) {
714721
form.setValue('next_plan', undefined)
715-
} else if (!form.watch('next_plan')) {
722+
handleFieldChange('next_plan', undefined)
723+
} else if (!form.watch('next_plan') || form.watch('next_plan') === null) {
716724
form.setValue('next_plan', {})
725+
handleFieldChange('next_plan', {})
717726
}
718727
// eslint-disable-next-line
719728
}, [nextPlanEnabled])
720729

730+
// Sync switch state when next_plan value changes (e.g., when editing a user)
731+
useEffect(() => {
732+
const nextPlan = form.watch('next_plan')
733+
const shouldBeEnabled = nextPlan !== undefined && nextPlan !== null && Object.keys(nextPlan).length > 0
734+
if (nextPlanEnabled !== shouldBeEnabled) {
735+
setNextPlanEnabled(shouldBeEnabled)
736+
}
737+
}, [form.watch('next_plan'), nextPlanEnabled])
738+
721739
// Helper to convert GB to bytes
722740
function gbToBytes(gb: string | number | undefined): number | undefined {
723741
if (gb === undefined || gb === null || gb === '') return undefined
@@ -772,6 +790,18 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
772790
return true
773791
}
774792

793+
// Special case for Next Plan enabled - if Next Plan is enabled and no other fields are touched,
794+
// consider the form valid (Next Plan fields are optional)
795+
if (nextPlanEnabled && editingUser && !isSubmit) {
796+
const hasTouchedNonNextPlanFields = Object.keys(touchedFields).some(key =>
797+
key !== 'next_plan' && !key.startsWith('next_plan.') && touchedFields[key]
798+
)
799+
if (!hasTouchedNonNextPlanFields) {
800+
form.clearErrors()
801+
return true
802+
}
803+
}
804+
775805
// Only validate fields that have been touched
776806
const fieldsToValidate = isSubmit
777807
? currentValues
@@ -976,6 +1006,8 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
9761006
expire: preparedValues.expire,
9771007
// Only include proxy_settings if they are filled
9781008
...(hasProxySettings ? { proxy_settings: cleanedProxySettings } : {}),
1009+
// Force send undefined when Next Plan is disabled
1010+
next_plan: nextPlanEnabled ? preparedValues.next_plan : undefined,
9791011
}
9801012

9811013
// Remove proxy_settings from the payload if it's empty or undefined
@@ -1321,14 +1353,14 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
13211353
</div>
13221354
{/* Data limit and expire fields - show data_limit only when no template is selected */}
13231355
{activeTab === 'groups' && (
1324-
<div className="flex w-full flex-col gap-4 lg:flex-row lg:items-start">
1356+
<div className="flex w-full flex-col gap-4 lg:flex-row lg:items-end">
13251357
{!selectedTemplateId && (
13261358
<>
13271359
<FormField
13281360
control={form.control}
13291361
name="data_limit"
13301362
render={({ field }) => (
1331-
<FormItem className="flex-1">
1363+
<FormItem className="flex-1 h-full">
13321364
<FormLabel>{t('userDialog.dataLimit', { defaultValue: 'Data Limit (GB)' })}</FormLabel>
13331365
<FormControl>
13341366
<Input
@@ -1391,7 +1423,7 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
13911423
)}
13921424
</>
13931425
)}
1394-
<div className="flex items-start gap-4 lg:w-52">
1426+
<div className="flex items-start gap-4 lg:w-52 h-full">
13951427
{status === 'on_hold' ? (
13961428
<FormField
13971429
control={form.control}
@@ -1800,7 +1832,13 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
18001832
<ListStart className="h-4 w-4" />
18011833
<div>{t('userDialog.nextPlanTitle', { defaultValue: 'Next Plan' })}</div>
18021834
</div>
1803-
<Switch checked={nextPlanEnabled} onCheckedChange={setNextPlanEnabled} />
1835+
<Switch checked={nextPlanEnabled} onCheckedChange={value => {
1836+
setNextPlanEnabled(value)
1837+
// Trigger validation when Next Plan toggle changes
1838+
const currentValues = form.getValues()
1839+
const isValid = validateAllFields(currentValues, touchedFields)
1840+
setIsFormValid(isValid)
1841+
}} />
18041842
</div>
18051843
{nextPlanEnabled && (
18061844
<div className="flex flex-col gap-4 py-4">
@@ -1816,8 +1854,10 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
18161854
onValueChange={val => {
18171855
if (val === 'none' || (field.value && String(field.value) === val)) {
18181856
field.onChange(undefined)
1857+
handleFieldChange('next_plan.user_template_id', undefined)
18191858
} else {
18201859
field.onChange(Number(val))
1860+
handleFieldChange('next_plan.user_template_id', Number(val))
18211861
}
18221862
}}
18231863
>
@@ -1858,6 +1898,7 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
18581898
const days = e.target.value ? Number(e.target.value) : 0
18591899
const seconds = dateUtils.daysToSeconds(days)
18601900
field.onChange(seconds)
1901+
handleFieldChange('next_plan.expire', seconds)
18611902
}}
18621903
/>
18631904
</FormControl>
@@ -1881,7 +1922,9 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
18811922
onChange={e => {
18821923
const value = e.target.value ? Number(e.target.value) : 0
18831924
// Convert GB to bytes (1 GB = 1024 * 1024 * 1024 bytes)
1884-
field.onChange(value ? value * 1024 * 1024 * 1024 : 0)
1925+
const bytesValue = value ? value * 1024 * 1024 * 1024 : 0
1926+
field.onChange(bytesValue)
1927+
handleFieldChange('next_plan.data_limit', bytesValue)
18851928
}}
18861929
value={field.value ? Math.round(field.value / (1024 * 1024 * 1024)) : ''}
18871930
/>
@@ -1900,7 +1943,10 @@ export default function UserModal({ isDialogOpen, onOpenChange, form, editingUse
19001943
render={({ field }) => (
19011944
<FormItem className="flex flex-row items-center justify-between w-full">
19021945
<FormLabel>{t('userDialog.nextPlanAddRemainingTraffic', { defaultValue: 'Add Remaining Traffic' })}</FormLabel>
1903-
<Switch checked={!!field.value} onCheckedChange={field.onChange} />
1946+
<Switch checked={!!field.value} onCheckedChange={value => {
1947+
field.onChange(value)
1948+
handleFieldChange('next_plan.add_remaining_traffic', value)
1949+
}} />
19041950
<FormMessage />
19051951
</FormItem>
19061952
)}

0 commit comments

Comments
 (0)