🛂 Migrate frontend to Shadcn (#2010)

* 🔧 Add Tailwind, update dependencies and config files

* ✨ Introduce new Shadcn components and remove old ones

* 🔧 Update dependencies

* Add new components.json file

* 🔥 Remove Chakra UI files

* 🔧 Add ThemeProvider component and integrate it into main

* 🔥 Remove common components

* Update primary color

* ✨ Add new components

* ✨ Add AuthLayout component

* 🔧 Add utility function cn

* 🔧 Refactor devtools integration and update dependencies

* ✨ Add Footer and Error components

* ♻️ Update Footer

* 🔥 Remove utils

* ♻️ Refactor error handling in useAuth

* ♻️ Refactor useCustomToast

* ♻️ Refactor Login component and form handling

* ♻️ Refactor SignUp component and form handling

* 🔧 Update dependencies

* ♻️ Refactor RecoverPassword component and form handling

* ♻️ Refactor ResetPassword and form handling

* ♻️ Add error component to root route

* ♻️ Refactor error handling in utils

* ♻️ Update buttons

* 🍱 Add icons and logos assets

* ♻️ Refactor Sidebar component

* 🎨 Format

* ♻️ Refactor ThemeProvider

* ♻️ Refactor Common components

* 🔥 Remove old Appearance component

* ✨ Add Sidebar components

* ♻️ Refactor DeleteAccount components

* ♻️ Refactor ChangePassword component

* ♻️ Refactor UserSettings

* ✨ Add TanStack table

* ♻️ Update SignUp

* ✨ Add Select component

* 🎨 Format

* ♻️ Update Footer

* ✨ Add useCopyToClipboard hook

* 🎨 Tweak table styles

* 🎨 Tweak styling

* ♻️ Refactor AddUser and AddItem components

* ♻️ Update DeleteConfirmation

* ✅ Update tests

* ✅ Update tests

* ✅ Fix tests

* ✨ Add DataTable for item and admin management

* ♻️ Refactor DeleteUser and DeleteItem components

* ✅ Fix tests

* ♻️ Refactor EditUser and EditItem components

* ♻️ Refactor UserInformation component

* 🎨 Format

* ♻️ Refactor pending components

* 🎨 Format

* ✅ Update tests

* ✅ Update tests

* ✅ Fix test

* ♻️ Minor tweaks

* ♻️ Update social media links
This commit is contained in:
Alejandra
2025-12-07 13:21:13 +01:00
committed by GitHub
parent 61b7cd673a
commit 8c2532a5c3
104 changed files with 8891 additions and 3287 deletions
+97 -96
View File
@@ -1,41 +1,49 @@
import {
Button,
DialogActionTrigger,
DialogTitle,
Input,
Text,
VStack,
} from "@chakra-ui/react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Plus } from "lucide-react"
import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form"
import { FaPlus } from "react-icons/fa"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type ItemCreate, ItemsService } from "@/client"
import type { ApiError } from "@/client/core/ApiError"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
import { Button } from "@/components/ui/button"
import {
DialogBody,
DialogCloseTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from "../ui/dialog"
import { Field } from "../ui/field"
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const formSchema = z.object({
title: z.string().min(1, { message: "Title is required" }),
description: z.string().optional(),
})
type FormData = z.infer<typeof formSchema>
const AddItem = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast()
const {
register,
handleSubmit,
reset,
formState: { errors, isValid, isSubmitting },
} = useForm<ItemCreate>({
const { showSuccessToast, showErrorToast } = useCustomToast()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
mode: "onBlur",
criteriaMode: "all",
defaultValues: {
@@ -48,95 +56,88 @@ const AddItem = () => {
mutationFn: (data: ItemCreate) =>
ItemsService.createItem({ requestBody: data }),
onSuccess: () => {
showSuccessToast("Item created successfully.")
reset()
showSuccessToast("Item created successfully")
form.reset()
setIsOpen(false)
},
onError: (err: ApiError) => {
handleError(err)
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] })
},
})
const onSubmit: SubmitHandler<ItemCreate> = (data) => {
const onSubmit = (data: FormData) => {
mutation.mutate(data)
}
return (
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button value="add-item" my={4}>
<FaPlus fontSize="16px" />
<Button className="my-4">
<Plus className="mr-2" />
Add Item
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Add Item</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>Fill in the details to add a new item.</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.title}
errorText={errors.title?.message}
label="Title"
>
<Input
{...register("title", {
required: "Title is required.",
})}
placeholder="Title"
type="text"
/>
</Field>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Item</DialogTitle>
<DialogDescription>
Fill in the details to add a new item.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-4 py-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
Title <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Title"
type="text"
{...field}
required
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Field
invalid={!!errors.description}
errorText={errors.description?.message}
label="Description"
>
<Input
{...register("description")}
placeholder="Description"
type="text"
/>
</Field>
</VStack>
</DialogBody>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="Description" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
type="submit"
disabled={!isValid}
loading={isSubmitting}
>
Save
</Button>
</DialogFooter>
</form>
<DialogCloseTrigger />
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel
</Button>
</DialogClose>
<LoadingButton type="submit" loading={mutation.isPending}>
Save
</LoadingButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</DialogRoot>
</Dialog>
)
}
+40 -50
View File
@@ -1,30 +1,34 @@
import { Button, DialogTitle, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Trash2 } from "lucide-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { FiTrash2 } from "react-icons/fi"
import { ItemsService } from "@/client"
import { Button } from "@/components/ui/button"
import {
DialogActionTrigger,
DialogBody,
DialogCloseTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
DialogTitle,
} from "@/components/ui/dialog"
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const DeleteItem = ({ id }: { id: string }) => {
interface DeleteItemProps {
id: string
onSuccess: () => void
}
const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const { handleSubmit } = useForm()
const deleteItem = async (id: string) => {
await ItemsService.deleteItem({ id: id })
@@ -35,10 +39,9 @@ const DeleteItem = ({ id }: { id: string }) => {
onSuccess: () => {
showSuccessToast("The item was deleted successfully")
setIsOpen(false)
onSuccess()
},
onError: () => {
showErrorToast("An error occurred while deleting the item")
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries()
},
@@ -49,55 +52,42 @@ const DeleteItem = ({ id }: { id: string }) => {
}
return (
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
role="alertdialog"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" colorPalette="red">
<FiTrash2 fontSize="16px" />
Delete Item
</Button>
</DialogTrigger>
<DialogContent>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuItem
variant="destructive"
onSelect={(e) => e.preventDefault()}
onClick={() => setIsOpen(true)}
>
<Trash2 />
Delete Item
</DropdownMenuItem>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit(onSubmit)}>
<DialogCloseTrigger />
<DialogHeader>
<DialogTitle>Delete Item</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
<DialogDescription>
This item will be permanently deleted. Are you sure? You will not
be able to undo this action.
</Text>
</DialogBody>
</DialogDescription>
</DialogHeader>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
colorPalette="red"
</DialogClose>
<LoadingButton
variant="destructive"
type="submit"
loading={isSubmitting}
loading={mutation.isPending}
>
Delete
</Button>
</LoadingButton>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>
</Dialog>
)
}
+99 -103
View File
@@ -1,148 +1,144 @@
import {
Button,
ButtonGroup,
DialogActionTrigger,
Input,
Text,
VStack,
} from "@chakra-ui/react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Pencil } from "lucide-react"
import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form"
import { FaExchangeAlt } from "react-icons/fa"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type ApiError, type ItemPublic, ItemsService } from "@/client"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
import { type ItemPublic, ItemsService } from "@/client"
import { Button } from "@/components/ui/button"
import {
DialogBody,
DialogCloseTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from "../ui/dialog"
import { Field } from "../ui/field"
} from "@/components/ui/dialog"
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 { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const formSchema = z.object({
title: z.string().min(1, { message: "Title is required" }),
description: z.string().optional(),
})
type FormData = z.infer<typeof formSchema>
interface EditItemProps {
item: ItemPublic
onSuccess: () => void
}
interface ItemUpdateForm {
title: string
description?: string
}
const EditItem = ({ item }: EditItemProps) => {
const EditItem = ({ item, onSuccess }: EditItemProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast()
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<ItemUpdateForm>({
const { showSuccessToast, showErrorToast } = useCustomToast()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
mode: "onBlur",
criteriaMode: "all",
defaultValues: {
...item,
title: item.title,
description: item.description ?? undefined,
},
})
const mutation = useMutation({
mutationFn: (data: ItemUpdateForm) =>
mutationFn: (data: FormData) =>
ItemsService.updateItem({ id: item.id, requestBody: data }),
onSuccess: () => {
showSuccessToast("Item updated successfully.")
reset()
showSuccessToast("Item updated successfully")
setIsOpen(false)
onSuccess()
},
onError: (err: ApiError) => {
handleError(err)
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] })
},
})
const onSubmit: SubmitHandler<ItemUpdateForm> = async (data) => {
const onSubmit = (data: FormData) => {
mutation.mutate(data)
}
return (
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button variant="ghost">
<FaExchangeAlt fontSize="16px" />
Edit Item
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit Item</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>Update the item details below.</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.title}
errorText={errors.title?.message}
label="Title"
>
<Input
{...register("title", {
required: "Title is required",
})}
placeholder="Title"
type="text"
/>
</Field>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
onClick={() => setIsOpen(true)}
>
<Pencil />
Edit Item
</DropdownMenuItem>
<DialogContent className="sm:max-w-md">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit Item</DialogTitle>
<DialogDescription>
Update the item details below.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
Title <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input placeholder="Title" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Field
invalid={!!errors.description}
errorText={errors.description?.message}
label="Description"
>
<Input
{...register("description")}
placeholder="Description"
type="text"
/>
</Field>
</VStack>
</DialogBody>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="Description" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter gap={2}>
<ButtonGroup>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel
</Button>
</DialogActionTrigger>
<Button variant="solid" type="submit" loading={isSubmitting}>
</DialogClose>
<LoadingButton type="submit" loading={mutation.isPending}>
Save
</Button>
</ButtonGroup>
</DialogFooter>
</form>
<DialogCloseTrigger />
</LoadingButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</DialogRoot>
</Dialog>
)
}
@@ -0,0 +1,34 @@
import { EllipsisVertical } from "lucide-react"
import { useState } from "react"
import type { ItemPublic } from "@/client"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import DeleteItem from "../Items/DeleteItem"
import EditItem from "../Items/EditItem"
interface ItemActionsMenuProps {
item: ItemPublic
}
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
const [open, setOpen] = useState(false)
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<EditItem item={item} onSuccess={() => setOpen(false)} />
<DeleteItem id={item.id} onSuccess={() => setOpen(false)} />
</DropdownMenuContent>
</DropdownMenu>
)
}
+73
View File
@@ -0,0 +1,73 @@
import type { ColumnDef } from "@tanstack/react-table"
import { Check, Copy } from "lucide-react"
import type { ItemPublic } from "@/client"
import { Button } from "@/components/ui/button"
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
import { cn } from "@/lib/utils"
import { ItemActionsMenu } from "./ItemActionsMenu"
function CopyId({ id }: { id: string }) {
const [copiedText, copy] = useCopyToClipboard()
const isCopied = copiedText === id
return (
<div className="flex items-center gap-1.5 group">
<span className="font-mono text-xs text-muted-foreground">{id}</span>
<Button
variant="ghost"
size="icon"
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => copy(id)}
>
{isCopied ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
<span className="sr-only">Copy ID</span>
</Button>
</div>
)
}
export const columns: ColumnDef<ItemPublic>[] = [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => <CopyId id={row.original.id} />,
},
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => (
<span className="font-medium">{row.original.title}</span>
),
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
const description = row.original.description
return (
<span
className={cn(
"max-w-xs truncate block text-muted-foreground",
!description && "italic",
)}
>
{description || "No description"}
</span>
)
},
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end">
<ItemActionsMenu item={row.original} />
</div>
),
},
]