From b9057686561f1fa697764a5e5831f466ccf84ac9 Mon Sep 17 00:00:00 2001 From: Alejandra <90076947+alejsdev@users.noreply.github.com> Date: Thu, 1 Aug 2024 13:01:03 -0500 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Tweaks=20in=20frontend=20(?= =?UTF-8?q?#1273)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Admin/AddUser.tsx | 5 +- frontend/src/components/Admin/EditUser.tsx | 5 +- frontend/src/components/Items/AddItem.tsx | 4 +- frontend/src/components/Items/EditItem.tsx | 4 +- .../UserSettings/ChangePassword.tsx | 5 +- .../UserSettings/DeleteConfirmation.tsx | 4 +- .../UserSettings/UserInformation.tsx | 13 +- frontend/src/hooks/useAuth.ts | 2 +- frontend/src/routes/_layout/admin.tsx | 190 +++++++++++------- frontend/src/routes/_layout/items.tsx | 32 +-- frontend/src/routes/recover-password.tsx | 5 +- frontend/src/routes/reset-password.tsx | 5 +- frontend/src/utils.ts | 11 + frontend/tests/auth.setup.ts | 1 - frontend/tests/config.ts | 22 +- 15 files changed, 184 insertions(+), 124 deletions(-) diff --git a/frontend/src/components/Admin/AddUser.tsx b/frontend/src/components/Admin/AddUser.tsx index 8ded725..a24a18a 100644 --- a/frontend/src/components/Admin/AddUser.tsx +++ b/frontend/src/components/Admin/AddUser.tsx @@ -20,7 +20,7 @@ import { type SubmitHandler, useForm } from "react-hook-form" import { type UserCreate, UsersService } from "../../client" import type { ApiError } from "../../client/core/ApiError" import useCustomToast from "../../hooks/useCustomToast" -import { emailPattern } from "../../utils" +import { emailPattern, handleError } from "../../utils" interface AddUserProps { isOpen: boolean @@ -62,8 +62,7 @@ const AddUser = ({ isOpen, onClose }: AddUserProps) => { onClose() }, onError: (err: ApiError) => { - const errDetail = (err.body as any)?.detail - showToast("Something went wrong.", `${errDetail}`, "error") + handleError(err, showToast) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["users"] }) diff --git a/frontend/src/components/Admin/EditUser.tsx b/frontend/src/components/Admin/EditUser.tsx index ffba135..d7885ab 100644 --- a/frontend/src/components/Admin/EditUser.tsx +++ b/frontend/src/components/Admin/EditUser.tsx @@ -24,7 +24,7 @@ import { UsersService, } from "../../client" import useCustomToast from "../../hooks/useCustomToast" -import { emailPattern } from "../../utils" +import { emailPattern, handleError } from "../../utils" interface EditUserProps { user: UserPublic @@ -60,8 +60,7 @@ const EditUser = ({ user, isOpen, onClose }: EditUserProps) => { onClose() }, onError: (err: ApiError) => { - const errDetail = (err.body as any)?.detail - showToast("Something went wrong.", `${errDetail}`, "error") + handleError(err, showToast) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["users"] }) diff --git a/frontend/src/components/Items/AddItem.tsx b/frontend/src/components/Items/AddItem.tsx index 21cc06d..fa5682d 100644 --- a/frontend/src/components/Items/AddItem.tsx +++ b/frontend/src/components/Items/AddItem.tsx @@ -17,6 +17,7 @@ import { type SubmitHandler, useForm } from "react-hook-form" import { type ApiError, type ItemCreate, ItemsService } from "../../client" import useCustomToast from "../../hooks/useCustomToast" +import { handleError } from "../../utils" interface AddItemProps { isOpen: boolean @@ -49,8 +50,7 @@ const AddItem = ({ isOpen, onClose }: AddItemProps) => { onClose() }, onError: (err: ApiError) => { - const errDetail = (err.body as any)?.detail - showToast("Something went wrong.", `${errDetail}`, "error") + handleError(err, showToast) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["items"] }) diff --git a/frontend/src/components/Items/EditItem.tsx b/frontend/src/components/Items/EditItem.tsx index 6bbe79a..3d40cdc 100644 --- a/frontend/src/components/Items/EditItem.tsx +++ b/frontend/src/components/Items/EditItem.tsx @@ -22,6 +22,7 @@ import { ItemsService, } from "../../client" import useCustomToast from "../../hooks/useCustomToast" +import { handleError } from "../../utils" interface EditItemProps { item: ItemPublic @@ -51,8 +52,7 @@ const EditItem = ({ item, isOpen, onClose }: EditItemProps) => { onClose() }, onError: (err: ApiError) => { - const errDetail = (err.body as any)?.detail - showToast("Something went wrong.", `${errDetail}`, "error") + handleError(err, showToast) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["items"] }) diff --git a/frontend/src/components/UserSettings/ChangePassword.tsx b/frontend/src/components/UserSettings/ChangePassword.tsx index 439ee80..7321793 100644 --- a/frontend/src/components/UserSettings/ChangePassword.tsx +++ b/frontend/src/components/UserSettings/ChangePassword.tsx @@ -14,7 +14,7 @@ import { type SubmitHandler, useForm } from "react-hook-form" import { type ApiError, type UpdatePassword, UsersService } from "../../client" import useCustomToast from "../../hooks/useCustomToast" -import { confirmPasswordRules, passwordRules } from "../../utils" +import { confirmPasswordRules, handleError, passwordRules } from "../../utils" interface UpdatePasswordForm extends UpdatePassword { confirm_password: string @@ -42,8 +42,7 @@ const ChangePassword = () => { reset() }, onError: (err: ApiError) => { - const errDetail = (err.body as any)?.detail - showToast("Something went wrong.", `${errDetail}`, "error") + handleError(err, showToast) }, }) diff --git a/frontend/src/components/UserSettings/DeleteConfirmation.tsx b/frontend/src/components/UserSettings/DeleteConfirmation.tsx index 7301d68..5bbdcdd 100644 --- a/frontend/src/components/UserSettings/DeleteConfirmation.tsx +++ b/frontend/src/components/UserSettings/DeleteConfirmation.tsx @@ -14,6 +14,7 @@ import { useForm } from "react-hook-form" import { type ApiError, UsersService } from "../../client" import useAuth from "../../hooks/useAuth" import useCustomToast from "../../hooks/useCustomToast" +import { handleError } from "../../utils" interface DeleteProps { isOpen: boolean @@ -42,8 +43,7 @@ const DeleteConfirmation = ({ isOpen, onClose }: DeleteProps) => { onClose() }, onError: (err: ApiError) => { - const errDetail = (err.body as any)?.detail - showToast("Something went wrong.", `${errDetail}`, "error") + handleError(err, showToast) }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["currentUser"] }) diff --git a/frontend/src/components/UserSettings/UserInformation.tsx b/frontend/src/components/UserSettings/UserInformation.tsx index 03e2fdf..d066a84 100644 --- a/frontend/src/components/UserSettings/UserInformation.tsx +++ b/frontend/src/components/UserSettings/UserInformation.tsx @@ -23,7 +23,7 @@ import { } from "../../client" import useAuth from "../../hooks/useAuth" import useCustomToast from "../../hooks/useCustomToast" -import { emailPattern } from "../../utils" +import { emailPattern, handleError } from "../../utils" const UserInformation = () => { const queryClient = useQueryClient() @@ -57,13 +57,10 @@ const UserInformation = () => { showToast("Success!", "User updated successfully.", "success") }, onError: (err: ApiError) => { - const errDetail = (err.body as any)?.detail - showToast("Something went wrong.", `${errDetail}`, "error") + handleError(err, showToast) }, onSettled: () => { - // TODO: can we do just one call now? - queryClient.invalidateQueries({ queryKey: ["users"] }) - queryClient.invalidateQueries({ queryKey: ["currentUser"] }) + queryClient.invalidateQueries() }, }) @@ -104,6 +101,8 @@ const UserInformation = () => { size="md" py={2} color={!currentUser?.full_name ? "ui.dim" : "inherit"} + isTruncated + maxWidth="250px" > {currentUser?.full_name || "N/A"} @@ -125,7 +124,7 @@ const UserInformation = () => { w="auto" /> ) : ( - + {currentUser?.email} )} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 20f09d1..76b0abd 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -47,7 +47,7 @@ const useAuth = () => { errDetail = err.message } - showToast("Something went wrong.", `${errDetail}`, "error") + showToast("Something went wrong.", errDetail, "error") }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["users"] }) diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout/admin.tsx index 9ecddad..644653f 100644 --- a/frontend/src/routes/_layout/admin.tsx +++ b/frontend/src/routes/_layout/admin.tsx @@ -1,6 +1,7 @@ import { Badge, Box, + Button, Container, Flex, Heading, @@ -13,90 +14,65 @@ import { Thead, Tr, } from "@chakra-ui/react" -import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query" -import { createFileRoute } from "@tanstack/react-router" +import { useQuery, useQueryClient } from "@tanstack/react-query" +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 AddUser from "../../components/Admin/AddUser" import ActionsMenu from "../../components/Common/ActionsMenu" import Navbar from "../../components/Common/Navbar" +const usersSearchSchema = z.object({ + page: z.number().catch(1), +}) + export const Route = createFileRoute("/_layout/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 currentUser = queryClient.getQueryData(["currentUser"]) + const { page } = Route.useSearch() + const navigate = useNavigate({ from: Route.fullPath }) + const setPage = (page: number) => + navigate({ search: (prev) => ({ ...prev, page }) }) - const { data: users } = useSuspenseQuery({ - queryKey: ["users"], - queryFn: () => UsersService.readUsers({}), + const { + data: users, + isPending, + isPlaceholderData, + } = useQuery({ + ...getUsersQueryOptions({ page }), + placeholderData: (prevData) => prevData, }) - return ( - - {users.data.map((user) => ( - - - {user.full_name || "N/A"} - {currentUser?.id === user.id && ( - - You - - )} - - {user.email} - {user.is_superuser ? "Superuser" : "User"} - - - - {user.is_active ? "Active" : "Inactive"} - - - - - - - ))} - - ) -} + const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE + const hasPreviousPage = page > 1 -const MembersBodySkeleton = () => { - return ( - - - {new Array(5).fill(null).map((_, index) => ( - - - - ))} - - - ) -} + useEffect(() => { + if (hasNextPage) { + queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 })) + } + }, [page, queryClient, hasNextPage]) -function Admin() { return ( - - - User Management - - + <> - +
@@ -106,11 +82,89 @@ function Admin() { - }> - - + {isPending ? ( + + + {new Array(4).fill(null).map((_, index) => ( + + ))} + + + ) : ( + + {users?.data.map((user) => ( + + + + + + + + ))} + + )}
Full nameActions
+ +
+ {user.full_name || "N/A"} + {currentUser?.id === user.id && ( + + You + + )} + + {user.email} + {user.is_superuser ? "Superuser" : "User"} + + + {user.is_active ? "Active" : "Inactive"} + + + +
+ + + Page {page} + + + + ) +} + +function Admin() { + return ( + + + Users Management + + + + ) } diff --git a/frontend/src/routes/_layout/items.tsx b/frontend/src/routes/_layout/items.tsx index 4b618c1..174fa83 100644 --- a/frontend/src/routes/_layout/items.tsx +++ b/frontend/src/routes/_layout/items.tsx @@ -3,7 +3,7 @@ import { Container, Flex, Heading, - Skeleton, + SkeletonText, Table, TableContainer, Tbody, @@ -64,7 +64,7 @@ function ItemsTable() { if (hasNextPage) { queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 })) } - }, [page, queryClient]) + }, [page, queryClient, hasNextPage]) return ( <> @@ -80,25 +80,27 @@ function ItemsTable() { {isPending ? ( - {new Array(5).fill(null).map((_, index) => ( - - {new Array(4).fill(null).map((_, index) => ( - - - - - - ))} - - ))} + + {new Array(4).fill(null).map((_, index) => ( + + + + ))} + ) : ( {items?.data.map((item) => ( {item.id} - {item.title} - + + {item.title} + + {item.description || "N/A"} diff --git a/frontend/src/routes/recover-password.tsx b/frontend/src/routes/recover-password.tsx index 6ea24bf..5716728 100644 --- a/frontend/src/routes/recover-password.tsx +++ b/frontend/src/routes/recover-password.tsx @@ -14,7 +14,7 @@ import { type SubmitHandler, useForm } from "react-hook-form" import { type ApiError, LoginService } from "../client" import { isLoggedIn } from "../hooks/useAuth" import useCustomToast from "../hooks/useCustomToast" -import { emailPattern } from "../utils" +import { emailPattern, handleError } from "../utils" interface FormData { email: string @@ -57,8 +57,7 @@ function RecoverPassword() { reset() }, onError: (err: ApiError) => { - const errDetail = (err.body as any)?.detail - showToast("Something went wrong.", `${errDetail}`, "error") + handleError(err, showToast) }, }) diff --git a/frontend/src/routes/reset-password.tsx b/frontend/src/routes/reset-password.tsx index 11bc552..f5ee763 100644 --- a/frontend/src/routes/reset-password.tsx +++ b/frontend/src/routes/reset-password.tsx @@ -15,7 +15,7 @@ import { type SubmitHandler, useForm } from "react-hook-form" import { type ApiError, LoginService, type NewPassword } from "../client" import { isLoggedIn } from "../hooks/useAuth" import useCustomToast from "../hooks/useCustomToast" -import { confirmPasswordRules, passwordRules } from "../utils" +import { confirmPasswordRules, handleError, passwordRules } from "../utils" interface NewPasswordForm extends NewPassword { confirm_password: string @@ -65,8 +65,7 @@ function ResetPassword() { navigate({ to: "/login" }) }, onError: (err: ApiError) => { - const errDetail = (err.body as any)?.detail - showToast("Something went wrong.", `${errDetail}`, "error") + handleError(err, showToast) }, }) diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 3d504b3..99f9063 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -1,3 +1,5 @@ +import type { ApiError } from "./client" + export const emailPattern = { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: "Invalid email address", @@ -40,3 +42,12 @@ export const confirmPasswordRules = ( 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") +} diff --git a/frontend/tests/auth.setup.ts b/frontend/tests/auth.setup.ts index d4e196e..3882f4f 100644 --- a/frontend/tests/auth.setup.ts +++ b/frontend/tests/auth.setup.ts @@ -3,7 +3,6 @@ import { firstSuperuser, firstSuperuserPassword } from "./config.ts" const authFile = "playwright/.auth/user.json" - setup("authenticate", async ({ page }) => { await page.goto("/login") await page.getByPlaceholder("Email").fill(firstSuperuser) diff --git a/frontend/tests/config.ts b/frontend/tests/config.ts index d2265bb..188cb36 100644 --- a/frontend/tests/config.ts +++ b/frontend/tests/config.ts @@ -1,21 +1,21 @@ -import dotenv from 'dotenv'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import path from "node:path" +import { fileURLToPath } from "node:url" +import dotenv from "dotenv" -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +const __filename = fileURLToPath(import.meta.url) +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") { - throw new Error("Environment variable FIRST_SUPERUSER is undefined"); + throw new Error("Environment variable FIRST_SUPERUSER is undefined") } 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 firstSuperuserPassword = FIRST_SUPERUSER_PASSWORD as string; +export const firstSuperuser = FIRST_SUPERUSER as string +export const firstSuperuserPassword = FIRST_SUPERUSER_PASSWORD as string