refactor: Align form validation UX across management dialogs

This commit is contained in:
2026-06-18 11:05:31 +05:30
parent d7c4027328
commit 9695c80e20
23 changed files with 1458 additions and 1017 deletions

View File

@@ -1,9 +1,10 @@
'use client';
import type { ComponentProps } from 'react';
import type { UseFormRegister } from 'react-hook-form';
import type { FieldErrors, UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { FormField } from '@/components/form';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -14,7 +15,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -32,7 +32,10 @@ interface TenantSheetProps {
tenantId?: number;
isEditMode: boolean;
register: UseFormRegister<TenantFormValues>;
errors: FieldErrors<TenantFormValues>;
isSubmitted: boolean;
onSubmit: ComponentProps<'form'>['onSubmit'];
canSubmit: boolean;
clientId: string;
planId: string;
onClientChange: (clientId: string) => void;
@@ -51,7 +54,10 @@ export function TenantSheet({
tenantId,
isEditMode,
register,
errors,
isSubmitted,
onSubmit,
canSubmit,
clientId,
planId,
onClientChange,
@@ -63,181 +69,234 @@ export function TenantSheet({
isSaving,
adminEmail,
}: TenantSheetProps) {
const fieldError = (field: keyof TenantFormValues) =>
isSubmitted ? errors[field]?.message : undefined;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>
<DialogContent className="flex max-h-[calc(100vh-2rem)] flex-col gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12">
<DialogTitle className="text-lg leading-none font-semibold tracking-tight">
{tenantId ? 'Edit Tenant' : 'Create Tenant'}
</DialogTitle>
<DialogDescription>
<DialogDescription className="text-sm">
Bind a client and subscription plan, then invite the tenant
administrator.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="space-y-1">
<p>Basic Information</p>
<p className="text-muted-foreground">
Tenant identity and subscription binding.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-name">Tenant Name</Label>
<Input
id="tenant-name"
placeholder="Acme Corp"
{...register('name', {
required: true,
onChange: (event) => onNameChange(event.target.value),
})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Slug</Label>
<Input
id="tenant-slug"
placeholder="acme-corp"
{...register('slug', { required: true })}
required
readOnly={isEditMode}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Client</Label>
<Select
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.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Subscription Plan</Label>
<Select
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>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-domain">Domain</Label>
<Input
id="tenant-domain"
placeholder="acme.example.com"
{...register('domain')}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="tenant-description">Description</Label>
<textarea
id="tenant-description"
placeholder="North zone operations"
{...register('description')}
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
/>
</div>
</div>
{!isEditMode ? (
<>
<div className="space-y-1">
<p>Tenant Administrator</p>
<p className="text-muted-foreground">
Invitation details for the primary tenant admin account.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="admin-first-name">First Name</Label>
<Input
id="admin-first-name"
placeholder="Jane"
{...register('admin_first_name', { required: !isEditMode })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-last-name">Last Name</Label>
<Input
id="admin-last-name"
placeholder="Doe"
{...register('admin_last_name', { required: !isEditMode })}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="admin-email">Admin Email</Label>
<Input
id="admin-email"
type="email"
placeholder="jane@acme.com"
{...register('admin_email', { required: !isEditMode })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-phone">Phone Number</Label>
<Input
id="admin-phone"
placeholder="+91-9000011111"
{...register('admin_phone_number')}
/>
</div>
</div>
</>
) : adminEmail ? (
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
Admin invitation details cannot be changed after tenant creation.
<p className="mt-1 text-foreground">
Current admin email: <span>{adminEmail}</span>
<form 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>
<p className="text-muted-foreground">
Tenant identity and subscription binding.
</p>
</div>
) : null}
<DialogFooter className="px-0">
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="tenant-name"
label="Tenant Name"
required
error={fieldError('name')}
>
<Input
id="tenant-name"
placeholder="Enter tenant name"
aria-invalid={!!fieldError('name')}
{...register('name', {
onChange: (event) => onNameChange(event.target.value),
})}
/>
</FormField>
<FormField
id="tenant-slug"
label="Slug"
required
error={fieldError('slug')}
>
<Input
id="tenant-slug"
placeholder="acme-corp"
aria-invalid={!!fieldError('slug')}
{...register('slug')}
readOnly={isEditMode}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="Client" required error={fieldError('client_id')}>
<Select
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.name}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register('client_id')}
value={clientId}
readOnly
/>
</FormField>
<FormField
label="Subscription Plan"
required
error={fieldError('plan_id')}
>
<Select
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>
<input
type="hidden"
{...register('plan_id')}
value={planId}
readOnly
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField id="tenant-domain" label="Domain">
<Input
id="tenant-domain"
placeholder="acme.example.com"
{...register('domain')}
/>
</FormField>
<FormField
id="tenant-description"
label="Description"
className="md:col-span-2"
>
<textarea
id="tenant-description"
placeholder="North zone operations"
{...register('description')}
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 outline-none focus-visible:border-ring"
/>
</FormField>
</div>
{!isEditMode ? (
<>
<div className="space-y-1">
<p>Tenant Administrator</p>
<p className="text-muted-foreground">
Invitation details for the primary tenant admin account.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="admin-first-name"
label="First Name"
required
error={fieldError('admin_first_name')}
>
<Input
id="admin-first-name"
placeholder="Enter first name"
aria-invalid={!!fieldError('admin_first_name')}
{...register('admin_first_name')}
/>
</FormField>
<FormField
id="admin-last-name"
label="Last Name"
required
error={fieldError('admin_last_name')}
>
<Input
id="admin-last-name"
placeholder="Enter last name"
aria-invalid={!!fieldError('admin_last_name')}
{...register('admin_last_name')}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField
id="admin-email"
label="Admin Email"
required
error={fieldError('admin_email')}
>
<Input
id="admin-email"
type="email"
placeholder="admin@example.com"
aria-invalid={!!fieldError('admin_email')}
{...register('admin_email')}
/>
</FormField>
<FormField
id="admin-phone"
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')}
/>
</FormField>
</div>
</>
) : adminEmail ? (
<div className="rounded-md border bg-muted/40 p-4 text-muted-foreground">
Admin invitation details cannot be changed after tenant
creation.
<p className="mt-1 text-foreground">
Current admin email: <span>{adminEmail}</span>
</p>
</div>
) : null}
</div>
<DialogFooter className="shrink-0 border-t px-6 py-4">
<Button
type="button"
variant="outline"
@@ -246,7 +305,10 @@ export function TenantSheet({
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || isLookupsLoading}>
<Button
type="submit"
disabled={isSaving || isLookupsLoading || !canSubmit}
>
{isSaving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : null}

View File

@@ -1,26 +1,74 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCallback } 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';
export interface TenantFormValues {
id?: number;
name: string;
slug: string;
client_id: string;
plan_id: string;
domain: string;
description: string;
admin_first_name: string;
admin_last_name: string;
admin_email: string;
admin_phone_number: string;
}
const optionalPhoneSchema = z
.string()
.trim()
.refine((value) => value === '' || /^\+(?:[0-9] ?|-){6,18}[0-9]$/.test(value), {
message: 'Please enter a valid phone number',
});
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'),
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,
})
.superRefine((values, ctx) => {
if (values.id) return;
if (!values.admin_first_name.trim()) {
ctx.addIssue({
code: 'custom',
path: ['admin_first_name'],
message: 'Admin first name is required',
});
}
if (!values.admin_last_name.trim()) {
ctx.addIssue({
code: 'custom',
path: ['admin_last_name'],
message: 'Admin last name is required',
});
}
if (!values.admin_email.trim()) {
ctx.addIssue({
code: 'custom',
path: ['admin_email'],
message: 'Admin email is required',
});
return;
}
if (!z.string().email().safeParse(values.admin_email).success) {
ctx.addIssue({
code: 'custom',
path: ['admin_email'],
message: 'Invalid admin email',
});
}
});
export type TenantFormValues = z.infer<typeof tenantFormSchema>;
const defaultValues: TenantFormValues = {
name: '',
@@ -57,9 +105,12 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
reset,
control,
setValue,
formState: { isSubmitting },
formState: { errors, isSubmitting, isSubmitted },
} = useForm<TenantFormValues>({
defaultValues,
mode: 'onSubmit',
reValidateMode: 'onChange',
resolver: zodResolver(tenantFormSchema),
});
const saveMutation = useSaveTenantMutation({
onSaved: () => {
@@ -71,30 +122,33 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
const tenantId = useWatch({ control, name: 'id' });
const clientId = useWatch({ control, name: 'client_id' }) || '';
const planId = useWatch({ control, name: 'plan_id' }) || '';
const name = useWatch({ control, name: 'name' }) || '';
const slug = useWatch({ control, name: 'slug' }) || '';
const adminFirstName = useWatch({ control, name: 'admin_first_name' }) || '';
const adminLastName = useWatch({ control, name: 'admin_last_name' }) || '';
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
const isEditMode = Boolean(tenantId);
const canSubmit =
name.trim().length > 0 &&
slug.trim().length > 0 &&
clientId.trim().length > 0 &&
planId.trim().length > 0 &&
(isEditMode ||
(adminFirstName.trim().length > 0 &&
adminLastName.trim().length > 0 &&
adminEmail.trim().length > 0));
const handleSubmit = submitForm(
(values) => {
if (!values.client_id || !values.plan_id) {
toast.error('Select a client and plan');
return;
}
(values) => saveMutation.mutate(values),
(formErrors) => {
const firstMessage = Object.values(formErrors).find(
(error) => error?.message,
)?.message;
if (!isEditMode) {
if (
!values.admin_first_name.trim() ||
!values.admin_last_name.trim() ||
!values.admin_email.trim()
) {
toast.error('Complete all required admin fields');
return;
}
if (firstMessage) {
toast.error(String(firstMessage));
}
saveMutation.mutate(values);
},
() => toast.error('Complete all required tenant fields'),
);
const openCreate = useCallback(() => {
@@ -154,6 +208,9 @@ export function useTenantForm({ onSaved }: { onSaved: () => void }) {
planId,
adminEmail,
isEditMode,
errors,
isSubmitted,
canSubmit,
applyClientAdminDefaults,
syncSlugFromName,
isSaving: isSubmitting || saveMutation.isPending,

View File

@@ -62,6 +62,9 @@ export default function TenantsPage() {
planId,
adminEmail,
isEditMode,
errors,
isSubmitted,
canSubmit,
applyClientAdminDefaults,
syncSlugFromName,
setValue,
@@ -180,7 +183,10 @@ export default function TenantsPage() {
tenantId={tenantId}
isEditMode={isEditMode}
register={register}
errors={errors}
isSubmitted={isSubmitted}
onSubmit={handleSubmit}
canSubmit={canSubmit}
clientId={clientId}
planId={planId}
onClientChange={handleClientChange}