Skip to content

Commit

Permalink
Add popup to edit user (#132)
Browse files Browse the repository at this point in the history
  • Loading branch information
nickbar01234 authored and johnny-t06 committed Mar 27, 2024
1 parent 7c82ba4 commit eca923a
Show file tree
Hide file tree
Showing 15 changed files with 241 additions and 62 deletions.
2 changes: 1 addition & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/app/api/route.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof unauthorizedErrorSchema>;
16 changes: 16 additions & 0 deletions src/app/api/user/[uid]/edit-position/route.client.ts
Original file line number Diff line number Diff line change
@@ -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<z.infer<typeof editPositionRequest>>,
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());
};
11 changes: 11 additions & 0 deletions src/app/api/user/[uid]/edit-position/route.schema.ts
Original file line number Diff line number Diff line change
@@ -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"),
}),
]);
38 changes: 38 additions & 0 deletions src/app/api/user/[uid]/edit-position/route.ts
Original file line number Diff line number Diff line change
@@ -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" }));
})
);
81 changes: 76 additions & 5 deletions src/app/private/[uid]/chapter-leader/users/MembersHomePage.tsx
Original file line number Diff line number Diff line change
@@ -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 | string>(null);
const [selectedPosition, setSelectedPosition] = useState<
typeof EBOARD_POSITIONS
>([]);
const router = useRouter();

const resetAssignment = () => {
setUidToEdit(null);
setSelectedPosition([]);
};

const displayMembers = (elem: User, index: number) => (
<UserTile
key={index}
student={elem}
link={`/private/${user.id}/chapter-leader/users/${elem.id}`}
dropdownComponent={
<TileEdit
options={[
{
name: "Edit role",
onClick: (e) => {
e.stopPropagation();
setUidToEdit(elem.id);
setSelectedPosition(
EBOARD_POSITIONS.filter(
(position) => position.position === elem.position
)
);
},
icon: <FontAwesomeIcon icon={faArrowUpFromBracket} />,
color: "#22555A",
},
]}
/>
}
/>
);

return (
<>
<div onClick={resetAssignment}>
<h1 className="font-merriweather pb-6 text-3xl">
{`Members (${members.length})`}
</h1>
{uidToEdit != null && (
<Popup>
<div className="text-3xl font-bold text-white">Assign to E-board</div>
<Dropdown
header="Select position"
elements={EBOARD_POSITIONS}
display={(element) => <>{element.position}</>}
selected={selectedPosition}
setSelected={setSelectedPosition}
onSave={async () => {
await editPosition(
{
body: { position: selectedPosition[0]?.position ?? "" },
},
uidToEdit
);
resetAssignment();
router.refresh();
}}
multipleChoice={false}
/>
</Popup>
)}
<SearchableContainer
display={displayMembers}
elements={members}
Expand All @@ -37,7 +108,7 @@ const MembersHomePage = ({ members, user }: MembersHomePageProps) => {
.includes(filter.toLowerCase())
}
/>
</>
</div>
);
};

Expand Down
14 changes: 6 additions & 8 deletions src/app/private/[uid]/chapter-leader/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MembersHomePage members={chapter.students} user={user} />;
return <MembersHomePage members={chapter.students} />;
};

export default MembersPage;
9 changes: 1 addition & 8 deletions src/app/private/[uid]/user/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,7 @@ const UserHomePage = async ({ params }: UserHomePageParams) => {
},
});

return (
<div className="mt-6 flex flex-col gap-y-6">
<div className="text-2xl font-bold text-[#000022]">
{chapter.chapterName}
</div>
<DisplayChapterInfo chapter={chapter} resources={resources} />
</div>
);
return <DisplayChapterInfo chapter={chapter} resources={resources} />;
};

export default UserHomePage;
4 changes: 3 additions & 1 deletion src/components/DisplayChapterInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ const DisplayChapterInfo = ({
<CardGrid
title={<div className="text-xl text-[#000022]">Executive Board</div>}
tiles={chapter.students
.filter((user) => user.role === "CHAPTER_LEADER")
.filter(
(user) => user.role === "CHAPTER_LEADER" || user.position !== ""
)
.map((user) => (
<UserTile
key={user.id}
Expand Down
2 changes: 1 addition & 1 deletion src/components/TileGrid/InfoTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const InfoTile = (params: InfoTileProps) => {

return (
<div className="flex h-fit w-full flex-col gap-y-4 rounded-lg bg-white p-6 shadow-lg">
<div className="flex justify-between">
<div className="relative flex justify-between">
<Link
href={href ?? ""}
className={`href ? "" : "cursor-default" truncate`}
Expand Down
2 changes: 1 addition & 1 deletion src/components/TileGrid/UserTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function UserTile({
</div>
</Link>
</div>
<div className="flex items-center justify-between p-2">
<div className="relative flex items-center justify-between p-2">
<div className="overflow-hidden">
<p className="overflow-hidden text-ellipsis whitespace-nowrap text-sm text-dark-teal before:invisible before:content-['\200B']">
{student
Expand Down
15 changes: 15 additions & 0 deletions src/components/container/Popup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
interface PopupProps {
children?: React.ReactNode;
}

const Popup = (props: PopupProps) => {
return (
<div className="fixed left-0 top-0 z-50 flex h-screen w-screen items-center justify-center backdrop-blur-[2px] backdrop-brightness-75">
<div className="flex h-48 w-[30rem] flex-col gap-y-6 rounded-[16px] bg-dark-teal px-6 py-9">
{props.children}
</div>
</div>
);
};

export default Popup;
1 change: 1 addition & 0 deletions src/components/container/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
50 changes: 35 additions & 15 deletions src/components/selector/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,55 +12,75 @@ interface DropdownProps<T extends IdentifiableObject> {
selected: T[];
setSelected: React.Dispatch<React.SetStateAction<T[]>>;
onSave: () => Promise<any>;
multipleChoice?: boolean;
}

const Dropdown = <T extends IdentifiableObject>(props: DropdownProps<T>) => {
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<HTMLDivElement, MouseEvent>
) => {
e.stopPropagation();
setDisplayDropdown(!displayDropdown);
};

const onSave = () => {
setLoading(true);
props.onSave().then(() => setLoading(false));
};

const onCheck = (element: T) => {
const onCheck = (
e: React.MouseEvent<HTMLDivElement, 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 (
<div className="relative min-w-[192px]">
<div className="relative w-full">
<div
className="flex cursor-pointer items-center justify-between rounded-lg border border-amber-red px-4 py-1.5"
onClick={() => 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}
>
<span className="text-amber-red">{header}</span>
<FontAwesomeIcon icon={faCaretDown} className="text-amber-red" />
<span className="text-dark-teal">{header}</span>
<FontAwesomeIcon icon={faCaretDown} className="text-dark-teal" />
</div>
{displayDropdown && (
<div className="absolute z-50 mt-2 inline-block w-full rounded border border-amber-red bg-white p-4">
<div className="absolute z-50 mt-2 inline-block w-full rounded border border-dark-teal bg-[#F5F0EA] p-4">
<div className="flex min-h-[128px] flex-col justify-between gap-y-3">
<div className="flex max-h-[128px] flex-col gap-y-3 overflow-y-auto">
{elements.map((element, idx) => (
<div
key={idx}
className="flex items-center gap-1.5 text-amber-red"
className="flex cursor-pointer items-center gap-1.5 text-dark-teal"
onClick={(e) => onCheck(e, element)}
>
<button
className="flex h-4 w-4 items-center justify-center border-2 border-amber-red bg-white"
onClick={() => onCheck(element)}
className={`flex h-4 w-4 items-center justify-center border-2 border-dark-teal ${
!multipleChoice && "rounded-full"
}`}
>
{selected.some((other) => element.id === other.id) && (
<FontAwesomeIcon icon={faCheck} size="xs" />
)}
{selected.some((other) => element.id === other.id) &&
(multipleChoice ? (
<FontAwesomeIcon icon={faCheck} size="xs" />
) : (
<div className="h-full w-full bg-dark-teal" />
))}
</button>
<span className="overflow-hidden truncate">
{display(element)}
Expand All @@ -74,7 +94,7 @@ const Dropdown = <T extends IdentifiableObject>(props: DropdownProps<T>) => {
</div>
) : (
<button
className="rounded bg-amber-red px-6 py-2.5 text-white"
className="rounded bg-dark-teal px-6 py-2.5 text-white"
onClick={onSave}
>
Save Changes
Expand Down
Loading

0 comments on commit eca923a

Please sign in to comment.