refactor: Align form validation UX across management dialogs
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user