From 3109cf03eaedcbf14a160edafc3707eeddb99bfd Mon Sep 17 00:00:00 2001 From: Matej Tarca Date: Sat, 28 Oct 2023 21:16:47 +0200 Subject: [PATCH] feat(team): implement editing team name, kicking team member and leaving team --- src/components/ui/button.tsx | 2 +- .../TeamManager/components/NewTeamDialog.tsx | 50 ++++++-- .../TeamManager/components/TeamInfo.tsx | 118 +++++++++++++----- src/server/actions/team/editTeamName.ts | 25 ++++ src/server/actions/team/kickTeamMember.ts | 29 +++++ src/server/actions/team/leaveTeam.ts | 46 +++++++ src/server/getters/team.ts | 2 + .../helpers/requireTeamOwnerSession.ts | 30 +++++ 8 files changed, 259 insertions(+), 43 deletions(-) create mode 100644 src/server/services/helpers/requireTeamOwnerSession.ts diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 1828567..c9c5c89 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( secondary: "bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80", ghost: - "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50", + "text-hkOrange hover:bg-slate-100 dark:hover:bg-slate-800 dark:hover:text-slate-50", link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50", combobox: "w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-hkOrange disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300", diff --git a/src/scenes/Application/components/TeamManager/components/NewTeamDialog.tsx b/src/scenes/Application/components/TeamManager/components/NewTeamDialog.tsx index 474d77a..e6aebf3 100644 --- a/src/scenes/Application/components/TeamManager/components/NewTeamDialog.tsx +++ b/src/scenes/Application/components/TeamManager/components/NewTeamDialog.tsx @@ -24,6 +24,8 @@ import { import { Input } from "@/components/ui/input"; import createTeam from "@/server/actions/team/createTeam"; import { PlusIcon } from "@heroicons/react/24/outline"; +import editTeamName from "@/server/actions/team/editTeamName"; +import { PencilIcon } from "@heroicons/react/24/solid"; const newTeamFormSchema = z.object({ name: z.string().min(1), @@ -31,7 +33,15 @@ const newTeamFormSchema = z.object({ type NewTeamForm = z.infer; -const NewTeamDialog = () => { +type NewTeamDialogProps = { + mode?: "create" | "edit"; + initialData?: NewTeamForm; +}; + +const NewTeamDialog = ({ + mode = "create", + initialData, +}: NewTeamDialogProps) => { const [isOpened, setIsOpened] = useState(false); const form = useForm({ resolver: zodResolver(newTeamFormSchema), @@ -41,25 +51,43 @@ const NewTeamDialog = () => { }); const onNewTeamCreation = async (data: NewTeamForm) => { - await createTeam(data); + if (mode === "edit") { + await editTeamName({ newName: data.name }); + } else if (mode === "create") { + await createTeam(data); + } + setIsOpened(false); }; useEffect(() => { - if (isOpened) form.reset(); - }, [form, isOpened]); + if (mode === "edit" && initialData && isOpened) { + form.reset(initialData); + } else if (mode === "create" && isOpened) { + form.reset(); + } + }, [form, initialData, isOpened, mode]); return ( - + {mode === "create" ? ( + + ) : ( + + )} - Create new team + + {mode === "create" ? "Create new team" : "Edit team name"} +
@@ -81,7 +109,9 @@ const NewTeamDialog = () => { )} /> - +
diff --git a/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx b/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx index 419f18b..aaa9ef8 100644 --- a/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx +++ b/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useState } from "react"; import { TeamData, TeamMemberData } from "@/server/getters/team"; import { Text } from "@/components/ui/text"; import { Button } from "@/components/ui/button"; @@ -8,12 +8,50 @@ import { Stack } from "@/components/ui/stack"; import { DocumentDuplicateIcon } from "@heroicons/react/24/outline"; import { ColumnDef } from "@tanstack/react-table"; import { DataTable } from "@/components/ui/data-table"; +import NewTeamDialog from "@/scenes/Application/components/TeamManager/components/NewTeamDialog"; +import ConfirmationDialog from "@/components/common/ConfirmationDialog"; +import kickTeamMember from "@/server/actions/team/kickTeamMember"; +import { ArrowLeftOnRectangleIcon } from "@heroicons/react/24/solid"; +import leaveTeam from "@/server/actions/team/leaveTeam"; type TeamInfoProps = { team: TeamData; isOwnerSession: boolean; }; +const ActionsCell = ({ member }: { member: TeamMemberData }) => { + const [isKickConfirmationDialogOpened, setIsKickConfirmationDialogOpened] = + useState(false); + + if (member.isCurrentUser) { + return null; + } + + return ( + <> + { + if (answer) { + await kickTeamMember({ memberId: member.id }); + } + setIsKickConfirmationDialogOpened(false); + }} + isManuallyOpened={isKickConfirmationDialogOpened} + /> + + + ); +}; + const getTeamMembersColumns = ( isOwnerSession: boolean ): ColumnDef[] => { @@ -33,50 +71,66 @@ const getTeamMembersColumns = ( columns.push({ header: "Actions", cell: ({ row }) => { - return ( - - - - ); + return ; }, }); } return columns; }; -const TeamInfo = ({ team, isOwnerSession }: TeamInfoProps) => { +const TeamInfo = ({ + team: { name, code, members }, + isOwnerSession, +}: TeamInfoProps) => { const teamMembersColumns = useMemo( () => getTeamMembersColumns(isOwnerSession), [isOwnerSession] ); + const onLeaveTeamClick = async () => { + await leaveTeam(); + }; return ( - <> - - Team name: {team.name} - - - Team's code: {team.code} - + + Team members ({members.length}/4): + + + {!isOwnerSession && ( + { + if (answer) { + await onLeaveTeamClick(); + } }} - variant="ghost" - size="icon" > - - - - Team members: - - + + + )} + ); }; diff --git a/src/server/actions/team/editTeamName.ts b/src/server/actions/team/editTeamName.ts index e69de29..be95439 100644 --- a/src/server/actions/team/editTeamName.ts +++ b/src/server/actions/team/editTeamName.ts @@ -0,0 +1,25 @@ +"use server"; + +import { prisma } from "@/services/prisma"; +import { revalidatePath } from "next/cache"; +import requireTeamOwnerSession from "@/server/services/helpers/requireTeamOwnerSession"; + +type EditTeamNameInput = { + newName: string; +}; +const editTeamName = async ({ newName }: EditTeamNameInput) => { + const { team } = await requireTeamOwnerSession(); + + await prisma.team.update({ + where: { + id: team.id, + }, + data: { + name: newName, + }, + }); + + revalidatePath("/application", "page"); +}; + +export default editTeamName; diff --git a/src/server/actions/team/kickTeamMember.ts b/src/server/actions/team/kickTeamMember.ts index e69de29..dae2f71 100644 --- a/src/server/actions/team/kickTeamMember.ts +++ b/src/server/actions/team/kickTeamMember.ts @@ -0,0 +1,29 @@ +"use server"; + +import { prisma } from "@/services/prisma"; +import { revalidatePath } from "next/cache"; +import requireTeamOwnerSession from "@/server/services/helpers/requireTeamOwnerSession"; + +type KickTeamMemberInput = { + memberId: number; +}; +const kickTeamMember = async ({ memberId }: KickTeamMemberInput) => { + const { hacker } = await requireTeamOwnerSession(); + + if (memberId === hacker.id) { + throw new Error("You cannot kick yourself"); + } + + await prisma.hacker.update({ + where: { + id: memberId, + }, + data: { + teamId: null, + }, + }); + + revalidatePath("/application", "page"); +}; + +export default kickTeamMember; diff --git a/src/server/actions/team/leaveTeam.ts b/src/server/actions/team/leaveTeam.ts index e69de29..c36cd1c 100644 --- a/src/server/actions/team/leaveTeam.ts +++ b/src/server/actions/team/leaveTeam.ts @@ -0,0 +1,46 @@ +"use server"; + +import { prisma } from "@/services/prisma"; +import requireHackerSession from "@/server/services/helpers/requireHackerSession"; +import { revalidatePath } from "next/cache"; + +const leaveTeam = async () => { + const hacker = await requireHackerSession(); + + if (!hacker.teamId) { + throw new Error("Hacker is not in a team"); + } + + const teamId = hacker.teamId; + + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + select: { + id: true, + ownerId: true, + }, + }); + + if (!team) { + throw new Error("Team not found"); + } + + if (team.ownerId === hacker.id) { + throw new Error("Owner cannot leave team"); + } + + await prisma.hacker.update({ + where: { + id: hacker.id, + }, + data: { + teamId: null, + }, + }); + + revalidatePath("/application", "page"); +}; + +export default leaveTeam; diff --git a/src/server/getters/team.ts b/src/server/getters/team.ts index beb1c34..41be2a2 100644 --- a/src/server/getters/team.ts +++ b/src/server/getters/team.ts @@ -6,6 +6,7 @@ export type TeamMemberData = { id: number; email: string; isOwner: boolean; + isCurrentUser: boolean; }; export type TeamData = { @@ -78,6 +79,7 @@ const getTeam = async (): Promise => { id: member.id, email: member.user.email, isOwner: member.id === ownerId, + isCurrentUser: member.id === hacker.id, })), }; diff --git a/src/server/services/helpers/requireTeamOwnerSession.ts b/src/server/services/helpers/requireTeamOwnerSession.ts new file mode 100644 index 0000000..b0ba945 --- /dev/null +++ b/src/server/services/helpers/requireTeamOwnerSession.ts @@ -0,0 +1,30 @@ +import requireHackerSession from "@/server/services/helpers/requireHackerSession"; +import { prisma } from "@/services/prisma"; + +const requireTeamOwnerSession = async () => { + const hacker = await requireHackerSession(); + + if (!hacker.teamId) { + throw new Error("Hacker is not in a team"); + } + + const teamId = hacker.teamId; + + const team = await prisma.team.findUnique({ + where: { + id: teamId, + }, + }); + + if (!team) { + throw new Error("Team not found"); + } + + if (team.ownerId !== hacker.id) { + throw new Error("You are not the owner of this team"); + } + + return { hacker, team }; +}; + +export default requireTeamOwnerSession;