feat: add zod validation for role and user forms
This commit is contained in:
27
package-lock.json
generated
27
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -605,6 +607,18 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@hookform/resolvers": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/utils": "^0.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-hook-form": "^7.55.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -2886,6 +2900,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -9826,10 +9846,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.6",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { UseFormRegister } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form';
|
||||||
import { PermissionTree } from '@/components/permission-tree';
|
import { PermissionTree } from '@/components/permission-tree';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetClose,
|
SheetClose,
|
||||||
@@ -26,11 +27,14 @@ interface RoleSheetProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
roleId?: number;
|
roleId?: number;
|
||||||
register: UseFormRegister<RoleFormValues>;
|
register: UseFormRegister<RoleFormValues>;
|
||||||
|
errors: FieldErrors<RoleFormValues>;
|
||||||
|
touchedFields: UseFormReturn<RoleFormValues>['formState']['touchedFields'];
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
permissionTree: PermissionTreeItem[];
|
permissionTree: PermissionTreeItem[];
|
||||||
permissionIds: number[];
|
permissionIds: number[];
|
||||||
onPermissionIdsChange: (ids: number[]) => void;
|
onPermissionIdsChange: (ids: number[]) => void;
|
||||||
isPermissionsLoading: boolean;
|
isPermissionsLoading: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,13 +43,21 @@ export function RoleSheet({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
roleId,
|
roleId,
|
||||||
register,
|
register,
|
||||||
|
errors,
|
||||||
|
touchedFields,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
permissionTree,
|
permissionTree,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
onPermissionIdsChange,
|
onPermissionIdsChange,
|
||||||
isPermissionsLoading,
|
isPermissionsLoading,
|
||||||
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: RoleSheetProps) {
|
}: RoleSheetProps) {
|
||||||
|
const nameErrorMessage = touchedFields.name ? errors.name?.message : undefined;
|
||||||
|
const displayNameErrorMessage = touchedFields.display_name
|
||||||
|
? errors.display_name?.message
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
@@ -62,41 +74,52 @@ export function RoleSheet({
|
|||||||
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
<form onSubmit={onSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="role-name">Role Name</Label>
|
id="role-name"
|
||||||
|
label="Role Name"
|
||||||
|
required
|
||||||
|
error={nameErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="role-name"
|
id="role-name"
|
||||||
placeholder="Enter role name"
|
placeholder="Enter role name"
|
||||||
{...register('name', { required: true })}
|
aria-invalid={!!nameErrorMessage}
|
||||||
required
|
{...register('name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="role-display-name">Display Name</Label>
|
id="role-display-name"
|
||||||
|
label="Display Name"
|
||||||
|
required
|
||||||
|
error={displayNameErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="role-display-name"
|
id="role-display-name"
|
||||||
placeholder="Enter display name"
|
placeholder="Enter display name"
|
||||||
{...register('display_name', { required: true })}
|
aria-invalid={!!displayNameErrorMessage}
|
||||||
required
|
{...register('display_name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<FormField id="role-description" label="Description">
|
||||||
<Label htmlFor="role-description">Description</Label>
|
<Textarea
|
||||||
<textarea
|
|
||||||
id="role-description"
|
id="role-description"
|
||||||
placeholder="Describe this role"
|
placeholder="Describe this role"
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="min-h-24"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<FormField
|
||||||
<div className="flex items-center justify-between gap-3">
|
label="Permissions"
|
||||||
<Label>Permissions</Label>
|
required
|
||||||
|
error={errors.permission_ids?.message}
|
||||||
|
className="space-y-3"
|
||||||
|
labelEnd={
|
||||||
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
|
<span className="text-xs text-muted-foreground">{permissionIds.length} selected</span>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{isPermissionsLoading ? (
|
{isPermissionsLoading ? (
|
||||||
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
<div className="rounded-md border p-6 text-sm text-muted-foreground">
|
||||||
Loading permissions...
|
Loading permissions...
|
||||||
@@ -108,11 +131,11 @@ export function RoleSheet({
|
|||||||
onChange={onPermissionIdsChange}
|
onChange={onPermissionIdsChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
<SheetFooter className="shrink-0 border-t px-6 py-4 sm:flex-row sm:justify-end">
|
||||||
<Button type="submit" disabled={isSaving}>
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
{roleId ? 'Update Role' : 'Create Role'}
|
{roleId ? 'Update Role' : 'Create Role'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,20 +6,24 @@ import {
|
|||||||
} from '@/components/permission-tree';
|
} from '@/components/permission-tree';
|
||||||
import { roleService } from '@/services/api';
|
import { roleService } from '@/services/api';
|
||||||
import type { PermissionTreeItem, Role } from '@/types';
|
import type { PermissionTreeItem, Role } from '@/types';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { roleKeys } from '../queries/roleKeys';
|
import { roleKeys } from '../queries/roleKeys';
|
||||||
|
|
||||||
export interface RoleFormValues {
|
const roleFormSchema = z.object({
|
||||||
id?: number;
|
id: z.number().optional(),
|
||||||
name: string;
|
name: z.string().trim().min(1, 'Role name is required'),
|
||||||
display_name: string;
|
display_name: z.string().trim().min(1, 'Display name is required'),
|
||||||
description: string;
|
description: z.string().optional(),
|
||||||
permission_ids: number[];
|
permission_ids: z.array(z.number()).min(1, 'Select at least one permission'),
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export type RoleFormValues = z.infer<typeof roleFormSchema>;
|
||||||
|
|
||||||
const defaultValues: RoleFormValues = {
|
const defaultValues: RoleFormValues = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -44,13 +48,20 @@ export function useRoleForm({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { isSubmitting },
|
formState: { errors, isSubmitting, touchedFields },
|
||||||
} = useForm<RoleFormValues>({
|
} = useForm<RoleFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: 'onTouched',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
resolver: zodResolver(roleFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
|
const permissionIds = useWatch({ control, name: 'permission_ids' }) || [];
|
||||||
const roleId = useWatch({ control, name: 'id' });
|
const roleId = useWatch({ control, name: 'id' });
|
||||||
|
const roleName = useWatch({ control, name: 'name' }) || '';
|
||||||
|
const displayName = useWatch({ control, name: 'display_name' }) || '';
|
||||||
|
const canSubmit =
|
||||||
|
roleName.trim().length > 0 && displayName.trim().length > 0 && permissionIds.length > 0;
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (values: RoleFormValues) =>
|
mutationFn: (values: RoleFormValues) =>
|
||||||
@@ -58,7 +69,7 @@ export function useRoleForm({
|
|||||||
id: values.id,
|
id: values.id,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
display_name: values.display_name.trim(),
|
display_name: values.display_name.trim(),
|
||||||
description: values.description.trim(),
|
description: values.description?.trim() ?? '',
|
||||||
organization_id: organizationId,
|
organization_id: organizationId,
|
||||||
permission_ids: values.permission_ids,
|
permission_ids: values.permission_ids,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
@@ -74,13 +85,22 @@ export function useRoleForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = handleSubmit((values) => {
|
const onSubmit = handleSubmit(
|
||||||
if (values.permission_ids.length === 0) {
|
(values) => saveMutation.mutate(values),
|
||||||
toast.error('Select at least one permission');
|
(formErrors) => {
|
||||||
|
if (formErrors.name?.message) {
|
||||||
|
toast.error(formErrors.name.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saveMutation.mutate(values);
|
if (formErrors.display_name?.message) {
|
||||||
});
|
toast.error(formErrors.display_name.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formErrors.permission_ids?.message) {
|
||||||
|
toast.error(formErrors.permission_ids.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const openCreate = useCallback(() => {
|
const openCreate = useCallback(() => {
|
||||||
reset({
|
reset({
|
||||||
@@ -115,6 +135,9 @@ export function useRoleForm({
|
|||||||
setPermissionIds,
|
setPermissionIds,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
roleId,
|
roleId,
|
||||||
|
errors,
|
||||||
|
touchedFields,
|
||||||
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export default function RolesPage() {
|
|||||||
roleId,
|
roleId,
|
||||||
permissionIds,
|
permissionIds,
|
||||||
setPermissionIds,
|
setPermissionIds,
|
||||||
|
errors,
|
||||||
|
touchedFields,
|
||||||
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = roleForm;
|
} = roleForm;
|
||||||
const statusMutation = useRoleStatusMutation();
|
const statusMutation = useRoleStatusMutation();
|
||||||
@@ -142,11 +145,14 @@ export default function RolesPage() {
|
|||||||
onOpenChange={setIsSheetOpen}
|
onOpenChange={setIsSheetOpen}
|
||||||
roleId={roleId}
|
roleId={roleId}
|
||||||
register={register}
|
register={register}
|
||||||
|
errors={errors}
|
||||||
|
touchedFields={touchedFields}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
permissionTree={permissionsQuery.permissionTree}
|
permissionTree={permissionsQuery.permissionTree}
|
||||||
permissionIds={permissionIds}
|
permissionIds={permissionIds}
|
||||||
onPermissionIdsChange={setPermissionIds}
|
onPermissionIdsChange={setPermissionIds}
|
||||||
isPermissionsLoading={permissionsQuery.isLoading}
|
isPermissionsLoading={permissionsQuery.isLoading}
|
||||||
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, type ComponentProps } from 'react';
|
import { useRef, type ComponentProps } from 'react';
|
||||||
|
import type { FieldErrors, UseFormRegister, UseFormReturn } from 'react-hook-form';
|
||||||
import type { UseFormRegister } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FormField } from '@/components/form';
|
||||||
import { RoleCombobox } from '@/components/lookups/RoleCombobox';
|
import { RoleCombobox } from '@/components/lookups/RoleCombobox';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,49 +15,42 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
|
|
||||||
import type { UserFormValues } from '../hooks/useUserForm';
|
import type { UserFormValues } from '../hooks/useUserForm';
|
||||||
|
|
||||||
interface UserSheetProps {
|
interface UserSheetProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
|
||||||
register: UseFormRegister<UserFormValues>;
|
register: UseFormRegister<UserFormValues>;
|
||||||
|
errors: FieldErrors<UserFormValues>;
|
||||||
|
touchedFields: UseFormReturn<UserFormValues>['formState']['touchedFields'];
|
||||||
onSubmit: ComponentProps<'form'>['onSubmit'];
|
onSubmit: ComponentProps<'form'>['onSubmit'];
|
||||||
|
|
||||||
roleId: string;
|
roleId: string;
|
||||||
|
|
||||||
onRoleChange: (roleId: string) => void;
|
onRoleChange: (roleId: string) => void;
|
||||||
|
canSubmit: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserSheet({
|
export function UserSheet({
|
||||||
open,
|
open,
|
||||||
|
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
||||||
userId,
|
userId,
|
||||||
|
|
||||||
register,
|
register,
|
||||||
|
errors,
|
||||||
|
touchedFields,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
||||||
roleId,
|
roleId,
|
||||||
|
|
||||||
onRoleChange,
|
onRoleChange,
|
||||||
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: UserSheetProps) {
|
}: UserSheetProps) {
|
||||||
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
|
const roleComboboxPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const firstNameErrorMessage = touchedFields.first_name ? errors.first_name?.message : undefined;
|
||||||
|
const lastNameErrorMessage = touchedFields.last_name ? errors.last_name?.message : undefined;
|
||||||
|
const emailErrorMessage = touchedFields.email ? errors.email?.message : undefined;
|
||||||
|
const phoneErrorMessage = touchedFields.phone_number ? errors.phone_number?.message : undefined;
|
||||||
|
const roleErrorMessage = touchedFields.role_id ? errors.role_id?.message : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -72,54 +63,63 @@ export function UserSheet({
|
|||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="first-name">First Name</Label>
|
id="first-name"
|
||||||
|
label="First Name"
|
||||||
|
required
|
||||||
|
error={firstNameErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="first-name"
|
id="first-name"
|
||||||
placeholder="Enter first name"
|
placeholder="Enter first name"
|
||||||
{...register('first_name', { required: true })}
|
aria-invalid={!!firstNameErrorMessage}
|
||||||
required
|
{...register('first_name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="last-name">Last Name</Label>
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="last-name"
|
||||||
|
label="Last Name"
|
||||||
|
required
|
||||||
|
error={lastNameErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="last-name"
|
id="last-name"
|
||||||
placeholder="Enter last name"
|
placeholder="Enter last name"
|
||||||
{...register('last_name', { required: true })}
|
aria-invalid={!!lastNameErrorMessage}
|
||||||
required
|
{...register('last_name')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<FormField
|
||||||
<Label htmlFor="email">Email</Label>
|
id="email"
|
||||||
|
label="Email"
|
||||||
|
required
|
||||||
|
error={emailErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
|
||||||
placeholder="name@example.com"
|
placeholder="name@example.com"
|
||||||
{...register('email', { required: true })}
|
aria-invalid={!!emailErrorMessage}
|
||||||
required
|
{...register('email')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="phone-number">Phone Number</Label>
|
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="phone-number"
|
||||||
|
label="Phone Number"
|
||||||
|
error={phoneErrorMessage}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id="phone-number"
|
id="phone-number"
|
||||||
placeholder="Enter phone number"
|
placeholder="+919876543210"
|
||||||
|
aria-invalid={!!phoneErrorMessage}
|
||||||
{...register('phone_number')}
|
{...register('phone_number')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Role</Label>
|
|
||||||
|
|
||||||
|
<FormField label="Role" required error={roleErrorMessage}>
|
||||||
<RoleCombobox
|
<RoleCombobox
|
||||||
value={roleId}
|
value={roleId}
|
||||||
onValueChange={onRoleChange}
|
onValueChange={onRoleChange}
|
||||||
@@ -132,11 +132,11 @@ export function UserSheet({
|
|||||||
|
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
type="hidden"
|
||||||
{...register('role_id', { required: true })}
|
{...register('role_id')}
|
||||||
value={roleId}
|
value={roleId}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
|
|
||||||
<DialogFooter className="px-0">
|
<DialogFooter className="px-0">
|
||||||
<Button
|
<Button
|
||||||
@@ -148,7 +148,7 @@ export function UserSheet({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" disabled={isSaving}>
|
<Button type="submit" disabled={isSaving || !canSubmit}>
|
||||||
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||||
|
|
||||||
{userId ? 'Update User' : 'Create User'}
|
{userId ? 'Update User' : 'Create User'}
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { AdministrationUser } from '@/types';
|
import type { AdministrationUser } from '@/types';
|
||||||
import { useSaveUserMutation } from './useUserMutations';
|
import { useSaveUserMutation } from './useUserMutations';
|
||||||
|
|
||||||
export interface UserFormValues {
|
const optionalPhoneSchema = z
|
||||||
id?: number;
|
.string()
|
||||||
first_name: string;
|
.trim()
|
||||||
last_name: string;
|
.refine((value) => value === '' || /^\+(?:[0-9] ?){6,14}[0-9]$/.test(value), {
|
||||||
email: string;
|
message: 'Please enter a valid phone number',
|
||||||
phone_number: string;
|
});
|
||||||
role_id: string;
|
|
||||||
}
|
const userFormSchema = z.object({
|
||||||
|
id: z.number().optional(),
|
||||||
|
first_name: z.string().trim().min(1, 'First name is required'),
|
||||||
|
last_name: z.string().trim().min(1, 'Last name is required'),
|
||||||
|
email: z.string().trim().min(1, 'Email is required').email('Invalid email'),
|
||||||
|
phone_number: optionalPhoneSchema,
|
||||||
|
role_id: z.string().trim().min(1, 'Role is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UserFormValues = z.infer<typeof userFormSchema>;
|
||||||
|
|
||||||
const defaultValues: UserFormValues = {
|
const defaultValues: UserFormValues = {
|
||||||
first_name: '',
|
first_name: '',
|
||||||
@@ -31,9 +42,12 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
handleSubmit: submitForm,
|
handleSubmit: submitForm,
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
formState: { isSubmitting, errors },
|
formState: { errors, isSubmitting, touchedFields },
|
||||||
} = useForm<UserFormValues>({
|
} = useForm<UserFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
mode: 'onTouched',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
|
resolver: zodResolver(userFormSchema),
|
||||||
});
|
});
|
||||||
const saveMutation = useSaveUserMutation({
|
const saveMutation = useSaveUserMutation({
|
||||||
onSaved: () => {
|
onSaved: () => {
|
||||||
@@ -44,16 +58,39 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
|
|
||||||
const userId = useWatch({ control, name: 'id' });
|
const userId = useWatch({ control, name: 'id' });
|
||||||
const roleId = useWatch({ control, name: 'role_id' }) || '';
|
const roleId = useWatch({ control, name: 'role_id' }) || '';
|
||||||
|
const firstName = useWatch({ control, name: 'first_name' }) || '';
|
||||||
|
const lastName = useWatch({ control, name: 'last_name' }) || '';
|
||||||
|
const email = useWatch({ control, name: 'email' }) || '';
|
||||||
|
const phoneNumber = useWatch({ control, name: 'phone_number' }) || '';
|
||||||
|
const isPhoneValid = phoneNumber.trim() === '' || /^\+(?:[0-9] ?){6,14}[0-9]$/.test(phoneNumber.trim());
|
||||||
|
const canSubmit =
|
||||||
|
firstName.trim().length > 0 &&
|
||||||
|
lastName.trim().length > 0 &&
|
||||||
|
email.trim().length > 0 &&
|
||||||
|
roleId.trim().length > 0 &&
|
||||||
|
isPhoneValid;
|
||||||
|
|
||||||
const handleSubmit = submitForm(
|
const handleSubmit = submitForm(
|
||||||
(values) => saveMutation.mutate(values),
|
(values) => saveMutation.mutate(values),
|
||||||
(formErrors) => {
|
(formErrors) => {
|
||||||
if (formErrors.first_name || formErrors.last_name || formErrors.email) {
|
if (formErrors.first_name?.message) {
|
||||||
toast.error('First name, last name and email are required');
|
toast.error(formErrors.first_name.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (formErrors.role_id) {
|
if (formErrors.last_name?.message) {
|
||||||
toast.error('Select a role');
|
toast.error(formErrors.last_name.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formErrors.email?.message) {
|
||||||
|
toast.error(formErrors.email.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formErrors.phone_number?.message) {
|
||||||
|
toast.error(formErrors.phone_number.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (formErrors.role_id?.message) {
|
||||||
|
toast.error(formErrors.role_id.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -94,6 +131,8 @@ export function useUserForm({ onSaved }: { onSaved: () => void }) {
|
|||||||
roleId,
|
roleId,
|
||||||
userId,
|
userId,
|
||||||
errors,
|
errors,
|
||||||
|
touchedFields,
|
||||||
|
canSubmit,
|
||||||
isSaving: isSubmitting || saveMutation.isPending,
|
isSaving: isSubmitting || saveMutation.isPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export default function UsersPage() {
|
|||||||
userId,
|
userId,
|
||||||
roleId,
|
roleId,
|
||||||
setRoleId,
|
setRoleId,
|
||||||
|
errors,
|
||||||
|
touchedFields,
|
||||||
|
canSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
} = userForm;
|
} = userForm;
|
||||||
const statusMutation = useUserStatusMutation();
|
const statusMutation = useUserStatusMutation();
|
||||||
@@ -145,9 +148,12 @@ export default function UsersPage() {
|
|||||||
onOpenChange={setIsSheetOpen}
|
onOpenChange={setIsSheetOpen}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
register={register}
|
register={register}
|
||||||
|
errors={errors}
|
||||||
|
touchedFields={touchedFields}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
roleId={roleId}
|
roleId={roleId}
|
||||||
onRoleChange={setRoleId}
|
onRoleChange={setRoleId}
|
||||||
|
canSubmit={canSubmit}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
43
src/components/form/FormField.tsx
Normal file
43
src/components/form/FormField.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface FormFieldProps {
|
||||||
|
id?: string;
|
||||||
|
label: ReactNode;
|
||||||
|
labelEnd?: ReactNode;
|
||||||
|
required?: boolean;
|
||||||
|
error?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
errorClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
labelEnd,
|
||||||
|
required = false,
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
errorClassName,
|
||||||
|
}: FormFieldProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label htmlFor={id} className={labelClassName}>
|
||||||
|
{label} {required ? <span className="text-destructive">*</span> : null}
|
||||||
|
</Label>
|
||||||
|
{labelEnd}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
{error ? (
|
||||||
|
<p className={cn('-mt-1 text-sm text-destructive', errorClassName)}>{error}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/form/index.ts
Normal file
1
src/components/form/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FormField } from './FormField';
|
||||||
Reference in New Issue
Block a user