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