♻️ Tweaks in frontend (#1273)

This commit is contained in:
Alejandra
2024-08-01 13:01:03 -05:00
committed by GitHub
parent 93e83c1ad4
commit b905768656
15 changed files with 186 additions and 126 deletions

View File

@@ -20,7 +20,7 @@ import { type SubmitHandler, useForm } from "react-hook-form"
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 } from "../../utils" import { emailPattern, handleError } from "../../utils"
interface AddUserProps { interface AddUserProps {
isOpen: boolean isOpen: boolean
@@ -62,8 +62,7 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => {
onClose() onClose()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
const errDetail = (err.body as any)?.detail handleError(err, showToast)
showToast("Something went wrong.", `${errDetail}`, "error")
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }) queryClient.invalidateQueries({ queryKey: ["users"] })

View File

@@ -24,7 +24,7 @@ import {
UsersService, UsersService,
} from "../../client" } from "../../client"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { emailPattern } from "../../utils" import { emailPattern, handleError } from "../../utils"
interface EditUserProps { interface EditUserProps {
user: UserPublic user: UserPublic
@@ -60,8 +60,7 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => {
onClose() onClose()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
const errDetail = (err.body as any)?.detail handleError(err, showToast)
showToast("Something went wrong.", `${errDetail}`, "error")
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }) queryClient.invalidateQueries({ queryKey: ["users"] })

View File

@@ -17,6 +17,7 @@ import { type SubmitHandler, useForm } from "react-hook-form"
import { type ApiError, type ItemCreate, ItemsService } from "../../client" import { type ApiError, type ItemCreate, ItemsService } from "../../client"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { handleError } from "../../utils"
interface AddItemProps { interface AddItemProps {
isOpen: boolean isOpen: boolean
@@ -49,8 +50,7 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => {
onClose() onClose()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
const errDetail = (err.body as any)?.detail handleError(err, showToast)
showToast("Something went wrong.", `${errDetail}`, "error")
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }) queryClient.invalidateQueries({ queryKey: ["items"] })

View File

@@ -22,6 +22,7 @@ import {
ItemsService, ItemsService,
} from "../../client" } from "../../client"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { handleError } from "../../utils"
interface EditItemProps { interface EditItemProps {
item: ItemPublic item: ItemPublic
@@ -51,8 +52,7 @@ const EditItem = ({ item, isOpen, onClose }: EditItemProps) => {
onClose() onClose()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
const errDetail = (err.body as any)?.detail handleError(err, showToast)
showToast("Something went wrong.", `${errDetail}`, "error")
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["items"] }) queryClient.invalidateQueries({ queryKey: ["items"] })

View File

@@ -14,7 +14,7 @@ import { type SubmitHandler, useForm } from "react-hook-form"
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, passwordRules } from "../../utils" import { confirmPasswordRules, handleError, passwordRules } from "../../utils"
interface UpdatePasswordForm extends UpdatePassword { interface UpdatePasswordForm extends UpdatePassword {
confirm_password: string confirm_password: string
@@ -42,8 +42,7 @@ const ChangePassword = () => {
reset() reset()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
const errDetail = (err.body as any)?.detail handleError(err, showToast)
showToast("Something went wrong.", `${errDetail}`, "error")
}, },
}) })

View File

@@ -14,6 +14,7 @@ import { useForm } from "react-hook-form"
import { type ApiError, UsersService } from "../../client" import { type ApiError, UsersService } from "../../client"
import useAuth from "../../hooks/useAuth" import useAuth from "../../hooks/useAuth"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { handleError } from "../../utils"
interface DeleteProps { interface DeleteProps {
isOpen: boolean isOpen: boolean
@@ -42,8 +43,7 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => {
onClose() onClose()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
const errDetail = (err.body as any)?.detail handleError(err, showToast)
showToast("Something went wrong.", `${errDetail}`, "error")
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["currentUser"] }) queryClient.invalidateQueries({ queryKey: ["currentUser"] })

View File

@@ -23,7 +23,7 @@ import {
} from "../../client" } from "../../client"
import useAuth from "../../hooks/useAuth" import useAuth from "../../hooks/useAuth"
import useCustomToast from "../../hooks/useCustomToast" import useCustomToast from "../../hooks/useCustomToast"
import { emailPattern } from "../../utils" import { emailPattern, handleError } from "../../utils"
const UserInformation = () => { const UserInformation = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -57,13 +57,10 @@ const UserInformation = () => {
showToast("Success!", "User updated successfully.", "success") showToast("Success!", "User updated successfully.", "success")
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
const errDetail = (err.body as any)?.detail handleError(err, showToast)
showToast("Something went wrong.", `${errDetail}`, "error")
}, },
onSettled: () => { onSettled: () => {
// TODO: can we do just one call now? queryClient.invalidateQueries()
queryClient.invalidateQueries({ queryKey: ["users"] })
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
}, },
}) })
@@ -104,6 +101,8 @@ const UserInformation = () => {
size="md" size="md"
py={2} py={2}
color={!currentUser?.full_name ? "ui.dim" : "inherit"} color={!currentUser?.full_name ? "ui.dim" : "inherit"}
isTruncated
maxWidth="250px"
> >
{currentUser?.full_name || "N/A"} {currentUser?.full_name || "N/A"}
</Text> </Text>
@@ -125,7 +124,7 @@ const UserInformation = () => {
w="auto" w="auto"
/> />
) : ( ) : (
<Text size="md" py={2}> <Text size="md" py={2} isTruncated maxWidth="250px">
{currentUser?.email} {currentUser?.email}
</Text> </Text>
)} )}

View File

@@ -47,7 +47,7 @@ const useAuth = () => {
errDetail = err.message errDetail = err.message
} }
showToast("Something went wrong.", `${errDetail}`, "error") showToast("Something went wrong.", errDetail, "error")
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] }) queryClient.invalidateQueries({ queryKey: ["users"] })

View File

@@ -1,6 +1,7 @@
import { import {
Badge, Badge,
Box, Box,
Button,
Container, Container,
Flex, Flex,
Heading, Heading,
@@ -13,33 +14,93 @@ import {
Thead, Thead,
Tr, Tr,
} from "@chakra-ui/react" } from "@chakra-ui/react"
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useEffect } from "react"
import { z } from "zod"
import { Suspense } from "react"
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 ActionsMenu from "../../components/Common/ActionsMenu"
import Navbar from "../../components/Common/Navbar" import Navbar from "../../components/Common/Navbar"
const usersSearchSchema = z.object({
page: z.number().catch(1),
})
export const Route = createFileRoute("/_layout/admin")({ export const Route = createFileRoute("/_layout/admin")({
component: Admin, component: Admin,
validateSearch: (search) => usersSearchSchema.parse(search),
}) })
const MembersTableBody = () => { const PER_PAGE = 5
function getUsersQueryOptions({ page }: { page: number }) {
return {
queryFn: () =>
UsersService.readUsers({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }),
queryKey: ["users", { page }],
}
}
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 setPage = (page: number) =>
navigate({ search: (prev) => ({ ...prev, page }) })
const { data: users } = useSuspenseQuery({ const {
queryKey: ["users"], data: users,
queryFn: () => UsersService.readUsers({}), isPending,
isPlaceholderData,
} = useQuery({
...getUsersQueryOptions({ page }),
placeholderData: (prevData) => prevData,
}) })
const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE
const hasPreviousPage = page > 1
useEffect(() => {
if (hasNextPage) {
queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 }))
}
}, [page, queryClient, hasNextPage])
return ( return (
<>
<TableContainer>
<Table size={{ base: "sm", md: "md" }}>
<Thead>
<Tr>
<Th width="20%">Full name</Th>
<Th width="50%">Email</Th>
<Th width="10%">Role</Th>
<Th width="10%">Status</Th>
<Th width="10%">Actions</Th>
</Tr>
</Thead>
{isPending ? (
<Tbody> <Tbody>
{users.data.map((user) => ( <Tr>
{new Array(4).fill(null).map((_, index) => (
<Td key={index}>
<SkeletonText noOfLines={1} paddingBlock="16px" />
</Td>
))}
</Tr>
</Tbody>
) : (
<Tbody>
{users?.data.map((user) => (
<Tr key={user.id}> <Tr key={user.id}>
<Td color={!user.full_name ? "ui.dim" : "inherit"}> <Td
color={!user.full_name ? "ui.dim" : "inherit"}
isTruncated
maxWidth="150px"
>
{user.full_name || "N/A"} {user.full_name || "N/A"}
{currentUser?.id === user.id && ( {currentUser?.id === user.id && (
<Badge ml="1" colorScheme="teal"> <Badge ml="1" colorScheme="teal">
@@ -47,7 +108,9 @@ const MembersTableBody = () => {
</Badge> </Badge>
)} )}
</Td> </Td>
<Td>{user.email}</Td> <Td isTruncated maxWidth="150px">
{user.email}
</Td>
<Td>{user.is_superuser ? "Superuser" : "User"}</Td> <Td>{user.is_superuser ? "Superuser" : "User"}</Td>
<Td> <Td>
<Flex gap={2}> <Flex gap={2}>
@@ -71,20 +134,25 @@ const MembersTableBody = () => {
</Tr> </Tr>
))} ))}
</Tbody> </Tbody>
) )}
} </Table>
</TableContainer>
const MembersBodySkeleton = () => { <Flex
return ( gap={4}
<Tbody> alignItems="center"
<Tr> mt={4}
{new Array(5).fill(null).map((_, index) => ( direction="row"
<Td key={index}> justifyContent="flex-end"
<SkeletonText noOfLines={1} paddingBlock="16px" /> >
</Td> <Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
))} Previous
</Tr> </Button>
</Tbody> <span>Page {page}</span>
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
Next
</Button>
</Flex>
</>
) )
} }
@@ -92,25 +160,11 @@ function Admin() {
return ( return (
<Container maxW="full"> <Container maxW="full">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}> <Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
User Management Users Management
</Heading> </Heading>
<Navbar type={"User"} addModalAs={AddUser} /> <Navbar type={"User"} addModalAs={AddUser} />
<TableContainer> <UsersTable />
<Table fontSize="md" size={{ base: "sm", md: "md" }}>
<Thead>
<Tr>
<Th width="20%">Full name</Th>
<Th width="50%">Email</Th>
<Th width="10%">Role</Th>
<Th width="10%">Status</Th>
<Th width="10%">Actions</Th>
</Tr>
</Thead>
<Suspense fallback={<MembersBodySkeleton />}>
<MembersTableBody />
</Suspense>
</Table>
</TableContainer>
</Container> </Container>
) )
} }

View File

@@ -3,7 +3,7 @@ import {
Container, Container,
Flex, Flex,
Heading, Heading,
Skeleton, SkeletonText,
Table, Table,
TableContainer, TableContainer,
Tbody, Tbody,
@@ -64,7 +64,7 @@ function ItemsTable() {
if (hasNextPage) { if (hasNextPage) {
queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 })) queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 }))
} }
}, [page, queryClient]) }, [page, queryClient, hasNextPage])
return ( return (
<> <>
@@ -80,25 +80,27 @@ function ItemsTable() {
</Thead> </Thead>
{isPending ? ( {isPending ? (
<Tbody> <Tbody>
{new Array(5).fill(null).map((_, index) => ( <Tr>
<Tr key={index}>
{new Array(4).fill(null).map((_, index) => ( {new Array(4).fill(null).map((_, index) => (
<Td key={index}> <Td key={index}>
<Flex> <SkeletonText noOfLines={1} paddingBlock="16px" />
<Skeleton height="20px" width="20px" />
</Flex>
</Td> </Td>
))} ))}
</Tr> </Tr>
))}
</Tbody> </Tbody>
) : ( ) : (
<Tbody> <Tbody>
{items?.data.map((item) => ( {items?.data.map((item) => (
<Tr key={item.id} opacity={isPlaceholderData ? 0.5 : 1}> <Tr key={item.id} opacity={isPlaceholderData ? 0.5 : 1}>
<Td>{item.id}</Td> <Td>{item.id}</Td>
<Td>{item.title}</Td> <Td isTruncated maxWidth="150px">
<Td color={!item.description ? "ui.dim" : "inherit"}> {item.title}
</Td>
<Td
color={!item.description ? "ui.dim" : "inherit"}
isTruncated
maxWidth="150px"
>
{item.description || "N/A"} {item.description || "N/A"}
</Td> </Td>
<Td> <Td>

View File

@@ -14,7 +14,7 @@ import { type SubmitHandler, useForm } from "react-hook-form"
import { type ApiError, LoginService } from "../client" import { type ApiError, LoginService } from "../client"
import { isLoggedIn } from "../hooks/useAuth" import { isLoggedIn } from "../hooks/useAuth"
import useCustomToast from "../hooks/useCustomToast" import useCustomToast from "../hooks/useCustomToast"
import { emailPattern } from "../utils" import { emailPattern, handleError } from "../utils"
interface FormData { interface FormData {
email: string email: string
@@ -57,8 +57,7 @@ function RecoverPassword() {
reset() reset()
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
const errDetail = (err.body as any)?.detail handleError(err, showToast)
showToast("Something went wrong.", `${errDetail}`, "error")
}, },
}) })

View File

@@ -15,7 +15,7 @@ import { type SubmitHandler, useForm } from "react-hook-form"
import { type ApiError, LoginService, type NewPassword } from "../client" import { type ApiError, LoginService, type NewPassword } from "../client"
import { isLoggedIn } from "../hooks/useAuth" import { isLoggedIn } from "../hooks/useAuth"
import useCustomToast from "../hooks/useCustomToast" import useCustomToast from "../hooks/useCustomToast"
import { confirmPasswordRules, passwordRules } from "../utils" import { confirmPasswordRules, handleError, passwordRules } from "../utils"
interface NewPasswordForm extends NewPassword { interface NewPasswordForm extends NewPassword {
confirm_password: string confirm_password: string
@@ -65,8 +65,7 @@ function ResetPassword() {
navigate({ to: "/login" }) navigate({ to: "/login" })
}, },
onError: (err: ApiError) => { onError: (err: ApiError) => {
const errDetail = (err.body as any)?.detail handleError(err, showToast)
showToast("Something went wrong.", `${errDetail}`, "error")
}, },
}) })

View File

@@ -1,3 +1,5 @@
import type { ApiError } from "./client"
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,
message: "Invalid email address", message: "Invalid email address",
@@ -40,3 +42,12 @@ export const confirmPasswordRules = (
return rules return rules
} }
export const handleError = (err: ApiError, showToast: any) => {
const errDetail = (err.body as any)?.detail
let errorMessage = errDetail || "Something went wrong."
if (Array.isArray(errDetail) && errDetail.length > 0) {
errorMessage = errDetail[0].msg
}
showToast("Error", errorMessage, "error")
}

View File

@@ -3,7 +3,6 @@ import { firstSuperuser, firstSuperuserPassword } from "./config.ts"
const authFile = "playwright/.auth/user.json" const authFile = "playwright/.auth/user.json"
setup("authenticate", async ({ page }) => { setup("authenticate", async ({ page }) => {
await page.goto("/login") await page.goto("/login")
await page.getByPlaceholder("Email").fill(firstSuperuser) await page.getByPlaceholder("Email").fill(firstSuperuser)

View File

@@ -1,21 +1,21 @@
import dotenv from 'dotenv'; import path from "node:path"
import path from 'path'; import { fileURLToPath } from "node:url"
import { fileURLToPath } from 'url'; import dotenv from "dotenv"
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename)
dotenv.config({ path: path.join(__dirname, '../../.env') }); dotenv.config({ path: path.join(__dirname, "../../.env") })
const { FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD } = process.env; const { FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD } = process.env
if (typeof FIRST_SUPERUSER !== "string") { if (typeof FIRST_SUPERUSER !== "string") {
throw new Error("Environment variable FIRST_SUPERUSER is undefined"); throw new Error("Environment variable FIRST_SUPERUSER is undefined")
} }
if (typeof FIRST_SUPERUSER_PASSWORD !== "string") { if (typeof FIRST_SUPERUSER_PASSWORD !== "string") {
throw new Error("Environment variable FIRST_SUPERUSER_PASSWORD is undefined"); throw new Error("Environment variable FIRST_SUPERUSER_PASSWORD is undefined")
} }
export const firstSuperuser = FIRST_SUPERUSER as string; export const firstSuperuser = FIRST_SUPERUSER as string
export const firstSuperuserPassword = FIRST_SUPERUSER_PASSWORD as string; export const firstSuperuserPassword = FIRST_SUPERUSER_PASSWORD as string