feat(forms): add PhoneInput and unify module form validation UX

This commit is contained in:
2026-06-23 16:28:28 +05:30
parent 27cf5c9d3f
commit 066374bebc
30 changed files with 1415 additions and 229 deletions

View File

@@ -1,10 +1,15 @@
'use client';
import type { ComponentProps } from 'react';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { useMemo, useRef, type ComponentProps } from 'react';
import {
Controller,
type Control,
type FieldErrors,
type UseFormRegister,
} from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { FormField, PhoneInput, SelectPopover } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -15,16 +20,12 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Client, Plan } from '@/types';
import type { TenantFormValues } from '../hooks/useTenantForm';
import type {
TenantAdminFieldLocks,
TenantFormValues,
} from '../hooks/useTenantForm';
interface TenantSheetProps {
open: boolean;
@@ -32,6 +33,7 @@ interface TenantSheetProps {
tenantId?: number;
isEditMode: boolean;
register: UseFormRegister<TenantFormValues>;
control: Control<TenantFormValues>;
errors: FieldErrors<TenantFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
@@ -46,6 +48,7 @@ interface TenantSheetProps {
isLookupsLoading: boolean;
isSaving: boolean;
adminEmail?: string;
adminFieldLocks: TenantAdminFieldLocks;
}
export function TenantSheet({
@@ -54,6 +57,7 @@ export function TenantSheet({
tenantId,
isEditMode,
register,
control,
errors,
isSubmitted,
onSubmit,
@@ -68,10 +72,30 @@ export function TenantSheet({
isLookupsLoading,
isSaving,
adminEmail,
adminFieldLocks,
}: TenantSheetProps) {
const dropdownPortalRef = useRef<HTMLDivElement | null>(null);
const fieldError = (field: keyof TenantFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
const clientOptions = useMemo(
() =>
clients.map((client) => ({
value: String(client.id),
label: [client.first_name, client.last_name].filter(Boolean).join(' '),
})),
[clients],
);
const planOptions = useMemo(
() =>
plans.map((plan) => ({
value: String(plan.id),
label: plan.name,
})),
[plans],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
@@ -85,7 +109,11 @@ export function TenantSheet({
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
<form
noValidate
onSubmit={onSubmit}
className="flex min-h-0 flex-1 flex-col"
>
<div className="max-h-[50vh] space-y-5 overflow-y-auto px-6 py-4">
<div className="space-y-1">
<p>Basic Information</p>
@@ -129,34 +157,23 @@ export function TenantSheet({
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="tenant-client"
label="Client"
required
error={fieldError('client_id')}
>
<Select
<SelectPopover
options={clientOptions}
value={clientId}
onValueChange={onClientChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
isLookupsLoading
? 'Loading clients...'
: 'Select client'
}
/>
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={String(client.id)}>
{[client.first_name, client.last_name]
.filter(Boolean)
.join(' ')}
</SelectItem>
))}
</SelectContent>
</Select>
portalContainer={dropdownPortalRef}
placeholder={
isLookupsLoading ? 'Loading clients...' : 'Select client'
}
searchPlaceholder="Search clients"
emptyMessage="No clients found."
/>
<input
type="hidden"
{...register('client_id')}
@@ -166,30 +183,23 @@ export function TenantSheet({
</FormField>
<FormField
id="tenant-plan"
label="Subscription Plan"
required
error={fieldError('plan_id')}
>
<Select
<SelectPopover
options={planOptions}
value={planId}
onValueChange={onPlanChange}
disabled={isLookupsLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
/>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
portalContainer={dropdownPortalRef}
placeholder={
isLookupsLoading ? 'Loading plans...' : 'Select plan'
}
searchPlaceholder="Search plans"
emptyMessage="No plans found."
/>
<input
type="hidden"
{...register('plan_id')}
@@ -242,6 +252,7 @@ export function TenantSheet({
id="admin-first-name"
placeholder="Enter first name"
aria-invalid={!!fieldError('admin_first_name')}
readOnly={adminFieldLocks.admin_first_name}
{...register('admin_first_name')}
/>
</FormField>
@@ -256,6 +267,7 @@ export function TenantSheet({
id="admin-last-name"
placeholder="Enter last name"
aria-invalid={!!fieldError('admin_last_name')}
readOnly={adminFieldLocks.admin_last_name}
{...register('admin_last_name')}
/>
</FormField>
@@ -273,6 +285,7 @@ export function TenantSheet({
type="email"
placeholder="admin@example.com"
aria-invalid={!!fieldError('admin_email')}
readOnly={adminFieldLocks.admin_email}
{...register('admin_email')}
/>
</FormField>
@@ -282,11 +295,20 @@ export function TenantSheet({
label="Phone Number"
error={fieldError('admin_phone_number')}
>
<Input
id="admin-phone"
placeholder="+91-9000011111"
aria-invalid={!!fieldError('admin_phone_number')}
{...register('admin_phone_number')}
<Controller
control={control}
name="admin_phone_number"
render={({ field }) => (
<PhoneInput
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="Enter phone number"
aria-invalid={!!fieldError('admin_phone_number')}
disabled={adminFieldLocks.admin_phone_number}
portalContainer={dropdownPortalRef}
/>
)}
/>
</FormField>
</div>
@@ -322,6 +344,8 @@ export function TenantSheet({
</Button>
</DialogFooter>
</form>
<div ref={dropdownPortalRef} />
</DialogContent>
</Dialog>
);

View File

@@ -1,35 +1,35 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } from 'react';
import { isValidPhoneNumber } from 'libphonenumber-js';
import { useCallback, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { Tenant } from '@/types';
import { useSaveTenantMutation } from './useTenantMutations';
const optionalPhoneSchema = z
.string()
.trim()
.refine((value) => value === '' || /^\+(?:[0-9] ?|-){6,18}[0-9]$/.test(value), {
message: 'Please enter a valid phone number',
});
const requiredText = (message: string) => z.string().trim().min(1, message);
const tenantFormSchema = z
.object({
id: z.number().optional(),
name: z.string().trim().min(1, 'Tenant name is required'),
slug: z.string().trim().min(1, 'Slug is required'),
client_id: z.string().trim().min(1, 'Client is required'),
plan_id: z.string().trim().min(1, 'Plan is required'),
name: requiredText('Tenant name is required'),
slug: requiredText('Slug is required'),
client_id: requiredText('Client is required'),
plan_id: requiredText('Plan is required'),
domain: z.string().trim(),
description: z.string().trim(),
admin_first_name: z.string().trim(),
admin_last_name: z.string().trim(),
admin_email: z.string().trim(),
admin_phone_number: optionalPhoneSchema,
admin_phone_number: z
.string()
.trim()
.refine((value) => value === '' || isValidPhoneNumber(value), {
message: 'Enter a valid phone number',
}),
})
.superRefine((values, ctx) => {
if (values.id) return;
@@ -38,7 +38,7 @@ const tenantFormSchema = z
ctx.addIssue({
code: 'custom',
path: ['admin_first_name'],
message: 'Admin first name is required',
message: 'First name is required',
});
}
@@ -46,7 +46,7 @@ const tenantFormSchema = z
ctx.addIssue({
code: 'custom',
path: ['admin_last_name'],
message: 'Admin last name is required',
message: 'Last name is required',
});
}
@@ -98,6 +98,20 @@ function splitContactName(contactName: string) {
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
}
export type TenantAdminFieldLocks = {
admin_first_name: boolean;
admin_last_name: boolean;
admin_email: boolean;
admin_phone_number: boolean;
};
const defaultAdminFieldLocks: TenantAdminFieldLocks = {
admin_first_name: false,
admin_last_name: false,
admin_email: false,
admin_phone_number: false,
};
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
const {
register,
@@ -115,10 +129,15 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
const saveMutation = useSaveTenantMutation({
onSaved: () => {
reset(defaultValues);
setAdminFieldLocks(defaultAdminFieldLocks);
onSaved();
},
});
const [adminFieldLocks, setAdminFieldLocks] = useState<TenantAdminFieldLocks>(
defaultAdminFieldLocks,
);
const tenantId = useWatch({ control, name: 'id' });
const clientId = useWatch({ control, name: 'client_id' }) || '';
const planId = useWatch({ control, name: 'plan_id' }) || '';
@@ -138,21 +157,11 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
adminLastName.trim().length > 0 &&
adminEmail.trim().length > 0));
const handleSubmit = submitForm(
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (firstMessage) {
toast.error(String(firstMessage));
}
},
);
const handleSubmit = submitForm((values) => saveMutation.mutate(values));
const openCreate = useCallback(() => {
reset(defaultValues);
setAdminFieldLocks(defaultAdminFieldLocks);
}, [reset]);
const openEdit = useCallback(
@@ -170,19 +179,37 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
admin_email: tenant.admin_email || '',
admin_phone_number: '',
});
setAdminFieldLocks(defaultAdminFieldLocks);
},
[reset],
);
const clearAdminFields = useCallback(() => {
setValue('admin_first_name', '', { shouldDirty: true });
setValue('admin_last_name', '', { shouldDirty: true });
setValue('admin_email', '', { shouldDirty: true });
setValue('admin_phone_number', '', { shouldDirty: true });
setAdminFieldLocks(defaultAdminFieldLocks);
}, [setValue]);
const applyClientAdminDefaults = useCallback(
(contactName: string, contactEmail: string, contactPhone: string) => {
if (isEditMode) return;
const { firstName, lastName } = splitContactName(contactName);
const trimmedEmail = contactEmail.trim();
const trimmedPhone = contactPhone.trim();
setValue('admin_first_name', firstName, { shouldDirty: true });
setValue('admin_last_name', lastName, { shouldDirty: true });
setValue('admin_email', contactEmail, { shouldDirty: true });
setValue('admin_phone_number', contactPhone, { shouldDirty: true });
setValue('admin_email', trimmedEmail, { shouldDirty: true });
setValue('admin_phone_number', trimmedPhone, { shouldDirty: true });
setAdminFieldLocks({
admin_first_name: firstName.trim().length > 0,
admin_last_name: lastName.trim().length > 0,
admin_email: trimmedEmail.length > 0,
admin_phone_number: trimmedPhone.length > 0,
});
},
[isEditMode, setValue],
);
@@ -197,9 +224,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
return {
register,
control,
handleSubmit,
reset,
control,
setValue,
openCreate,
openEdit,
@@ -211,7 +238,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
errors,
isSubmitted,
canSubmit,
adminFieldLocks,
applyClientAdminDefaults,
clearAdminFields,
syncSlugFromName,
isSaving: isSubmitting || saveMutation.isPending,
};

View File

@@ -54,6 +54,7 @@ export default function TenantsPage() {
});
const {
register,
control,
handleSubmit,
openCreate: prepareCreateTenant,
openEdit: prepareEditTenant,
@@ -65,7 +66,9 @@ export default function TenantsPage() {
errors,
isSubmitted,
canSubmit,
adminFieldLocks,
applyClientAdminDefaults,
clearAdminFields,
syncSlugFromName,
setValue,
isSaving,
@@ -77,7 +80,11 @@ export default function TenantsPage() {
(value: string) => {
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
if (!value || isEditMode) return;
if (isEditMode) return;
clearAdminFields();
if (!value) return;
clientService.getClientById(Number(value)).then((client) => {
applyClientAdminDefaults(
@@ -87,7 +94,7 @@ export default function TenantsPage() {
);
});
},
[applyClientAdminDefaults, isEditMode, setValue],
[applyClientAdminDefaults, clearAdminFields, isEditMode, setValue],
);
const handlePlanChange = useCallback(
@@ -183,6 +190,7 @@ export default function TenantsPage() {
tenantId={tenantId}
isEditMode={isEditMode}
register={register}
control={control}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={handleSubmit}
@@ -197,6 +205,7 @@ export default function TenantsPage() {
isLookupsLoading={isLookupsLoading}
isSaving={isSaving}
adminEmail={adminEmail}
adminFieldLocks={adminFieldLocks}
/>
</>
);