From 1d9e5c73978e3edcf618c67faa992fbf87ddee77 Mon Sep 17 00:00:00 2001 From: Nick Doan Date: Mon, 25 Mar 2024 16:36:15 -0400 Subject: [PATCH 1/4] Add popup to edit user (#132) --- prisma/schema.prisma | 2 +- src/app/api/route.schema.ts | 10 +++ .../user/[uid]/edit-position/route.client.ts | 16 ++++ .../user/[uid]/edit-position/route.schema.ts | 11 +++ src/app/api/user/[uid]/edit-position/route.ts | 38 +++++++++ .../chapter-leader/users/MembersHomePage.tsx | 81 +++++++++++++++++-- .../[uid]/chapter-leader/users/page.tsx | 14 ++-- src/app/private/[uid]/user/home/page.tsx | 9 +-- src/components/DisplayChapterInfo.tsx | 4 +- src/components/TileGrid/InfoTile.tsx | 2 +- src/components/TileGrid/UserTile.tsx | 2 +- src/components/container/Popup.tsx | 15 ++++ src/components/container/index.tsx | 1 + src/components/selector/Dropdown.tsx | 50 ++++++++---- src/components/senior/assignment/index.tsx | 48 ++++++----- 15 files changed, 241 insertions(+), 62 deletions(-) create mode 100644 src/app/api/user/[uid]/edit-position/route.client.ts create mode 100644 src/app/api/user/[uid]/edit-position/route.schema.ts create mode 100644 src/app/api/user/[uid]/edit-position/route.ts create mode 100644 src/components/container/Popup.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0d4da84c..06ee2645 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,7 +57,7 @@ model User { email String? @unique emailVerified DateTime? image String? - position String? + position String @default("") // @deprecated Pending for removal when we expand to multiple universities admin Boolean @default(false) diff --git a/src/app/api/route.schema.ts b/src/app/api/route.schema.ts index 6db30c7c..c5ec230d 100644 --- a/src/app/api/route.schema.ts +++ b/src/app/api/route.schema.ts @@ -39,4 +39,14 @@ export const invalidFormReponse = invalidFormErrorSchema.parse({ message: "The form is not valid", }); +export const invalidRequestSchema = z.object({ + code: z.literal("INVALID_REQUEST"), + message: z.string(), +}); + +export const invalidRequestResponse = invalidRequestSchema.parse({ + code: "INVALID_REQUEST", + message: "Request body is invalid", +}); + export type IUnauthorizedErrorSchema = z.infer; diff --git a/src/app/api/user/[uid]/edit-position/route.client.ts b/src/app/api/user/[uid]/edit-position/route.client.ts new file mode 100644 index 00000000..c90058f8 --- /dev/null +++ b/src/app/api/user/[uid]/edit-position/route.client.ts @@ -0,0 +1,16 @@ +import { TypedRequest } from "@server/type"; +import { z } from "zod"; +import { editPositionRequest, editPositionResponse } from "./route.schema"; + +export const editPosition = async ( + request: TypedRequest>, + uid: string +) => { + const { body, ...options } = request; + const response = await fetch(`/api/user/${uid}/edit-position`, { + method: "PATCH", + body: JSON.stringify(body), + ...options, + }); + return editPositionResponse.parse(await response.json()); +}; diff --git a/src/app/api/user/[uid]/edit-position/route.schema.ts b/src/app/api/user/[uid]/edit-position/route.schema.ts new file mode 100644 index 00000000..38a1b39c --- /dev/null +++ b/src/app/api/user/[uid]/edit-position/route.schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const editPositionRequest = z.object({ + position: z.string(), +}); + +export const editPositionResponse = z.discriminatedUnion("code", [ + z.object({ + code: z.literal("SUCCESS"), + }), +]); diff --git a/src/app/api/user/[uid]/edit-position/route.ts b/src/app/api/user/[uid]/edit-position/route.ts new file mode 100644 index 00000000..7d4421d0 --- /dev/null +++ b/src/app/api/user/[uid]/edit-position/route.ts @@ -0,0 +1,38 @@ +import { prisma } from "@server/db/client"; +import { withRole, withSession } from "@server/decorator"; +import { NextResponse } from "next/server"; +import { editPositionRequest, editPositionResponse } from "./route.schema"; +import { + invalidRequestResponse, + unauthorizedErrorResponse, +} from "@api/route.schema"; + +export const PATCH = withSession( + withRole(["CHAPTER_LEADER"], async ({ session, req, params }) => { + const { user: me } = session; + const otherUid: string = params.params.uid; + const other = await prisma.user.findUnique({ + where: { id: otherUid }, + }); + const request = editPositionRequest.safeParse(await req.json()); + + if (other == null || !request.success) { + return NextResponse.json(invalidRequestResponse, { status: 400 }); + } + + if (me.role !== "CHAPTER_LEADER" || me.ChapterID !== other.ChapterID) { + return NextResponse.json(unauthorizedErrorResponse, { status: 401 }); + } + + await prisma.user.update({ + where: { + id: otherUid, + }, + data: { + position: request.data.position, + }, + }); + + return NextResponse.json(editPositionResponse.parse({ code: "SUCCESS" })); + }) +); diff --git a/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx b/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx index f9dcf72c..03d633ae 100644 --- a/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx +++ b/src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx @@ -1,28 +1,99 @@ "use client"; -import { UserTile } from "@components/TileGrid"; +import { TileEdit, UserTile } from "@components/TileGrid"; import SearchableContainer from "@components/SearchableContainer"; import { User } from "@prisma/client"; +import { useContext, useState } from "react"; +import { UserContext } from "@context/UserProvider"; +import { editPosition } from "@api/user/[uid]/edit-position/route.client"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowUpFromBracket } from "@fortawesome/free-solid-svg-icons"; +import { Dropdown } from "@components/selector"; +import { Popup } from "@components/container"; +import { useRouter } from "next/navigation"; type MembersHomePageProps = { members: User[]; - user: User; }; -const MembersHomePage = ({ members, user }: MembersHomePageProps) => { +const EBOARD_POSITIONS = [ + "Social Coordinator", + "Senior Outreach Coordinator", + "Head of Media", + "Secretary", + "Treasurer", + "Match Coordinator", +].map((position, idx) => ({ id: idx.toString(), position: position })); + +const MembersHomePage = ({ members }: MembersHomePageProps) => { + const { user } = useContext(UserContext); + const [uidToEdit, setUidToEdit] = useState(null); + const [selectedPosition, setSelectedPosition] = useState< + typeof EBOARD_POSITIONS + >([]); + const router = useRouter(); + + const resetAssignment = () => { + setUidToEdit(null); + setSelectedPosition([]); + }; + const displayMembers = (elem: User, index: number) => ( { + e.stopPropagation(); + setUidToEdit(elem.id); + setSelectedPosition( + EBOARD_POSITIONS.filter( + (position) => position.position === elem.position + ) + ); + }, + icon: , + color: "#22555A", + }, + ]} + /> + } /> ); return ( - <> +

{`Members (${members.length})`}

+ {uidToEdit != null && ( + +
Assign to E-board
+ <>{element.position}} + selected={selectedPosition} + setSelected={setSelectedPosition} + onSave={async () => { + await editPosition( + { + body: { position: selectedPosition[0]?.position ?? "" }, + }, + uidToEdit + ); + resetAssignment(); + router.refresh(); + }} + multipleChoice={false} + /> +
+ )} { .includes(filter.toLowerCase()) } /> - +
); }; diff --git a/src/app/private/[uid]/chapter-leader/users/page.tsx b/src/app/private/[uid]/chapter-leader/users/page.tsx index 1165f864..93e30e13 100644 --- a/src/app/private/[uid]/chapter-leader/users/page.tsx +++ b/src/app/private/[uid]/chapter-leader/users/page.tsx @@ -3,22 +3,20 @@ import { prisma } from "@server/db/client"; import MembersHomePage from "./MembersHomePage"; const MembersPage = async ({ params }: { params: { uid: string } }) => { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: params.uid, - }, - }); - const chapter = await prisma.chapter.findFirstOrThrow({ where: { - id: user.ChapterID ?? "", + students: { + some: { + id: params.uid, + }, + }, }, include: { students: true, }, }); - return ; + return ; }; export default MembersPage; diff --git a/src/app/private/[uid]/user/home/page.tsx b/src/app/private/[uid]/user/home/page.tsx index 87dd88ab..7ceb9441 100644 --- a/src/app/private/[uid]/user/home/page.tsx +++ b/src/app/private/[uid]/user/home/page.tsx @@ -29,14 +29,7 @@ const UserHomePage = async ({ params }: UserHomePageParams) => { }, }); - return ( -
-
- {chapter.chapterName} -
- -
- ); + return ; }; export default UserHomePage; diff --git a/src/components/DisplayChapterInfo.tsx b/src/components/DisplayChapterInfo.tsx index d0772973..0867ecb3 100644 --- a/src/components/DisplayChapterInfo.tsx +++ b/src/components/DisplayChapterInfo.tsx @@ -45,7 +45,9 @@ const DisplayChapterInfo = ({ Executive Board} tiles={chapter.students - .filter((user) => user.role === "CHAPTER_LEADER") + .filter( + (user) => user.role === "CHAPTER_LEADER" || user.position !== "" + ) .map((user) => ( { return (
-
+
-
+

{student diff --git a/src/components/container/Popup.tsx b/src/components/container/Popup.tsx new file mode 100644 index 00000000..b0c01a60 --- /dev/null +++ b/src/components/container/Popup.tsx @@ -0,0 +1,15 @@ +interface PopupProps { + children?: React.ReactNode; +} + +const Popup = (props: PopupProps) => { + return ( +

+
+ {props.children} +
+
+ ); +}; + +export default Popup; diff --git a/src/components/container/index.tsx b/src/components/container/index.tsx index b1062046..26caaba4 100644 --- a/src/components/container/index.tsx +++ b/src/components/container/index.tsx @@ -1,3 +1,4 @@ export { default as HeaderContainer } from "./HeaderContainer"; export { default as CardGrid } from "./CardGrid"; export { default as CollapsableSidebarContainer } from "./CollapsableSidebarContainer"; +export { default as Popup } from "./Popup"; diff --git a/src/components/selector/Dropdown.tsx b/src/components/selector/Dropdown.tsx index 2369946f..696a1fb4 100644 --- a/src/components/selector/Dropdown.tsx +++ b/src/components/selector/Dropdown.tsx @@ -12,55 +12,75 @@ interface DropdownProps { selected: T[]; setSelected: React.Dispatch>; onSave: () => Promise; + multipleChoice?: boolean; } const Dropdown = (props: DropdownProps) => { const { header, display, elements, selected, setSelected } = props; + const multipleChoice = props.multipleChoice ?? true; const [displayDropdown, setDisplayDropdown] = React.useState(false); const [loading, setLoading] = React.useState(false); const [_, startTransition] = React.useTransition(); + const onDisplayDropdown = ( + e: React.MouseEvent + ) => { + e.stopPropagation(); + setDisplayDropdown(!displayDropdown); + }; + const onSave = () => { setLoading(true); props.onSave().then(() => setLoading(false)); }; - const onCheck = (element: T) => { + const onCheck = ( + e: React.MouseEvent, + element: T + ) => { + e.stopPropagation(); startTransition(() => { if (selected.some((other) => element.id === other.id)) { setSelected((prev) => prev.filter((other) => element.id !== other.id)); - } else { + } else if (multipleChoice) { setSelected((prev) => [...prev, element]); + } else { + setSelected([element]); } }); }; // TODO(nickbar01234) - Handle click outside return ( -
+
setDisplayDropdown(!displayDropdown)} + className="flex cursor-pointer items-center justify-between rounded-lg border border-dark-teal bg-[#F5F0EA] px-4 py-1.5" + onClick={onDisplayDropdown} > - {header} - + {header} +
{displayDropdown && ( -
+
{elements.map((element, idx) => (
onCheck(e, element)} > {display(element)} @@ -74,7 +94,7 @@ const Dropdown = (props: DropdownProps) => {
) : (