Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add popup to edit user #132

Merged
merged 1 commit into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can get all the necessary information with 1 call to the database!

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">
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this for the popup with the ... icon. Otherwise, it will fly out of the screen.

<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