♻️ 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,90 +14,65 @@ 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"
export const Route = createFileRoute("/_layout/admin")({ const usersSearchSchema = z.object({
component: Admin, page: z.number().catch(1),
}) })
const MembersTableBody = () => { export const Route = createFileRoute("/_layout/admin")({
component: Admin,
validateSearch: (search) => usersSearchSchema.parse(search),
})
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,
}) })
return ( const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE
<Tbody> const hasPreviousPage = page > 1
{users.data.map((user) => (
<Tr key={user.id}>
<Td color={!user.full_name ? "ui.dim" : "inherit"}>
{user.full_name || "N/A"}
{currentUser?.id === user.id && (
<Badge ml="1" colorScheme="teal">
You
</Badge>
)}
</Td>
<Td>{user.email}</Td>
<Td>{user.is_superuser ? "Superuser" : "User"}</Td>
<Td>
<Flex gap={2}>
<Box
w="2"
h="2"
borderRadius="50%"
bg={user.is_active ? "ui.success" : "ui.danger"}
alignSelf="center"
/>
{user.is_active ? "Active" : "Inactive"}
</Flex>
</Td>
<Td>
<ActionsMenu
type="User"
value={user}
disabled={currentUser?.id === user.id ? true : false}
/>
</Td>
</Tr>
))}
</Tbody>
)
}
const MembersBodySkeleton = () => { useEffect(() => {
return ( if (hasNextPage) {
<Tbody> queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 }))
<Tr> }
{new Array(5).fill(null).map((_, index) => ( }, [page, queryClient, hasNextPage])
<Td key={index}>
<SkeletonText noOfLines={1} paddingBlock="16px" />
</Td>
))}
</Tr>
</Tbody>
)
}
function Admin() {
return ( return (
<Container maxW="full"> <>
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
User Management
</Heading>
<Navbar type={"User"} addModalAs={AddUser} />
<TableContainer> <TableContainer>
<Table fontSize="md" size={{ base: "sm", md: "md" }}> <Table size={{ base: "sm", md: "md" }}>
<Thead> <Thead>
<Tr> <Tr>
<Th width="20%">Full name</Th> <Th width="20%">Full name</Th>
@@ -106,11 +82,89 @@ function Admin() {
<Th width="10%">Actions</Th> <Th width="10%">Actions</Th>
</Tr> </Tr>
</Thead> </Thead>
<Suspense fallback={<MembersBodySkeleton />}> {isPending ? (
<MembersTableBody /> <Tbody>
</Suspense> <Tr>
{new Array(4).fill(null).map((_, index) => (
<Td key={index}>
<SkeletonText noOfLines={1} paddingBlock="16px" />
</Td>
))}
</Tr>
</Tbody>
) : (
<Tbody>
{users?.data.map((user) => (
<Tr key={user.id}>
<Td
color={!user.full_name ? "ui.dim" : "inherit"}
isTruncated
maxWidth="150px"
>
{user.full_name || "N/A"}
{currentUser?.id === user.id && (
<Badge ml="1" colorScheme="teal">
You
</Badge>
)}
</Td>
<Td isTruncated maxWidth="150px">
{user.email}
</Td>
<Td>{user.is_superuser ? "Superuser" : "User"}</Td>
<Td>
<Flex gap={2}>
<Box
w="2"
h="2"
borderRadius="50%"
bg={user.is_active ? "ui.success" : "ui.danger"}
alignSelf="center"
/>
{user.is_active ? "Active" : "Inactive"}
</Flex>
</Td>
<Td>
<ActionsMenu
type="User"
value={user}
disabled={currentUser?.id === user.id ? true : false}
/>
</Td>
</Tr>
))}
</Tbody>
)}
</Table> </Table>
</TableContainer> </TableContainer>
<Flex
gap={4}
alignItems="center"
mt={4}
direction="row"
justifyContent="flex-end"
>
<Button onClick={() => setPage(page - 1)} isDisabled={!hasPreviousPage}>
Previous
</Button>
<span>Page {page}</span>
<Button isDisabled={!hasNextPage} onClick={() => setPage(page + 1)}>
Next
</Button>
</Flex>
</>
)
}
function Admin() {
return (
<Container maxW="full">
<Heading size="lg" textAlign={{ base: "center", md: "left" }} pt={12}>
Users Management
</Heading>
<Navbar type={"User"} addModalAs={AddUser} />
<UsersTable />
</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}> <SkeletonText noOfLines={1} paddingBlock="16px" />
<Flex> </Td>
<Skeleton height="20px" width="20px" /> ))}
</Flex> </Tr>
</Td>
))}
</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