Browse Source

🛂 Migrate to Chakra UI v3 (#1496)

Co-authored-by: github-actions <github-actions@github.com>
pull/13907/head
Alejandra 6 months ago
committed by GitHub
parent
commit
55df823739
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      frontend/openapi-ts.config.ts
  2. 8793
      frontend/package-lock.json
  3. 10
      frontend/package.json
  4. 292
      frontend/src/components/Admin/AddUser.tsx
  5. 103
      frontend/src/components/Admin/DeleteUser.tsx
  6. 287
      frontend/src/components/Admin/EditUser.tsx
  7. 75
      frontend/src/components/Common/ActionsMenu.tsx
  8. 113
      frontend/src/components/Common/DeleteAlert.tsx
  9. 27
      frontend/src/components/Common/ItemActionsMenu.tsx
  10. 54
      frontend/src/components/Common/Navbar.tsx
  11. 68
      frontend/src/components/Common/NotFound.tsx
  12. 36
      frontend/src/components/Common/PaginationFooter.tsx
  13. 123
      frontend/src/components/Common/Sidebar.tsx
  14. 53
      frontend/src/components/Common/SidebarItems.tsx
  15. 28
      frontend/src/components/Common/UserActionsMenu.tsx
  16. 77
      frontend/src/components/Common/UserMenu.tsx
  17. 167
      frontend/src/components/Items/AddItem.tsx
  18. 103
      frontend/src/components/Items/DeleteItem.tsx
  19. 192
      frontend/src/components/Items/EditItem.tsx
  20. 34
      frontend/src/components/Pending/PendingItems.tsx
  21. 38
      frontend/src/components/Pending/PendingUsers.tsx
  22. 34
      frontend/src/components/UserSettings/Appearance.tsx
  23. 88
      frontend/src/components/UserSettings/ChangePassword.tsx
  24. 38
      frontend/src/components/UserSettings/DeleteAccount.tsx
  25. 124
      frontend/src/components/UserSettings/DeleteConfirmation.tsx
  26. 57
      frontend/src/components/UserSettings/UserInformation.tsx
  27. 40
      frontend/src/components/ui/button.tsx
  28. 25
      frontend/src/components/ui/checkbox.tsx
  29. 17
      frontend/src/components/ui/close-button.tsx
  30. 107
      frontend/src/components/ui/color-mode.tsx
  31. 62
      frontend/src/components/ui/dialog.tsx
  32. 52
      frontend/src/components/ui/drawer.tsx
  33. 33
      frontend/src/components/ui/field.tsx
  34. 53
      frontend/src/components/ui/input-group.tsx
  35. 12
      frontend/src/components/ui/link-button.tsx
  36. 112
      frontend/src/components/ui/menu.tsx
  37. 211
      frontend/src/components/ui/pagination.tsx
  38. 162
      frontend/src/components/ui/password-input.tsx
  39. 18
      frontend/src/components/ui/provider.tsx
  40. 24
      frontend/src/components/ui/radio.tsx
  41. 47
      frontend/src/components/ui/skeleton.tsx
  42. 43
      frontend/src/components/ui/toaster.tsx
  43. 32
      frontend/src/hooks/useAuth.ts
  44. 34
      frontend/src/hooks/useCustomToast.ts
  45. 10
      frontend/src/main.tsx
  46. 26
      frontend/src/routes/_layout.tsx
  47. 192
      frontend/src/routes/_layout/admin.tsx
  48. 181
      frontend/src/routes/_layout/items.tsx
  49. 55
      frontend/src/routes/_layout/settings.tsx
  50. 95
      frontend/src/routes/login.tsx
  51. 55
      frontend/src/routes/recover-password.tsx
  52. 60
      frontend/src/routes/reset-password.tsx
  53. 130
      frontend/src/routes/signup.tsx
  54. 74
      frontend/src/theme.tsx
  55. 21
      frontend/src/theme/button.recipe.ts
  56. 6
      frontend/src/utils.ts
  57. 12
      frontend/tests/reset-password.spec.ts
  58. 4
      frontend/tests/sign-up.spec.ts
  59. 115
      frontend/tests/user-settings.spec.ts
  60. 5
      frontend/tests/utils/user.ts

2
frontend/openapi-ts.config.ts

@ -15,7 +15,7 @@ export default defineConfig({
// @ts-ignore // @ts-ignore
let name: string = operation.name let name: string = operation.name
// @ts-ignore // @ts-ignore
let service: string = operation.service const service: string = operation.service
if (service && name.toLowerCase().startsWith(service.toLowerCase())) { if (service && name.toLowerCase().startsWith(service.toLowerCase())) {
name = name.slice(service.length) name = name.slice(service.length)

8793
frontend/package-lock.json

File diff suppressed because it is too large

10
frontend/package.json

@ -11,21 +11,19 @@
"generate-client": "openapi-ts" "generate-client": "openapi-ts"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/icons": "2.1.1", "@chakra-ui/react": "^3.8.0",
"@chakra-ui/react": "2.8.2", "@emotion/react": "^11.14.0",
"@emotion/react": "11.11.3",
"@emotion/styled": "11.11.0",
"@tanstack/react-query": "^5.28.14", "@tanstack/react-query": "^5.28.14",
"@tanstack/react-query-devtools": "^5.28.14", "@tanstack/react-query-devtools": "^5.28.14",
"@tanstack/react-router": "1.19.1", "@tanstack/react-router": "1.19.1",
"axios": "1.7.4", "axios": "1.7.4",
"form-data": "4.0.0", "form-data": "4.0.0",
"framer-motion": "10.16.16", "next-themes": "^0.4.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-hook-form": "7.49.3", "react-hook-form": "7.49.3",
"react-icons": "5.0.1" "react-icons": "^5.4.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.6.1", "@biomejs/biome": "1.6.1",

292
frontend/src/components/Admin/AddUser.tsx

@ -1,45 +1,48 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
import { import {
Button, Button,
Checkbox, DialogActionTrigger,
DialogTitle,
Flex, Flex,
FormControl,
FormErrorMessage,
FormLabel,
Input, Input,
Modal, Text,
ModalBody, VStack,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react" } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form" import { FaPlus } from "react-icons/fa"
import { type UserCreate, UsersService } from "../../client" import { type UserCreate, UsersService } from "../../client"
import type { ApiError } from "../../client/core/ApiError" import type { ApiError } from "../../client/core/ApiError"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { emailPattern, handleError } from "../../utils" import { emailPattern, handleError } from "../../utils"
import { Checkbox } from "../ui/checkbox"
interface AddUserProps { import {
isOpen: boolean DialogBody,
onClose: () => void DialogCloseTrigger,
} DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
} from "../ui/dialog"
import { Field } from "../ui/field"
interface UserCreateForm extends UserCreate { interface UserCreateForm extends UserCreate {
confirm_password: string confirm_password: string
} }
const AddUser = ({ isOpen, onClose }: AddUserProps) => { const AddUser = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const showToast = useCustomToast() const { showSuccessToast } = useCustomToast()
const { const {
control,
register, register,
handleSubmit, handleSubmit,
reset, reset,
getValues, getValues,
formState: { errors, isSubmitting }, formState: { errors, isValid, isSubmitting },
} = useForm<UserCreateForm>({ } = useForm<UserCreateForm>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
@ -57,12 +60,12 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
mutationFn: (data: UserCreate) => mutationFn: (data: UserCreate) =>
UsersService.createUser({ requestBody: data }), UsersService.createUser({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showToast("Success!", "User created successfully.", "success") showSuccessToast("User created successfully.")
reset() reset()
onClose() setIsOpen(false)
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
handleError(err, showToast) handleError(err)
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }) queryClient.invalidateQueries({ queryKey: ["users"] })
@ -74,108 +77,153 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
} }
return ( return (
<> <DialogRoot
<Modal size={{ base: "xs", md: "md" }}
isOpen={isOpen} placement="center"
onClose={onClose} open={isOpen}
size={{ base: "sm", md: "md" }} onOpenChange={({ open }) => setIsOpen(open)}
isCentered >
> <DialogTrigger asChild>
<ModalOverlay /> <Button value="add-user" my={4}>
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}> <FaPlus fontSize="16px" />
<ModalHeader>Add User</ModalHeader> Add User
<ModalCloseButton /> </Button>
<ModalBody pb={6}> </DialogTrigger>
<FormControl isRequired isInvalid={!!errors.email}> <DialogContent>
<FormLabel htmlFor="email">Email</FormLabel> <form onSubmit={handleSubmit(onSubmit)}>
<Input <DialogHeader>
id="email" <DialogTitle>Add User</DialogTitle>
{...register("email", { </DialogHeader>
required: "Email is required", <DialogBody>
pattern: emailPattern, <Text mb={4}>
})} Fill in the form below to add a new user to the system.
placeholder="Email" </Text>
type="email" <VStack gap={4}>
/> <Field
{errors.email && ( required
<FormErrorMessage>{errors.email.message}</FormErrorMessage> invalid={!!errors.email}
)} errorText={errors.email?.message}
</FormControl> label="Email"
<FormControl mt={4} isInvalid={!!errors.full_name}> >
<FormLabel htmlFor="name">Full name</FormLabel> <Input
<Input id="email"
id="name" {...register("email", {
{...register("full_name")} required: "Email is required",
placeholder="Full name" pattern: emailPattern,
type="text" })}
/> placeholder="Email"
{errors.full_name && ( type="email"
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage> />
)} </Field>
</FormControl>
<FormControl mt={4} isRequired isInvalid={!!errors.password}> <Field
<FormLabel htmlFor="password">Set Password</FormLabel> invalid={!!errors.full_name}
<Input errorText={errors.full_name?.message}
id="password" label="Full Name"
{...register("password", { >
required: "Password is required", <Input
minLength: { id="name"
value: 8, {...register("full_name")}
message: "Password must be at least 8 characters", placeholder="Full name"
}, type="text"
})} />
placeholder="Password" </Field>
type="password"
<Field
required
invalid={!!errors.password}
errorText={errors.password?.message}
label="Set Password"
>
<Input
id="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password"
type="password"
/>
</Field>
<Field
required
invalid={!!errors.confirm_password}
errorText={errors.confirm_password?.message}
label="Confirm Password"
>
<Input
id="confirm_password"
{...register("confirm_password", {
required: "Please confirm your password",
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password"
type="password"
/>
</Field>
</VStack>
<Flex mt={4} direction="column" gap={4}>
<Controller
control={control}
name="is_superuser"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is superuser?
</Checkbox>
</Field>
)}
/> />
{errors.password && ( <Controller
<FormErrorMessage>{errors.password.message}</FormErrorMessage> control={control}
)} name="is_active"
</FormControl> render={({ field }) => (
<FormControl <Field disabled={field.disabled} colorPalette="teal">
mt={4} <Checkbox
isRequired checked={field.value}
isInvalid={!!errors.confirm_password} onCheckedChange={({ checked }) => field.onChange(checked)}
> >
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel> Is active?
<Input </Checkbox>
id="confirm_password" </Field>
{...register("confirm_password", { )}
required: "Please confirm your password",
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password"
type="password"
/> />
{errors.confirm_password && (
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
<Flex mt={4}>
<FormControl>
<Checkbox {...register("is_superuser")} colorScheme="teal">
Is superuser?
</Checkbox>
</FormControl>
<FormControl>
<Checkbox {...register("is_active")} colorScheme="teal">
Is active?
</Checkbox>
</FormControl>
</Flex> </Flex>
</ModalBody> </DialogBody>
<ModalFooter gap={3}>
<Button variant="primary" type="submit" isLoading={isSubmitting}> <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 Save
</Button> </Button>
<Button onClick={onClose}>Cancel</Button> </DialogFooter>
</ModalFooter> </form>
</ModalContent> <DialogCloseTrigger />
</Modal> </DialogContent>
</> </DialogRoot>
) )
} }

103
frontend/src/components/Admin/DeleteUser.tsx

@ -0,0 +1,103 @@
import { Button, DialogTitle, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { FiTrash2 } from "react-icons/fi"
import { UsersService } from "../../client"
import {
DialogActionTrigger,
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
} from "../../components/ui/dialog"
import useCustomToast from "../../hooks/useCustomToast"
const DeleteUser = ({ id }: { id: string }) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const deleteUser = async (id: string) => {
await UsersService.deleteUser({ userId: id })
}
const mutation = useMutation({
mutationFn: deleteUser,
onSuccess: () => {
showSuccessToast("The user was deleted successfully")
setIsOpen(false)
},
onError: () => {
showErrorToast("An error occurred while deleting the user")
},
onSettled: () => {
queryClient.invalidateQueries()
},
})
const onSubmit = async () => {
mutation.mutate(id)
}
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 User
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
All items associated with this user will also be{" "}
<strong>permanently deleted.</strong> Are you sure? You will not
be able to undo this action.
</Text>
</DialogBody>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
colorPalette="red"
type="submit"
loading={isSubmitting}
>
Delete
</Button>
</DialogFooter>
<DialogCloseTrigger />
</form>
</DialogContent>
</DialogRoot>
)
}
export default DeleteUser

287
frontend/src/components/Admin/EditUser.tsx

@ -1,51 +1,52 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
import { import {
Button, Button,
Checkbox, DialogActionTrigger,
DialogRoot,
DialogTrigger,
Flex, Flex,
FormControl,
FormErrorMessage,
FormLabel,
Input, Input,
Modal, Text,
ModalBody, VStack,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react" } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form" import { FaExchangeAlt } from "react-icons/fa"
import { type UserPublic, type UserUpdate, UsersService } from "../../client"
import { import type { ApiError } from "../../client/core/ApiError"
type ApiError,
type UserPublic,
type UserUpdate,
UsersService,
} from "../../client"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { emailPattern, handleError } from "../../utils" import { emailPattern, handleError } from "../../utils"
import { Checkbox } from "../ui/checkbox"
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { Field } from "../ui/field"
interface EditUserProps { interface EditUserProps {
user: UserPublic user: UserPublic
isOpen: boolean
onClose: () => void
} }
interface UserUpdateForm extends UserUpdate { interface UserUpdateForm extends UserUpdate {
confirm_password: string confirm_password?: string
} }
const EditUser = ({ user, isOpen, onClose }: EditUserProps) => { const EditUser = ({ user }: EditUserProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const showToast = useCustomToast() const { showSuccessToast } = useCustomToast()
const { const {
control,
register, register,
handleSubmit, handleSubmit,
reset, reset,
getValues, getValues,
formState: { errors, isSubmitting, isDirty }, formState: { errors, isSubmitting },
} = useForm<UserUpdateForm>({ } = useForm<UserUpdateForm>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
@ -56,11 +57,12 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
mutationFn: (data: UserUpdateForm) => mutationFn: (data: UserUpdateForm) =>
UsersService.updateUser({ userId: user.id, requestBody: data }), UsersService.updateUser({ userId: user.id, requestBody: data }),
onSuccess: () => { onSuccess: () => {
showToast("Success!", "User updated successfully.", "success") showSuccessToast("User updated successfully.")
onClose() reset()
setIsOpen(false)
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
handleError(err, showToast) handleError(err)
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }) queryClient.invalidateQueries({ queryKey: ["users"] })
@ -74,106 +76,145 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
mutation.mutate(data) mutation.mutate(data)
} }
const onCancel = () => {
reset()
onClose()
}
return ( return (
<> <DialogRoot
<Modal size={{ base: "xs", md: "md" }}
isOpen={isOpen} placement="center"
onClose={onClose} open={isOpen}
size={{ base: "sm", md: "md" }} onOpenChange={({ open }) => setIsOpen(open)}
isCentered >
> <DialogTrigger asChild>
<ModalOverlay /> <Button variant="ghost" size="sm">
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}> <FaExchangeAlt fontSize="16px" />
<ModalHeader>Edit User</ModalHeader> Edit User
<ModalCloseButton /> </Button>
<ModalBody pb={6}> </DialogTrigger>
<FormControl isInvalid={!!errors.email}> <DialogContent>
<FormLabel htmlFor="email">Email</FormLabel> <form onSubmit={handleSubmit(onSubmit)}>
<Input <DialogHeader>
id="email" <DialogTitle>Edit User</DialogTitle>
{...register("email", { </DialogHeader>
required: "Email is required", <DialogBody>
pattern: emailPattern, <Text mb={4}>Update the user details below.</Text>
})} <VStack gap={4}>
placeholder="Email" <Field
type="email" required
/> invalid={!!errors.email}
{errors.email && ( errorText={errors.email?.message}
<FormErrorMessage>{errors.email.message}</FormErrorMessage> label="Email"
)} >
</FormControl> <Input
<FormControl mt={4}> id="email"
<FormLabel htmlFor="name">Full name</FormLabel> {...register("email", {
<Input id="name" {...register("full_name")} type="text" /> required: "Email is required",
</FormControl> pattern: emailPattern,
<FormControl mt={4} isInvalid={!!errors.password}> })}
<FormLabel htmlFor="password">Set Password</FormLabel> placeholder="Email"
<Input type="email"
id="password" />
{...register("password", { </Field>
minLength: {
value: 8, <Field
message: "Password must be at least 8 characters", invalid={!!errors.full_name}
}, errorText={errors.full_name?.message}
})} label="Full Name"
placeholder="Password" >
type="password" <Input
id="name"
{...register("full_name")}
placeholder="Full name"
type="text"
/>
</Field>
<Field
required
invalid={!!errors.password}
errorText={errors.password?.message}
label="Set Password"
>
<Input
id="password"
{...register("password", {
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password"
type="password"
/>
</Field>
<Field
required
invalid={!!errors.confirm_password}
errorText={errors.confirm_password?.message}
label="Confirm Password"
>
<Input
id="confirm_password"
{...register("confirm_password", {
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password"
type="password"
/>
</Field>
</VStack>
<Flex mt={4} direction="column" gap={4}>
<Controller
control={control}
name="is_superuser"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is superuser?
</Checkbox>
</Field>
)}
/> />
{errors.password && ( <Controller
<FormErrorMessage>{errors.password.message}</FormErrorMessage> control={control}
)} name="is_active"
</FormControl> render={({ field }) => (
<FormControl mt={4} isInvalid={!!errors.confirm_password}> <Field disabled={field.disabled} colorPalette="teal">
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel> <Checkbox
<Input checked={field.value}
id="confirm_password" onCheckedChange={({ checked }) => field.onChange(checked)}
{...register("confirm_password", { >
validate: (value) => Is active?
value === getValues().password || </Checkbox>
"The passwords do not match", </Field>
})} )}
placeholder="Password"
type="password"
/> />
{errors.confirm_password && (
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
<Flex>
<FormControl mt={4}>
<Checkbox {...register("is_superuser")} colorScheme="teal">
Is superuser?
</Checkbox>
</FormControl>
<FormControl mt={4}>
<Checkbox {...register("is_active")} colorScheme="teal">
Is active?
</Checkbox>
</FormControl>
</Flex> </Flex>
</ModalBody> </DialogBody>
<ModalFooter gap={3}> <DialogFooter gap={2}>
<Button <DialogActionTrigger asChild>
variant="primary" <Button
type="submit" variant="subtle"
isLoading={isSubmitting} colorPalette="gray"
isDisabled={!isDirty} disabled={isSubmitting}
> >
Cancel
</Button>
</DialogActionTrigger>
<Button variant="solid" type="submit" loading={isSubmitting}>
Save Save
</Button> </Button>
<Button onClick={onCancel}>Cancel</Button> </DialogFooter>
</ModalFooter> <DialogCloseTrigger />
</ModalContent> </form>
</Modal> </DialogContent>
</> </DialogRoot>
) )
} }

75
frontend/src/components/Common/ActionsMenu.tsx

@ -1,75 +0,0 @@
import {
Button,
Menu,
MenuButton,
MenuItem,
MenuList,
useDisclosure,
} from "@chakra-ui/react"
import { BsThreeDotsVertical } from "react-icons/bs"
import { FiEdit, FiTrash } from "react-icons/fi"
import type { ItemPublic, UserPublic } from "../../client"
import EditUser from "../Admin/EditUser"
import EditItem from "../Items/EditItem"
import Delete from "./DeleteAlert"
interface ActionsMenuProps {
type: string
value: ItemPublic | UserPublic
disabled?: boolean
}
const ActionsMenu = ({ type, value, disabled }: ActionsMenuProps) => {
const editUserModal = useDisclosure()
const deleteModal = useDisclosure()
return (
<>
<Menu>
<MenuButton
isDisabled={disabled}
as={Button}
rightIcon={<BsThreeDotsVertical />}
variant="unstyled"
/>
<MenuList>
<MenuItem
onClick={editUserModal.onOpen}
icon={<FiEdit fontSize="16px" />}
>
Edit {type}
</MenuItem>
<MenuItem
onClick={deleteModal.onOpen}
icon={<FiTrash fontSize="16px" />}
color="ui.danger"
>
Delete {type}
</MenuItem>
</MenuList>
{type === "User" ? (
<EditUser
user={value as UserPublic}
isOpen={editUserModal.isOpen}
onClose={editUserModal.onClose}
/>
) : (
<EditItem
item={value as ItemPublic}
isOpen={editUserModal.isOpen}
onClose={editUserModal.onClose}
/>
)}
<Delete
type={type}
id={value.id}
isOpen={deleteModal.isOpen}
onClose={deleteModal.onClose}
/>
</Menu>
</>
)
}
export default ActionsMenu

113
frontend/src/components/Common/DeleteAlert.tsx

@ -1,113 +0,0 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import React from "react"
import { useForm } from "react-hook-form"
import { ItemsService, UsersService } from "../../client"
import useCustomToast from "../../hooks/useCustomToast"
interface DeleteProps {
type: string
id: string
isOpen: boolean
onClose: () => void
}
const Delete = ({ type, id, isOpen, onClose }: DeleteProps) => {
const queryClient = useQueryClient()
const showToast = useCustomToast()
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const deleteEntity = async (id: string) => {
if (type === "Item") {
await ItemsService.deleteItem({ id: id })
} else if (type === "User") {
await UsersService.deleteUser({ userId: id })
} else {
throw new Error(`Unexpected type: ${type}`)
}
}
const mutation = useMutation({
mutationFn: deleteEntity,
onSuccess: () => {
showToast(
"Success",
`The ${type.toLowerCase()} was deleted successfully.`,
"success",
)
onClose()
},
onError: () => {
showToast(
"An error occurred.",
`An error occurred while deleting the ${type.toLowerCase()}.`,
"error",
)
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: [type === "Item" ? "items" : "users"],
})
},
})
const onSubmit = async () => {
mutation.mutate(id)
}
return (
<>
<AlertDialog
isOpen={isOpen}
onClose={onClose}
leastDestructiveRef={cancelRef}
size={{ base: "sm", md: "md" }}
isCentered
>
<AlertDialogOverlay>
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
<AlertDialogHeader>Delete {type}</AlertDialogHeader>
<AlertDialogBody>
{type === "User" && (
<span>
All items associated with this user will also be{" "}
<strong>permanently deleted. </strong>
</span>
)}
Are you sure? You will not be able to undo this action.
</AlertDialogBody>
<AlertDialogFooter gap={3}>
<Button variant="danger" type="submit" isLoading={isSubmitting}>
Delete
</Button>
<Button
ref={cancelRef}
onClick={onClose}
isDisabled={isSubmitting}
>
Cancel
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
)
}
export default Delete

27
frontend/src/components/Common/ItemActionsMenu.tsx

@ -0,0 +1,27 @@
import { IconButton } from "@chakra-ui/react"
import { BsThreeDotsVertical } from "react-icons/bs"
import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu"
import type { ItemPublic } from "../../client"
import DeleteItem from "../Items/DeleteItem"
import EditItem from "../Items/EditItem"
interface ItemActionsMenuProps {
item: ItemPublic
}
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
return (
<MenuRoot>
<MenuTrigger asChild>
<IconButton variant="ghost" color="inherit">
<BsThreeDotsVertical />
</IconButton>
</MenuTrigger>
<MenuContent>
<EditItem item={item} />
<DeleteItem id={item.id} />
</MenuContent>
</MenuRoot>
)
}

54
frontend/src/components/Common/Navbar.tsx

@ -1,38 +1,30 @@
import type { ComponentType, ElementType } from "react" import { Flex, Image, useBreakpointValue } from "@chakra-ui/react"
import { Link } from "@tanstack/react-router"
import Logo from "/assets/images/fastapi-logo.svg"
import UserMenu from "./UserMenu"
import { Button, Flex, Icon, useDisclosure } from "@chakra-ui/react" function Navbar() {
import { FaPlus } from "react-icons/fa" const display = useBreakpointValue({ base: "none", md: "flex" })
interface NavbarProps {
type: string
addModalAs: ComponentType | ElementType
}
const Navbar = ({ type, addModalAs }: NavbarProps) => {
const addModal = useDisclosure()
const AddModal = addModalAs
return ( return (
<> <Flex
<Flex py={8} gap={4}> display={display}
{/* TODO: Complete search functionality */} justify="space-between"
{/* <InputGroup w={{ base: '100%', md: 'auto' }}> position="sticky"
<InputLeftElement pointerEvents='none'> color="white"
<Icon as={FaSearch} color='ui.dim' /> align="center"
</InputLeftElement> bg="bg.muted"
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' /> w="100%"
</InputGroup> */} top={0}
<Button p={4}
variant="primary" >
gap={1} <Link to="/">
fontSize={{ base: "sm", md: "inherit" }} <Image src={Logo} alt="Logo" w="180px" maxW="2xs" px={2} />
onClick={addModal.onOpen} </Link>
> <Flex gap={2} alignItems="center">
<Icon as={FaPlus} /> Add {type} <UserMenu />
</Button>
<AddModal isOpen={addModal.isOpen} onClose={addModal.onClose} />
</Flex> </Flex>
</> </Flex>
) )
} }

68
frontend/src/components/Common/NotFound.tsx

@ -1,39 +1,55 @@
import { Button, Container, Text } from "@chakra-ui/react" import { Button, Center, Flex, Text } from "@chakra-ui/react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
const NotFound = () => { const NotFound = () => {
return ( return (
<> <>
<Container <Flex
h="100vh" height="100vh"
alignItems="stretch" align="center"
justifyContent="center" justify="center"
textAlign="center" flexDir="column"
maxW="sm" data-testid="not-found"
centerContent p={4}
> >
<Flex alignItems="center" zIndex={1}>
<Flex flexDir="column" ml={4} align="center" justify="center" p={4}>
<Text
fontSize={{ base: "6xl", md: "8xl" }}
fontWeight="bold"
lineHeight="1"
mb={4}
>
404
</Text>
<Text fontSize="2xl" fontWeight="bold" mb={2}>
Oops!
</Text>
</Flex>
</Flex>
<Text <Text
fontSize="8xl" fontSize="lg"
color="ui.main" color="gray.600"
fontWeight="bold"
lineHeight="1"
mb={4} mb={4}
textAlign="center"
zIndex={1}
> >
404 The page you are looking for was not found.
</Text> </Text>
<Text fontSize="md">Oops!</Text> <Center zIndex={1}>
<Text fontSize="md">Page not found.</Text> <Link to="/">
<Button <Button
as={Link} variant="solid"
to="/" colorScheme="teal"
color="ui.main" mt={4}
borderColor="ui.main" alignSelf="center"
variant="outline" >
mt={4} Go Back
> </Button>
Go back </Link>
</Button> </Center>
</Container> </Flex>
</> </>
) )
} }

36
frontend/src/components/Common/PaginationFooter.tsx

@ -1,36 +0,0 @@
import { Button, Flex } from "@chakra-ui/react"
type PaginationFooterProps = {
hasNextPage?: boolean
hasPreviousPage?: boolean
onChangePage: (newPage: number) => void
page: number
}
export function PaginationFooter({
hasNextPage,
hasPreviousPage,
onChangePage,
page,
}: PaginationFooterProps) {
return (
<Flex
gap={4}
alignItems="center"
mt={4}
direction="row"
justifyContent="flex-end"
>
<Button
onClick={() => onChangePage(page - 1)}
isDisabled={!hasPreviousPage || page <= 1}
>
Previous
</Button>
<span>Page {page}</span>
<Button isDisabled={!hasNextPage} onClick={() => onChangePage(page + 1)}>
Next
</Button>
</Flex>
)
}

123
frontend/src/components/Common/Sidebar.tsx

@ -1,33 +1,26 @@
import { import { Box, Flex, IconButton, Text } from "@chakra-ui/react"
Box,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerOverlay,
Flex,
IconButton,
Image,
Text,
useColorModeValue,
useDisclosure,
} from "@chakra-ui/react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { FiLogOut, FiMenu } from "react-icons/fi" import { useState } from "react"
import { FaBars } from "react-icons/fa"
import Logo from "/assets/images/fastapi-logo.svg" import { FiLogOut } from "react-icons/fi"
import type { UserPublic } from "../../client" import type { UserPublic } from "../../client"
import useAuth from "../../hooks/useAuth" import useAuth from "../../hooks/useAuth"
import {
DrawerBackdrop,
DrawerBody,
DrawerCloseTrigger,
DrawerContent,
DrawerRoot,
DrawerTrigger,
} from "../ui/drawer"
import SidebarItems from "./SidebarItems" import SidebarItems from "./SidebarItems"
const Sidebar = () => { const Sidebar = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const bgColor = useColorModeValue("ui.light", "ui.dark")
const textColor = useColorModeValue("ui.dark", "ui.light")
const secBgColor = useColorModeValue("ui.secondary", "ui.darkSlate")
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"]) const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const { isOpen, onOpen, onClose } = useDisclosure()
const { logout } = useAuth() const { logout } = useAuth()
const [open, setOpen] = useState(false)
const handleLogout = async () => { const handleLogout = async () => {
logout() logout()
@ -36,78 +29,68 @@ const Sidebar = () => {
return ( return (
<> <>
{/* Mobile */} {/* Mobile */}
<IconButton <DrawerRoot
onClick={onOpen} placement="start"
display={{ base: "flex", md: "none" }} open={open}
aria-label="Open Menu" onOpenChange={(e) => setOpen(e.open)}
position="absolute" >
fontSize="20px" <DrawerBackdrop />
m={4} <DrawerTrigger asChild>
icon={<FiMenu />} <IconButton
/> variant="ghost"
<Drawer isOpen={isOpen} placement="left" onClose={onClose}> color="inherit"
<DrawerOverlay /> display={{ base: "flex", md: "none" }}
<DrawerContent maxW="250px"> aria-label="Open Menu"
<DrawerCloseButton /> position="absolute"
<DrawerBody py={8}> zIndex="100"
m={4}
>
<FaBars />
</IconButton>
</DrawerTrigger>
<DrawerContent maxW="280px">
<DrawerCloseTrigger />
<DrawerBody>
<Flex flexDir="column" justify="space-between"> <Flex flexDir="column" justify="space-between">
<Box> <Box>
<Image src={Logo} alt="logo" p={6} /> <SidebarItems />
<SidebarItems onClose={onClose} />
<Flex <Flex
as="button" as="button"
onClick={handleLogout} onClick={handleLogout}
p={2}
color="ui.danger"
fontWeight="bold"
alignItems="center" alignItems="center"
gap={4}
px={4}
py={2}
> >
<FiLogOut /> <FiLogOut />
<Text ml={2}>Log out</Text> <Text>Log Out</Text>
</Flex> </Flex>
</Box> </Box>
{currentUser?.email && ( {currentUser?.email && (
<Text color={textColor} noOfLines={2} fontSize="sm" p={2}> <Text fontSize="sm" p={2}>
Logged in as: {currentUser.email} Logged in as: {currentUser.email}
</Text> </Text>
)} )}
</Flex> </Flex>
</DrawerBody> </DrawerBody>
<DrawerCloseTrigger />
</DrawerContent> </DrawerContent>
</Drawer> </DrawerRoot>
{/* Desktop */} {/* Desktop */}
<Box <Box
bg={bgColor}
p={3}
h="100vh"
position="sticky"
top="0"
display={{ base: "none", md: "flex" }} display={{ base: "none", md: "flex" }}
position="sticky"
bg="bg.subtle"
top={0}
minW="280px"
h="100vh"
p={4}
> >
<Flex <Box w="100%">
flexDir="column" <SidebarItems />
justify="space-between" </Box>
bg={secBgColor}
p={4}
borderRadius={12}
>
<Box>
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" p={6} />
<SidebarItems />
</Box>
{currentUser?.email && (
<Text
color={textColor}
noOfLines={2}
fontSize="sm"
p={2}
maxW="180px"
>
Logged in as: {currentUser.email}
</Text>
)}
</Flex>
</Box> </Box>
</> </>
) )

53
frontend/src/components/Common/SidebarItems.tsx

@ -1,8 +1,9 @@
import { Box, Flex, Icon, Text, useColorModeValue } from "@chakra-ui/react" import { Box, Flex, Icon, Text } from "@chakra-ui/react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router" import { Link as RouterLink } from "@tanstack/react-router"
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
import type { IconType } from "react-icons/lib"
import type { UserPublic } from "../../client" import type { UserPublic } from "../../client"
const items = [ const items = [
@ -15,39 +16,43 @@ interface SidebarItemsProps {
onClose?: () => void onClose?: () => void
} }
interface Item {
icon: IconType
title: string
path: string
}
const SidebarItems = ({ onClose }: SidebarItemsProps) => { const SidebarItems = ({ onClose }: SidebarItemsProps) => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const textColor = useColorModeValue("ui.main", "ui.light")
const bgActive = useColorModeValue("#E2E8F0", "#4A5568")
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"]) const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const finalItems = currentUser?.is_superuser const finalItems: Item[] = currentUser?.is_superuser
? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }] ? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }]
: items : items
const listItems = finalItems.map(({ icon, title, path }) => ( const listItems = finalItems.map(({ icon, title, path }) => (
<Flex <RouterLink key={title} to={path} onClick={onClose}>
as={Link} <Flex
to={path} gap={4}
w="100%" px={4}
p={2} py={2}
key={title} _hover={{
activeProps={{ background: "gray.subtle",
style: { }}
background: bgActive, alignItems="center"
borderRadius: "12px", fontSize="sm"
}, >
}} <Icon as={icon} alignSelf="center" />
color={textColor} <Text ml={2}>{title}</Text>
onClick={onClose} </Flex>
> </RouterLink>
<Icon as={icon} alignSelf="center" />
<Text ml={2}>{title}</Text>
</Flex>
)) ))
return ( return (
<> <>
<Text fontSize="xs" px={4} py={2} fontWeight="bold">
Menu
</Text>
<Box>{listItems}</Box> <Box>{listItems}</Box>
</> </>
) )

28
frontend/src/components/Common/UserActionsMenu.tsx

@ -0,0 +1,28 @@
import { IconButton } from "@chakra-ui/react"
import { BsThreeDotsVertical } from "react-icons/bs"
import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu"
import type { UserPublic } from "../../client"
import DeleteUser from "../Admin/DeleteUser"
import EditUser from "../Admin/EditUser"
interface UserActionsMenuProps {
user: UserPublic
disabled?: boolean
}
export const UserActionsMenu = ({ user, disabled }: UserActionsMenuProps) => {
return (
<MenuRoot>
<MenuTrigger asChild>
<IconButton variant="ghost" color="inherit" disabled={disabled}>
<BsThreeDotsVertical />
</IconButton>
</MenuTrigger>
<MenuContent>
<EditUser user={user} />
<DeleteUser id={user.id} />
</MenuContent>
</MenuRoot>
)
}

77
frontend/src/components/Common/UserMenu.tsx

@ -1,19 +1,13 @@
import { import { Box, Button, Flex, Text } from "@chakra-ui/react"
Box,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
} from "@chakra-ui/react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { FaUserAstronaut } from "react-icons/fa" import { FaUserAstronaut } from "react-icons/fa"
import { FiLogOut, FiUser } from "react-icons/fi"
import { FiLogOut, FiUser } from "react-icons/fi"
import useAuth from "../../hooks/useAuth" import useAuth from "../../hooks/useAuth"
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "../ui/menu"
const UserMenu = () => { const UserMenu = () => {
const { logout } = useAuth() const { user, logout } = useAuth()
const handleLogout = async () => { const handleLogout = async () => {
logout() logout()
@ -22,36 +16,47 @@ const UserMenu = () => {
return ( return (
<> <>
{/* Desktop */} {/* Desktop */}
<Box <Flex>
display={{ base: "none", md: "block" }} <MenuRoot>
position="fixed" <MenuTrigger asChild p={2}>
top={4} <Button
right={4} data-testid="user-menu"
> variant="solid"
<Menu> maxW="150px"
<MenuButton truncate
as={IconButton} >
aria-label="Options" <FaUserAstronaut fontSize="18" />
icon={<FaUserAstronaut color="white" fontSize="18px" />} <Text>{user?.full_name || "User"}</Text>
bg="ui.main" </Button>
isRound </MenuTrigger>
data-testid="user-menu"
/> <MenuContent>
<MenuList> <Link to="settings">
<MenuItem icon={<FiUser fontSize="18px" />} as={Link} to="settings"> <MenuItem
My profile closeOnSelect
</MenuItem> value="user-settings"
gap={2}
py={2}
style={{ cursor: "pointer" }}
>
<FiUser fontSize="18px" />
<Box flex="1">My Profile</Box>
</MenuItem>
</Link>
<MenuItem <MenuItem
icon={<FiLogOut fontSize="18px" />} value="logout"
gap={2}
py={2}
onClick={handleLogout} onClick={handleLogout}
color="ui.danger" style={{ cursor: "pointer" }}
fontWeight="bold"
> >
Log out <FiLogOut />
Log Out
</MenuItem> </MenuItem>
</MenuList> </MenuContent>
</Menu> </MenuRoot>
</Box> </Flex>
</> </>
) )
} }

167
frontend/src/components/Items/AddItem.tsx

@ -1,37 +1,40 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { type SubmitHandler, useForm } from "react-hook-form"
import { import {
Button, Button,
FormControl, DialogActionTrigger,
FormErrorMessage, DialogTitle,
FormLabel,
Input, Input,
Modal, Text,
ModalBody, VStack,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react" } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form" import { FaPlus } from "react-icons/fa"
import { type ItemCreate, ItemsService } from "../../client"
import { type ApiError, type ItemCreate, ItemsService } from "../../client" import type { ApiError } from "../../client/core/ApiError"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { handleError } from "../../utils" import { handleError } from "../../utils"
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
} from "../ui/dialog"
import { Field } from "../ui/field"
interface AddItemProps { const AddItem = () => {
isOpen: boolean const [isOpen, setIsOpen] = useState(false)
onClose: () => void
}
const AddItem = ({ isOpen, onClose }: AddItemProps) => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const showToast = useCustomToast() const { showSuccessToast } = useCustomToast()
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
formState: { errors, isSubmitting }, formState: { errors, isValid, isSubmitting },
} = useForm<ItemCreate>({ } = useForm<ItemCreate>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
@ -45,12 +48,12 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
mutationFn: (data: ItemCreate) => mutationFn: (data: ItemCreate) =>
ItemsService.createItem({ requestBody: data }), ItemsService.createItem({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showToast("Success!", "Item created successfully.", "success") showSuccessToast("Item created successfully.")
reset() reset()
onClose() setIsOpen(false)
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
handleError(err, showToast) handleError(err)
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }) queryClient.invalidateQueries({ queryKey: ["items"] })
@ -62,52 +65,80 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
} }
return ( return (
<> <DialogRoot
<Modal size={{ base: "xs", md: "md" }}
isOpen={isOpen} placement="center"
onClose={onClose} open={isOpen}
size={{ base: "sm", md: "md" }} onOpenChange={({ open }) => setIsOpen(open)}
isCentered >
> <DialogTrigger asChild>
<ModalOverlay /> <Button value="add-item" my={4}>
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}> <FaPlus fontSize="16px" />
<ModalHeader>Add Item</ModalHeader> Add Item
<ModalCloseButton /> </Button>
<ModalBody pb={6}> </DialogTrigger>
<FormControl isRequired isInvalid={!!errors.title}> <DialogContent>
<FormLabel htmlFor="title">Title</FormLabel> <form onSubmit={handleSubmit(onSubmit)}>
<Input <DialogHeader>
id="title" <DialogTitle>Add Item</DialogTitle>
{...register("title", { </DialogHeader>
required: "Title is required.", <DialogBody>
})} <Text mb={4}>Fill in the details to add a new item.</Text>
placeholder="Title" <VStack gap={4}>
type="text" <Field
/> required
{errors.title && ( invalid={!!errors.title}
<FormErrorMessage>{errors.title.message}</FormErrorMessage> errorText={errors.title?.message}
)} label="Title"
</FormControl> >
<FormControl mt={4}> <Input
<FormLabel htmlFor="description">Description</FormLabel> id="title"
<Input {...register("title", {
id="description" required: "Title is required.",
{...register("description")} })}
placeholder="Description" placeholder="Title"
type="text" type="text"
/> />
</FormControl> </Field>
</ModalBody>
<Field
invalid={!!errors.description}
errorText={errors.description?.message}
label="Description"
>
<Input
id="description"
{...register("description")}
placeholder="Description"
type="text"
/>
</Field>
</VStack>
</DialogBody>
<ModalFooter gap={3}> <DialogFooter gap={2}>
<Button variant="primary" type="submit" isLoading={isSubmitting}> <DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
type="submit"
disabled={!isValid}
loading={isSubmitting}
>
Save Save
</Button> </Button>
<Button onClick={onClose}>Cancel</Button> </DialogFooter>
</ModalFooter> </form>
</ModalContent> <DialogCloseTrigger />
</Modal> </DialogContent>
</> </DialogRoot>
) )
} }

103
frontend/src/components/Items/DeleteItem.tsx

@ -0,0 +1,103 @@
import { Button, DialogTitle, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { FiTrash2 } from "react-icons/fi"
import { ItemsService } from "../../client"
import {
DialogActionTrigger,
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
} from "../../components/ui/dialog"
import useCustomToast from "../../hooks/useCustomToast"
const DeleteItem = ({ id }: { id: string }) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const deleteItem = async (id: string) => {
await ItemsService.deleteItem({ id: id })
}
const mutation = useMutation({
mutationFn: deleteItem,
onSuccess: () => {
showSuccessToast("The item was deleted successfully")
setIsOpen(false)
},
onError: () => {
showErrorToast("An error occurred while deleting the item")
},
onSettled: () => {
queryClient.invalidateQueries()
},
})
const onSubmit = async () => {
mutation.mutate(id)
}
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>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogCloseTrigger />
<DialogHeader>
<DialogTitle>Delete Item</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
This item will be permanently deleted. Are you sure? You will not
be able to undo this action.
</Text>
</DialogBody>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
colorPalette="red"
type="submit"
loading={isSubmitting}
>
Delete
</Button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>
)
}
export default DeleteItem

192
frontend/src/components/Items/EditItem.tsx

@ -1,123 +1,149 @@
import { import {
Button, Button,
FormControl, ButtonGroup,
FormErrorMessage, DialogActionTrigger,
FormLabel,
Input, Input,
Modal, Text,
ModalBody, VStack,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react" } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { type SubmitHandler, useForm } from "react-hook-form" import { type SubmitHandler, useForm } from "react-hook-form"
import { FaExchangeAlt } from "react-icons/fa"
import { import { type ApiError, type ItemPublic, ItemsService } from "../../client"
type ApiError,
type ItemPublic,
type ItemUpdate,
ItemsService,
} from "../../client"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { handleError } from "../../utils" import { handleError } from "../../utils"
import {
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from "../ui/dialog"
import { Field } from "../ui/field"
interface EditItemProps { interface EditItemProps {
item: ItemPublic item: ItemPublic
isOpen: boolean
onClose: () => void
} }
const EditItem = ({ item, isOpen, onClose }: EditItemProps) => { interface ItemUpdateForm {
title: string
description?: string
}
const EditItem = ({ item }: EditItemProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const showToast = useCustomToast() const { showSuccessToast } = useCustomToast()
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
formState: { isSubmitting, errors, isDirty }, formState: { errors, isSubmitting },
} = useForm<ItemUpdate>({ } = useForm<ItemUpdateForm>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
defaultValues: item, defaultValues: {
...item,
description: item.description ?? undefined,
},
}) })
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: ItemUpdate) => mutationFn: (data: ItemUpdateForm) =>
ItemsService.updateItem({ id: item.id, requestBody: data }), ItemsService.updateItem({ id: item.id, requestBody: data }),
onSuccess: () => { onSuccess: () => {
showToast("Success!", "Item updated successfully.", "success") showSuccessToast("Item updated successfully.")
onClose() reset()
setIsOpen(false)
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
handleError(err, showToast) handleError(err)
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }) queryClient.invalidateQueries({ queryKey: ["items"] })
}, },
}) })
const onSubmit: SubmitHandler<ItemUpdate> = async (data) => { const onSubmit: SubmitHandler<ItemUpdateForm> = async (data) => {
mutation.mutate(data) mutation.mutate(data)
} }
const onCancel = () => {
reset()
onClose()
}
return ( return (
<> <DialogRoot
<Modal size={{ base: "xs", md: "md" }}
isOpen={isOpen} placement="center"
onClose={onClose} open={isOpen}
size={{ base: "sm", md: "md" }} onOpenChange={({ open }) => setIsOpen(open)}
isCentered >
> <DialogTrigger asChild>
<ModalOverlay /> <Button variant="ghost">
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}> <FaExchangeAlt fontSize="16px" />
<ModalHeader>Edit Item</ModalHeader> Edit Item
<ModalCloseButton /> </Button>
<ModalBody pb={6}> </DialogTrigger>
<FormControl isInvalid={!!errors.title}> <DialogContent>
<FormLabel htmlFor="title">Title</FormLabel> <form onSubmit={handleSubmit(onSubmit)}>
<Input <DialogHeader>
id="title" <DialogTitle>Edit Item</DialogTitle>
{...register("title", { </DialogHeader>
required: "Title is required", <DialogBody>
})} <Text mb={4}>Update the item details below.</Text>
type="text" <VStack gap={4}>
/> <Field
{errors.title && ( required
<FormErrorMessage>{errors.title.message}</FormErrorMessage> invalid={!!errors.title}
)} errorText={errors.title?.message}
</FormControl> label="Title"
<FormControl mt={4}> >
<FormLabel htmlFor="description">Description</FormLabel> <Input
<Input id="title"
id="description" {...register("title", {
{...register("description")} required: "Title is required",
placeholder="Description" })}
type="text" placeholder="Title"
/> type="text"
</FormControl> />
</ModalBody> </Field>
<ModalFooter gap={3}>
<Button <Field
variant="primary" invalid={!!errors.description}
type="submit" errorText={errors.description?.message}
isLoading={isSubmitting} label="Description"
isDisabled={!isDirty} >
> <Input
Save id="description"
</Button> {...register("description")}
<Button onClick={onCancel}>Cancel</Button> placeholder="Description"
</ModalFooter> type="text"
</ModalContent> />
</Modal> </Field>
</> </VStack>
</DialogBody>
<DialogFooter gap={2}>
<ButtonGroup>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button variant="solid" type="submit" loading={isSubmitting}>
Save
</Button>
</ButtonGroup>
</DialogFooter>
</form>
<DialogCloseTrigger />
</DialogContent>
</DialogRoot>
) )
} }

34
frontend/src/components/Pending/PendingItems.tsx

@ -0,0 +1,34 @@
import { Skeleton, Table } from "@chakra-ui/react"
const PendingItems = () => (
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="30%">ID</Table.ColumnHeader>
<Table.ColumnHeader w="30%">Title</Table.ColumnHeader>
<Table.ColumnHeader w="30%">Description</Table.ColumnHeader>
<Table.ColumnHeader w="10%">Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{[...Array(5)].map((_, index) => (
<Table.Row key={index}>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)
export default PendingItems

38
frontend/src/components/Pending/PendingUsers.tsx

@ -0,0 +1,38 @@
import { Skeleton, Table } from "@chakra-ui/react"
const PendingUsers = () => (
<Table.Root size={{ base: "sm", md: "md" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader w="20%">Full name</Table.ColumnHeader>
<Table.ColumnHeader w="25%">Email</Table.ColumnHeader>
<Table.ColumnHeader w="15%">Role</Table.ColumnHeader>
<Table.ColumnHeader w="20%">Status</Table.ColumnHeader>
<Table.ColumnHeader w="20%">Actions</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{[...Array(5)].map((_, index) => (
<Table.Row key={index}>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
<Table.Cell>
<Skeleton h="20px" />
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)
export default PendingUsers

34
frontend/src/components/UserSettings/Appearance.tsx

@ -1,15 +1,9 @@
import { import { Container, Heading, Stack } from "@chakra-ui/react"
Badge, import { useTheme } from "next-themes"
Container, import { Radio, RadioGroup } from "../../components/ui/radio"
Heading,
Radio,
RadioGroup,
Stack,
useColorMode,
} from "@chakra-ui/react"
const Appearance = () => { const Appearance = () => {
const { colorMode, toggleColorMode } = useColorMode() const { theme, setTheme } = useTheme()
return ( return (
<> <>
@ -17,18 +11,16 @@ const Appearance = () => {
<Heading size="sm" py={4}> <Heading size="sm" py={4}>
Appearance Appearance
</Heading> </Heading>
<RadioGroup onChange={toggleColorMode} value={colorMode}>
<RadioGroup
onValueChange={(e) => setTheme(e.value)}
value={theme}
colorPalette="teal"
>
<Stack> <Stack>
{/* TODO: Add system default option */} <Radio value="system">System</Radio>
<Radio value="light" colorScheme="teal"> <Radio value="light">Light Mode</Radio>
Light Mode <Radio value="dark">Dark Mode</Radio>
<Badge ml="1" colorScheme="teal">
Default
</Badge>
</Radio>
<Radio value="dark" colorScheme="teal">
Dark Mode
</Radio>
</Stack> </Stack>
</RadioGroup> </RadioGroup>
</Container> </Container>

88
frontend/src/components/UserSettings/ChangePassword.tsx

@ -1,34 +1,25 @@
import { import { Box, Button, Container, Heading, VStack } from "@chakra-ui/react"
Box,
Button,
Container,
FormControl,
FormErrorMessage,
FormLabel,
Heading,
Input,
useColorModeValue,
} from "@chakra-ui/react"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { type SubmitHandler, useForm } from "react-hook-form" import { type SubmitHandler, useForm } from "react-hook-form"
import { FiLock } from "react-icons/fi"
import { type ApiError, type UpdatePassword, UsersService } from "../../client" import { type ApiError, type UpdatePassword, UsersService } from "../../client"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { confirmPasswordRules, handleError, passwordRules } from "../../utils" import { confirmPasswordRules, handleError, passwordRules } from "../../utils"
import { PasswordInput } from "../ui/password-input"
interface UpdatePasswordForm extends UpdatePassword { interface UpdatePasswordForm extends UpdatePassword {
confirm_password: string confirm_password: string
} }
const ChangePassword = () => { const ChangePassword = () => {
const color = useColorModeValue("inherit", "ui.light") const { showSuccessToast } = useCustomToast()
const showToast = useCustomToast()
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
getValues, getValues,
formState: { errors, isSubmitting }, formState: { errors, isValid, isSubmitting },
} = useForm<UpdatePasswordForm>({ } = useForm<UpdatePasswordForm>({
mode: "onBlur", mode: "onBlur",
criteriaMode: "all", criteriaMode: "all",
@ -38,11 +29,11 @@ const ChangePassword = () => {
mutationFn: (data: UpdatePassword) => mutationFn: (data: UpdatePassword) =>
UsersService.updatePasswordMe({ requestBody: data }), UsersService.updatePasswordMe({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showToast("Success!", "Password updated successfully.", "success") showSuccessToast("Password updated successfully.")
reset() reset()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
handleError(err, showToast) handleError(err)
}, },
}) })
@ -57,60 +48,39 @@ const ChangePassword = () => {
Change Password Change Password
</Heading> </Heading>
<Box <Box
w={{ sm: "full", md: "50%" }} w={{ sm: "full", md: "300px" }}
as="form" as="form"
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
> >
<FormControl isRequired isInvalid={!!errors.current_password}> <VStack gap={4}>
<FormLabel color={color} htmlFor="current_password"> <PasswordInput
Current Password type="current_password"
</FormLabel> startElement={<FiLock />}
<Input {...register("current_password", passwordRules())}
id="current_password" placeholder="Current Password"
{...register("current_password")} errors={errors}
placeholder="Password"
type="password"
w="auto"
/> />
{errors.current_password && ( <PasswordInput
<FormErrorMessage> type="new_password"
{errors.current_password.message} startElement={<FiLock />}
</FormErrorMessage>
)}
</FormControl>
<FormControl mt={4} isRequired isInvalid={!!errors.new_password}>
<FormLabel htmlFor="password">Set Password</FormLabel>
<Input
id="password"
{...register("new_password", passwordRules())} {...register("new_password", passwordRules())}
placeholder="Password" placeholder="New Password"
type="password" errors={errors}
w="auto"
/> />
{errors.new_password && ( <PasswordInput
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage> type="confirm_password"
)} startElement={<FiLock />}
</FormControl>
<FormControl mt={4} isRequired isInvalid={!!errors.confirm_password}>
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
<Input
id="confirm_password"
{...register("confirm_password", confirmPasswordRules(getValues))} {...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Password" placeholder="Confirm Password"
type="password" errors={errors}
w="auto"
/> />
{errors.confirm_password && ( </VStack>
<FormErrorMessage>
{errors.confirm_password.message}
</FormErrorMessage>
)}
</FormControl>
<Button <Button
variant="primary" variant="solid"
mt={4} mt={4}
type="submit" type="submit"
isLoading={isSubmitting} loading={isSubmitting}
disabled={!isValid}
> >
Save Save
</Button> </Button>

38
frontend/src/components/UserSettings/DeleteAccount.tsx

@ -1,35 +1,19 @@
import { import { Container, Heading, Text } from "@chakra-ui/react"
Button,
Container,
Heading,
Text,
useDisclosure,
} from "@chakra-ui/react"
import DeleteConfirmation from "./DeleteConfirmation" import DeleteConfirmation from "./DeleteConfirmation"
const DeleteAccount = () => { const DeleteAccount = () => {
const confirmationModal = useDisclosure()
return ( return (
<> <Container maxW="full">
<Container maxW="full"> <Heading size="sm" py={4}>
<Heading size="sm" py={4}> Delete Account
Delete Account </Heading>
</Heading> <Text>
<Text> Permanently delete your data and everything associated with your
Permanently delete your data and everything associated with your account.
account. </Text>
</Text> <DeleteConfirmation />
<Button variant="danger" mt={4} onClick={confirmationModal.onOpen}> </Container>
Delete
</Button>
<DeleteConfirmation
isOpen={confirmationModal.isOpen}
onClose={confirmationModal.onClose}
/>
</Container>
</>
) )
} }
export default DeleteAccount export default DeleteAccount

124
frontend/src/components/UserSettings/DeleteConfirmation.tsx

@ -1,30 +1,27 @@
import { import { Button, ButtonGroup, Text } from "@chakra-ui/react"
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
} from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import React from "react" import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { type ApiError, UsersService } from "../../client" import { type ApiError, UsersService } from "../../client"
import {
DialogActionTrigger,
DialogBody,
DialogCloseTrigger,
DialogContent,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog"
import useAuth from "../../hooks/useAuth" import useAuth from "../../hooks/useAuth"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { handleError } from "../../utils" import { handleError } from "../../utils"
interface DeleteProps { const DeleteConfirmation = () => {
isOpen: boolean const [isOpen, setIsOpen] = useState(false)
onClose: () => void
}
const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const showToast = useCustomToast() const { showSuccessToast } = useCustomToast()
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
const { const {
handleSubmit, handleSubmit,
formState: { isSubmitting }, formState: { isSubmitting },
@ -34,16 +31,12 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => UsersService.deleteUserMe(), mutationFn: () => UsersService.deleteUserMe(),
onSuccess: () => { onSuccess: () => {
showToast( showSuccessToast("Your account has been successfully deleted")
"Success", setIsOpen(false)
"Your account has been successfully deleted.",
"success",
)
logout() logout()
onClose()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
handleError(err, showToast) handleError(err)
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["currentUser"] }) queryClient.invalidateQueries({ queryKey: ["currentUser"] })
@ -56,39 +49,58 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
return ( return (
<> <>
<AlertDialog <DialogRoot
isOpen={isOpen} size={{ base: "xs", md: "md" }}
onClose={onClose} role="alertdialog"
leastDestructiveRef={cancelRef} placement="center"
size={{ base: "sm", md: "md" }} open={isOpen}
isCentered onOpenChange={({ open }) => setIsOpen(open)}
> >
<AlertDialogOverlay> <DialogTrigger asChild>
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}> <Button variant="solid" colorPalette="red" mt={4}>
<AlertDialogHeader>Confirmation Required</AlertDialogHeader> Delete
</Button>
</DialogTrigger>
<AlertDialogBody> <DialogContent>
All your account data will be{" "} <form onSubmit={handleSubmit(onSubmit)}>
<strong>permanently deleted.</strong> If you are sure, please <DialogCloseTrigger />
click <strong>"Confirm"</strong> to proceed. This action cannot be <DialogHeader>
undone. <DialogTitle>Confirmation Required</DialogTitle>
</AlertDialogBody> </DialogHeader>
<DialogBody>
<Text mb={4}>
All your account data will be{" "}
<strong>permanently deleted.</strong> If you are sure, please
click <strong>"Confirm"</strong> to proceed. This action cannot
be undone.
</Text>
</DialogBody>
<AlertDialogFooter gap={3}> <DialogFooter gap={2}>
<Button variant="danger" type="submit" isLoading={isSubmitting}> <ButtonGroup>
Confirm <DialogActionTrigger asChild>
</Button> <Button
<Button variant="subtle"
ref={cancelRef} colorPalette="gray"
onClick={onClose} disabled={isSubmitting}
isDisabled={isSubmitting} >
> Cancel
Cancel </Button>
</Button> </DialogActionTrigger>
</AlertDialogFooter> <Button
</AlertDialogContent> variant="solid"
</AlertDialogOverlay> colorPalette="red"
</AlertDialog> type="submit"
loading={isSubmitting}
>
Delete
</Button>
</ButtonGroup>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>
</> </>
) )
} }

57
frontend/src/components/UserSettings/UserInformation.tsx

@ -3,13 +3,9 @@ import {
Button, Button,
Container, Container,
Flex, Flex,
FormControl,
FormErrorMessage,
FormLabel,
Heading, Heading,
Input, Input,
Text, Text,
useColorModeValue,
} from "@chakra-ui/react" } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useState } from "react" import { useState } from "react"
@ -24,11 +20,11 @@ import {
import useAuth from "../../hooks/useAuth" import useAuth from "../../hooks/useAuth"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { emailPattern, handleError } from "../../utils" import { emailPattern, handleError } from "../../utils"
import { Field } from "../ui/field"
const UserInformation = () => { const UserInformation = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const color = useColorModeValue("inherit", "ui.light") const { showSuccessToast } = useCustomToast()
const showToast = useCustomToast()
const [editMode, setEditMode] = useState(false) const [editMode, setEditMode] = useState(false)
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
const { const {
@ -54,10 +50,10 @@ const UserInformation = () => {
mutationFn: (data: UserUpdateMe) => mutationFn: (data: UserUpdateMe) =>
UsersService.updateUserMe({ requestBody: data }), UsersService.updateUserMe({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
showToast("Success!", "User updated successfully.", "success") showSuccessToast("User updated successfully.")
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
handleError(err, showToast) handleError(err)
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries() queryClient.invalidateQueries()
@ -84,13 +80,9 @@ const UserInformation = () => {
as="form" as="form"
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
> >
<FormControl> <Field label="Full name">
<FormLabel color={color} htmlFor="name">
Full name
</FormLabel>
{editMode ? ( {editMode ? (
<Input <Input
id="name"
{...register("full_name", { maxLength: 30 })} {...register("full_name", { maxLength: 30 })}
type="text" type="text"
size="md" size="md"
@ -98,23 +90,24 @@ const UserInformation = () => {
/> />
) : ( ) : (
<Text <Text
size="md" fontSize="md"
py={2} py={2}
color={!currentUser?.full_name ? "ui.dim" : "inherit"} color={!currentUser?.full_name ? "gray" : "inherit"}
isTruncated truncate
maxWidth="250px" maxWidth="250px"
> >
{currentUser?.full_name || "N/A"} {currentUser?.full_name || "N/A"}
</Text> </Text>
)} )}
</FormControl> </Field>
<FormControl mt={4} isInvalid={!!errors.email}> <Field
<FormLabel color={color} htmlFor="email"> mt={4}
Email label="Email"
</FormLabel> invalid={!!errors.email}
errorText={errors.email?.message}
>
{editMode ? ( {editMode ? (
<Input <Input
id="email"
{...register("email", { {...register("email", {
required: "Email is required", required: "Email is required",
pattern: emailPattern, pattern: emailPattern,
@ -124,26 +117,28 @@ const UserInformation = () => {
w="auto" w="auto"
/> />
) : ( ) : (
<Text size="md" py={2} isTruncated maxWidth="250px"> <Text fontSize="md" py={2} truncate maxWidth="250px">
{currentUser?.email} {currentUser?.email}
</Text> </Text>
)} )}
{errors.email && ( </Field>
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<Flex mt={4} gap={3}> <Flex mt={4} gap={3}>
<Button <Button
variant="primary" variant="solid"
onClick={toggleEditMode} onClick={toggleEditMode}
type={editMode ? "button" : "submit"} type={editMode ? "button" : "submit"}
isLoading={editMode ? isSubmitting : false} loading={editMode ? isSubmitting : false}
isDisabled={editMode ? !isDirty || !getValues("email") : false} disabled={editMode ? !isDirty || !getValues("email") : false}
> >
{editMode ? "Save" : "Edit"} {editMode ? "Save" : "Edit"}
</Button> </Button>
{editMode && ( {editMode && (
<Button onClick={onCancel} isDisabled={isSubmitting}> <Button
variant="subtle"
colorPalette="gray"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel Cancel
</Button> </Button>
)} )}

40
frontend/src/components/ui/button.tsx

@ -0,0 +1,40 @@
import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react"
import {
AbsoluteCenter,
Button as ChakraButton,
Span,
Spinner,
} from "@chakra-ui/react"
import * as React from "react"
interface ButtonLoadingProps {
loading?: boolean
loadingText?: React.ReactNode
}
export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
function Button(props, ref) {
const { loading, disabled, loadingText, children, ...rest } = props
return (
<ChakraButton disabled={loading || disabled} ref={ref} {...rest}>
{loading && !loadingText ? (
<>
<AbsoluteCenter display="inline-flex">
<Spinner size="inherit" color="inherit" />
</AbsoluteCenter>
<Span opacity={0}>{children}</Span>
</>
) : loading && loadingText ? (
<>
<Spinner size="inherit" color="inherit" />
{loadingText}
</>
) : (
children
)}
</ChakraButton>
)
},
)

25
frontend/src/components/ui/checkbox.tsx

@ -0,0 +1,25 @@
import { Checkbox as ChakraCheckbox } from "@chakra-ui/react"
import * as React from "react"
export interface CheckboxProps extends ChakraCheckbox.RootProps {
icon?: React.ReactNode
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
rootRef?: React.Ref<HTMLLabelElement>
}
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(props, ref) {
const { icon, children, inputProps, rootRef, ...rest } = props
return (
<ChakraCheckbox.Root ref={rootRef} {...rest}>
<ChakraCheckbox.HiddenInput ref={ref} {...inputProps} />
<ChakraCheckbox.Control>
{icon || <ChakraCheckbox.Indicator />}
</ChakraCheckbox.Control>
{children != null && (
<ChakraCheckbox.Label>{children}</ChakraCheckbox.Label>
)}
</ChakraCheckbox.Root>
)
},
)

17
frontend/src/components/ui/close-button.tsx

@ -0,0 +1,17 @@
import type { ButtonProps } from "@chakra-ui/react"
import { IconButton as ChakraIconButton } from "@chakra-ui/react"
import * as React from "react"
import { LuX } from "react-icons/lu"
export type CloseButtonProps = ButtonProps
export const CloseButton = React.forwardRef<
HTMLButtonElement,
CloseButtonProps
>(function CloseButton(props, ref) {
return (
<ChakraIconButton variant="ghost" aria-label="Close" ref={ref} {...props}>
{props.children ?? <LuX />}
</ChakraIconButton>
)
})

107
frontend/src/components/ui/color-mode.tsx

@ -0,0 +1,107 @@
"use client"
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
import { ThemeProvider, useTheme } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
import * as React from "react"
import { LuMoon, LuSun } from "react-icons/lu"
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return (
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
)
}
export type ColorMode = "light" | "dark"
export interface UseColorModeReturn {
colorMode: ColorMode
setColorMode: (colorMode: ColorMode) => void
toggleColorMode: () => void
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme } = useTheme()
const toggleColorMode = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
return {
colorMode: resolvedTheme as ColorMode,
setColorMode: setTheme,
toggleColorMode,
}
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode()
return colorMode === "dark" ? dark : light
}
export function ColorModeIcon() {
const { colorMode } = useColorMode()
return colorMode === "dark" ? <LuMoon /> : <LuSun />
}
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
export const ColorModeButton = React.forwardRef<
HTMLButtonElement,
ColorModeButtonProps
>(function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode()
return (
<ClientOnly fallback={<Skeleton boxSize="8" />}>
<IconButton
onClick={toggleColorMode}
variant="ghost"
aria-label="Toggle color mode"
size="sm"
ref={ref}
{...props}
css={{
_icon: {
width: "5",
height: "5",
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
)
})
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function LightMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme light"
colorPalette="gray"
colorScheme="light"
ref={ref}
{...props}
/>
)
},
)
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function DarkMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme dark"
colorPalette="gray"
colorScheme="dark"
ref={ref}
{...props}
/>
)
},
)

62
frontend/src/components/ui/dialog.tsx

@ -0,0 +1,62 @@
import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react"
import * as React from "react"
import { CloseButton } from "./close-button"
interface DialogContentProps extends ChakraDialog.ContentProps {
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
backdrop?: boolean
}
export const DialogContent = React.forwardRef<
HTMLDivElement,
DialogContentProps
>(function DialogContent(props, ref) {
const {
children,
portalled = true,
portalRef,
backdrop = true,
...rest
} = props
return (
<Portal disabled={!portalled} container={portalRef}>
{backdrop && <ChakraDialog.Backdrop />}
<ChakraDialog.Positioner>
<ChakraDialog.Content ref={ref} {...rest} asChild={false}>
{children}
</ChakraDialog.Content>
</ChakraDialog.Positioner>
</Portal>
)
})
export const DialogCloseTrigger = React.forwardRef<
HTMLButtonElement,
ChakraDialog.CloseTriggerProps
>(function DialogCloseTrigger(props, ref) {
return (
<ChakraDialog.CloseTrigger
position="absolute"
top="2"
insetEnd="2"
{...props}
asChild
>
<CloseButton size="sm" ref={ref}>
{props.children}
</CloseButton>
</ChakraDialog.CloseTrigger>
)
})
export const DialogRoot = ChakraDialog.Root
export const DialogFooter = ChakraDialog.Footer
export const DialogHeader = ChakraDialog.Header
export const DialogBody = ChakraDialog.Body
export const DialogBackdrop = ChakraDialog.Backdrop
export const DialogTitle = ChakraDialog.Title
export const DialogDescription = ChakraDialog.Description
export const DialogTrigger = ChakraDialog.Trigger
export const DialogActionTrigger = ChakraDialog.ActionTrigger

52
frontend/src/components/ui/drawer.tsx

@ -0,0 +1,52 @@
import { Drawer as ChakraDrawer, Portal } from "@chakra-ui/react"
import * as React from "react"
import { CloseButton } from "./close-button"
interface DrawerContentProps extends ChakraDrawer.ContentProps {
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
offset?: ChakraDrawer.ContentProps["padding"]
}
export const DrawerContent = React.forwardRef<
HTMLDivElement,
DrawerContentProps
>(function DrawerContent(props, ref) {
const { children, portalled = true, portalRef, offset, ...rest } = props
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraDrawer.Positioner padding={offset}>
<ChakraDrawer.Content ref={ref} {...rest} asChild={false}>
{children}
</ChakraDrawer.Content>
</ChakraDrawer.Positioner>
</Portal>
)
})
export const DrawerCloseTrigger = React.forwardRef<
HTMLButtonElement,
ChakraDrawer.CloseTriggerProps
>(function DrawerCloseTrigger(props, ref) {
return (
<ChakraDrawer.CloseTrigger
position="absolute"
top="2"
insetEnd="2"
{...props}
asChild
>
<CloseButton size="sm" ref={ref} />
</ChakraDrawer.CloseTrigger>
)
})
export const DrawerTrigger = ChakraDrawer.Trigger
export const DrawerRoot = ChakraDrawer.Root
export const DrawerFooter = ChakraDrawer.Footer
export const DrawerHeader = ChakraDrawer.Header
export const DrawerBody = ChakraDrawer.Body
export const DrawerBackdrop = ChakraDrawer.Backdrop
export const DrawerDescription = ChakraDrawer.Description
export const DrawerTitle = ChakraDrawer.Title
export const DrawerActionTrigger = ChakraDrawer.ActionTrigger

33
frontend/src/components/ui/field.tsx

@ -0,0 +1,33 @@
import { Field as ChakraField } from "@chakra-ui/react"
import * as React from "react"
export interface FieldProps extends Omit<ChakraField.RootProps, "label"> {
label?: React.ReactNode
helperText?: React.ReactNode
errorText?: React.ReactNode
optionalText?: React.ReactNode
}
export const Field = React.forwardRef<HTMLDivElement, FieldProps>(
function Field(props, ref) {
const { label, children, helperText, errorText, optionalText, ...rest } =
props
return (
<ChakraField.Root ref={ref} {...rest}>
{label && (
<ChakraField.Label>
{label}
<ChakraField.RequiredIndicator fallback={optionalText} />
</ChakraField.Label>
)}
{children}
{helperText && (
<ChakraField.HelperText>{helperText}</ChakraField.HelperText>
)}
{errorText && (
<ChakraField.ErrorText>{errorText}</ChakraField.ErrorText>
)}
</ChakraField.Root>
)
},
)

53
frontend/src/components/ui/input-group.tsx

@ -0,0 +1,53 @@
import type { BoxProps, InputElementProps } from "@chakra-ui/react"
import { Group, InputElement } from "@chakra-ui/react"
import * as React from "react"
export interface InputGroupProps extends BoxProps {
startElementProps?: InputElementProps
endElementProps?: InputElementProps
startElement?: React.ReactNode
endElement?: React.ReactNode
children: React.ReactElement<InputElementProps>
startOffset?: InputElementProps["paddingStart"]
endOffset?: InputElementProps["paddingEnd"]
}
export const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
function InputGroup(props, ref) {
const {
startElement,
startElementProps,
endElement,
endElementProps,
children,
startOffset = "6px",
endOffset = "6px",
...rest
} = props
const child =
React.Children.only<React.ReactElement<InputElementProps>>(children)
return (
<Group ref={ref} {...rest}>
{startElement && (
<InputElement pointerEvents="none" {...startElementProps}>
{startElement}
</InputElement>
)}
{React.cloneElement(child, {
...(startElement && {
ps: `calc(var(--input-height) - ${startOffset})`,
}),
...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
...children.props,
})}
{endElement && (
<InputElement placement="end" {...endElementProps}>
{endElement}
</InputElement>
)}
</Group>
)
},
)

12
frontend/src/components/ui/link-button.tsx

@ -0,0 +1,12 @@
"use client"
import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react"
import { createRecipeContext } from "@chakra-ui/react"
export interface LinkButtonProps
extends HTMLChakraProps<"a", RecipeProps<"button">> {}
const { withContext } = createRecipeContext({ key: "button" })
// Replace "a" with your framework's link component
export const LinkButton = withContext<HTMLAnchorElement, LinkButtonProps>("a")

112
frontend/src/components/ui/menu.tsx

@ -0,0 +1,112 @@
"use client"
import { AbsoluteCenter, Menu as ChakraMenu, Portal } from "@chakra-ui/react"
import * as React from "react"
import { LuCheck, LuChevronRight } from "react-icons/lu"
interface MenuContentProps extends ChakraMenu.ContentProps {
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
}
export const MenuContent = React.forwardRef<HTMLDivElement, MenuContentProps>(
function MenuContent(props, ref) {
const { portalled = true, portalRef, ...rest } = props
return (
<Portal disabled={!portalled} container={portalRef}>
<ChakraMenu.Positioner>
<ChakraMenu.Content ref={ref} {...rest} />
</ChakraMenu.Positioner>
</Portal>
)
},
)
export const MenuArrow = React.forwardRef<
HTMLDivElement,
ChakraMenu.ArrowProps
>(function MenuArrow(props, ref) {
return (
<ChakraMenu.Arrow ref={ref} {...props}>
<ChakraMenu.ArrowTip />
</ChakraMenu.Arrow>
)
})
export const MenuCheckboxItem = React.forwardRef<
HTMLDivElement,
ChakraMenu.CheckboxItemProps
>(function MenuCheckboxItem(props, ref) {
return (
<ChakraMenu.CheckboxItem ps="8" ref={ref} {...props}>
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
<ChakraMenu.ItemIndicator>
<LuCheck />
</ChakraMenu.ItemIndicator>
</AbsoluteCenter>
{props.children}
</ChakraMenu.CheckboxItem>
)
})
export const MenuRadioItem = React.forwardRef<
HTMLDivElement,
ChakraMenu.RadioItemProps
>(function MenuRadioItem(props, ref) {
const { children, ...rest } = props
return (
<ChakraMenu.RadioItem ps="8" ref={ref} {...rest}>
<AbsoluteCenter axis="horizontal" insetStart="4" asChild>
<ChakraMenu.ItemIndicator>
<LuCheck />
</ChakraMenu.ItemIndicator>
</AbsoluteCenter>
<ChakraMenu.ItemText>{children}</ChakraMenu.ItemText>
</ChakraMenu.RadioItem>
)
})
export const MenuItemGroup = React.forwardRef<
HTMLDivElement,
ChakraMenu.ItemGroupProps
>(function MenuItemGroup(props, ref) {
const { title, children, ...rest } = props
return (
<ChakraMenu.ItemGroup ref={ref} {...rest}>
{title && (
<ChakraMenu.ItemGroupLabel userSelect="none">
{title}
</ChakraMenu.ItemGroupLabel>
)}
{children}
</ChakraMenu.ItemGroup>
)
})
export interface MenuTriggerItemProps extends ChakraMenu.ItemProps {
startIcon?: React.ReactNode
}
export const MenuTriggerItem = React.forwardRef<
HTMLDivElement,
MenuTriggerItemProps
>(function MenuTriggerItem(props, ref) {
const { startIcon, children, ...rest } = props
return (
<ChakraMenu.TriggerItem ref={ref} {...rest}>
{startIcon}
{children}
<LuChevronRight />
</ChakraMenu.TriggerItem>
)
})
export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup
export const MenuContextTrigger = ChakraMenu.ContextTrigger
export const MenuRoot = ChakraMenu.Root
export const MenuSeparator = ChakraMenu.Separator
export const MenuItem = ChakraMenu.Item
export const MenuItemText = ChakraMenu.ItemText
export const MenuItemCommand = ChakraMenu.ItemCommand
export const MenuTrigger = ChakraMenu.Trigger

211
frontend/src/components/ui/pagination.tsx

@ -0,0 +1,211 @@
"use client"
import type { ButtonProps, TextProps } from "@chakra-ui/react"
import {
Button,
Pagination as ChakraPagination,
IconButton,
Text,
createContext,
usePaginationContext,
} from "@chakra-ui/react"
import * as React from "react"
import {
HiChevronLeft,
HiChevronRight,
HiMiniEllipsisHorizontal,
} from "react-icons/hi2"
import { LinkButton } from "./link-button"
interface ButtonVariantMap {
current: ButtonProps["variant"]
default: ButtonProps["variant"]
ellipsis: ButtonProps["variant"]
}
type PaginationVariant = "outline" | "solid" | "subtle"
interface ButtonVariantContext {
size: ButtonProps["size"]
variantMap: ButtonVariantMap
getHref?: (page: number) => string
}
const [RootPropsProvider, useRootProps] = createContext<ButtonVariantContext>({
name: "RootPropsProvider",
})
export interface PaginationRootProps
extends Omit<ChakraPagination.RootProps, "type"> {
size?: ButtonProps["size"]
variant?: PaginationVariant
getHref?: (page: number) => string
}
const variantMap: Record<PaginationVariant, ButtonVariantMap> = {
outline: { default: "ghost", ellipsis: "plain", current: "outline" },
solid: { default: "outline", ellipsis: "outline", current: "solid" },
subtle: { default: "ghost", ellipsis: "plain", current: "subtle" },
}
export const PaginationRoot = React.forwardRef<
HTMLDivElement,
PaginationRootProps
>(function PaginationRoot(props, ref) {
const { size = "sm", variant = "outline", getHref, ...rest } = props
return (
<RootPropsProvider
value={{ size, variantMap: variantMap[variant], getHref }}
>
<ChakraPagination.Root
ref={ref}
type={getHref ? "link" : "button"}
{...rest}
/>
</RootPropsProvider>
)
})
export const PaginationEllipsis = React.forwardRef<
HTMLDivElement,
ChakraPagination.EllipsisProps
>(function PaginationEllipsis(props, ref) {
const { size, variantMap } = useRootProps()
return (
<ChakraPagination.Ellipsis ref={ref} {...props} asChild>
<Button as="span" variant={variantMap.ellipsis} size={size}>
<HiMiniEllipsisHorizontal />
</Button>
</ChakraPagination.Ellipsis>
)
})
export const PaginationItem = React.forwardRef<
HTMLButtonElement,
ChakraPagination.ItemProps
>(function PaginationItem(props, ref) {
const { page } = usePaginationContext()
const { size, variantMap, getHref } = useRootProps()
const current = page === props.value
const variant = current ? variantMap.current : variantMap.default
if (getHref) {
return (
<LinkButton href={getHref(props.value)} variant={variant} size={size}>
{props.value}
</LinkButton>
)
}
return (
<ChakraPagination.Item ref={ref} {...props} asChild>
<Button variant={variant} size={size}>
{props.value}
</Button>
</ChakraPagination.Item>
)
})
export const PaginationPrevTrigger = React.forwardRef<
HTMLButtonElement,
ChakraPagination.PrevTriggerProps
>(function PaginationPrevTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps()
const { previousPage } = usePaginationContext()
if (getHref) {
return (
<LinkButton
href={previousPage != null ? getHref(previousPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronLeft />
</LinkButton>
)
}
return (
<ChakraPagination.PrevTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronLeft />
</IconButton>
</ChakraPagination.PrevTrigger>
)
})
export const PaginationNextTrigger = React.forwardRef<
HTMLButtonElement,
ChakraPagination.NextTriggerProps
>(function PaginationNextTrigger(props, ref) {
const { size, variantMap, getHref } = useRootProps()
const { nextPage } = usePaginationContext()
if (getHref) {
return (
<LinkButton
href={nextPage != null ? getHref(nextPage) : undefined}
variant={variantMap.default}
size={size}
>
<HiChevronRight />
</LinkButton>
)
}
return (
<ChakraPagination.NextTrigger ref={ref} asChild {...props}>
<IconButton variant={variantMap.default} size={size}>
<HiChevronRight />
</IconButton>
</ChakraPagination.NextTrigger>
)
})
export const PaginationItems = (props: React.HTMLAttributes<HTMLElement>) => {
return (
<ChakraPagination.Context>
{({ pages }) =>
pages.map((page, index) => {
return page.type === "ellipsis" ? (
<PaginationEllipsis key={index} index={index} {...props} />
) : (
<PaginationItem
key={index}
type="page"
value={page.value}
{...props}
/>
)
})
}
</ChakraPagination.Context>
)
}
interface PageTextProps extends TextProps {
format?: "short" | "compact" | "long"
}
export const PaginationPageText = React.forwardRef<
HTMLParagraphElement,
PageTextProps
>(function PaginationPageText(props, ref) {
const { format = "compact", ...rest } = props
const { page, totalPages, pageRange, count } = usePaginationContext()
const content = React.useMemo(() => {
if (format === "short") return `${page} / ${totalPages}`
if (format === "compact") return `${page} of ${totalPages}`
return `${pageRange.start + 1} - ${Math.min(
pageRange.end,
count,
)} of ${count}`
}, [format, page, totalPages, pageRange, count])
return (
<Text fontWeight="medium" ref={ref} {...rest}>
{content}
</Text>
)
})

162
frontend/src/components/ui/password-input.tsx

@ -0,0 +1,162 @@
"use client"
import type {
ButtonProps,
GroupProps,
InputProps,
StackProps,
} from "@chakra-ui/react"
import {
Box,
HStack,
IconButton,
Input,
Stack,
mergeRefs,
useControllableState,
} from "@chakra-ui/react"
import { forwardRef, useRef } from "react"
import { FiEye, FiEyeOff } from "react-icons/fi"
import { Field } from "./field"
import { InputGroup } from "./input-group"
export interface PasswordVisibilityProps {
defaultVisible?: boolean
visible?: boolean
onVisibleChange?: (visible: boolean) => void
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
}
export interface PasswordInputProps
extends InputProps,
PasswordVisibilityProps {
rootProps?: GroupProps
startElement?: React.ReactNode
type: string
errors: any
}
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
visible: visibleProp,
onVisibleChange,
visibilityIcon = { on: <FiEye />, off: <FiEyeOff /> },
startElement,
type,
errors,
...rest
} = props
const [visible, setVisible] = useControllableState({
value: visibleProp,
defaultValue: defaultVisible || false,
onChange: onVisibleChange,
})
const inputRef = useRef<HTMLInputElement>(null)
return (
<Field
invalid={!!errors[type]}
errorText={errors[type]?.message}
alignSelf="start"
>
<InputGroup
width="100%"
startElement={startElement}
endElement={
<VisibilityTrigger
disabled={rest.disabled}
onPointerDown={(e) => {
if (rest.disabled) return
if (e.button !== 0) return
e.preventDefault()
setVisible(!visible)
}}
>
{visible ? visibilityIcon.off : visibilityIcon.on}
</VisibilityTrigger>
}
{...rootProps}
>
<Input
{...rest}
ref={mergeRefs(ref, inputRef)}
type={visible ? "text" : "password"}
/>
</InputGroup>
</Field>
)
},
)
const VisibilityTrigger = forwardRef<HTMLButtonElement, ButtonProps>(
function VisibilityTrigger(props, ref) {
return (
<IconButton
tabIndex={-1}
ref={ref}
me="-2"
aspectRatio="square"
size="sm"
variant="ghost"
height="calc(100% - {spacing.2})"
aria-label="Toggle password visibility"
color="inherit"
{...props}
/>
)
},
)
interface PasswordStrengthMeterProps extends StackProps {
max?: number
value: number
}
export const PasswordStrengthMeter = forwardRef<
HTMLDivElement,
PasswordStrengthMeterProps
>(function PasswordStrengthMeter(props, ref) {
const { max = 4, value, ...rest } = props
const percent = (value / max) * 100
const { label, colorPalette } = getColorPalette(percent)
return (
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
<HStack width="full" ref={ref} {...rest}>
{Array.from({ length: max }).map((_, index) => (
<Box
key={index}
height="1"
flex="1"
rounded="sm"
data-selected={index < value ? "" : undefined}
layerStyle="fill.subtle"
colorPalette="gray"
_selected={{
colorPalette,
layerStyle: "fill.solid",
}}
/>
))}
</HStack>
{label && <HStack textStyle="xs">{label}</HStack>}
</Stack>
)
})
function getColorPalette(percent: number) {
switch (true) {
case percent < 33:
return { label: "Low", colorPalette: "red" }
case percent < 66:
return { label: "Medium", colorPalette: "orange" }
default:
return { label: "High", colorPalette: "green" }
}
}

18
frontend/src/components/ui/provider.tsx

@ -0,0 +1,18 @@
"use client"
import { ChakraProvider } from "@chakra-ui/react"
import React, { type PropsWithChildren } from "react"
import { system } from "../../theme"
import { ColorModeProvider } from "./color-mode"
import { Toaster } from "./toaster"
export function CustomProvider(props: PropsWithChildren) {
return (
<ChakraProvider value={system}>
<ColorModeProvider defaultTheme="light">
{props.children}
</ColorModeProvider>
<Toaster />
</ChakraProvider>
)
}

24
frontend/src/components/ui/radio.tsx

@ -0,0 +1,24 @@
import { RadioGroup as ChakraRadioGroup } from "@chakra-ui/react"
import * as React from "react"
export interface RadioProps extends ChakraRadioGroup.ItemProps {
rootRef?: React.Ref<HTMLDivElement>
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
}
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
function Radio(props, ref) {
const { children, inputProps, rootRef, ...rest } = props
return (
<ChakraRadioGroup.Item ref={rootRef} {...rest}>
<ChakraRadioGroup.ItemHiddenInput ref={ref} {...inputProps} />
<ChakraRadioGroup.ItemIndicator />
{children && (
<ChakraRadioGroup.ItemText>{children}</ChakraRadioGroup.ItemText>
)}
</ChakraRadioGroup.Item>
)
},
)
export const RadioGroup = ChakraRadioGroup.Root

47
frontend/src/components/ui/skeleton.tsx

@ -0,0 +1,47 @@
import type {
SkeletonProps as ChakraSkeletonProps,
CircleProps,
} from "@chakra-ui/react"
import { Skeleton as ChakraSkeleton, Circle, Stack } from "@chakra-ui/react"
import * as React from "react"
export interface SkeletonCircleProps extends ChakraSkeletonProps {
size?: CircleProps["size"]
}
export const SkeletonCircle = React.forwardRef<
HTMLDivElement,
SkeletonCircleProps
>(function SkeletonCircle(props, ref) {
const { size, ...rest } = props
return (
<Circle size={size} asChild ref={ref}>
<ChakraSkeleton {...rest} />
</Circle>
)
})
export interface SkeletonTextProps extends ChakraSkeletonProps {
noOfLines?: number
}
export const SkeletonText = React.forwardRef<HTMLDivElement, SkeletonTextProps>(
function SkeletonText(props, ref) {
const { noOfLines = 3, gap, ...rest } = props
return (
<Stack gap={gap} width="full" ref={ref}>
{Array.from({ length: noOfLines }).map((_, index) => (
<ChakraSkeleton
height="4"
key={index}
{...props}
_last={{ maxW: "80%" }}
{...rest}
/>
))}
</Stack>
)
},
)
export const Skeleton = ChakraSkeleton

43
frontend/src/components/ui/toaster.tsx

@ -0,0 +1,43 @@
"use client"
import {
Toaster as ChakraToaster,
Portal,
Spinner,
Stack,
Toast,
createToaster,
} from "@chakra-ui/react"
export const toaster = createToaster({
placement: "top-end",
pauseOnPageIdle: true,
})
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
{(toast) => (
<Toast.Root width={{ md: "sm" }} color={toast.meta?.color}>
{toast.type === "loading" ? (
<Spinner size="sm" color="blue.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}
</Stack>
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.meta?.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
)
}

32
frontend/src/hooks/useAuth.ts

@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { useState } from "react" import { useState } from "react"
import { AxiosError } from "axios"
import { import {
type Body_login_login_access_token as AccessToken, type Body_login_login_access_token as AccessToken,
type ApiError, type ApiError,
@ -11,7 +10,7 @@ import {
type UserRegister, type UserRegister,
UsersService, UsersService,
} from "../client" } from "../client"
import useCustomToast from "./useCustomToast" import { handleError } from "../utils"
const isLoggedIn = () => { const isLoggedIn = () => {
return localStorage.getItem("access_token") !== null return localStorage.getItem("access_token") !== null
@ -20,9 +19,8 @@ const isLoggedIn = () => {
const useAuth = () => { const useAuth = () => {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const navigate = useNavigate() const navigate = useNavigate()
const showToast = useCustomToast()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: user, isLoading } = useQuery<UserPublic | null, Error>({ const { data: user } = useQuery<UserPublic | null, Error>({
queryKey: ["currentUser"], queryKey: ["currentUser"],
queryFn: UsersService.readUserMe, queryFn: UsersService.readUserMe,
enabled: isLoggedIn(), enabled: isLoggedIn(),
@ -34,20 +32,9 @@ const useAuth = () => {
onSuccess: () => { onSuccess: () => {
navigate({ to: "/login" }) navigate({ to: "/login" })
showToast(
"Account created.",
"Your account has been created successfully.",
"success",
)
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
let errDetail = (err.body as any)?.detail handleError(err)
if (err instanceof AxiosError) {
errDetail = err.message
}
showToast("Something went wrong.", errDetail, "error")
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }) queryClient.invalidateQueries({ queryKey: ["users"] })
@ -67,17 +54,7 @@ const useAuth = () => {
navigate({ to: "/" }) navigate({ to: "/" })
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
let errDetail = (err.body as any)?.detail handleError(err)
if (err instanceof AxiosError) {
errDetail = err.message
}
if (Array.isArray(errDetail)) {
errDetail = "Something went wrong"
}
setError(errDetail)
}, },
}) })
@ -91,7 +68,6 @@ const useAuth = () => {
loginMutation, loginMutation,
logout, logout,
user, user,
isLoading,
error, error,
resetError: () => setError(null), resetError: () => setError(null),
} }

34
frontend/src/hooks/useCustomToast.ts

@ -1,23 +1,25 @@
import { useToast } from "@chakra-ui/react" "use client"
import { useCallback } from "react"
import { toaster } from "../components/ui/toaster"
const useCustomToast = () => { const useCustomToast = () => {
const toast = useToast() const showSuccessToast = (description: string) => {
toaster.create({
title: "Success!",
description,
type: "success",
})
}
const showToast = useCallback( const showErrorToast = (description: string) => {
(title: string, description: string, status: "success" | "error") => { toaster.create({
toast({ title: "Something went wrong!",
title, description,
description, type: "error",
status, })
isClosable: true, }
position: "bottom-right",
})
},
[toast],
)
return showToast return { showSuccessToast, showErrorToast }
} }
export default useCustomToast export default useCustomToast

10
frontend/src/main.tsx

@ -1,12 +1,12 @@
import { ChakraProvider } from "@chakra-ui/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { RouterProvider, createRouter } from "@tanstack/react-router" import { RouterProvider, createRouter } from "@tanstack/react-router"
import React from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
import { StrictMode } from "react" import { StrictMode } from "react"
import { OpenAPI } from "./client" import { OpenAPI } from "./client"
import theme from "./theme" import { CustomProvider } from "./components/ui/provider"
OpenAPI.BASE = import.meta.env.VITE_API_URL OpenAPI.BASE = import.meta.env.VITE_API_URL
OpenAPI.TOKEN = async () => { OpenAPI.TOKEN = async () => {
@ -15,7 +15,7 @@ OpenAPI.TOKEN = async () => {
const queryClient = new QueryClient() const queryClient = new QueryClient()
const router = createRouter({ routeTree }) const router = createRouter({ routeTree, context: { queryClient } })
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: typeof router router: typeof router
@ -24,10 +24,10 @@ declare module "@tanstack/react-router" {
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ChakraProvider theme={theme}> <CustomProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<RouterProvider router={router} /> <RouterProvider router={router} />
</QueryClientProvider> </QueryClientProvider>
</ChakraProvider> </CustomProvider>
</StrictMode>, </StrictMode>,
) )

26
frontend/src/routes/_layout.tsx

@ -1,9 +1,9 @@
import { Flex, Spinner } from "@chakra-ui/react" import { Flex } from "@chakra-ui/react"
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router" import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"
import Navbar from "../components/Common/Navbar"
import Sidebar from "../components/Common/Sidebar" import Sidebar from "../components/Common/Sidebar"
import UserMenu from "../components/Common/UserMenu" import { isLoggedIn } from "../hooks/useAuth"
import useAuth, { isLoggedIn } from "../hooks/useAuth"
export const Route = createFileRoute("/_layout")({ export const Route = createFileRoute("/_layout")({
component: Layout, component: Layout,
@ -17,19 +17,17 @@ export const Route = createFileRoute("/_layout")({
}) })
function Layout() { function Layout() {
const { isLoading } = useAuth()
return ( return (
<Flex maxW="large" h="auto" position="relative"> <Flex direction="column" h="100vh">
<Sidebar /> <Navbar />
{isLoading ? ( <Flex flex="1" overflow="hidden">
<Flex justify="center" align="center" height="100vh" width="full"> <Sidebar />
<Spinner size="xl" color="ui.main" /> <Flex flex="1" direction="column" p={4} overflowY="auto">
<Outlet />
</Flex> </Flex>
) : ( </Flex>
<Outlet />
)}
<UserMenu />
</Flex> </Flex>
) )
} }
export default Layout

192
frontend/src/routes/_layout/admin.tsx

@ -1,38 +1,23 @@
import { import { Badge, Container, Flex, Heading, Table } from "@chakra-ui/react"
Badge,
Box,
Container,
Flex,
Heading,
SkeletonText,
Table,
TableContainer,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react"
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router" import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import { z } from "zod" import { z } from "zod"
import { type UserPublic, UsersService } from "../../client" import { type UserPublic, UsersService } from "../../client"
import AddUser from "../../components/Admin/AddUser" import AddUser from "../../components/Admin/AddUser"
import ActionsMenu from "../../components/Common/ActionsMenu" import { UserActionsMenu } from "../../components/Common/UserActionsMenu"
import Navbar from "../../components/Common/Navbar" import PendingUsers from "../../components/Pending/PendingUsers"
import { PaginationFooter } from "../../components/Common/PaginationFooter.tsx" import {
PaginationItems,
PaginationNextTrigger,
PaginationPrevTrigger,
PaginationRoot,
} from "../../components/ui/pagination.tsx"
const usersSearchSchema = z.object({ const usersSearchSchema = z.object({
page: z.number().catch(1), page: z.number().catch(1),
}) })
export const Route = createFileRoute("/_layout/admin")({
component: Admin,
validateSearch: (search) => usersSearchSchema.parse(search),
})
const PER_PAGE = 5 const PER_PAGE = 5
function getUsersQueryOptions({ page }: { page: number }) { function getUsersQueryOptions({ page }: { page: number }) {
@ -43,106 +28,87 @@ function getUsersQueryOptions({ page }: { page: number }) {
} }
} }
export const Route = createFileRoute("/_layout/admin")({
component: Admin,
validateSearch: (search) => usersSearchSchema.parse(search),
})
function UsersTable() { function UsersTable() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"]) const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const { page } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath }) const navigate = useNavigate({ from: Route.fullPath })
const setPage = (page: number) => const { page } = Route.useSearch()
navigate({ search: (prev: {[key: string]: string}) => ({ ...prev, page }) })
const { const { data, isLoading, isPlaceholderData } = useQuery({
data: users,
isPending,
isPlaceholderData,
} = useQuery({
...getUsersQueryOptions({ page }), ...getUsersQueryOptions({ page }),
placeholderData: (prevData) => prevData, placeholderData: (prevData) => prevData,
}) })
const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE const setPage = (page: number) =>
const hasPreviousPage = page > 1 navigate({
search: (prev: { [key: string]: string }) => ({ ...prev, page }),
})
const users = data?.data.slice(0, PER_PAGE) ?? []
const count = data?.count ?? 0
useEffect(() => { if (isLoading) {
if (hasNextPage) { return <PendingUsers />
queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 })) }
}
}, [page, queryClient, hasNextPage])
return ( return (
<> <>
<TableContainer> <Table.Root size={{ base: "sm", md: "md" }}>
<Table size={{ base: "sm", md: "md" }}> <Table.Header>
<Thead> <Table.Row>
<Tr> <Table.ColumnHeader w="20%">Full name</Table.ColumnHeader>
<Th width="20%">Full name</Th> <Table.ColumnHeader w="25%">Email</Table.ColumnHeader>
<Th width="50%">Email</Th> <Table.ColumnHeader w="15%">Role</Table.ColumnHeader>
<Th width="10%">Role</Th> <Table.ColumnHeader w="20%">Status</Table.ColumnHeader>
<Th width="10%">Status</Th> <Table.ColumnHeader w="20%">Actions</Table.ColumnHeader>
<Th width="10%">Actions</Th> </Table.Row>
</Tr> </Table.Header>
</Thead> <Table.Body>
{isPending ? ( {users?.map((user) => (
<Tbody> <Table.Row key={user.id} opacity={isPlaceholderData ? 0.5 : 1}>
<Tr> <Table.Cell w="20%" color={!user.full_name ? "gray" : "inherit"}>
{new Array(4).fill(null).map((_, index) => ( {user.full_name || "N/A"}
<Td key={index}> {currentUser?.id === user.id && (
<SkeletonText noOfLines={1} paddingBlock="16px" /> <Badge ml="1" colorScheme="teal">
</Td> You
))} </Badge>
</Tr> )}
</Tbody> </Table.Cell>
) : ( <Table.Cell w="25%">{user.email}</Table.Cell>
<Tbody> <Table.Cell w="15%">
{users?.data.map((user) => ( {user.is_superuser ? "Superuser" : "User"}
<Tr key={user.id}> </Table.Cell>
<Td <Table.Cell w="20%">
color={!user.full_name ? "ui.dim" : "inherit"} {user.is_active ? "Active" : "Inactive"}
isTruncated </Table.Cell>
maxWidth="150px" <Table.Cell w="20%">
> <UserActionsMenu
{user.full_name || "N/A"} user={user}
{currentUser?.id === user.id && ( disabled={currentUser?.id === user.id}
<Badge ml="1" colorScheme="teal"> />
You </Table.Cell>
</Badge> </Table.Row>
)} ))}
</Td> </Table.Body>
<Td isTruncated maxWidth="150px"> </Table.Root>
{user.email} <Flex justifyContent="flex-end" mt={4}>
</Td> <PaginationRoot
<Td>{user.is_superuser ? "Superuser" : "User"}</Td> count={count}
<Td> pageSize={PER_PAGE}
<Flex gap={2}> onPageChange={({ page }) => setPage(page)}
<Box >
w="2" <Flex>
h="2" <PaginationPrevTrigger />
borderRadius="50%" <PaginationItems />
bg={user.is_active ? "ui.success" : "ui.danger"} <PaginationNextTrigger />
alignSelf="center" </Flex>
/> </PaginationRoot>
{user.is_active ? "Active" : "Inactive"} </Flex>
</Flex>
</Td>
<Td>
<ActionsMenu
type="User"
value={user}
disabled={currentUser?.id === user.id}
/>
</Td>
</Tr>
))}
</Tbody>
)}
</Table>
</TableContainer>
<PaginationFooter
onChangePage={setPage}
page={page}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
/>
</> </>
) )
} }
@ -150,11 +116,11 @@ function UsersTable() {
function Admin() { function Admin() {
return ( return (
<Container maxW="full"> <Container maxW="full">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}> <Heading size="lg" pt={12}>
Users Management Users Management
</Heading> </Heading>
<Navbar type={"User"} addModalAs={AddUser} /> <AddUser />
<UsersTable /> <UsersTable />
</Container> </Container>
) )

181
frontend/src/routes/_layout/items.tsx

@ -1,35 +1,31 @@
import { import {
Container, Container,
EmptyState,
Flex,
Heading, Heading,
SkeletonText,
Table, Table,
TableContainer, VStack,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react" } from "@chakra-ui/react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, useNavigate } from "@tanstack/react-router" import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useEffect } from "react" import { FiSearch } from "react-icons/fi"
import { z } from "zod" import { z } from "zod"
import { useQuery } from "@tanstack/react-query"
import { ItemsService } from "../../client" import { ItemsService } from "../../client"
import ActionsMenu from "../../components/Common/ActionsMenu" import { ItemActionsMenu } from "../../components/Common/ItemActionsMenu"
import Navbar from "../../components/Common/Navbar"
import AddItem from "../../components/Items/AddItem" import AddItem from "../../components/Items/AddItem"
import { PaginationFooter } from "../../components/Common/PaginationFooter.tsx" import PendingItems from "../../components/Pending/PendingItems"
import {
PaginationItems,
PaginationNextTrigger,
PaginationPrevTrigger,
PaginationRoot,
} from "../../components/ui/pagination.tsx"
const itemsSearchSchema = z.object({ const itemsSearchSchema = z.object({
page: z.number().catch(1), page: z.number().catch(1),
}) })
export const Route = createFileRoute("/_layout/items")({
component: Items,
validateSearch: (search) => itemsSearchSchema.parse(search),
})
const PER_PAGE = 5 const PER_PAGE = 5
function getItemsQueryOptions({ page }: { page: number }) { function getItemsQueryOptions({ page }: { page: number }) {
@ -40,83 +36,97 @@ function getItemsQueryOptions({ page }: { page: number }) {
} }
} }
export const Route = createFileRoute("/_layout/items")({
component: Items,
validateSearch: (search) => itemsSearchSchema.parse(search),
})
function ItemsTable() { function ItemsTable() {
const queryClient = useQueryClient()
const { page } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath }) const navigate = useNavigate({ from: Route.fullPath })
const setPage = (page: number) => const { page } = Route.useSearch()
navigate({ search: (prev: {[key: string]: string}) => ({ ...prev, page }) })
const { const { data, isLoading, isPlaceholderData } = useQuery({
data: items,
isPending,
isPlaceholderData,
} = useQuery({
...getItemsQueryOptions({ page }), ...getItemsQueryOptions({ page }),
placeholderData: (prevData) => prevData, placeholderData: (prevData) => prevData,
}) })
const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE const setPage = (page: number) =>
const hasPreviousPage = page > 1 navigate({
search: (prev: { [key: string]: string }) => ({ ...prev, page }),
})
const items = data?.data.slice(0, PER_PAGE) ?? []
const count = data?.count ?? 0
useEffect(() => { if (isLoading) {
if (hasNextPage) { return <PendingItems />
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 })) }
}
}, [page, queryClient, hasNextPage]) if (items.length === 0) {
return (
<EmptyState.Root>
<EmptyState.Content>
<EmptyState.Indicator>
<FiSearch />
</EmptyState.Indicator>
<VStack textAlign="center">
<EmptyState.Title>You don't have any items yet</EmptyState.Title>
<EmptyState.Description>
Add a new item to get started
</EmptyState.Description>
</VStack>
</EmptyState.Content>
</EmptyState.Root>
)
}
return ( return (
<> <>
<TableContainer> <Table.Root size={{ base: "sm", md: "md" }}>
<Table size={{ base: "sm", md: "md" }}> <Table.Header>
<Thead> <Table.Row>
<Tr> <Table.ColumnHeader w="30%">ID</Table.ColumnHeader>
<Th>ID</Th> <Table.ColumnHeader w="30%">Title</Table.ColumnHeader>
<Th>Title</Th> <Table.ColumnHeader w="30%">Description</Table.ColumnHeader>
<Th>Description</Th> <Table.ColumnHeader w="10%">Actions</Table.ColumnHeader>
<Th>Actions</Th> </Table.Row>
</Tr> </Table.Header>
</Thead> <Table.Body>
{isPending ? ( {items?.map((item) => (
<Tbody> <Table.Row key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
<Tr> <Table.Cell truncate maxW="30%">
{new Array(4).fill(null).map((_, index) => ( {item.id}
<Td key={index}> </Table.Cell>
<SkeletonText noOfLines={1} paddingBlock="16px" /> <Table.Cell truncate maxW="30%">
</Td> {item.title}
))} </Table.Cell>
</Tr> <Table.Cell
</Tbody> color={!item.description ? "gray" : "inherit"}
) : ( truncate
<Tbody> maxW="30%"
{items?.data.map((item) => ( >
<Tr key={item.id} opacity={isPlaceholderData ? 0.5 : 1}> {item.description || "N/A"}
<Td>{item.id}</Td> </Table.Cell>
<Td isTruncated maxWidth="150px"> <Table.Cell width="10%">
{item.title} <ItemActionsMenu item={item} />
</Td> </Table.Cell>
<Td </Table.Row>
color={!item.description ? "ui.dim" : "inherit"} ))}
isTruncated </Table.Body>
maxWidth="150px" </Table.Root>
> <Flex justifyContent="flex-end" mt={4}>
{item.description || "N/A"} <PaginationRoot
</Td> count={count}
<Td> pageSize={PER_PAGE}
<ActionsMenu type={"Item"} value={item} /> onPageChange={({ page }) => setPage(page)}
</Td> >
</Tr> <Flex>
))} <PaginationPrevTrigger />
</Tbody> <PaginationItems />
)} <PaginationNextTrigger />
</Table> </Flex>
</TableContainer> </PaginationRoot>
<PaginationFooter </Flex>
page={page}
onChangePage={setPage}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
/>
</> </>
) )
} }
@ -124,11 +134,10 @@ function ItemsTable() {
function Items() { function Items() {
return ( return (
<Container maxW="full"> <Container maxW="full">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}> <Heading size="lg" pt={12}>
Items Management Items Management
</Heading> </Heading>
<AddItem />
<Navbar type={"Item"} addModalAs={AddItem} />
<ItemsTable /> <ItemsTable />
</Container> </Container>
) )

55
frontend/src/routes/_layout/settings.tsx

@ -1,26 +1,17 @@
import { import { Container, Heading, Tabs } from "@chakra-ui/react"
Container,
Heading,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from "@chakra-ui/react"
import { useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import type { UserPublic } from "../../client"
import Appearance from "../../components/UserSettings/Appearance" import Appearance from "../../components/UserSettings/Appearance"
import ChangePassword from "../../components/UserSettings/ChangePassword" import ChangePassword from "../../components/UserSettings/ChangePassword"
import DeleteAccount from "../../components/UserSettings/DeleteAccount" import DeleteAccount from "../../components/UserSettings/DeleteAccount"
import UserInformation from "../../components/UserSettings/UserInformation" import UserInformation from "../../components/UserSettings/UserInformation"
import useAuth from "../../hooks/useAuth"
const tabsConfig = [ const tabsConfig = [
{ title: "My profile", component: UserInformation }, { value: "my-profile", title: "My profile", component: UserInformation },
{ title: "Password", component: ChangePassword }, { value: "password", title: "Password", component: ChangePassword },
{ title: "Appearance", component: Appearance }, { value: "appearance", title: "Appearance", component: Appearance },
{ title: "Danger zone", component: DeleteAccount }, { value: "danger-zone", title: "Danger zone", component: DeleteAccount },
] ]
export const Route = createFileRoute("/_layout/settings")({ export const Route = createFileRoute("/_layout/settings")({
@ -28,31 +19,35 @@ export const Route = createFileRoute("/_layout/settings")({
}) })
function UserSettings() { function UserSettings() {
const queryClient = useQueryClient() const { user: currentUser } = useAuth()
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
const finalTabs = currentUser?.is_superuser const finalTabs = currentUser?.is_superuser
? tabsConfig.slice(0, 3) ? tabsConfig.slice(0, 3)
: tabsConfig : tabsConfig
if (!currentUser) {
return null
}
return ( return (
<Container maxW="full"> <Container maxW="full">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}> <Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
User Settings User Settings
</Heading> </Heading>
<Tabs variant="enclosed">
<TabList> <Tabs.Root defaultValue="my-profile" variant="subtle">
{finalTabs.map((tab, index) => ( <Tabs.List>
<Tab key={index}>{tab.title}</Tab> {finalTabs.map((tab) => (
))} <Tabs.Trigger key={tab.value} value={tab.value}>
</TabList> {tab.title}
<TabPanels> </Tabs.Trigger>
{finalTabs.map((tab, index) => (
<TabPanel key={index}>
<tab.component />
</TabPanel>
))} ))}
</TabPanels> </Tabs.List>
</Tabs> {finalTabs.map((tab) => (
<Tabs.Content key={tab.value} value={tab.value}>
<tab.component />
</Tabs.Content>
))}
</Tabs.Root>
</Container> </Container>
) )
} }

95
frontend/src/routes/login.tsx

@ -1,18 +1,4 @@
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons" import { Container, Image, Input, Text } from "@chakra-ui/react"
import {
Button,
Container,
FormControl,
FormErrorMessage,
Icon,
Image,
Input,
InputGroup,
InputRightElement,
Link,
Text,
useBoolean,
} from "@chakra-ui/react"
import { import {
Link as RouterLink, Link as RouterLink,
createFileRoute, createFileRoute,
@ -20,10 +6,15 @@ import {
} from "@tanstack/react-router" } from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form" import { type SubmitHandler, useForm } from "react-hook-form"
import { FiLock, FiMail } from "react-icons/fi"
import Logo from "/assets/images/fastapi-logo.svg" import Logo from "/assets/images/fastapi-logo.svg"
import type { Body_login_login_access_token as AccessToken } from "../client" import type { Body_login_login_access_token as AccessToken } from "../client"
import { Button } from "../components/ui/button"
import { Field } from "../components/ui/field"
import { InputGroup } from "../components/ui/input-group"
import { PasswordInput } from "../components/ui/password-input"
import useAuth, { isLoggedIn } from "../hooks/useAuth" import useAuth, { isLoggedIn } from "../hooks/useAuth"
import { emailPattern } from "../utils" import { emailPattern, passwordRules } from "../utils"
export const Route = createFileRoute("/login")({ export const Route = createFileRoute("/login")({
component: Login, component: Login,
@ -37,7 +28,6 @@ export const Route = createFileRoute("/login")({
}) })
function Login() { function Login() {
const [show, setShow] = useBoolean()
const { loginMutation, error, resetError } = useAuth() const { loginMutation, error, resetError } = useAuth()
const { const {
register, register,
@ -84,59 +74,40 @@ function Login() {
alignSelf="center" alignSelf="center"
mb={4} mb={4}
/> />
<FormControl id="username" isInvalid={!!errors.username || !!error}> <Field
<Input invalid={!!errors.username}
id="username" errorText={errors.username?.message || !!error}
{...register("username", { >
required: "Username is required", <InputGroup w="100%" startElement={<FiMail />}>
pattern: emailPattern,
})}
placeholder="Email"
type="email"
required
/>
{errors.username && (
<FormErrorMessage>{errors.username.message}</FormErrorMessage>
)}
</FormControl>
<FormControl id="password" isInvalid={!!error}>
<InputGroup>
<Input <Input
{...register("password", { id="username"
required: "Password is required", {...register("username", {
required: "Username is required",
pattern: emailPattern,
})} })}
type={show ? "text" : "password"} placeholder="Email"
placeholder="Password" type="email"
required
/> />
<InputRightElement
color="ui.dim"
_hover={{
cursor: "pointer",
}}
>
<Icon
as={show ? ViewOffIcon : ViewIcon}
onClick={setShow.toggle}
aria-label={show ? "Hide password" : "Show password"}
>
{show ? <ViewOffIcon /> : <ViewIcon />}
</Icon>
</InputRightElement>
</InputGroup> </InputGroup>
{error && <FormErrorMessage>{error}</FormErrorMessage>} </Field>
</FormControl> <PasswordInput
<Link as={RouterLink} to="/recover-password" color="blue.500"> type="password"
Forgot password? startElement={<FiLock />}
</Link> {...register("password", passwordRules())}
<Button variant="primary" type="submit" isLoading={isSubmitting}> placeholder="Password"
errors={errors}
/>
<RouterLink to="/recover-password" className="main-link">
Forgot Password?
</RouterLink>
<Button variant="solid" type="submit" loading={isSubmitting} size="md">
Log In Log In
</Button> </Button>
<Text> <Text>
Don't have an account?{" "} Don't have an account?{" "}
<Link as={RouterLink} to="/signup" color="blue.500"> <RouterLink to="/signup" className="main-link">
Sign up Sign Up
</Link> </RouterLink>
</Text> </Text>
</Container> </Container>
</> </>

55
frontend/src/routes/recover-password.tsx

@ -1,17 +1,13 @@
import { import { Container, Heading, Input, Text } from "@chakra-ui/react"
Button,
Container,
FormControl,
FormErrorMessage,
Heading,
Input,
Text,
} from "@chakra-ui/react"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router" import { createFileRoute, redirect } from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form" import { type SubmitHandler, useForm } from "react-hook-form"
import { FiMail } from "react-icons/fi"
import { type ApiError, LoginService } from "../client" import { type ApiError, LoginService } from "../client"
import { Button } from "../components/ui/button"
import { Field } from "../components/ui/field"
import { InputGroup } from "../components/ui/input-group"
import { isLoggedIn } from "../hooks/useAuth" import { isLoggedIn } from "../hooks/useAuth"
import useCustomToast from "../hooks/useCustomToast" import useCustomToast from "../hooks/useCustomToast"
import { emailPattern, handleError } from "../utils" import { emailPattern, handleError } from "../utils"
@ -38,7 +34,7 @@ function RecoverPassword() {
reset, reset,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<FormData>() } = useForm<FormData>()
const showToast = useCustomToast() const { showSuccessToast } = useCustomToast()
const recoverPassword = async (data: FormData) => { const recoverPassword = async (data: FormData) => {
await LoginService.recoverPassword({ await LoginService.recoverPassword({
@ -49,15 +45,11 @@ function RecoverPassword() {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: recoverPassword, mutationFn: recoverPassword,
onSuccess: () => { onSuccess: () => {
showToast( showSuccessToast("Password recovery email sent successfully.")
"Email sent.",
"We sent an email with a link to get back into your account.",
"success",
)
reset() reset()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
handleError(err, showToast) handleError(err)
}, },
}) })
@ -79,24 +71,23 @@ function RecoverPassword() {
<Heading size="xl" color="ui.main" textAlign="center" mb={2}> <Heading size="xl" color="ui.main" textAlign="center" mb={2}>
Password Recovery Password Recovery
</Heading> </Heading>
<Text align="center"> <Text textAlign="center">
A password recovery email will be sent to the registered account. A password recovery email will be sent to the registered account.
</Text> </Text>
<FormControl isInvalid={!!errors.email}> <Field invalid={!!errors.email} errorText={errors.email?.message}>
<Input <InputGroup w="100%" startElement={<FiMail />}>
id="email" <Input
{...register("email", { id="email"
required: "Email is required", {...register("email", {
pattern: emailPattern, required: "Email is required",
})} pattern: emailPattern,
placeholder="Email" })}
type="email" placeholder="Email"
/> type="email"
{errors.email && ( />
<FormErrorMessage>{errors.email.message}</FormErrorMessage> </InputGroup>
)} </Field>
</FormControl> <Button variant="solid" type="submit" loading={isSubmitting}>
<Button variant="primary" type="submit" isLoading={isSubmitting}>
Continue Continue
</Button> </Button>
</Container> </Container>

60
frontend/src/routes/reset-password.tsx

@ -1,18 +1,12 @@
import { import { Container, Heading, Text } from "@chakra-ui/react"
Button,
Container,
FormControl,
FormErrorMessage,
FormLabel,
Heading,
Input,
Text,
} from "@chakra-ui/react"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form" import { type SubmitHandler, useForm } from "react-hook-form"
import { FiLock } from "react-icons/fi"
import { type ApiError, LoginService, type NewPassword } from "../client" import { type ApiError, LoginService, type NewPassword } from "../client"
import { Button } from "../components/ui/button"
import { PasswordInput } from "../components/ui/password-input"
import { isLoggedIn } from "../hooks/useAuth" import { isLoggedIn } from "../hooks/useAuth"
import useCustomToast from "../hooks/useCustomToast" import useCustomToast from "../hooks/useCustomToast"
import { confirmPasswordRules, handleError, passwordRules } from "../utils" import { confirmPasswordRules, handleError, passwordRules } from "../utils"
@ -46,7 +40,7 @@ function ResetPassword() {
new_password: "", new_password: "",
}, },
}) })
const showToast = useCustomToast() const { showSuccessToast } = useCustomToast()
const navigate = useNavigate() const navigate = useNavigate()
const resetPassword = async (data: NewPassword) => { const resetPassword = async (data: NewPassword) => {
@ -60,12 +54,12 @@ function ResetPassword() {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: resetPassword, mutationFn: resetPassword,
onSuccess: () => { onSuccess: () => {
showToast("Success!", "Password updated successfully.", "success") showSuccessToast("Password updated successfully.")
reset() reset()
navigate({ to: "/login" }) navigate({ to: "/login" })
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
handleError(err, showToast) handleError(err)
}, },
}) })
@ -90,31 +84,21 @@ function ResetPassword() {
<Text textAlign="center"> <Text textAlign="center">
Please enter your new password and confirm it to reset your password. Please enter your new password and confirm it to reset your password.
</Text> </Text>
<FormControl mt={4} isInvalid={!!errors.new_password}> <PasswordInput
<FormLabel htmlFor="password">Set Password</FormLabel> startElement={<FiLock />}
<Input type="new_password"
id="password" errors={errors}
{...register("new_password", passwordRules())} {...register("new_password", passwordRules())}
placeholder="Password" placeholder="New Password"
type="password" />
/> <PasswordInput
{errors.new_password && ( startElement={<FiLock />}
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage> type="confirm_password"
)} errors={errors}
</FormControl> {...register("confirm_password", confirmPasswordRules(getValues))}
<FormControl mt={4} isInvalid={!!errors.confirm_password}> placeholder="Confirm Password"
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel> />
<Input <Button variant="solid" type="submit">
id="confirm_password"
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Password"
type="password"
/>
{errors.confirm_password && (
<FormErrorMessage>{errors.confirm_password.message}</FormErrorMessage>
)}
</FormControl>
<Button variant="primary" type="submit">
Reset Password Reset Password
</Button> </Button>
</Container> </Container>

130
frontend/src/routes/signup.tsx

@ -1,15 +1,4 @@
import { import { Container, Flex, Image, Input, Text } from "@chakra-ui/react"
Button,
Container,
Flex,
FormControl,
FormErrorMessage,
FormLabel,
Image,
Input,
Link,
Text,
} from "@chakra-ui/react"
import { import {
Link as RouterLink, Link as RouterLink,
createFileRoute, createFileRoute,
@ -17,8 +6,13 @@ import {
} from "@tanstack/react-router" } from "@tanstack/react-router"
import { type SubmitHandler, useForm } from "react-hook-form" import { type SubmitHandler, useForm } from "react-hook-form"
import { FiLock, FiUser } from "react-icons/fi"
import Logo from "/assets/images/fastapi-logo.svg" import Logo from "/assets/images/fastapi-logo.svg"
import type { UserRegister } from "../client" import type { UserRegister } from "../client"
import { Button } from "../components/ui/button"
import { Field } from "../components/ui/field"
import { InputGroup } from "../components/ui/input-group"
import { PasswordInput } from "../components/ui/password-input"
import useAuth, { isLoggedIn } from "../hooks/useAuth" import useAuth, { isLoggedIn } from "../hooks/useAuth"
import { confirmPasswordRules, emailPattern, passwordRules } from "../utils" import { confirmPasswordRules, emailPattern, passwordRules } from "../utils"
@ -80,80 +74,58 @@ function SignUp() {
alignSelf="center" alignSelf="center"
mb={4} mb={4}
/> />
<FormControl id="full_name" isInvalid={!!errors.full_name}> <Field
<FormLabel htmlFor="full_name" srOnly> invalid={!!errors.full_name}
Full Name errorText={errors.full_name?.message}
</FormLabel>
<Input
id="full_name"
minLength={3}
{...register("full_name", { required: "Full Name is required" })}
placeholder="Full Name"
type="text"
/>
{errors.full_name && (
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
)}
</FormControl>
<FormControl id="email" isInvalid={!!errors.email}>
<FormLabel htmlFor="email" srOnly>
Email
</FormLabel>
<Input
id="email"
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
{errors.email && (
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
)}
</FormControl>
<FormControl id="password" isInvalid={!!errors.password}>
<FormLabel htmlFor="password" srOnly>
Password
</FormLabel>
<Input
id="password"
{...register("password", passwordRules())}
placeholder="Password"
type="password"
/>
{errors.password && (
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
)}
</FormControl>
<FormControl
id="confirm_password"
isInvalid={!!errors.confirm_password}
> >
<FormLabel htmlFor="confirm_password" srOnly> <InputGroup w="100%" startElement={<FiUser />}>
Confirm Password <Input
</FormLabel> id="full_name"
minLength={3}
{...register("full_name", {
required: "Full Name is required",
})}
placeholder="Full Name"
type="text"
/>
</InputGroup>
</Field>
<Input <Field invalid={!!errors.email} errorText={errors.email?.message}>
id="confirm_password" <InputGroup w="100%" startElement={<FiUser />}>
{...register("confirm_password", confirmPasswordRules(getValues))} <Input
placeholder="Repeat Password" id="email"
type="password" {...register("email", {
/> required: "Email is required",
{errors.confirm_password && ( pattern: emailPattern,
<FormErrorMessage> })}
{errors.confirm_password.message} placeholder="Email"
</FormErrorMessage> type="email"
)} />
</FormControl> </InputGroup>
<Button variant="primary" type="submit" isLoading={isSubmitting}> </Field>
<PasswordInput
type="password"
startElement={<FiLock />}
{...register("password", passwordRules())}
placeholder="Password"
errors={errors}
/>
<PasswordInput
type="confirm_password"
startElement={<FiLock />}
{...register("confirm_password", confirmPasswordRules(getValues))}
placeholder="Confirm Password"
errors={errors}
/>
<Button variant="solid" type="submit" loading={isSubmitting}>
Sign Up Sign Up
</Button> </Button>
<Text> <Text>
Already have an account?{" "} Already have an account?{" "}
<Link as={RouterLink} to="/login" color="blue.500"> <RouterLink to="/login" className="main-link">
Log In Log In
</Link> </RouterLink>
</Text> </Text>
</Container> </Container>
</Flex> </Flex>

74
frontend/src/theme.tsx

@ -1,61 +1,31 @@
import { extendTheme } from "@chakra-ui/react" import { createSystem, defaultConfig } from "@chakra-ui/react"
import { buttonRecipe } from "./theme/button.recipe"
const disabledStyles = { export const system = createSystem(defaultConfig, {
_disabled: { globalCss: {
backgroundColor: "ui.main", html: {
}, fontSize: "16px",
} },
body: {
const theme = extendTheme({ fontSize: "0.875rem",
colors: { margin: 0,
ui: { padding: 0,
main: "#009688", },
secondary: "#EDF2F7", ".main-link": {
success: "#48BB78", color: "ui.main",
danger: "#E53E3E", fontWeight: "bold",
light: "#FAFAFA",
dark: "#1A202C",
darkSlate: "#252D3D",
dim: "#A0AEC0",
}, },
}, },
components: { theme: {
Button: { tokens: {
variants: { colors: {
primary: { ui: {
backgroundColor: "ui.main", main: { value: "#009688" },
color: "ui.light",
_hover: {
backgroundColor: "#00766C",
},
_disabled: {
...disabledStyles,
_hover: {
...disabledStyles,
},
},
},
danger: {
backgroundColor: "ui.danger",
color: "ui.light",
_hover: {
backgroundColor: "#E32727",
},
}, },
}, },
}, },
Tabs: { recipes: {
variants: { button: buttonRecipe,
enclosed: {
tab: {
_selected: {
color: "ui.main",
},
},
},
},
}, },
}, },
}) })
export default theme

21
frontend/src/theme/button.recipe.ts

@ -0,0 +1,21 @@
import { defineRecipe } from "@chakra-ui/react"
export const buttonRecipe = defineRecipe({
base: {
fontWeight: "bold",
display: "flex",
alignItems: "center",
justifyContent: "center",
colorPalette: "teal",
},
variants: {
variant: {
ghost: {
bg: "transparent",
_hover: {
bg: "gray.100",
},
},
},
},
})

6
frontend/src/utils.ts

@ -1,4 +1,5 @@
import type { ApiError } from "./client" import type { ApiError } from "./client"
import useCustomToast from "./hooks/useCustomToast"
export const emailPattern = { export const emailPattern = {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
@ -43,11 +44,12 @@ export const confirmPasswordRules = (
return rules return rules
} }
export const handleError = (err: ApiError, showToast: any) => { export const handleError = (err: ApiError) => {
const { showErrorToast } = useCustomToast()
const errDetail = (err.body as any)?.detail const errDetail = (err.body as any)?.detail
let errorMessage = errDetail || "Something went wrong." let errorMessage = errDetail || "Something went wrong."
if (Array.isArray(errDetail) && errDetail.length > 0) { if (Array.isArray(errDetail) && errDetail.length > 0) {
errorMessage = errDetail[0].msg errorMessage = errDetail[0].msg
} }
showToast("Error", errorMessage, "error") showErrorToast(errorMessage)
} }

12
frontend/tests/reset-password.spec.ts

@ -64,8 +64,8 @@ test("User can reset password successfully using the link", async ({
// Set the new password and confirm it // Set the new password and confirm it
await page.goto(url) await page.goto(url)
await page.getByLabel("Set Password").fill(newPassword) await page.getByPlaceholder("New Password").fill(newPassword)
await page.getByLabel("Confirm Password").fill(newPassword) await page.getByPlaceholder("Confirm Password").fill(newPassword)
await page.getByRole("button", { name: "Reset Password" }).click() await page.getByRole("button", { name: "Reset Password" }).click()
await expect(page.getByText("Password updated successfully")).toBeVisible() await expect(page.getByText("Password updated successfully")).toBeVisible()
@ -79,8 +79,8 @@ test("Expired or invalid reset link", async ({ page }) => {
await page.goto(invalidUrl) await page.goto(invalidUrl)
await page.getByLabel("Set Password").fill(password) await page.getByPlaceholder("New Password").fill(password)
await page.getByLabel("Confirm Password").fill(password) await page.getByPlaceholder("Confirm Password").fill(password)
await page.getByRole("button", { name: "Reset Password" }).click() await page.getByRole("button", { name: "Reset Password" }).click()
await expect(page.getByText("Invalid token")).toBeVisible() await expect(page.getByText("Invalid token")).toBeVisible()
@ -115,8 +115,8 @@ test("Weak new password validation", async ({ page, request }) => {
// Set a weak new password // Set a weak new password
await page.goto(url) await page.goto(url)
await page.getByLabel("Set Password").fill(weakPassword) await page.getByPlaceholder("New Password").fill(weakPassword)
await page.getByLabel("Confirm Password").fill(weakPassword) await page.getByPlaceholder("Confirm Password").fill(weakPassword)
await page.getByRole("button", { name: "Reset Password" }).click() await page.getByRole("button", { name: "Reset Password" }).click()
await expect( await expect(

4
frontend/tests/sign-up.spec.ts

@ -18,7 +18,7 @@ const fillForm = async (
await page.getByPlaceholder("Full Name").fill(full_name) await page.getByPlaceholder("Full Name").fill(full_name)
await page.getByPlaceholder("Email").fill(email) await page.getByPlaceholder("Email").fill(email)
await page.getByPlaceholder("Password", { exact: true }).fill(password) await page.getByPlaceholder("Password", { exact: true }).fill(password)
await page.getByPlaceholder("Repeat Password").fill(confirm_password) await page.getByPlaceholder("Confirm Password").fill(confirm_password)
} }
const verifyInput = async ( const verifyInput = async (
@ -38,7 +38,7 @@ test("Inputs are visible, empty and editable", async ({ page }) => {
await verifyInput(page, "Full Name") await verifyInput(page, "Full Name")
await verifyInput(page, "Email") await verifyInput(page, "Email")
await verifyInput(page, "Password", { exact: true }) await verifyInput(page, "Password", { exact: true })
await verifyInput(page, "Repeat Password") await verifyInput(page, "Confirm Password")
}) })
test("Sign Up button is visible", async ({ page }) => { test("Sign Up button is visible", async ({ page }) => {

115
frontend/tests/user-settings.spec.ts

@ -1,8 +1,8 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { firstSuperuser, firstSuperuserPassword } from "./config.ts" import { firstSuperuser, firstSuperuserPassword } from "./config.ts"
import { createUser } from "./utils/privateApi.ts"
import { randomEmail, randomPassword } from "./utils/random" import { randomEmail, randomPassword } from "./utils/random"
import { logInUser, logOutUser } from "./utils/user" import { logInUser, logOutUser } from "./utils/user"
import { createUser } from "./utils/privateApi.ts"
const tabs = ["My profile", "Password", "Appearance"] const tabs = ["My profile", "Password", "Appearance"]
@ -151,9 +151,9 @@ test.describe("Change password successfully", () => {
await page.goto("/settings") await page.goto("/settings")
await page.getByRole("tab", { name: "Password" }).click() await page.getByRole("tab", { name: "Password" }).click()
await page.getByLabel("Current Password*").fill(password) await page.getByPlaceholder("Current Password").fill(password)
await page.getByLabel("Set Password*").fill(NewPassword) await page.getByPlaceholder("New Password").fill(NewPassword)
await page.getByLabel("Confirm Password*").fill(NewPassword) await page.getByPlaceholder("Confirm Password").fill(NewPassword)
await page.getByRole("button", { name: "Save" }).click() await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Password updated successfully.")).toBeVisible() await expect(page.getByText("Password updated successfully.")).toBeVisible()
@ -179,9 +179,9 @@ test.describe("Change password with invalid data", () => {
await page.goto("/settings") await page.goto("/settings")
await page.getByRole("tab", { name: "Password" }).click() await page.getByRole("tab", { name: "Password" }).click()
await page.getByLabel("Current Password*").fill(password) await page.getByPlaceholder("Current Password").fill(password)
await page.getByLabel("Set Password*").fill(weakPassword) await page.getByPlaceholder("New Password").fill(weakPassword)
await page.getByLabel("Confirm Password*").fill(weakPassword) await page.getByPlaceholder("Confirm Password").fill(weakPassword)
await expect( await expect(
page.getByText("Password must be at least 8 characters"), page.getByText("Password must be at least 8 characters"),
).toBeVisible() ).toBeVisible()
@ -202,11 +202,11 @@ test.describe("Change password with invalid data", () => {
await page.goto("/settings") await page.goto("/settings")
await page.getByRole("tab", { name: "Password" }).click() await page.getByRole("tab", { name: "Password" }).click()
await page.getByLabel("Current Password*").fill(password) await page.getByPlaceholder("Current Password").fill(password)
await page.getByLabel("Set Password*").fill(newPassword) await page.getByPlaceholder("New Password").fill(newPassword)
await page.getByLabel("Confirm Password*").fill(confirmPassword) await page.getByPlaceholder("Confirm Password").fill(confirmPassword)
await page.getByRole("button", { name: "Save" }).click() await page.getByLabel("Password", { exact: true }).locator("form").click()
await expect(page.getByText("Passwords do not match")).toBeVisible() await expect(page.getByText("The passwords do not match")).toBeVisible()
}) })
test("Current password and new password are the same", async ({ page }) => { test("Current password and new password are the same", async ({ page }) => {
@ -220,9 +220,9 @@ test.describe("Change password with invalid data", () => {
await page.goto("/settings") await page.goto("/settings")
await page.getByRole("tab", { name: "Password" }).click() await page.getByRole("tab", { name: "Password" }).click()
await page.getByLabel("Current Password*").fill(password) await page.getByPlaceholder("Current Password").fill(password)
await page.getByLabel("Set Password*").fill(password) await page.getByPlaceholder("New Password").fill(password)
await page.getByLabel("Confirm Password*").fill(password) await page.getByPlaceholder("Confirm Password").fill(password)
await page.getByRole("button", { name: "Save" }).click() await page.getByRole("button", { name: "Save" }).click()
await expect( await expect(
page.getByText("New password cannot be the same as the current one"), page.getByText("New password cannot be the same as the current one"),
@ -238,22 +238,50 @@ test("Appearance tab is visible", async ({ page }) => {
await expect(page.getByLabel("Appearance")).toBeVisible() await expect(page.getByLabel("Appearance")).toBeVisible()
}) })
test("User can switch from light mode to dark mode", async ({ page }) => { test("User can switch from light mode to dark mode and vice versa", async ({
page,
}) => {
await page.goto("/settings") await page.goto("/settings")
await page.getByRole("tab", { name: "Appearance" }).click() await page.getByRole("tab", { name: "Appearance" }).click()
await page.getByLabel("Appearance").locator("span").nth(3).click()
// Ensure the initial state is light mode
if (
await page.evaluate(() =>
document.documentElement.classList.contains("dark"),
)
) {
await page
.locator("label")
.filter({ hasText: "Light Mode" })
.locator("span")
.first()
.click()
}
let isLightMode = await page.evaluate(() =>
document.documentElement.classList.contains("light"),
)
expect(isLightMode).toBe(true)
await page
.locator("label")
.filter({ hasText: "Dark Mode" })
.locator("span")
.first()
.click()
const isDarkMode = await page.evaluate(() => const isDarkMode = await page.evaluate(() =>
document.body.classList.contains("chakra-ui-dark"), document.documentElement.classList.contains("dark"),
) )
expect(isDarkMode).toBe(true) expect(isDarkMode).toBe(true)
})
test("User can switch from dark mode to light mode", async ({ page }) => { await page
await page.goto("/settings") .locator("label")
await page.getByRole("tab", { name: "Appearance" }).click() .filter({ hasText: "Light Mode" })
await page.getByLabel("Appearance").locator("span").first().click() .locator("span")
const isLightMode = await page.evaluate(() => .first()
document.body.classList.contains("chakra-ui-light"), .click()
isLightMode = await page.evaluate(() =>
document.documentElement.classList.contains("light"),
) )
expect(isLightMode).toBe(true) expect(isLightMode).toBe(true)
}) })
@ -261,13 +289,42 @@ test("User can switch from dark mode to light mode", async ({ page }) => {
test("Selected mode is preserved across sessions", async ({ page }) => { test("Selected mode is preserved across sessions", async ({ page }) => {
await page.goto("/settings") await page.goto("/settings")
await page.getByRole("tab", { name: "Appearance" }).click() await page.getByRole("tab", { name: "Appearance" }).click()
await page.getByLabel("Appearance").locator("span").nth(3).click()
await logOutUser(page) // Ensure the initial state is light mode
if (
await page.evaluate(() =>
document.documentElement.classList.contains("dark"),
)
) {
await page
.locator("label")
.filter({ hasText: "Light Mode" })
.locator("span")
.first()
.click()
}
const isLightMode = await page.evaluate(() =>
document.documentElement.classList.contains("light"),
)
expect(isLightMode).toBe(true)
await page
.locator("label")
.filter({ hasText: "Dark Mode" })
.locator("span")
.first()
.click()
let isDarkMode = await page.evaluate(() =>
document.documentElement.classList.contains("dark"),
)
expect(isDarkMode).toBe(true)
await logOutUser(page)
await logInUser(page, firstSuperuser, firstSuperuserPassword) await logInUser(page, firstSuperuser, firstSuperuserPassword)
const isDarkMode = await page.evaluate(() =>
document.body.classList.contains("chakra-ui-dark"), isDarkMode = await page.evaluate(() =>
document.documentElement.classList.contains("dark"),
) )
expect(isDarkMode).toBe(true) expect(isDarkMode).toBe(true)
}) })

5
frontend/tests/utils/user.ts

@ -11,11 +11,8 @@ export async function signUpNewUser(
await page.getByPlaceholder("Full Name").fill(name) await page.getByPlaceholder("Full Name").fill(name)
await page.getByPlaceholder("Email").fill(email) await page.getByPlaceholder("Email").fill(email)
await page.getByPlaceholder("Password", { exact: true }).fill(password) await page.getByPlaceholder("Password", { exact: true }).fill(password)
await page.getByPlaceholder("Repeat Password").fill(password) await page.getByPlaceholder("Confirm Password").fill(password)
await page.getByRole("button", { name: "Sign Up" }).click() await page.getByRole("button", { name: "Sign Up" }).click()
await expect(
page.getByText("Your account has been created successfully"),
).toBeVisible()
await page.goto("/login") await page.goto("/login")
} }

Loading…
Cancel
Save