From 3f5b86ad477068dd313d2b2066fa5ba0a8528b32 Mon Sep 17 00:00:00 2001 From: Matej Tarca Date: Fri, 27 Oct 2023 22:15:52 +0200 Subject: [PATCH 1/3] feat(team): implement creating and joining a team --- src/components/ui/data-table.tsx | 2 +- src/components/ui/stack.tsx | 1 + src/scenes/Application/Application.tsx | 15 +-- .../components/ApplicationStep.tsx | 2 +- .../components/TeamManager/TeamManager.tsx | 37 ++++++-- .../TeamManager/components/JoinTeamDialog.tsx | 85 +++++++++++++++++ .../TeamManager/components/NewTeamDialog.tsx | 93 +++++++++++++++++++ .../TeamManager/components/TeamInfo.tsx | 83 +++++++++++++++++ .../components/EditTitleDialog.tsx | 2 +- src/server/actions/team/createTeam.ts | 37 ++++++++ src/server/actions/team/editTeamName.ts | 0 src/server/actions/team/joinTeam.ts | 51 ++++++++++ src/server/actions/team/kickTeamMember.ts | 0 src/server/actions/team/leaveTeam.ts | 0 src/server/getters/team.ts | 71 ++++++++++---- .../services/helpers/requireHackerSession.ts | 2 + .../helpers/requireOrganizerSession.ts | 2 + 17 files changed, 449 insertions(+), 34 deletions(-) create mode 100644 src/scenes/Application/components/TeamManager/components/JoinTeamDialog.tsx create mode 100644 src/scenes/Application/components/TeamManager/components/NewTeamDialog.tsx create mode 100644 src/scenes/Application/components/TeamManager/components/TeamInfo.tsx create mode 100644 src/server/actions/team/createTeam.ts create mode 100644 src/server/actions/team/editTeamName.ts create mode 100644 src/server/actions/team/joinTeam.ts create mode 100644 src/server/actions/team/kickTeamMember.ts create mode 100644 src/server/actions/team/leaveTeam.ts diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index eece4d4..9801803 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -85,7 +85,7 @@ export function DataTable({ /> )} - +
diff --git a/src/components/ui/stack.tsx b/src/components/ui/stack.tsx index 88147e5..823fe96 100644 --- a/src/components/ui/stack.tsx +++ b/src/components/ui/stack.tsx @@ -9,6 +9,7 @@ const stackVariants = cva("flex", { row: "flex-row", }, spacing: { + none: "", small: "gap-2", medium: "gap-4", large: "gap-7", diff --git a/src/scenes/Application/Application.tsx b/src/scenes/Application/Application.tsx index 00457f7..2c16401 100644 --- a/src/scenes/Application/Application.tsx +++ b/src/scenes/Application/Application.tsx @@ -2,6 +2,7 @@ import React, { Suspense } from "react"; import ApplicationSteps from "@/scenes/Application/components/ApplicationSteps/ApplicationSteps"; import TeamManager from "@/scenes/Application/components/TeamManager/TeamManager"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Stack } from "@/components/ui/stack"; const Application = () => { return ( @@ -10,12 +11,14 @@ const Application = () => { Welcome to Hack Kosice Application portal! - Loading...}> - - - Loading...}> - - + + Loading...}> + + + Loading...}> + + + ); diff --git a/src/scenes/Application/components/ApplicationSteps/components/ApplicationStep.tsx b/src/scenes/Application/components/ApplicationSteps/components/ApplicationStep.tsx index c13e0f9..7989796 100644 --- a/src/scenes/Application/components/ApplicationSteps/components/ApplicationStep.tsx +++ b/src/scenes/Application/components/ApplicationSteps/components/ApplicationStep.tsx @@ -25,7 +25,7 @@ const ApplicationStep = ({ step, shouldUseLocalIsCompleted }: Props) => { }, [shouldUseLocalIsCompleted, step]); return ( - +
{ - const { data } = await getTeam(); + const { status, team, isOwnerSession } = await getTeam(); + if (status === "not_signed_in") { + return ( +
+ Your team + You can create or join teams after you sign in +
+ ); + } + if (team === null) { + return ( +
+ Your team + + You are not in any team + + + +
+ ); + } + return ( - <> +
Your team - {data.team ? ( - Team name: {data.team.name} - ) : ( - You are not in a team - )} - + +
); }; diff --git a/src/scenes/Application/components/TeamManager/components/JoinTeamDialog.tsx b/src/scenes/Application/components/TeamManager/components/JoinTeamDialog.tsx new file mode 100644 index 0000000..a90dd0b --- /dev/null +++ b/src/scenes/Application/components/TeamManager/components/JoinTeamDialog.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useForm } from "react-hook-form"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import * as z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import joinTeam from "@/server/actions/team/joinTeam"; + +const joinTeamFormSchema = z.object({ + code: z.string().min(1), +}); + +type JoinTeamForm = z.infer; + +const JoinTeamDialog = () => { + const [isOpened, setIsOpened] = useState(false); + const form = useForm({ + resolver: zodResolver(joinTeamFormSchema), + defaultValues: { + code: "", + }, + }); + + const onEditTitleModalSave = async (data: JoinTeamForm) => { + await joinTeam(data); + setIsOpened(false); + }; + + useEffect(() => { + if (isOpened) form.reset(); + }, [form, isOpened]); + + return ( + + + + + + + Join existing team + +
+ + ( + + Team code + + + + + + )} + /> + + + + + +
+
+ ); +}; + +export default JoinTeamDialog; diff --git a/src/scenes/Application/components/TeamManager/components/NewTeamDialog.tsx b/src/scenes/Application/components/TeamManager/components/NewTeamDialog.tsx new file mode 100644 index 0000000..474d77a --- /dev/null +++ b/src/scenes/Application/components/TeamManager/components/NewTeamDialog.tsx @@ -0,0 +1,93 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useForm } from "react-hook-form"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import * as z from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import createTeam from "@/server/actions/team/createTeam"; +import { PlusIcon } from "@heroicons/react/24/outline"; + +const newTeamFormSchema = z.object({ + name: z.string().min(1), +}); + +type NewTeamForm = z.infer; + +const NewTeamDialog = () => { + const [isOpened, setIsOpened] = useState(false); + const form = useForm({ + resolver: zodResolver(newTeamFormSchema), + defaultValues: { + name: "", + }, + }); + + const onNewTeamCreation = async (data: NewTeamForm) => { + await createTeam(data); + setIsOpened(false); + }; + + useEffect(() => { + if (isOpened) form.reset(); + }, [form, isOpened]); + + return ( + + + + + + + Create new team + +
+ + ( + + Team name + + + + + + )} + /> + + + + + +
+
+ ); +}; + +export default NewTeamDialog; diff --git a/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx b/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx new file mode 100644 index 0000000..419f18b --- /dev/null +++ b/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx @@ -0,0 +1,83 @@ +"use client"; + +import React, { useMemo } from "react"; +import { TeamData, TeamMemberData } from "@/server/getters/team"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +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"; + +type TeamInfoProps = { + team: TeamData; + isOwnerSession: boolean; +}; + +const getTeamMembersColumns = ( + isOwnerSession: boolean +): ColumnDef[] => { + const columns: ColumnDef[] = [ + { + header: "Email", + cell: ({ row }) => { + return ( + + {row.original.email} {row.original.isOwner && " (owner)"} + + ); + }, + }, + ]; + if (isOwnerSession) { + columns.push({ + header: "Actions", + cell: ({ row }) => { + return ( + + + + ); + }, + }); + } + + return columns; +}; +const TeamInfo = ({ team, isOwnerSession }: TeamInfoProps) => { + const teamMembersColumns = useMemo( + () => getTeamMembersColumns(isOwnerSession), + [isOwnerSession] + ); + return ( + <> + + Team name: {team.name} + + + Team's code: {team.code} + + + Team members: + + + ); +}; + +export default TeamInfo; diff --git a/src/scenes/Dashboard/scenes/ApplicationFormEditor/scenes/EditStepForm/components/EditTitleDialog.tsx b/src/scenes/Dashboard/scenes/ApplicationFormEditor/scenes/EditStepForm/components/EditTitleDialog.tsx index 94da2bb..ef56732 100644 --- a/src/scenes/Dashboard/scenes/ApplicationFormEditor/scenes/EditStepForm/components/EditTitleDialog.tsx +++ b/src/scenes/Dashboard/scenes/ApplicationFormEditor/scenes/EditStepForm/components/EditTitleDialog.tsx @@ -71,7 +71,7 @@ const EditTitleDialog = ({ initialValue, stepId }: Props) => { New title - + diff --git a/src/server/actions/team/createTeam.ts b/src/server/actions/team/createTeam.ts new file mode 100644 index 0000000..73a8ec3 --- /dev/null +++ b/src/server/actions/team/createTeam.ts @@ -0,0 +1,37 @@ +"use server"; + +import { prisma } from "@/services/prisma"; +import requireHackerSession from "@/server/services/helpers/requireHackerSession"; +import { randomBytes } from "crypto"; +import { revalidatePath } from "next/cache"; + +type CreateTeamInput = { + name: string; +}; +const createTeam = async ({ name }: CreateTeamInput) => { + const { id: hackerId } = await requireHackerSession(); + + const code = randomBytes(6).toString("hex"); // random string with length 12 + const { id: newTeamId } = await prisma.team.create({ + data: { + name, + code, + ownerId: hackerId, + }, + select: { + id: true, + }, + }); + await prisma.hacker.update({ + where: { + id: hackerId, + }, + data: { + teamId: newTeamId, + }, + }); + + revalidatePath("/application", "page"); +}; + +export default createTeam; diff --git a/src/server/actions/team/editTeamName.ts b/src/server/actions/team/editTeamName.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/server/actions/team/joinTeam.ts b/src/server/actions/team/joinTeam.ts new file mode 100644 index 0000000..67c02d3 --- /dev/null +++ b/src/server/actions/team/joinTeam.ts @@ -0,0 +1,51 @@ +"use server"; + +import { prisma } from "@/services/prisma"; +import requireHackerSession from "@/server/services/helpers/requireHackerSession"; +import { revalidatePath } from "next/cache"; + +type JoinTeamInput = { + code: string; +}; +const joinTeam = async ({ code }: JoinTeamInput) => { + const hacker = await requireHackerSession(); + + if (hacker.teamId) { + throw new Error("Hacker already has a team"); + } + + const team = await prisma.team.findUnique({ + where: { + code, + }, + select: { + id: true, + members: { + select: { + id: true, + }, + }, + }, + }); + + if (!team) { + throw new Error("Team not found"); + } + + if (team.members.length >= 4) { + throw new Error("Team is full"); + } + + await prisma.hacker.update({ + where: { + id: hacker.id, + }, + data: { + teamId: team.id, + }, + }); + + revalidatePath("/application", "page"); +}; + +export default joinTeam; diff --git a/src/server/actions/team/kickTeamMember.ts b/src/server/actions/team/kickTeamMember.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/server/actions/team/leaveTeam.ts b/src/server/actions/team/leaveTeam.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/server/getters/team.ts b/src/server/getters/team.ts index d1e8d3e..beb1c34 100644 --- a/src/server/getters/team.ts +++ b/src/server/getters/team.ts @@ -2,25 +2,31 @@ import { prisma } from "@/services/prisma"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +export type TeamMemberData = { + id: number; + email: string; + isOwner: boolean; +}; + export type TeamData = { - message: string; - data: { - team: { - id: number; - name: string; - code: string; - } | null; - }; + id: number; + name: string; + code: string; + members: TeamMemberData[]; +}; +export type GetTeamData = { + status: "not_signed_in" | "success"; + team: TeamData | null; + isOwnerSession: boolean; }; -const getTeam = async (): Promise => { +const getTeam = async (): Promise => { const session = await getServerSession(authOptions); if (!session?.id) { return { - message: "User has to be signed in", - data: { - team: null, - }, + status: "not_signed_in", + team: null, + isOwnerSession: false, }; } const hacker = await prisma.hacker.findUnique({ @@ -31,6 +37,17 @@ const getTeam = async (): Promise => { id: true, name: true, code: true, + ownerId: true, + members: { + select: { + id: true, + user: { + select: { + email: true, + }, + }, + }, + }, }, }, }, @@ -43,11 +60,31 @@ const getTeam = async (): Promise => { throw new Error("Hacker not found"); } + if (!hacker.team) { + return { + status: "success", + team: null, + isOwnerSession: false, + }; + } + + const ownerId = hacker.team.ownerId; + + const team: TeamData = { + id: hacker.team.id, + name: hacker.team.name, + code: hacker.team.code, + members: hacker.team.members.map((member) => ({ + id: member.id, + email: member.user.email, + isOwner: member.id === ownerId, + })), + }; + return { - message: "Successfully retrieved team", - data: { - team: hacker.team, - }, + status: "success", + team, + isOwnerSession: ownerId === hacker.id, }; }; diff --git a/src/server/services/helpers/requireHackerSession.ts b/src/server/services/helpers/requireHackerSession.ts index 331f87c..1184d00 100644 --- a/src/server/services/helpers/requireHackerSession.ts +++ b/src/server/services/helpers/requireHackerSession.ts @@ -18,6 +18,8 @@ const requireHackerSession = async () => { if (!hacker) { throw new Error("Hacker not found"); } + + return hacker; }; export default requireHackerSession; diff --git a/src/server/services/helpers/requireOrganizerSession.ts b/src/server/services/helpers/requireOrganizerSession.ts index 4dbacba..f835906 100644 --- a/src/server/services/helpers/requireOrganizerSession.ts +++ b/src/server/services/helpers/requireOrganizerSession.ts @@ -18,6 +18,8 @@ const requireOrganizerSession = async () => { if (!organizer) { throw new Error("Organizer not found"); } + + return organizer; }; export default requireOrganizerSession; From fa5410633b970957ccd67d6479b203e4c3f62f2d Mon Sep 17 00:00:00 2001 From: Matej Tarca Date: Sat, 28 Oct 2023 21:16:47 +0200 Subject: [PATCH 2/3] 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; From 45b64e4d81ee791acfab7fee6ce08e735e316671 Mon Sep 17 00:00:00 2001 From: Matej Tarca Date: Sat, 28 Oct 2023 21:59:50 +0200 Subject: [PATCH 3/3] feat(e2e): add e2e tests for team management --- e2e/applicationForm.spec.ts | 16 +- e2e/fixtures/ApplicationPage.ts | 19 +- e2e/helpers/prepareDBBeforeTest.ts | 36 +++- e2e/team.spec.ts | 203 ++++++++++++++++++ playwright.config.ts | 1 + .../TeamManager/components/TeamInfo.tsx | 1 + 6 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 e2e/team.spec.ts diff --git a/e2e/applicationForm.spec.ts b/e2e/applicationForm.spec.ts index c304e68..7bacfea 100644 --- a/e2e/applicationForm.spec.ts +++ b/e2e/applicationForm.spec.ts @@ -60,7 +60,9 @@ test.describe("application form", () => { await page .getByLabel("Label") .fill("What is your experience with hackathons?"); - await page.getByLabel("Name (has to be unique across the form)").fill("experience"); + await page + .getByLabel("Name (has to be unique across the form)") + .fill("experience"); await page.getByText("Select a field type").click(); await page.getByLabel("textarea").getByText("textarea").click(); await page.getByLabel("Required").check(); @@ -74,7 +76,9 @@ test.describe("application form", () => { await page .getByLabel("Label") .fill("I have been at the hackathon in the past."); - await page.getByLabel("Name (has to be unique across the form)").fill("hackathonsPast"); + await page + .getByLabel("Name (has to be unique across the form)") + .fill("hackathonsPast"); await page.getByText("Select a field type").click(); await page.getByLabel("checkbox").getByText("checkbox").click(); await page.getByRole("button", { name: "Save new field" }).click(); @@ -85,7 +89,9 @@ test.describe("application form", () => { await page.getByRole("button", { name: "Create new field" }).click(); await page.getByLabel("Label").fill("What company do you work for?"); - await page.getByLabel("Name (has to be unique across the form)").fill("company"); + await page + .getByLabel("Name (has to be unique across the form)") + .fill("company"); await page.getByText("Select a field type").click(); await page.getByLabel("text", { exact: true }).getByText("text").click(); await page.getByRole("button", { name: "Save new field" }).click(); @@ -107,7 +113,9 @@ test.describe("application form", () => { await page .getByLabel("Label") .fill("I have been at the hackathon in the past."); - await page.getByLabel("Name (has to be unique across the form)").fill("hackathonsPast"); + await page + .getByLabel("Name (has to be unique across the form)") + .fill("hackathonsPast"); await page.getByText("Select a field type").click(); await page.getByLabel("checkbox").getByText("checkbox").click(); await page.getByRole("button", { name: "Save new field" }).click(); diff --git a/e2e/fixtures/ApplicationPage.ts b/e2e/fixtures/ApplicationPage.ts index e8c72ae..2a288cc 100644 --- a/e2e/fixtures/ApplicationPage.ts +++ b/e2e/fixtures/ApplicationPage.ts @@ -1,5 +1,4 @@ -import type { Page, Locator } from "@playwright/test"; -import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; export class ApplicationPage { constructor(public readonly page: Page) {} @@ -8,11 +7,21 @@ export class ApplicationPage { await this.page.goto("/"); } - async openSignedIn() { + async openSignedIn( + { hackerIndex }: { hackerIndex: number } = { hackerIndex: 1 } + ) { await this.page.getByRole("link", { name: "Sign in" }).click(); - await this.page.fill('input[name="email"]', "test-hacker@test.com"); - await this.page.fill('input[name="password"]', "test123"); + if (hackerIndex > 1) { + await this.page.fill( + 'input[name="email"]', + `test-hacker-${hackerIndex}@test.com` + ); + await this.page.fill('input[name="password"]', "test123"); + } else { + await this.page.fill('input[name="email"]', "test-hacker@test.com"); + await this.page.fill('input[name="password"]', "test123"); + } await this.page.getByRole("button", { name: /^Sign in$/ }).click(); } diff --git a/e2e/helpers/prepareDBBeforeTest.ts b/e2e/helpers/prepareDBBeforeTest.ts index 4be2bcb..db58f36 100644 --- a/e2e/helpers/prepareDBBeforeTest.ts +++ b/e2e/helpers/prepareDBBeforeTest.ts @@ -8,6 +8,7 @@ async function clearDb(prisma: PrismaClient) { await prisma.formField.deleteMany(); await prisma.applicationFormStep.deleteMany(); await prisma.application.deleteMany(); + await prisma.team.deleteMany(); await prisma.hacker.deleteMany(); await prisma.organizer.deleteMany(); await prisma.user.deleteMany(); @@ -16,7 +17,15 @@ async function clearDb(prisma: PrismaClient) { await prisma.optionList.deleteMany(); } -export async function main(prisma: PrismaClient) { +type Options = { + numberOfHackers?: number; +}; +export async function main( + prisma: PrismaClient, + options: Options = { + numberOfHackers: 1, + } +) { await clearDb(prisma); const { id: hackathonId } = await prisma.hackathon.create({ @@ -51,6 +60,31 @@ export async function main(prisma: PrismaClient) { }, }); + if (options.numberOfHackers && options.numberOfHackers > 1) { + for (let i = 0; i < options.numberOfHackers - 1; i++) { + const { id: userId } = await prisma.user.create({ + data: { + email: `test-hacker-${i + 2}@test.com`, + password: await hash("test123"), + }, + }); + + const { id: hackerId } = await prisma.hacker.create({ + data: { + userId, + hackathonId, + }, + }); + + await prisma.application.create({ + data: { + hackerId, + statusId: 1, + }, + }); + } + } + const { id: userOrganizerId } = await prisma.user.create({ data: { email: "test-org@hackkosice.com", diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts new file mode 100644 index 0000000..905ea8b --- /dev/null +++ b/e2e/team.spec.ts @@ -0,0 +1,203 @@ +import { test, expect } from "./fixtures/custom-test"; +import { PrismaClient } from "@prisma/client"; +import prepareDBBeforeTest from "./helpers/prepareDBBeforeTest"; + +let teamCode = ""; + +test.describe("Team", () => { + test.describe.configure({ mode: "serial" }); + + test.beforeAll(async () => { + const prisma = new PrismaClient(); + + await prepareDBBeforeTest(prisma, { + numberOfHackers: 2, + }); + + await prisma.$disconnect(); + }); + + test("unsigned user cannot create a team", async ({ + page, + applicationPage, + }) => { + await applicationPage.openUnsigned(); + + await expect(page.getByText("Your team")).toBeVisible(); + await expect( + page.getByText("You can create or join teams after you sign in") + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Create new team" }) + ).not.toBeVisible(); + + await expect( + page.getByRole("button", { name: "Join existing team" }) + ).not.toBeVisible(); + }); + + test("signed in hacker can create a team", async ({ + page, + applicationPage, + }) => { + await applicationPage.openSignedIn(); + + await expect(page.getByText("Your team")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Create new team" }) + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Join existing team" }) + ).toBeVisible(); + + await page.getByRole("button", { name: "Create new team" }).click(); + + await expect( + page.getByRole("heading", { name: "Create new team" }) + ).toBeVisible(); + await page.getByLabel("Team name").fill("Best team"); + await page.getByRole("button", { name: "Create" }).click(); + + await expect(page.getByText("Team name:")).toBeVisible(); + await expect(page.getByText("Best team")).toBeVisible(); + await expect(page.getByText("Team members (1/4):")).toBeVisible(); + await expect(page.getByText("test-hacker@test.com (owner)")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Leave team" }) + ).not.toBeVisible(); + + // Editing team name + await page.getByRole("button", { name: "Edit team name" }).click(); + await expect( + page.getByRole("heading", { name: "Edit team name" }) + ).toBeVisible(); + await expect(page.getByLabel("Team name", { exact: true })).toHaveValue( + "Best team" + ); + await page.getByLabel("Team name", { exact: true }).fill("Test team"); + await page.getByRole("button", { name: "Save" }).click(); + + await expect(page.getByText("Team name:")).toBeVisible(); + await expect(page.getByText("Test team")).toBeVisible(); + await expect(page.getByText("Best team")).not.toBeVisible(); + + // Copying team code + await page.getByRole("button", { name: "Copy team code" }).click(); + teamCode = await page.evaluate("navigator.clipboard.readText()"); + expect(teamCode).not.toBe(""); + }); + + test("signed in hacker can join a team", async ({ + page, + applicationPage, + }) => { + await applicationPage.openSignedIn({ hackerIndex: 2 }); + + await expect(page.getByText("Your team")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Join existing team" }) + ).toBeVisible(); + + await page.getByRole("button", { name: "Join existing team" }).click(); + + await expect( + page.getByRole("heading", { name: "Join existing team" }) + ).toBeVisible(); + await page.getByLabel("Team code").fill(teamCode); + await page.getByRole("button", { name: "Join" }).click(); + + await expect(page.getByText("Team name:")).toBeVisible(); + await expect(page.getByText("Test team")).toBeVisible(); + await expect(page.getByText("Team members (2/4):")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Leave team" }) + ).toBeVisible(); + await expect(page.getByText("test-hacker@test.com (owner)")).toBeVisible(); + await expect(page.getByText("test-hacker-2@test.com")).toBeVisible(); + await expect(page.getByText("Kick")).not.toBeVisible(); + }); + + test("owner can kick a team member", async ({ page, applicationPage }) => { + await applicationPage.openSignedIn(); + + await expect(page.getByText("Team members (2/4):")).toBeVisible(); + await expect(page.getByText("test-hacker-2@test.com")).toBeVisible(); + + await page.getByRole("button", { name: "Kick" }).click(); + await expect( + page.getByText("Are you sure you want to kick test-hacker-2@test.com?") + ).toBeVisible(); + await page.getByRole("button", { name: "No" }).click(); + + await expect( + page.getByText("Are you sure you want to kick test-hacker-2@test.com?") + ).not.toBeVisible(); + + await expect(page.getByText("Team members (2/4):")).toBeVisible(); + await expect(page.getByText("test-hacker-2@test.com")).toBeVisible(); + + await page.getByRole("button", { name: "Kick" }).click(); + await expect( + page.getByText("Are you sure you want to kick test-hacker-2@test.com?") + ).toBeVisible(); + await page.getByRole("button", { name: "Yes" }).click(); + + await expect(page.getByText("Team members (1/4):")).toBeVisible(); + await expect(page.getByText("test-hacker-2@test.com")).not.toBeVisible(); + }); + + test("member can leave a team", async ({ page, applicationPage }) => { + await applicationPage.openSignedIn({ hackerIndex: 2 }); + + await page.getByRole("button", { name: "Join existing team" }).click(); + await page.getByLabel("Team code").fill(teamCode); + await page.getByRole("button", { name: "Join" }).click(); + + await expect(page.getByText("Test team")).toBeVisible(); + + await page.getByRole("button", { name: "Leave team" }).click(); + await expect( + page.getByText("Are you sure you want to leave this team?") + ).toBeVisible(); + await page.getByRole("button", { name: "No" }).click(); + + await expect(page.getByText("Test team")).toBeVisible(); + + await page.getByRole("button", { name: "Leave team" }).click(); + await expect( + page.getByText("Are you sure you want to leave this team?") + ).toBeVisible(); + await page.getByRole("button", { name: "Yes" }).click(); + + await expect(page.getByText("Test team")).not.toBeVisible(); + await expect( + page.getByRole("button", { name: "Join existing team" }) + ).toBeVisible(); + }); + + test("hacker can join a team after submitting application", async ({ + page, + applicationPage, + }) => { + await applicationPage.openSignedIn({ hackerIndex: 2 }); + + await expect(page.getByText("Application status: open")).toBeVisible(); + + await page.getByText("General info").click(); + await expect( + page.getByRole("heading", { name: "General info" }) + ).toBeVisible(); + await page.getByLabel("Full name").fill("Test Hacker 2"); + await page.getByRole("button", { name: "Save" }).click(); + + await page.getByRole("button", { name: "Submit application" }).click(); + await page.getByRole("button", { name: "Yes" }).click(); + await expect(page.getByText("Application status: submitted")).toBeVisible(); + + await page.getByRole("button", { name: "Join existing team" }).click(); + await page.getByLabel("Team code").fill(teamCode); + await page.getByRole("button", { name: "Join" }).click(); + + await expect(page.getByText("Test team")).toBeVisible(); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 620e1fc..1ff4bd9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "retry-with-trace", + permissions: ["clipboard-read", "clipboard-write"], }, /* Configure projects for major browsers */ diff --git a/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx b/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx index aaa9ef8..7fe667d 100644 --- a/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx +++ b/src/scenes/Application/components/TeamManager/components/TeamInfo.tsx @@ -108,6 +108,7 @@ const TeamInfo = ({ }} variant="ghost" size="icon" + aria-label="Copy team code" >