refactor(organization): migrate to react-hook-form with zod validation

This commit is contained in:
Mauricio Siu
2025-02-25 23:33:26 -06:00
parent 7d77e14319
commit 8c2707c4ea
3 changed files with 96 additions and 46 deletions

View File

@@ -9,18 +9,39 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Plus } from "lucide-react"; import { PenBoxIcon, Plus } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
const organizationSchema = z.object({
name: z.string().min(1, {
message: "Organization name is required",
}),
logo: z.string().optional(),
});
type OrganizationFormValues = z.infer<typeof organizationSchema>;
interface Props { interface Props {
organizationId?: string; organizationId?: string;
children?: React.ReactNode; children?: React.ReactNode;
} }
export function AddOrganization({ organizationId }: Props) { export function AddOrganization({ organizationId }: Props) {
const [open, setOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { data: organization } = api.organization.one.useQuery( const { data: organization } = api.organization.one.useQuery(
{ {
@@ -33,24 +54,37 @@ export function AddOrganization({ organizationId }: Props) {
const { mutateAsync, isLoading } = organizationId const { mutateAsync, isLoading } = organizationId
? api.organization.update.useMutation() ? api.organization.update.useMutation()
: api.organization.create.useMutation(); : api.organization.create.useMutation();
const [open, setOpen] = useState(false);
const [name, setName] = useState(""); const form = useForm<OrganizationFormValues>({
const [logo, setLogo] = useState(""); resolver: zodResolver(organizationSchema),
defaultValues: {
name: "",
logo: "",
},
});
useEffect(() => { useEffect(() => {
if (organization) { if (organization) {
setName(organization.name); form.reset({
setLogo(organization.logo || ""); name: organization.name,
logo: organization.logo || "",
});
} }
}, [organization]); }, [organization, form]);
const handleSubmit = async () => {
await mutateAsync({ name, logo, organizationId: organizationId ?? "" }) const onSubmit = async (values: OrganizationFormValues) => {
await mutateAsync({
name: values.name,
logo: values.logo,
organizationId: organizationId ?? "",
})
.then(() => { .then(() => {
setOpen(false); form.reset();
toast.success( toast.success(
`Organization ${organizationId ? "updated" : "created"} successfully`, `Organization ${organizationId ? "updated" : "created"} successfully`,
); );
utils.organization.all.invalidate(); utils.organization.all.invalidate();
setOpen(false);
}) })
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
@@ -59,6 +93,7 @@ export function AddOrganization({ organizationId }: Props) {
); );
}); });
}; };
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -67,14 +102,11 @@ export function AddOrganization({ organizationId }: Props) {
className="group cursor-pointer hover:bg-blue-500/10" className="group cursor-pointer hover:bg-blue-500/10"
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" /> <PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem <DropdownMenuItem
className="gap-2 p-2" className="gap-2 p-2"
onClick={() => {
setOpen(true);
}}
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
<div className="flex size-6 items-center justify-center rounded-md border bg-background"> <div className="flex size-6 items-center justify-center rounded-md border bg-background">
@@ -97,36 +129,53 @@ export function AddOrganization({ organizationId }: Props) {
: "Create a new organization to manage your projects."} : "Create a new organization to manage your projects."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <Form {...form}>
<div className="grid grid-cols-4 items-center gap-4"> <form
<Label htmlFor="name" className="text-right"> onSubmit={form.handleSubmit(onSubmit)}
Name className="grid gap-4 py-4"
</Label> >
<Input <FormField
id="name" control={form.control}
value={name} name="name"
onChange={(e) => setName(e.target.value)} render={({ field }) => (
className="col-span-3" <FormItem className="tems-center gap-4">
<FormLabel className="text-right">Name</FormLabel>
<FormControl>
<Input
placeholder="Organization name"
{...field}
className="col-span-3"
/>
</FormControl>
<FormMessage className="" />
</FormItem>
)}
/> />
</div> <FormField
<div className="grid grid-cols-4 items-center gap-4"> control={form.control}
<Label htmlFor="logo" className="text-right"> name="logo"
Logo URL render={({ field }) => (
</Label> <FormItem className=" gap-4">
<Input <FormLabel className="text-right">Logo URL</FormLabel>
id="logo" <FormControl>
value={logo} <Input
onChange={(e) => setLogo(e.target.value)} placeholder="https://example.com/logo.png"
placeholder="https://example.com/logo.png" {...field}
className="col-span-3" value={field.value || ""}
className="col-span-3"
/>
</FormControl>
<FormMessage className="col-span-3 col-start-2" />
</FormItem>
)}
/> />
</div> <DialogFooter className="mt-4">
</div> <Button type="submit" isLoading={isLoading}>
<DialogFooter> {organizationId ? "Update organization" : "Create organization"}
<Button type="submit" onClick={handleSubmit} isLoading={isLoading}> </Button>
{organizationId ? "Update organization" : "Create organization"} </DialogFooter>
</Button> </form>
</DialogFooter> </Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -534,7 +534,7 @@ function SidebarLogo() {
)} )}
> >
{/* Organization Logo and Selector */} {/* Organization Logo and Selector */}
<SidebarMenuItem> <SidebarMenuItem className={"w-full"}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton <SidebarMenuButton
@@ -562,7 +562,7 @@ function SidebarLogo() {
"transition-all", "transition-all",
state === "collapsed" ? "size-4" : "size-5", state === "collapsed" ? "size-4" : "size-5",
)} )}
logoUrl={activeOrganization?.logo} logoUrl={activeOrganization?.logo || undefined}
/> />
</div> </div>
<div <div

View File

@@ -1,3 +1,5 @@
import { cn } from "@/lib/utils";
interface Props { interface Props {
className?: string; className?: string;
logoUrl?: string; logoUrl?: string;
@@ -9,8 +11,7 @@ export const Logo = ({ className = "size-14", logoUrl }: Props) => {
<img <img
src={logoUrl} src={logoUrl}
alt="Organization Logo" alt="Organization Logo"
className={className} className={cn(className, "object-contain rounded-sm")}
style={{ objectFit: "contain" }}
/> />
); );
} }