🛂 Migrate to Chakra UI v3 (#1496)
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
@@ -15,7 +15,7 @@ export default defineConfig({
|
||||
// @ts-ignore
|
||||
let name: string = operation.name
|
||||
// @ts-ignore
|
||||
let service: string = operation.service
|
||||
const service: string = operation.service
|
||||
|
||||
if (service && name.toLowerCase().startsWith(service.toLowerCase())) {
|
||||
name = name.slice(service.length)
|
||||
|
||||
4713
frontend/package-lock.json
generated
4713
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,21 +11,19 @@
|
||||
"generate-client": "openapi-ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/icons": "2.1.1",
|
||||
"@chakra-ui/react": "2.8.2",
|
||||
"@emotion/react": "11.11.3",
|
||||
"@emotion/styled": "11.11.0",
|
||||
"@chakra-ui/react": "^3.8.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@tanstack/react-query": "^5.28.14",
|
||||
"@tanstack/react-query-devtools": "^5.28.14",
|
||||
"@tanstack/react-router": "1.19.1",
|
||||
"axios": "1.7.4",
|
||||
"form-data": "4.0.0",
|
||||
"framer-motion": "10.16.16",
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-hook-form": "7.49.3",
|
||||
"react-icons": "5.0.1"
|
||||
"react-icons": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.6.1",
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DialogActionTrigger,
|
||||
DialogTitle,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FaPlus } from "react-icons/fa"
|
||||
import { type UserCreate, UsersService } from "../../client"
|
||||
import type { ApiError } from "../../client/core/ApiError"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
|
||||
interface AddUserProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
import { Checkbox } from "../ui/checkbox"
|
||||
import {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog"
|
||||
import { Field } from "../ui/field"
|
||||
|
||||
interface UserCreateForm extends UserCreate {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
const AddUser = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<UserCreateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
@@ -57,12 +60,12 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
mutationFn: (data: UserCreate) =>
|
||||
UsersService.createUser({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User created successfully.", "success")
|
||||
showSuccessToast("User created successfully.")
|
||||
reset()
|
||||
onClose()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
@@ -74,108 +77,153 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add User</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isRequired isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="email">Email</FormLabel>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button value="add-user" my={4}>
|
||||
<FaPlus fontSize="16px" />
|
||||
Add User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>
|
||||
Fill in the form below to add a new user to the system.
|
||||
</Text>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.email}
|
||||
errorText={errors.email?.message}
|
||||
label="Email"
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
invalid={!!errors.full_name}
|
||||
errorText={errors.full_name?.message}
|
||||
label="Full Name"
|
||||
>
|
||||
<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", {
|
||||
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.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.full_name}>
|
||||
<FormLabel htmlFor="name">Full name</FormLabel>
|
||||
<Input
|
||||
id="name"
|
||||
{...register("full_name")}
|
||||
placeholder="Full name"
|
||||
type="text"
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<Field disabled={field.disabled} colorPalette="teal">
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={({ checked }) => field.onChange(checked)}
|
||||
>
|
||||
Is active?
|
||||
</Checkbox>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{errors.full_name && (
|
||||
<FormErrorMessage>{errors.full_name.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl
|
||||
mt={4}
|
||||
isRequired
|
||||
isInvalid={!!errors.confirm_password}
|
||||
>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<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"
|
||||
/>
|
||||
{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>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter gap={2}>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button
|
||||
variant="solid"
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<DialogCloseTrigger />
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
103
frontend/src/components/Admin/DeleteUser.tsx
Normal file
103
frontend/src/components/Admin/DeleteUser.tsx
Normal file
@@ -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
|
||||
@@ -1,51 +1,52 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type UserPublic,
|
||||
type UserUpdate,
|
||||
UsersService,
|
||||
} from "../../client"
|
||||
Button,
|
||||
DialogActionTrigger,
|
||||
DialogRoot,
|
||||
DialogTrigger,
|
||||
Flex,
|
||||
Input,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useState } from "react"
|
||||
import { FaExchangeAlt } from "react-icons/fa"
|
||||
import { type UserPublic, type UserUpdate, UsersService } from "../../client"
|
||||
import type { ApiError } from "../../client/core/ApiError"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
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 {
|
||||
user: UserPublic
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
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 showToast = useCustomToast()
|
||||
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UserUpdateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
@@ -56,11 +57,12 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
mutationFn: (data: UserUpdateForm) =>
|
||||
UsersService.updateUser({ userId: user.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User updated successfully.", "success")
|
||||
onClose()
|
||||
showSuccessToast("User updated successfully.")
|
||||
reset()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
@@ -74,106 +76,145 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit User</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<FormLabel htmlFor="email">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 mt={4}>
|
||||
<FormLabel htmlFor="name">Full name</FormLabel>
|
||||
<Input id="name" {...register("full_name")} type="text" />
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("password", {
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters",
|
||||
},
|
||||
})}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormErrorMessage>{errors.password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_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>
|
||||
<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>
|
||||
</ModalBody>
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<FaExchangeAlt fontSize="16px" />
|
||||
Edit User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>Update the user details below.</Text>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.email}
|
||||
errorText={errors.email?.message}
|
||||
label="Email"
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
<Field
|
||||
invalid={!!errors.full_name}
|
||||
errorText={errors.full_name?.message}
|
||||
label="Full Name"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<Field disabled={field.disabled} colorPalette="teal">
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={({ checked }) => field.onChange(checked)}
|
||||
>
|
||||
Is active?
|
||||
</Checkbox>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter gap={2}>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
</DialogFooter>
|
||||
<DialogCloseTrigger />
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
Normal file
27
frontend/src/components/Common/ItemActionsMenu.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
import { FaPlus } from "react-icons/fa"
|
||||
function Navbar() {
|
||||
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 (
|
||||
<>
|
||||
<Flex py={8} gap={4}>
|
||||
{/* TODO: Complete search functionality */}
|
||||
{/* <InputGroup w={{ base: '100%', md: 'auto' }}>
|
||||
<InputLeftElement pointerEvents='none'>
|
||||
<Icon as={FaSearch} color='ui.dim' />
|
||||
</InputLeftElement>
|
||||
<Input type='text' placeholder='Search' fontSize={{ base: 'sm', md: 'inherit' }} borderRadius='8px' />
|
||||
</InputGroup> */}
|
||||
<Button
|
||||
variant="primary"
|
||||
gap={1}
|
||||
fontSize={{ base: "sm", md: "inherit" }}
|
||||
onClick={addModal.onOpen}
|
||||
>
|
||||
<Icon as={FaPlus} /> Add {type}
|
||||
</Button>
|
||||
<AddModal isOpen={addModal.isOpen} onClose={addModal.onClose} />
|
||||
<Flex
|
||||
display={display}
|
||||
justify="space-between"
|
||||
position="sticky"
|
||||
color="white"
|
||||
align="center"
|
||||
bg="bg.muted"
|
||||
w="100%"
|
||||
top={0}
|
||||
p={4}
|
||||
>
|
||||
<Link to="/">
|
||||
<Image src={Logo} alt="Logo" w="180px" maxW="2xs" px={2} />
|
||||
</Link>
|
||||
<Flex gap={2} alignItems="center">
|
||||
<UserMenu />
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<>
|
||||
<Container
|
||||
h="100vh"
|
||||
alignItems="stretch"
|
||||
justifyContent="center"
|
||||
textAlign="center"
|
||||
maxW="sm"
|
||||
centerContent
|
||||
<Flex
|
||||
height="100vh"
|
||||
align="center"
|
||||
justify="center"
|
||||
flexDir="column"
|
||||
data-testid="not-found"
|
||||
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
|
||||
fontSize="8xl"
|
||||
color="ui.main"
|
||||
fontWeight="bold"
|
||||
lineHeight="1"
|
||||
fontSize="lg"
|
||||
color="gray.600"
|
||||
mb={4}
|
||||
textAlign="center"
|
||||
zIndex={1}
|
||||
>
|
||||
404
|
||||
The page you are looking for was not found.
|
||||
</Text>
|
||||
<Text fontSize="md">Oops!</Text>
|
||||
<Text fontSize="md">Page not found.</Text>
|
||||
<Button
|
||||
as={Link}
|
||||
to="/"
|
||||
color="ui.main"
|
||||
borderColor="ui.main"
|
||||
variant="outline"
|
||||
mt={4}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</Container>
|
||||
<Center zIndex={1}>
|
||||
<Link to="/">
|
||||
<Button
|
||||
variant="solid"
|
||||
colorScheme="teal"
|
||||
mt={4}
|
||||
alignSelf="center"
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
</Link>
|
||||
</Center>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,26 @@
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
IconButton,
|
||||
Image,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import { Box, Flex, IconButton, Text } from "@chakra-ui/react"
|
||||
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 useAuth from "../../hooks/useAuth"
|
||||
import {
|
||||
DrawerBackdrop,
|
||||
DrawerBody,
|
||||
DrawerCloseTrigger,
|
||||
DrawerContent,
|
||||
DrawerRoot,
|
||||
DrawerTrigger,
|
||||
} from "../ui/drawer"
|
||||
import SidebarItems from "./SidebarItems"
|
||||
|
||||
const Sidebar = () => {
|
||||
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 { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { logout } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
logout()
|
||||
@@ -36,78 +29,68 @@ const Sidebar = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile */}
|
||||
<IconButton
|
||||
onClick={onOpen}
|
||||
display={{ base: "flex", md: "none" }}
|
||||
aria-label="Open Menu"
|
||||
position="absolute"
|
||||
fontSize="20px"
|
||||
m={4}
|
||||
icon={<FiMenu />}
|
||||
/>
|
||||
<Drawer isOpen={isOpen} placement="left" onClose={onClose}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent maxW="250px">
|
||||
<DrawerCloseButton />
|
||||
<DrawerBody py={8}>
|
||||
<DrawerRoot
|
||||
placement="start"
|
||||
open={open}
|
||||
onOpenChange={(e) => setOpen(e.open)}
|
||||
>
|
||||
<DrawerBackdrop />
|
||||
<DrawerTrigger asChild>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="inherit"
|
||||
display={{ base: "flex", md: "none" }}
|
||||
aria-label="Open Menu"
|
||||
position="absolute"
|
||||
zIndex="100"
|
||||
m={4}
|
||||
>
|
||||
<FaBars />
|
||||
</IconButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent maxW="280px">
|
||||
<DrawerCloseTrigger />
|
||||
<DrawerBody>
|
||||
<Flex flexDir="column" justify="space-between">
|
||||
<Box>
|
||||
<Image src={Logo} alt="logo" p={6} />
|
||||
<SidebarItems onClose={onClose} />
|
||||
<SidebarItems />
|
||||
<Flex
|
||||
as="button"
|
||||
onClick={handleLogout}
|
||||
p={2}
|
||||
color="ui.danger"
|
||||
fontWeight="bold"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
<FiLogOut />
|
||||
<Text ml={2}>Log out</Text>
|
||||
<Text>Log Out</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
{currentUser?.email && (
|
||||
<Text color={textColor} noOfLines={2} fontSize="sm" p={2}>
|
||||
<Text fontSize="sm" p={2}>
|
||||
Logged in as: {currentUser.email}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</DrawerBody>
|
||||
<DrawerCloseTrigger />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</DrawerRoot>
|
||||
|
||||
{/* Desktop */}
|
||||
|
||||
<Box
|
||||
bg={bgColor}
|
||||
p={3}
|
||||
h="100vh"
|
||||
position="sticky"
|
||||
top="0"
|
||||
display={{ base: "none", md: "flex" }}
|
||||
position="sticky"
|
||||
bg="bg.subtle"
|
||||
top={0}
|
||||
minW="280px"
|
||||
h="100vh"
|
||||
p={4}
|
||||
>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
justify="space-between"
|
||||
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 w="100%">
|
||||
<SidebarItems />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 { Link } from "@tanstack/react-router"
|
||||
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
|
||||
import { Link as RouterLink } from "@tanstack/react-router"
|
||||
|
||||
import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi"
|
||||
import type { IconType } from "react-icons/lib"
|
||||
import type { UserPublic } from "../../client"
|
||||
|
||||
const items = [
|
||||
@@ -15,39 +16,43 @@ interface SidebarItemsProps {
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
interface Item {
|
||||
icon: IconType
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const SidebarItems = ({ onClose }: SidebarItemsProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const textColor = useColorModeValue("ui.main", "ui.light")
|
||||
const bgActive = useColorModeValue("#E2E8F0", "#4A5568")
|
||||
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
|
||||
|
||||
const listItems = finalItems.map(({ icon, title, path }) => (
|
||||
<Flex
|
||||
as={Link}
|
||||
to={path}
|
||||
w="100%"
|
||||
p={2}
|
||||
key={title}
|
||||
activeProps={{
|
||||
style: {
|
||||
background: bgActive,
|
||||
borderRadius: "12px",
|
||||
},
|
||||
}}
|
||||
color={textColor}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon as={icon} alignSelf="center" />
|
||||
<Text ml={2}>{title}</Text>
|
||||
</Flex>
|
||||
<RouterLink key={title} to={path} onClick={onClose}>
|
||||
<Flex
|
||||
gap={4}
|
||||
px={4}
|
||||
py={2}
|
||||
_hover={{
|
||||
background: "gray.subtle",
|
||||
}}
|
||||
alignItems="center"
|
||||
fontSize="sm"
|
||||
>
|
||||
<Icon as={icon} alignSelf="center" />
|
||||
<Text ml={2}>{title}</Text>
|
||||
</Flex>
|
||||
</RouterLink>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text fontSize="xs" px={4} py={2} fontWeight="bold">
|
||||
Menu
|
||||
</Text>
|
||||
<Box>{listItems}</Box>
|
||||
</>
|
||||
)
|
||||
|
||||
28
frontend/src/components/Common/UserActionsMenu.tsx
Normal file
28
frontend/src/components/Common/UserActionsMenu.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from "@chakra-ui/react"
|
||||
import { Box, Button, Flex, Text } from "@chakra-ui/react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
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 { MenuContent, MenuItem, MenuRoot, MenuTrigger } from "../ui/menu"
|
||||
|
||||
const UserMenu = () => {
|
||||
const { logout } = useAuth()
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
const handleLogout = async () => {
|
||||
logout()
|
||||
@@ -22,36 +16,47 @@ const UserMenu = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Desktop */}
|
||||
<Box
|
||||
display={{ base: "none", md: "block" }}
|
||||
position="fixed"
|
||||
top={4}
|
||||
right={4}
|
||||
>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<FaUserAstronaut color="white" fontSize="18px" />}
|
||||
bg="ui.main"
|
||||
isRound
|
||||
data-testid="user-menu"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<FiUser fontSize="18px" />} as={Link} to="settings">
|
||||
My profile
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<FiLogOut fontSize="18px" />}
|
||||
onClick={handleLogout}
|
||||
color="ui.danger"
|
||||
fontWeight="bold"
|
||||
<Flex>
|
||||
<MenuRoot>
|
||||
<MenuTrigger asChild p={2}>
|
||||
<Button
|
||||
data-testid="user-menu"
|
||||
variant="solid"
|
||||
maxW="150px"
|
||||
truncate
|
||||
>
|
||||
Log out
|
||||
<FaUserAstronaut fontSize="18" />
|
||||
<Text>{user?.full_name || "User"}</Text>
|
||||
</Button>
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuContent>
|
||||
<Link to="settings">
|
||||
<MenuItem
|
||||
closeOnSelect
|
||||
value="user-settings"
|
||||
gap={2}
|
||||
py={2}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<FiUser fontSize="18px" />
|
||||
<Box flex="1">My Profile</Box>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
|
||||
<MenuItem
|
||||
value="logout"
|
||||
gap={2}
|
||||
py={2}
|
||||
onClick={handleLogout}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<FiLogOut />
|
||||
Log Out
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Box>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,40 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { type ApiError, type ItemCreate, ItemsService } from "../../client"
|
||||
import {
|
||||
Button,
|
||||
DialogActionTrigger,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useState } from "react"
|
||||
import { FaPlus } from "react-icons/fa"
|
||||
import { type ItemCreate, ItemsService } from "../../client"
|
||||
import type { ApiError } from "../../client/core/ApiError"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog"
|
||||
import { Field } from "../ui/field"
|
||||
|
||||
interface AddItemProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddItem = ({ isOpen, onClose }: AddItemProps) => {
|
||||
const AddItem = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<ItemCreate>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
@@ -45,12 +48,12 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
|
||||
mutationFn: (data: ItemCreate) =>
|
||||
ItemsService.createItem({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Item created successfully.", "success")
|
||||
showSuccessToast("Item created successfully.")
|
||||
reset()
|
||||
onClose()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
@@ -62,52 +65,80 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Add Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isRequired isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required.",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button value="add-item" my={4}>
|
||||
<FaPlus fontSize="16px" />
|
||||
Add Item
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>Fill in the details to add a new item.</Text>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.title}
|
||||
errorText={errors.title?.message}
|
||||
label="Title"
|
||||
>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required.",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<ModalFooter gap={3}>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
<Field
|
||||
invalid={!!errors.description}
|
||||
errorText={errors.description?.message}
|
||||
label="Description"
|
||||
>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</Field>
|
||||
</VStack>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter gap={2}>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button
|
||||
variant="solid"
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<DialogCloseTrigger />
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
103
frontend/src/components/Items/DeleteItem.tsx
Normal file
103
frontend/src/components/Items/DeleteItem.tsx
Normal file
@@ -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
|
||||
@@ -1,123 +1,149 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
ButtonGroup,
|
||||
DialogActionTrigger,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type ItemPublic,
|
||||
type ItemUpdate,
|
||||
ItemsService,
|
||||
} from "../../client"
|
||||
import { FaExchangeAlt } from "react-icons/fa"
|
||||
import { type ApiError, type ItemPublic, ItemsService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
import {
|
||||
DialogBody,
|
||||
DialogCloseTrigger,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../ui/dialog"
|
||||
import { Field } from "../ui/field"
|
||||
|
||||
interface EditItemProps {
|
||||
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 showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting, errors, isDirty },
|
||||
} = useForm<ItemUpdate>({
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ItemUpdateForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: item,
|
||||
defaultValues: {
|
||||
...item,
|
||||
description: item.description ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ItemUpdate) =>
|
||||
mutationFn: (data: ItemUpdateForm) =>
|
||||
ItemsService.updateItem({ id: item.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Item updated successfully.", "success")
|
||||
onClose()
|
||||
showSuccessToast("Item updated successfully.")
|
||||
reset()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<ItemUpdate> = async (data) => {
|
||||
const onSubmit: SubmitHandler<ItemUpdateForm> = async (data) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<ModalHeader>Edit Item</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<FormControl isInvalid={!!errors.title}>
|
||||
<FormLabel htmlFor="title">Title</FormLabel>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required",
|
||||
})}
|
||||
type="text"
|
||||
/>
|
||||
{errors.title && (
|
||||
<FormErrorMessage>{errors.title.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4}>
|
||||
<FormLabel htmlFor="description">Description</FormLabel>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
<ModalFooter gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost">
|
||||
<FaExchangeAlt fontSize="16px" />
|
||||
Edit Item
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Text mb={4}>Update the item details below.</Text>
|
||||
<VStack gap={4}>
|
||||
<Field
|
||||
required
|
||||
invalid={!!errors.title}
|
||||
errorText={errors.title?.message}
|
||||
label="Title"
|
||||
>
|
||||
<Input
|
||||
id="title"
|
||||
{...register("title", {
|
||||
required: "Title is required",
|
||||
})}
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
invalid={!!errors.description}
|
||||
errorText={errors.description?.message}
|
||||
label="Description"
|
||||
>
|
||||
<Input
|
||||
id="description"
|
||||
{...register("description")}
|
||||
placeholder="Description"
|
||||
type="text"
|
||||
/>
|
||||
</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
Normal file
34
frontend/src/components/Pending/PendingItems.tsx
Normal file
@@ -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
Normal file
38
frontend/src/components/Pending/PendingUsers.tsx
Normal file
@@ -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
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
Badge,
|
||||
Container,
|
||||
Heading,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
useColorMode,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Heading, Stack } from "@chakra-ui/react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Radio, RadioGroup } from "../../components/ui/radio"
|
||||
|
||||
const Appearance = () => {
|
||||
const { colorMode, toggleColorMode } = useColorMode()
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -17,18 +11,16 @@ const Appearance = () => {
|
||||
<Heading size="sm" py={4}>
|
||||
Appearance
|
||||
</Heading>
|
||||
<RadioGroup onChange={toggleColorMode} value={colorMode}>
|
||||
|
||||
<RadioGroup
|
||||
onValueChange={(e) => setTheme(e.value)}
|
||||
value={theme}
|
||||
colorPalette="teal"
|
||||
>
|
||||
<Stack>
|
||||
{/* TODO: Add system default option */}
|
||||
<Radio value="light" colorScheme="teal">
|
||||
Light Mode
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
Default
|
||||
</Badge>
|
||||
</Radio>
|
||||
<Radio value="dark" colorScheme="teal">
|
||||
Dark Mode
|
||||
</Radio>
|
||||
<Radio value="system">System</Radio>
|
||||
<Radio value="light">Light Mode</Radio>
|
||||
<Radio value="dark">Dark Mode</Radio>
|
||||
</Stack>
|
||||
</RadioGroup>
|
||||
</Container>
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react"
|
||||
import { Box, Button, Container, Heading, VStack } from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { FiLock } from "react-icons/fi"
|
||||
import { type ApiError, type UpdatePassword, UsersService } from "../../client"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "../../utils"
|
||||
import { PasswordInput } from "../ui/password-input"
|
||||
|
||||
interface UpdatePasswordForm extends UpdatePassword {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
const ChangePassword = () => {
|
||||
const color = useColorModeValue("inherit", "ui.light")
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isValid, isSubmitting },
|
||||
} = useForm<UpdatePasswordForm>({
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
@@ -38,11 +29,11 @@ const ChangePassword = () => {
|
||||
mutationFn: (data: UpdatePassword) =>
|
||||
UsersService.updatePasswordMe({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Password updated successfully.", "success")
|
||||
showSuccessToast("Password updated successfully.")
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -57,60 +48,39 @@ const ChangePassword = () => {
|
||||
Change Password
|
||||
</Heading>
|
||||
<Box
|
||||
w={{ sm: "full", md: "50%" }}
|
||||
w={{ sm: "full", md: "300px" }}
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl isRequired isInvalid={!!errors.current_password}>
|
||||
<FormLabel color={color} htmlFor="current_password">
|
||||
Current Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="current_password"
|
||||
{...register("current_password")}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
<VStack gap={4}>
|
||||
<PasswordInput
|
||||
type="current_password"
|
||||
startElement={<FiLock />}
|
||||
{...register("current_password", passwordRules())}
|
||||
placeholder="Current Password"
|
||||
errors={errors}
|
||||
/>
|
||||
{errors.current_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.current_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.new_password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
<PasswordInput
|
||||
type="new_password"
|
||||
startElement={<FiLock />}
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
placeholder="New Password"
|
||||
errors={errors}
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isRequired isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
<PasswordInput
|
||||
type="confirm_password"
|
||||
startElement={<FiLock />}
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
w="auto"
|
||||
placeholder="Confirm Password"
|
||||
errors={errors}
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
</VStack>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="solid"
|
||||
mt={4}
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Heading, Text } from "@chakra-ui/react"
|
||||
|
||||
import DeleteConfirmation from "./DeleteConfirmation"
|
||||
|
||||
const DeleteAccount = () => {
|
||||
const confirmationModal = useDisclosure()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Delete Account
|
||||
</Heading>
|
||||
<Text>
|
||||
Permanently delete your data and everything associated with your
|
||||
account.
|
||||
</Text>
|
||||
<Button variant="danger" mt={4} onClick={confirmationModal.onOpen}>
|
||||
Delete
|
||||
</Button>
|
||||
<DeleteConfirmation
|
||||
isOpen={confirmationModal.isOpen}
|
||||
onClose={confirmationModal.onClose}
|
||||
/>
|
||||
</Container>
|
||||
</>
|
||||
<Container maxW="full">
|
||||
<Heading size="sm" py={4}>
|
||||
Delete Account
|
||||
</Heading>
|
||||
<Text>
|
||||
Permanently delete your data and everything associated with your
|
||||
account.
|
||||
</Text>
|
||||
<DeleteConfirmation />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
export default DeleteAccount
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
Button,
|
||||
} from "@chakra-ui/react"
|
||||
import { Button, ButtonGroup, Text } from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import React from "react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
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 useCustomToast from "../../hooks/useCustomToast"
|
||||
import { handleError } from "../../utils"
|
||||
|
||||
interface DeleteProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
|
||||
const DeleteConfirmation = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const showToast = useCustomToast()
|
||||
const cancelRef = React.useRef<HTMLButtonElement | null>(null)
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
@@ -34,16 +31,12 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => UsersService.deleteUserMe(),
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Success",
|
||||
"Your account has been successfully deleted.",
|
||||
"success",
|
||||
)
|
||||
showSuccessToast("Your account has been successfully deleted")
|
||||
setIsOpen(false)
|
||||
logout()
|
||||
onClose()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
|
||||
@@ -56,39 +49,58 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertDialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
leastDestructiveRef={cancelRef}
|
||||
size={{ base: "sm", md: "md" }}
|
||||
isCentered
|
||||
<DialogRoot
|
||||
size={{ base: "xs", md: "md" }}
|
||||
role="alertdialog"
|
||||
placement="center"
|
||||
open={isOpen}
|
||||
onOpenChange={({ open }) => setIsOpen(open)}
|
||||
>
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent as="form" onSubmit={handleSubmit(onSubmit)}>
|
||||
<AlertDialogHeader>Confirmation Required</AlertDialogHeader>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="solid" colorPalette="red" mt={4}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<AlertDialogBody>
|
||||
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.
|
||||
</AlertDialogBody>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogCloseTrigger />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmation Required</DialogTitle>
|
||||
</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}>
|
||||
<Button variant="danger" type="submit" isLoading={isSubmitting}>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={onClose}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
<DialogFooter gap={2}>
|
||||
<ButtonGroup>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="red"
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,9 @@ import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
@@ -24,11 +20,11 @@ import {
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
import useCustomToast from "../../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../../utils"
|
||||
import { Field } from "../ui/field"
|
||||
|
||||
const UserInformation = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const color = useColorModeValue("inherit", "ui.light")
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const { user: currentUser } = useAuth()
|
||||
const {
|
||||
@@ -54,10 +50,10 @@ const UserInformation = () => {
|
||||
mutationFn: (data: UserUpdateMe) =>
|
||||
UsersService.updateUserMe({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "User updated successfully.", "success")
|
||||
showSuccessToast("User updated successfully.")
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries()
|
||||
@@ -84,13 +80,9 @@ const UserInformation = () => {
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormControl>
|
||||
<FormLabel color={color} htmlFor="name">
|
||||
Full name
|
||||
</FormLabel>
|
||||
<Field label="Full name">
|
||||
{editMode ? (
|
||||
<Input
|
||||
id="name"
|
||||
{...register("full_name", { maxLength: 30 })}
|
||||
type="text"
|
||||
size="md"
|
||||
@@ -98,23 +90,24 @@ const UserInformation = () => {
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
size="md"
|
||||
fontSize="md"
|
||||
py={2}
|
||||
color={!currentUser?.full_name ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
color={!currentUser?.full_name ? "gray" : "inherit"}
|
||||
truncate
|
||||
maxWidth="250px"
|
||||
>
|
||||
{currentUser?.full_name || "N/A"}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.email}>
|
||||
<FormLabel color={color} htmlFor="email">
|
||||
Email
|
||||
</FormLabel>
|
||||
</Field>
|
||||
<Field
|
||||
mt={4}
|
||||
label="Email"
|
||||
invalid={!!errors.email}
|
||||
errorText={errors.email?.message}
|
||||
>
|
||||
{editMode ? (
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
@@ -124,26 +117,28 @@ const UserInformation = () => {
|
||||
w="auto"
|
||||
/>
|
||||
) : (
|
||||
<Text size="md" py={2} isTruncated maxWidth="250px">
|
||||
<Text fontSize="md" py={2} truncate maxWidth="250px">
|
||||
{currentUser?.email}
|
||||
</Text>
|
||||
)}
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
</Field>
|
||||
<Flex mt={4} gap={3}>
|
||||
<Button
|
||||
variant="primary"
|
||||
variant="solid"
|
||||
onClick={toggleEditMode}
|
||||
type={editMode ? "button" : "submit"}
|
||||
isLoading={editMode ? isSubmitting : false}
|
||||
isDisabled={editMode ? !isDirty || !getValues("email") : false}
|
||||
loading={editMode ? isSubmitting : false}
|
||||
disabled={editMode ? !isDirty || !getValues("email") : false}
|
||||
>
|
||||
{editMode ? "Save" : "Edit"}
|
||||
</Button>
|
||||
{editMode && (
|
||||
<Button onClick={onCancel} isDisabled={isSubmitting}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
colorPalette="gray"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
40
frontend/src/components/ui/button.tsx
Normal file
40
frontend/src/components/ui/button.tsx
Normal file
@@ -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
Normal file
25
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -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
Normal file
17
frontend/src/components/ui/close-button.tsx
Normal file
@@ -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
Normal file
107
frontend/src/components/ui/color-mode.tsx
Normal file
@@ -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
Normal file
62
frontend/src/components/ui/dialog.tsx
Normal file
@@ -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
Normal file
52
frontend/src/components/ui/drawer.tsx
Normal file
@@ -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
Normal file
33
frontend/src/components/ui/field.tsx
Normal file
@@ -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
Normal file
53
frontend/src/components/ui/input-group.tsx
Normal file
@@ -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
Normal file
12
frontend/src/components/ui/link-button.tsx
Normal file
@@ -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
Normal file
112
frontend/src/components/ui/menu.tsx
Normal file
@@ -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
Normal file
211
frontend/src/components/ui/pagination.tsx
Normal file
@@ -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
Normal file
162
frontend/src/components/ui/password-input.tsx
Normal file
@@ -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
Normal file
18
frontend/src/components/ui/provider.tsx
Normal file
@@ -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
Normal file
24
frontend/src/components/ui/radio.tsx
Normal file
@@ -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
Normal file
47
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -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
Normal file
43
frontend/src/components/ui/toaster.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
|
||||
import { AxiosError } from "axios"
|
||||
import {
|
||||
type Body_login_login_access_token as AccessToken,
|
||||
type ApiError,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
type UserRegister,
|
||||
UsersService,
|
||||
} from "../client"
|
||||
import useCustomToast from "./useCustomToast"
|
||||
import { handleError } from "../utils"
|
||||
|
||||
const isLoggedIn = () => {
|
||||
return localStorage.getItem("access_token") !== null
|
||||
@@ -20,9 +19,8 @@ const isLoggedIn = () => {
|
||||
const useAuth = () => {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
const showToast = useCustomToast()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: user, isLoading } = useQuery<UserPublic | null, Error>({
|
||||
const { data: user } = useQuery<UserPublic | null, Error>({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: UsersService.readUserMe,
|
||||
enabled: isLoggedIn(),
|
||||
@@ -34,20 +32,9 @@ const useAuth = () => {
|
||||
|
||||
onSuccess: () => {
|
||||
navigate({ to: "/login" })
|
||||
showToast(
|
||||
"Account created.",
|
||||
"Your account has been created successfully.",
|
||||
"success",
|
||||
)
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
let errDetail = (err.body as any)?.detail
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
errDetail = err.message
|
||||
}
|
||||
|
||||
showToast("Something went wrong.", errDetail, "error")
|
||||
handleError(err)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
@@ -67,17 +54,7 @@ const useAuth = () => {
|
||||
navigate({ to: "/" })
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
let errDetail = (err.body as any)?.detail
|
||||
|
||||
if (err instanceof AxiosError) {
|
||||
errDetail = err.message
|
||||
}
|
||||
|
||||
if (Array.isArray(errDetail)) {
|
||||
errDetail = "Something went wrong"
|
||||
}
|
||||
|
||||
setError(errDetail)
|
||||
handleError(err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -91,7 +68,6 @@ const useAuth = () => {
|
||||
loginMutation,
|
||||
logout,
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
resetError: () => setError(null),
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { useToast } from "@chakra-ui/react"
|
||||
import { useCallback } from "react"
|
||||
"use client"
|
||||
|
||||
import { toaster } from "../components/ui/toaster"
|
||||
|
||||
const useCustomToast = () => {
|
||||
const toast = useToast()
|
||||
const showSuccessToast = (description: string) => {
|
||||
toaster.create({
|
||||
title: "Success!",
|
||||
description,
|
||||
type: "success",
|
||||
})
|
||||
}
|
||||
|
||||
const showToast = useCallback(
|
||||
(title: string, description: string, status: "success" | "error") => {
|
||||
toast({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
isClosable: true,
|
||||
position: "bottom-right",
|
||||
})
|
||||
},
|
||||
[toast],
|
||||
)
|
||||
const showErrorToast = (description: string) => {
|
||||
toaster.create({
|
||||
title: "Something went wrong!",
|
||||
description,
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
|
||||
return showToast
|
||||
return { showSuccessToast, showErrorToast }
|
||||
}
|
||||
|
||||
export default useCustomToast
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ChakraProvider } from "@chakra-ui/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
import { StrictMode } from "react"
|
||||
import { OpenAPI } from "./client"
|
||||
import theme from "./theme"
|
||||
import { CustomProvider } from "./components/ui/provider"
|
||||
|
||||
OpenAPI.BASE = import.meta.env.VITE_API_URL
|
||||
OpenAPI.TOKEN = async () => {
|
||||
@@ -15,7 +15,7 @@ OpenAPI.TOKEN = async () => {
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
const router = createRouter({ routeTree, context: { queryClient } })
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
@@ -24,10 +24,10 @@ declare module "@tanstack/react-router" {
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
<CustomProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ChakraProvider>
|
||||
</CustomProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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 Navbar from "../components/Common/Navbar"
|
||||
import Sidebar from "../components/Common/Sidebar"
|
||||
import UserMenu from "../components/Common/UserMenu"
|
||||
import useAuth, { isLoggedIn } from "../hooks/useAuth"
|
||||
import { isLoggedIn } from "../hooks/useAuth"
|
||||
|
||||
export const Route = createFileRoute("/_layout")({
|
||||
component: Layout,
|
||||
@@ -17,19 +17,17 @@ export const Route = createFileRoute("/_layout")({
|
||||
})
|
||||
|
||||
function Layout() {
|
||||
const { isLoading } = useAuth()
|
||||
|
||||
return (
|
||||
<Flex maxW="large" h="auto" position="relative">
|
||||
<Sidebar />
|
||||
{isLoading ? (
|
||||
<Flex justify="center" align="center" height="100vh" width="full">
|
||||
<Spinner size="xl" color="ui.main" />
|
||||
<Flex direction="column" h="100vh">
|
||||
<Navbar />
|
||||
<Flex flex="1" overflow="hidden">
|
||||
<Sidebar />
|
||||
<Flex flex="1" direction="column" p={4} overflowY="auto">
|
||||
<Outlet />
|
||||
</Flex>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
<UserMenu />
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
|
||||
@@ -1,38 +1,23 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react"
|
||||
import { Badge, Container, Flex, Heading, Table } from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type UserPublic, UsersService } from "../../client"
|
||||
import AddUser from "../../components/Admin/AddUser"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import { PaginationFooter } from "../../components/Common/PaginationFooter.tsx"
|
||||
import { UserActionsMenu } from "../../components/Common/UserActionsMenu"
|
||||
import PendingUsers from "../../components/Pending/PendingUsers"
|
||||
import {
|
||||
PaginationItems,
|
||||
PaginationNextTrigger,
|
||||
PaginationPrevTrigger,
|
||||
PaginationRoot,
|
||||
} from "../../components/ui/pagination.tsx"
|
||||
|
||||
const usersSearchSchema = z.object({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/admin")({
|
||||
component: Admin,
|
||||
validateSearch: (search) => usersSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
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() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev: {[key: string]: string}) => ({ ...prev, page }) })
|
||||
const { page } = Route.useSearch()
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
const { data, isLoading, isPlaceholderData } = useQuery({
|
||||
...getUsersQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
const setPage = (page: number) =>
|
||||
navigate({
|
||||
search: (prev: { [key: string]: string }) => ({ ...prev, page }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
const users = data?.data.slice(0, PER_PAGE) ?? []
|
||||
const count = data?.count ?? 0
|
||||
|
||||
if (isLoading) {
|
||||
return <PendingUsers />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th width="20%">Full name</Th>
|
||||
<Th width="50%">Email</Th>
|
||||
<Th width="10%">Role</Th>
|
||||
<Th width="10%">Status</Th>
|
||||
<Th width="10%">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{users?.data.map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td
|
||||
color={!user.full_name ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
>
|
||||
{user.full_name || "N/A"}
|
||||
{currentUser?.id === user.id && (
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
{user.email}
|
||||
</Td>
|
||||
<Td>{user.is_superuser ? "Superuser" : "User"}</Td>
|
||||
<Td>
|
||||
<Flex gap={2}>
|
||||
<Box
|
||||
w="2"
|
||||
h="2"
|
||||
borderRadius="50%"
|
||||
bg={user.is_active ? "ui.success" : "ui.danger"}
|
||||
alignSelf="center"
|
||||
/>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</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}
|
||||
/>
|
||||
<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>
|
||||
{users?.map((user) => (
|
||||
<Table.Row key={user.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Table.Cell w="20%" color={!user.full_name ? "gray" : "inherit"}>
|
||||
{user.full_name || "N/A"}
|
||||
{currentUser?.id === user.id && (
|
||||
<Badge ml="1" colorScheme="teal">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell w="25%">{user.email}</Table.Cell>
|
||||
<Table.Cell w="15%">
|
||||
{user.is_superuser ? "Superuser" : "User"}
|
||||
</Table.Cell>
|
||||
<Table.Cell w="20%">
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Table.Cell>
|
||||
<Table.Cell w="20%">
|
||||
<UserActionsMenu
|
||||
user={user}
|
||||
disabled={currentUser?.id === user.id}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
<Flex justifyContent="flex-end" mt={4}>
|
||||
<PaginationRoot
|
||||
count={count}
|
||||
pageSize={PER_PAGE}
|
||||
onPageChange={({ page }) => setPage(page)}
|
||||
>
|
||||
<Flex>
|
||||
<PaginationPrevTrigger />
|
||||
<PaginationItems />
|
||||
<PaginationNextTrigger />
|
||||
</Flex>
|
||||
</PaginationRoot>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -150,11 +116,11 @@ function UsersTable() {
|
||||
function Admin() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
<Heading size="lg" pt={12}>
|
||||
Users Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"User"} addModalAs={AddUser} />
|
||||
<AddUser />
|
||||
<UsersTable />
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
import {
|
||||
Container,
|
||||
EmptyState,
|
||||
Flex,
|
||||
Heading,
|
||||
SkeletonText,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { ItemsService } from "../../client"
|
||||
import ActionsMenu from "../../components/Common/ActionsMenu"
|
||||
import Navbar from "../../components/Common/Navbar"
|
||||
import { ItemActionsMenu } from "../../components/Common/ItemActionsMenu"
|
||||
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({
|
||||
page: z.number().catch(1),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/_layout/items")({
|
||||
component: Items,
|
||||
validateSearch: (search) => itemsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const PER_PAGE = 5
|
||||
|
||||
function getItemsQueryOptions({ page }: { page: number }) {
|
||||
@@ -40,83 +36,97 @@ function getItemsQueryOptions({ page }: { page: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
function ItemsTable() {
|
||||
const queryClient = useQueryClient()
|
||||
const { page } = Route.useSearch()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const setPage = (page: number) =>
|
||||
navigate({ search: (prev: {[key: string]: string}) => ({ ...prev, page }) })
|
||||
export const Route = createFileRoute("/_layout/items")({
|
||||
component: Items,
|
||||
validateSearch: (search) => itemsSearchSchema.parse(search),
|
||||
})
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isPending,
|
||||
isPlaceholderData,
|
||||
} = useQuery({
|
||||
function ItemsTable() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { page } = Route.useSearch()
|
||||
|
||||
const { data, isLoading, isPlaceholderData } = useQuery({
|
||||
...getItemsQueryOptions({ page }),
|
||||
placeholderData: (prevData) => prevData,
|
||||
})
|
||||
|
||||
const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE
|
||||
const hasPreviousPage = page > 1
|
||||
const setPage = (page: number) =>
|
||||
navigate({
|
||||
search: (prev: { [key: string]: string }) => ({ ...prev, page }),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (hasNextPage) {
|
||||
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
|
||||
}
|
||||
}, [page, queryClient, hasNextPage])
|
||||
const items = data?.data.slice(0, PER_PAGE) ?? []
|
||||
const count = data?.count ?? 0
|
||||
|
||||
if (isLoading) {
|
||||
return <PendingItems />
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size={{ base: "sm", md: "md" }}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>ID</Th>
|
||||
<Th>Title</Th>
|
||||
<Th>Description</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
{isPending ? (
|
||||
<Tbody>
|
||||
<Tr>
|
||||
{new Array(4).fill(null).map((_, index) => (
|
||||
<Td key={index}>
|
||||
<SkeletonText noOfLines={1} paddingBlock="16px" />
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</Tbody>
|
||||
) : (
|
||||
<Tbody>
|
||||
{items?.data.map((item) => (
|
||||
<Tr key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Td>{item.id}</Td>
|
||||
<Td isTruncated maxWidth="150px">
|
||||
{item.title}
|
||||
</Td>
|
||||
<Td
|
||||
color={!item.description ? "ui.dim" : "inherit"}
|
||||
isTruncated
|
||||
maxWidth="150px"
|
||||
>
|
||||
{item.description || "N/A"}
|
||||
</Td>
|
||||
<Td>
|
||||
<ActionsMenu type={"Item"} value={item} />
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
)}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<PaginationFooter
|
||||
page={page}
|
||||
onChangePage={setPage}
|
||||
hasNextPage={hasNextPage}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
/>
|
||||
<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>
|
||||
{items?.map((item) => (
|
||||
<Table.Row key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
|
||||
<Table.Cell truncate maxW="30%">
|
||||
{item.id}
|
||||
</Table.Cell>
|
||||
<Table.Cell truncate maxW="30%">
|
||||
{item.title}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
color={!item.description ? "gray" : "inherit"}
|
||||
truncate
|
||||
maxW="30%"
|
||||
>
|
||||
{item.description || "N/A"}
|
||||
</Table.Cell>
|
||||
<Table.Cell width="10%">
|
||||
<ItemActionsMenu item={item} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
<Flex justifyContent="flex-end" mt={4}>
|
||||
<PaginationRoot
|
||||
count={count}
|
||||
pageSize={PER_PAGE}
|
||||
onPageChange={({ page }) => setPage(page)}
|
||||
>
|
||||
<Flex>
|
||||
<PaginationPrevTrigger />
|
||||
<PaginationItems />
|
||||
<PaginationNextTrigger />
|
||||
</Flex>
|
||||
</PaginationRoot>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -124,11 +134,10 @@ function ItemsTable() {
|
||||
function Items() {
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
|
||||
<Heading size="lg" pt={12}>
|
||||
Items Management
|
||||
</Heading>
|
||||
|
||||
<Navbar type={"Item"} addModalAs={AddItem} />
|
||||
<AddItem />
|
||||
<ItemsTable />
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { Container, Heading, Tabs } from "@chakra-ui/react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import type { UserPublic } from "../../client"
|
||||
import Appearance from "../../components/UserSettings/Appearance"
|
||||
import ChangePassword from "../../components/UserSettings/ChangePassword"
|
||||
import DeleteAccount from "../../components/UserSettings/DeleteAccount"
|
||||
import UserInformation from "../../components/UserSettings/UserInformation"
|
||||
import useAuth from "../../hooks/useAuth"
|
||||
|
||||
const tabsConfig = [
|
||||
{ title: "My profile", component: UserInformation },
|
||||
{ title: "Password", component: ChangePassword },
|
||||
{ title: "Appearance", component: Appearance },
|
||||
{ title: "Danger zone", component: DeleteAccount },
|
||||
{ value: "my-profile", title: "My profile", component: UserInformation },
|
||||
{ value: "password", title: "Password", component: ChangePassword },
|
||||
{ value: "appearance", title: "Appearance", component: Appearance },
|
||||
{ value: "danger-zone", title: "Danger zone", component: DeleteAccount },
|
||||
]
|
||||
|
||||
export const Route = createFileRoute("/_layout/settings")({
|
||||
@@ -28,31 +19,35 @@ export const Route = createFileRoute("/_layout/settings")({
|
||||
})
|
||||
|
||||
function UserSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = queryClient.getQueryData<UserPublic>(["currentUser"])
|
||||
const { user: currentUser } = useAuth()
|
||||
const finalTabs = currentUser?.is_superuser
|
||||
? tabsConfig.slice(0, 3)
|
||||
: tabsConfig
|
||||
|
||||
if (!currentUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxW="full">
|
||||
<Heading size="lg" textAlign={{ base: "center", md: "left" }} py={12}>
|
||||
User Settings
|
||||
</Heading>
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<Tab key={index}>{tab.title}</Tab>
|
||||
|
||||
<Tabs.Root defaultValue="my-profile" variant="subtle">
|
||||
<Tabs.List>
|
||||
{finalTabs.map((tab) => (
|
||||
<Tabs.Trigger key={tab.value} value={tab.value}>
|
||||
{tab.title}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{finalTabs.map((tab, index) => (
|
||||
<TabPanel key={index}>
|
||||
<tab.component />
|
||||
</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Tabs.List>
|
||||
{finalTabs.map((tab) => (
|
||||
<Tabs.Content key={tab.value} value={tab.value}>
|
||||
<tab.component />
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</Tabs.Root>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons"
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Icon,
|
||||
Image,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Link,
|
||||
Text,
|
||||
useBoolean,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Image, Input, Text } from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
@@ -20,10 +6,15 @@ import {
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { FiLock, FiMail } from "react-icons/fi"
|
||||
import Logo from "/assets/images/fastapi-logo.svg"
|
||||
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 { emailPattern } from "../utils"
|
||||
import { emailPattern, passwordRules } from "../utils"
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: Login,
|
||||
@@ -37,7 +28,6 @@ export const Route = createFileRoute("/login")({
|
||||
})
|
||||
|
||||
function Login() {
|
||||
const [show, setShow] = useBoolean()
|
||||
const { loginMutation, error, resetError } = useAuth()
|
||||
const {
|
||||
register,
|
||||
@@ -84,59 +74,40 @@ function Login() {
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="username" isInvalid={!!errors.username || !!error}>
|
||||
<Input
|
||||
id="username"
|
||||
{...register("username", {
|
||||
required: "Username is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
{errors.username && (
|
||||
<FormErrorMessage>{errors.username.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl id="password" isInvalid={!!error}>
|
||||
<InputGroup>
|
||||
<Field
|
||||
invalid={!!errors.username}
|
||||
errorText={errors.username?.message || !!error}
|
||||
>
|
||||
<InputGroup w="100%" startElement={<FiMail />}>
|
||||
<Input
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
id="username"
|
||||
{...register("username", {
|
||||
required: "Username is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
type={show ? "text" : "password"}
|
||||
placeholder="Password"
|
||||
required
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
<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>
|
||||
{error && <FormErrorMessage>{error}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
<Link as={RouterLink} to="/recover-password" color="blue.500">
|
||||
Forgot password?
|
||||
</Link>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
</Field>
|
||||
<PasswordInput
|
||||
type="password"
|
||||
startElement={<FiLock />}
|
||||
{...register("password", passwordRules())}
|
||||
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
|
||||
</Button>
|
||||
<Text>
|
||||
Don't have an account?{" "}
|
||||
<Link as={RouterLink} to="/signup" color="blue.500">
|
||||
Sign up
|
||||
</Link>
|
||||
<RouterLink to="/signup" className="main-link">
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</Text>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Heading, Input, Text } from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { FiMail } from "react-icons/fi"
|
||||
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 useCustomToast from "../hooks/useCustomToast"
|
||||
import { emailPattern, handleError } from "../utils"
|
||||
@@ -38,7 +34,7 @@ function RecoverPassword() {
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormData>()
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
|
||||
const recoverPassword = async (data: FormData) => {
|
||||
await LoginService.recoverPassword({
|
||||
@@ -49,15 +45,11 @@ function RecoverPassword() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: recoverPassword,
|
||||
onSuccess: () => {
|
||||
showToast(
|
||||
"Email sent.",
|
||||
"We sent an email with a link to get back into your account.",
|
||||
"success",
|
||||
)
|
||||
showSuccessToast("Password recovery email sent successfully.")
|
||||
reset()
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -79,24 +71,23 @@ function RecoverPassword() {
|
||||
<Heading size="xl" color="ui.main" textAlign="center" mb={2}>
|
||||
Password Recovery
|
||||
</Heading>
|
||||
<Text align="center">
|
||||
<Text textAlign="center">
|
||||
A password recovery email will be sent to the registered account.
|
||||
</Text>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormErrorMessage>{errors.email.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
<Field invalid={!!errors.email} errorText={errors.email?.message}>
|
||||
<InputGroup w="100%" startElement={<FiMail />}>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
<Button variant="solid" type="submit" loading={isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</Container>
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Heading, Text } from "@chakra-ui/react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { FiLock } from "react-icons/fi"
|
||||
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 useCustomToast from "../hooks/useCustomToast"
|
||||
import { confirmPasswordRules, handleError, passwordRules } from "../utils"
|
||||
@@ -46,7 +40,7 @@ function ResetPassword() {
|
||||
new_password: "",
|
||||
},
|
||||
})
|
||||
const showToast = useCustomToast()
|
||||
const { showSuccessToast } = useCustomToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const resetPassword = async (data: NewPassword) => {
|
||||
@@ -60,12 +54,12 @@ function ResetPassword() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: resetPassword,
|
||||
onSuccess: () => {
|
||||
showToast("Success!", "Password updated successfully.", "success")
|
||||
showSuccessToast("Password updated successfully.")
|
||||
reset()
|
||||
navigate({ to: "/login" })
|
||||
},
|
||||
onError: (err: ApiError) => {
|
||||
handleError(err, showToast)
|
||||
handleError(err)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -90,31 +84,21 @@ function ResetPassword() {
|
||||
<Text textAlign="center">
|
||||
Please enter your new password and confirm it to reset your password.
|
||||
</Text>
|
||||
<FormControl mt={4} isInvalid={!!errors.new_password}>
|
||||
<FormLabel htmlFor="password">Set Password</FormLabel>
|
||||
<Input
|
||||
id="password"
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<FormErrorMessage>{errors.new_password.message}</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormControl mt={4} isInvalid={!!errors.confirm_password}>
|
||||
<FormLabel htmlFor="confirm_password">Confirm Password</FormLabel>
|
||||
<Input
|
||||
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">
|
||||
<PasswordInput
|
||||
startElement={<FiLock />}
|
||||
type="new_password"
|
||||
errors={errors}
|
||||
{...register("new_password", passwordRules())}
|
||||
placeholder="New Password"
|
||||
/>
|
||||
<PasswordInput
|
||||
startElement={<FiLock />}
|
||||
type="confirm_password"
|
||||
errors={errors}
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Confirm Password"
|
||||
/>
|
||||
<Button variant="solid" type="submit">
|
||||
Reset Password
|
||||
</Button>
|
||||
</Container>
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormLabel,
|
||||
Image,
|
||||
Input,
|
||||
Link,
|
||||
Text,
|
||||
} from "@chakra-ui/react"
|
||||
import { Container, Flex, Image, Input, Text } from "@chakra-ui/react"
|
||||
import {
|
||||
Link as RouterLink,
|
||||
createFileRoute,
|
||||
@@ -17,8 +6,13 @@ import {
|
||||
} from "@tanstack/react-router"
|
||||
import { type SubmitHandler, useForm } from "react-hook-form"
|
||||
|
||||
import { FiLock, FiUser } from "react-icons/fi"
|
||||
import Logo from "/assets/images/fastapi-logo.svg"
|
||||
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 { confirmPasswordRules, emailPattern, passwordRules } from "../utils"
|
||||
|
||||
@@ -80,80 +74,58 @@ function SignUp() {
|
||||
alignSelf="center"
|
||||
mb={4}
|
||||
/>
|
||||
<FormControl id="full_name" isInvalid={!!errors.full_name}>
|
||||
<FormLabel htmlFor="full_name" srOnly>
|
||||
Full Name
|
||||
</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}
|
||||
<Field
|
||||
invalid={!!errors.full_name}
|
||||
errorText={errors.full_name?.message}
|
||||
>
|
||||
<FormLabel htmlFor="confirm_password" srOnly>
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
<InputGroup w="100%" startElement={<FiUser />}>
|
||||
<Input
|
||||
id="full_name"
|
||||
minLength={3}
|
||||
{...register("full_name", {
|
||||
required: "Full Name is required",
|
||||
})}
|
||||
placeholder="Full Name"
|
||||
type="text"
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
<Input
|
||||
id="confirm_password"
|
||||
{...register("confirm_password", confirmPasswordRules(getValues))}
|
||||
placeholder="Repeat Password"
|
||||
type="password"
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<FormErrorMessage>
|
||||
{errors.confirm_password.message}
|
||||
</FormErrorMessage>
|
||||
)}
|
||||
</FormControl>
|
||||
<Button variant="primary" type="submit" isLoading={isSubmitting}>
|
||||
<Field invalid={!!errors.email} errorText={errors.email?.message}>
|
||||
<InputGroup w="100%" startElement={<FiUser />}>
|
||||
<Input
|
||||
id="email"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: emailPattern,
|
||||
})}
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
/>
|
||||
</InputGroup>
|
||||
</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
|
||||
</Button>
|
||||
<Text>
|
||||
Already have an account?{" "}
|
||||
<Link as={RouterLink} to="/login" color="blue.500">
|
||||
<RouterLink to="/login" className="main-link">
|
||||
Log In
|
||||
</Link>
|
||||
</RouterLink>
|
||||
</Text>
|
||||
</Container>
|
||||
</Flex>
|
||||
|
||||
@@ -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 = {
|
||||
_disabled: {
|
||||
backgroundColor: "ui.main",
|
||||
},
|
||||
}
|
||||
|
||||
const theme = extendTheme({
|
||||
colors: {
|
||||
ui: {
|
||||
main: "#009688",
|
||||
secondary: "#EDF2F7",
|
||||
success: "#48BB78",
|
||||
danger: "#E53E3E",
|
||||
light: "#FAFAFA",
|
||||
dark: "#1A202C",
|
||||
darkSlate: "#252D3D",
|
||||
dim: "#A0AEC0",
|
||||
export const system = createSystem(defaultConfig, {
|
||||
globalCss: {
|
||||
html: {
|
||||
fontSize: "16px",
|
||||
},
|
||||
body: {
|
||||
fontSize: "0.875rem",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
},
|
||||
".main-link": {
|
||||
color: "ui.main",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
variants: {
|
||||
primary: {
|
||||
backgroundColor: "ui.main",
|
||||
color: "ui.light",
|
||||
_hover: {
|
||||
backgroundColor: "#00766C",
|
||||
},
|
||||
_disabled: {
|
||||
...disabledStyles,
|
||||
_hover: {
|
||||
...disabledStyles,
|
||||
},
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: "ui.danger",
|
||||
color: "ui.light",
|
||||
_hover: {
|
||||
backgroundColor: "#E32727",
|
||||
},
|
||||
theme: {
|
||||
tokens: {
|
||||
colors: {
|
||||
ui: {
|
||||
main: { value: "#009688" },
|
||||
},
|
||||
},
|
||||
},
|
||||
Tabs: {
|
||||
variants: {
|
||||
enclosed: {
|
||||
tab: {
|
||||
_selected: {
|
||||
color: "ui.main",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
recipes: {
|
||||
button: buttonRecipe,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default theme
|
||||
|
||||
21
frontend/src/theme/button.recipe.ts
Normal file
21
frontend/src/theme/button.recipe.ts
Normal file
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiError } from "./client"
|
||||
import useCustomToast from "./hooks/useCustomToast"
|
||||
|
||||
export const emailPattern = {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
@@ -43,11 +44,12 @@ export const confirmPasswordRules = (
|
||||
return rules
|
||||
}
|
||||
|
||||
export const handleError = (err: ApiError, showToast: any) => {
|
||||
export const handleError = (err: ApiError) => {
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const errDetail = (err.body as any)?.detail
|
||||
let errorMessage = errDetail || "Something went wrong."
|
||||
if (Array.isArray(errDetail) && errDetail.length > 0) {
|
||||
errorMessage = errDetail[0].msg
|
||||
}
|
||||
showToast("Error", errorMessage, "error")
|
||||
showErrorToast(errorMessage)
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ test("User can reset password successfully using the link", async ({
|
||||
// Set the new password and confirm it
|
||||
await page.goto(url)
|
||||
|
||||
await page.getByLabel("Set Password").fill(newPassword)
|
||||
await page.getByLabel("Confirm Password").fill(newPassword)
|
||||
await page.getByPlaceholder("New Password").fill(newPassword)
|
||||
await page.getByPlaceholder("Confirm Password").fill(newPassword)
|
||||
await page.getByRole("button", { name: "Reset Password" }).click()
|
||||
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.getByLabel("Set Password").fill(password)
|
||||
await page.getByLabel("Confirm Password").fill(password)
|
||||
await page.getByPlaceholder("New Password").fill(password)
|
||||
await page.getByPlaceholder("Confirm Password").fill(password)
|
||||
await page.getByRole("button", { name: "Reset Password" }).click()
|
||||
|
||||
await expect(page.getByText("Invalid token")).toBeVisible()
|
||||
@@ -115,8 +115,8 @@ test("Weak new password validation", async ({ page, request }) => {
|
||||
|
||||
// Set a weak new password
|
||||
await page.goto(url)
|
||||
await page.getByLabel("Set Password").fill(weakPassword)
|
||||
await page.getByLabel("Confirm Password").fill(weakPassword)
|
||||
await page.getByPlaceholder("New Password").fill(weakPassword)
|
||||
await page.getByPlaceholder("Confirm Password").fill(weakPassword)
|
||||
await page.getByRole("button", { name: "Reset Password" }).click()
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -18,7 +18,7 @@ const fillForm = async (
|
||||
await page.getByPlaceholder("Full Name").fill(full_name)
|
||||
await page.getByPlaceholder("Email").fill(email)
|
||||
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 (
|
||||
@@ -38,7 +38,7 @@ test("Inputs are visible, empty and editable", async ({ page }) => {
|
||||
await verifyInput(page, "Full Name")
|
||||
await verifyInput(page, "Email")
|
||||
await verifyInput(page, "Password", { exact: true })
|
||||
await verifyInput(page, "Repeat Password")
|
||||
await verifyInput(page, "Confirm Password")
|
||||
})
|
||||
|
||||
test("Sign Up button is visible", async ({ page }) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { firstSuperuser, firstSuperuserPassword } from "./config.ts"
|
||||
import { createUser } from "./utils/privateApi.ts"
|
||||
import { randomEmail, randomPassword } from "./utils/random"
|
||||
import { logInUser, logOutUser } from "./utils/user"
|
||||
import { createUser } from "./utils/privateApi.ts"
|
||||
|
||||
const tabs = ["My profile", "Password", "Appearance"]
|
||||
|
||||
@@ -151,9 +151,9 @@ test.describe("Change password successfully", () => {
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Password" }).click()
|
||||
await page.getByLabel("Current Password*").fill(password)
|
||||
await page.getByLabel("Set Password*").fill(NewPassword)
|
||||
await page.getByLabel("Confirm Password*").fill(NewPassword)
|
||||
await page.getByPlaceholder("Current Password").fill(password)
|
||||
await page.getByPlaceholder("New Password").fill(NewPassword)
|
||||
await page.getByPlaceholder("Confirm Password").fill(NewPassword)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
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.getByRole("tab", { name: "Password" }).click()
|
||||
await page.getByLabel("Current Password*").fill(password)
|
||||
await page.getByLabel("Set Password*").fill(weakPassword)
|
||||
await page.getByLabel("Confirm Password*").fill(weakPassword)
|
||||
await page.getByPlaceholder("Current Password").fill(password)
|
||||
await page.getByPlaceholder("New Password").fill(weakPassword)
|
||||
await page.getByPlaceholder("Confirm Password").fill(weakPassword)
|
||||
await expect(
|
||||
page.getByText("Password must be at least 8 characters"),
|
||||
).toBeVisible()
|
||||
@@ -202,11 +202,11 @@ test.describe("Change password with invalid data", () => {
|
||||
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Password" }).click()
|
||||
await page.getByLabel("Current Password*").fill(password)
|
||||
await page.getByLabel("Set Password*").fill(newPassword)
|
||||
await page.getByLabel("Confirm Password*").fill(confirmPassword)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(page.getByText("Passwords do not match")).toBeVisible()
|
||||
await page.getByPlaceholder("Current Password").fill(password)
|
||||
await page.getByPlaceholder("New Password").fill(newPassword)
|
||||
await page.getByPlaceholder("Confirm Password").fill(confirmPassword)
|
||||
await page.getByLabel("Password", { exact: true }).locator("form").click()
|
||||
await expect(page.getByText("The passwords do not match")).toBeVisible()
|
||||
})
|
||||
|
||||
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.getByRole("tab", { name: "Password" }).click()
|
||||
await page.getByLabel("Current Password*").fill(password)
|
||||
await page.getByLabel("Set Password*").fill(password)
|
||||
await page.getByLabel("Confirm Password*").fill(password)
|
||||
await page.getByPlaceholder("Current Password").fill(password)
|
||||
await page.getByPlaceholder("New Password").fill(password)
|
||||
await page.getByPlaceholder("Confirm Password").fill(password)
|
||||
await page.getByRole("button", { name: "Save" }).click()
|
||||
await expect(
|
||||
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()
|
||||
})
|
||||
|
||||
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.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(() =>
|
||||
document.body.classList.contains("chakra-ui-dark"),
|
||||
document.documentElement.classList.contains("dark"),
|
||||
)
|
||||
expect(isDarkMode).toBe(true)
|
||||
})
|
||||
|
||||
test("User can switch from dark mode to light mode", async ({ page }) => {
|
||||
await page.goto("/settings")
|
||||
await page.getByRole("tab", { name: "Appearance" }).click()
|
||||
await page.getByLabel("Appearance").locator("span").first().click()
|
||||
const isLightMode = await page.evaluate(() =>
|
||||
document.body.classList.contains("chakra-ui-light"),
|
||||
await page
|
||||
.locator("label")
|
||||
.filter({ hasText: "Light Mode" })
|
||||
.locator("span")
|
||||
.first()
|
||||
.click()
|
||||
isLightMode = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains("light"),
|
||||
)
|
||||
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 }) => {
|
||||
await page.goto("/settings")
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -11,11 +11,8 @@ export async function signUpNewUser(
|
||||
await page.getByPlaceholder("Full Name").fill(name)
|
||||
await page.getByPlaceholder("Email").fill(email)
|
||||
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 expect(
|
||||
page.getByText("Your account has been created successfully"),
|
||||
).toBeVisible()
|
||||
await page.goto("/login")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user