From 18599e24448b9caf970c35237dcb16fe0accbcdc Mon Sep 17 00:00:00 2001 From: Tay Yi Hsuen Date: Sat, 11 Nov 2023 14:35:15 +0800 Subject: [PATCH 01/36] Back-merge prod into master (#233) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 262ebf23..5f79a4ca 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ it in case you forget later on when you have a lot more files to commit. yarn workspace frontend build ## For first time setup run the build command yarn workspace frontend start ## For subsequent runs ``` + 8. **Running everything at once:** To run everything at once and still maintain the ability to hot-reload your changes, use: ```bash From 1a4c94d837e04df60330a1631d67db018c8abf82 Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Sat, 11 Nov 2023 15:00:31 +0800 Subject: [PATCH 02/36] Add @types for sanitize-html --- frontend/package.json | 1 + yarn.lock | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index 01f3fdda..8e3d0f22 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -66,6 +66,7 @@ "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", "@types/react-syntax-highlighter": "^15.5.10", + "@types/sanitize-html": "^2.9.4", "@types/socket.io-client": "^3.0.0", "eslint-config-next": "^13.5.6" } diff --git a/yarn.lock b/yarn.lock index 7d275ec1..7db30759 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3108,6 +3108,13 @@ dependencies: htmlparser2 "^8.0.0" +"@types/sanitize-html@^2.9.4": + version "2.9.4" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.9.4.tgz#bfc2df463ec35904fecc57b29ba080e53732a140" + integrity sha512-Ym4hjmAFxF/eux7nW2yDPAj2o9RYh0vP/9V5ECoHtgJ/O9nPGslUd20CMn6WatRMlFVfjMTg3lMcWq8YyO6QnA== + dependencies: + htmlparser2 "^8.0.0" + "@types/scheduler@*": version "0.16.5" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.5.tgz#4751153abbf8d6199babb345a52e1eb4167d64af" From 4f0f5b7c61b883a215df92473c1f47a2c69565ea Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Sat, 11 Nov 2023 15:14:41 +0800 Subject: [PATCH 03/36] Update admin-service readme --- services/admin-service/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/admin-service/README.md b/services/admin-service/README.md index 6538b693..83c34cd6 100644 --- a/services/admin-service/README.md +++ b/services/admin-service/README.md @@ -32,9 +32,9 @@ This corresponds to the service account for the project's development environmen ## How to add/remove admin rights for users on the application -1. Run the command below. This will start up the admin service locally: +1. Run the command below from the root of the entire project. This will start up the admin service locally: ```shell -dotenv -e -c development -- yarn dev +dotenv -e -- yarn workspace admin-service dev ``` Although the command is `dev`, the Firebase admin rights can also be added to/removed from the users on the production From a4776c7169d0f7d9747598273b022732063017eb Mon Sep 17 00:00:00 2001 From: "YIHSUEN\\Yi Hsuen" Date: Sat, 11 Nov 2023 15:31:21 +0800 Subject: [PATCH 04/36] Remove unused import in frontend --- frontend/src/hooks/useCollaboration.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/hooks/useCollaboration.tsx b/frontend/src/hooks/useCollaboration.tsx index ca96321a..e8b768d6 100644 --- a/frontend/src/hooks/useCollaboration.tsx +++ b/frontend/src/hooks/useCollaboration.tsx @@ -10,7 +10,6 @@ import { Room, connect } from "twilio-video"; import { wsCollaborationProxyGatewayAddress } from "@/gateway-address/gateway-address"; import { AuthContext } from "@/contexts/AuthContext"; import { toast } from "react-toastify"; -import { collaborationServiceAddress } from "./../../../services/gateway/src/proxied_routes/service_names"; import { useRouter } from "next/router"; import { fetchRoomData } from "@/pages/api/collaborationHandler"; From 92045b305bfd7c85253c909bcef60ddb11ca1042 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Sat, 11 Nov 2023 17:57:55 +0800 Subject: [PATCH 05/36] linked room and user logic --- frontend/src/hooks/useCollaboration.tsx | 30 +++++++++---------- frontend/src/pages/room/[id].tsx | 10 +++---- .../src/providers/MatchmakingProvider.tsx | 19 +++++++++++- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/frontend/src/hooks/useCollaboration.tsx b/frontend/src/hooks/useCollaboration.tsx index e8b768d6..1b1b6b36 100644 --- a/frontend/src/hooks/useCollaboration.tsx +++ b/frontend/src/hooks/useCollaboration.tsx @@ -53,21 +53,21 @@ const useCollaboration = ({ const router = useRouter(); const { id } = router.query; - useEffect(() => { - if (id && currentUser) { - try { - const response = fetchRoomData(id?.toString(), currentUser); - response.then((res) => { - if (res.message === "Room exists") { - console.log(res); - setQuestionId(res.questionId); - } - }); - } catch (err) { - toast.error((err as Error).message); - } - } - }, [id, currentUser]); + // useEffect(() => { + // if (id && currentUser) { + // try { + // const response = fetchRoomData(id?.toString(), currentUser); + // response.then((res) => { + // if (res.message === "Room exists") { + // console.log(res); + // setQuestionId(res.questionId); + // } + // }); + // } catch (err) { + // toast.error((err as Error).message); + // } + // } + // }, [id, currentUser]); useEffect(() => { if (currentUser) { diff --git a/frontend/src/pages/room/[id].tsx b/frontend/src/pages/room/[id].tsx index c08ee423..773f4f77 100644 --- a/frontend/src/pages/room/[id].tsx +++ b/frontend/src/pages/room/[id].tsx @@ -9,15 +9,17 @@ import { Difficulty, Question } from "../../types/QuestionTypes"; import { Match } from "../../types/MatchTypes"; import { useQuestions } from "@/hooks/useQuestions"; import { useMatch } from "@/hooks/useMatch"; -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { MrMiyagi } from "@uiball/loaders"; import { useMatchmaking } from "@/hooks/useMatchmaking"; import Solution from "@/components/room/solution"; +import { AuthContext } from "@/contexts/AuthContext"; export default function Room() { const router = useRouter(); const roomId = router.query.id as string; - const userId = (router.query.userId as string) || "user1"; + const { user: currentUser } = useContext(AuthContext); + const userId = (currentUser.uid as string) || "user1"; const disableVideo = (router.query.disableVideo as string)?.toLowerCase() === "true"; @@ -145,9 +147,7 @@ export default function Room() { ) : question != null && "solution" in question ? ( - + ) : (
diff --git a/frontend/src/providers/MatchmakingProvider.tsx b/frontend/src/providers/MatchmakingProvider.tsx index dcfbd512..a5b7ea12 100644 --- a/frontend/src/providers/MatchmakingProvider.tsx +++ b/frontend/src/providers/MatchmakingProvider.tsx @@ -9,6 +9,8 @@ import { io, Socket } from "socket.io-client"; import { Match } from "@prisma/client"; import { AuthContext } from "@/contexts/AuthContext"; import { wsMatchProxyGatewayAddress } from "@/gateway-address/gateway-address"; +import { useRouter } from "next/router"; +import { toast } from "react-toastify"; const SERVER_URL = wsMatchProxyGatewayAddress; @@ -40,6 +42,7 @@ export const MatchmakingProvider: React.FC = ({ const [error, setError] = useState(""); const { user: currentUser, authIsReady } = useContext(AuthContext); + const router = useRouter(); const generateRandomNumber = () => { // Return a random number either 0 or 1 as a string @@ -70,6 +73,20 @@ export const MatchmakingProvider: React.FC = ({ } }, [currentUser]); + useEffect(() => { + if (!socket) return; + + // else we should join the room if they are in an exsiting match + // (i.e. they refreshed the page) + if ( + match && + router.route !== "/interviews/match-found" && + router.route !== "/interviews/find-match" + ) { + router.push(`/room/${match?.roomId}`); + } + }, [match]); + useEffect(() => { if (!socket) return; @@ -87,7 +104,7 @@ export const MatchmakingProvider: React.FC = ({ socket.on("matchLeft", (match: Match) => { console.log("Match left:", match); setMatch(null); - }) + }); socket.on("receiveMessage", (message: string) => { console.log("Message received:", message); From ef211523274875a458c5addf4145e0b68093fb26 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Sat, 11 Nov 2023 18:02:06 +0800 Subject: [PATCH 06/36] kick one user out the moment the other user leaves --- frontend/src/pages/room/[id].tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/room/[id].tsx b/frontend/src/pages/room/[id].tsx index 773f4f77..118b480a 100644 --- a/frontend/src/pages/room/[id].tsx +++ b/frontend/src/pages/room/[id].tsx @@ -14,12 +14,13 @@ import { MrMiyagi } from "@uiball/loaders"; import { useMatchmaking } from "@/hooks/useMatchmaking"; import Solution from "@/components/room/solution"; import { AuthContext } from "@/contexts/AuthContext"; +import { toast } from "react-toastify"; export default function Room() { const router = useRouter(); const roomId = router.query.id as string; const { user: currentUser } = useContext(AuthContext); - const userId = (currentUser.uid as string) || "user1"; + const userId = (currentUser?.uid as string) || "user1"; const disableVideo = (router.query.disableVideo as string)?.toLowerCase() === "true"; @@ -59,6 +60,13 @@ export default function Room() { }); } + if (!match) { + // leave room and redirect to interviews page + leaveMatch(); + toast.info("Other user has left"); + router.push("/interviews"); + } + setLoading(false); // eslint-disable-next-line react-hooks/exhaustive-deps }, [match, questionId]); From c250df1a2f47a1313ec79c34458e0dbe278b8791 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Sat, 11 Nov 2023 19:09:09 +0800 Subject: [PATCH 07/36] fixed bug --- frontend/src/hooks/useCollaboration.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/hooks/useCollaboration.tsx b/frontend/src/hooks/useCollaboration.tsx index 1b1b6b36..0b678849 100644 --- a/frontend/src/hooks/useCollaboration.tsx +++ b/frontend/src/hooks/useCollaboration.tsx @@ -70,7 +70,7 @@ const useCollaboration = ({ // }, [id, currentUser]); useEffect(() => { - if (currentUser) { + if (currentUser && roomId) { currentUser.getIdToken(true).then((token) => { const socketConnection = io(wsCollaborationProxyGatewayAddress, { extraHeaders: { From 6e384809a7958ba0a88f2cbb1054a718ceecaa49 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Sun, 12 Nov 2023 16:24:54 +0800 Subject: [PATCH 08/36] fixed locked loading state --- frontend/src/pages/interviews/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/pages/interviews/index.tsx b/frontend/src/pages/interviews/index.tsx index 5ea7b28f..3f730c22 100644 --- a/frontend/src/pages/interviews/index.tsx +++ b/frontend/src/pages/interviews/index.tsx @@ -98,6 +98,10 @@ export default function Interviews() { } setIsLoading(false); }); + } else { + setTimeout(() => { + setIsLoading(false); + }, 2000); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentUser]); From 4aabdcbb6a2a7291a033850aa83b760f9eba0dd3 Mon Sep 17 00:00:00 2001 From: chunweii <47494777+chunweii@users.noreply.github.com> Date: Sun, 12 Nov 2023 17:16:41 +0800 Subject: [PATCH 09/36] Add timer to find-match --- frontend/src/pages/interviews/find-match.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/interviews/find-match.tsx b/frontend/src/pages/interviews/find-match.tsx index e2d6428f..7550cb6e 100644 --- a/frontend/src/pages/interviews/find-match.tsx +++ b/frontend/src/pages/interviews/find-match.tsx @@ -2,7 +2,7 @@ import Loader from "@/components/interviews/loader"; import { Button } from "@/components/ui/button"; import { TypographyBody, TypographyH2 } from "@/components/ui/typography"; import { useRouter } from "next/router"; -import { useEffect } from "react"; +import { use, useEffect, useState } from "react"; import { useMatchmaking } from "@/hooks/useMatchmaking"; export default function FindMatch() { @@ -10,12 +10,22 @@ export default function FindMatch() { const { match, cancelLooking } = useMatchmaking(); const { query } = router; const { retry } = query; + const [timeElapsed, setTimeElapsed] = useState(0); const onClickCancel = () => { cancelLooking(); router.push("/interviews"); }; + useEffect(() => { + const interval = setInterval(() => { + setTimeElapsed((prev) => prev + 1); + }, 1000); + return () => { + clearInterval(interval); + }; + }, []); + useEffect(() => { if (retry) { router.push("/interviews"); @@ -43,7 +53,7 @@ export default function FindMatch() {
Finding a match for your interview prep... - Estimated time: 25 secs + Time elapsed: {timeElapsed} secs
From 536b279e425b3ea3420e8c4b158b73a248394c79 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Sun, 12 Nov 2023 17:47:59 +0800 Subject: [PATCH 10/36] fixed small bugs and update user flow for question submission --- frontend/src/components/profile/columns.tsx | 10 +++--- .../src/components/profile/data-table.tsx | 25 +++++++++------ frontend/src/components/room/code-editor.tsx | 32 ++++++++++++------- frontend/src/pages/questions/[id]/index.tsx | 4 +-- 4 files changed, 44 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/profile/columns.tsx b/frontend/src/components/profile/columns.tsx index 2f0cea8f..73d9cf15 100644 --- a/frontend/src/components/profile/columns.tsx +++ b/frontend/src/components/profile/columns.tsx @@ -1,5 +1,5 @@ -import { Attempt } from "@/types/UserTypes" -import { ColumnDef } from "@tanstack/react-table" +import { Attempt } from "@/types/UserTypes"; +import { ColumnDef } from "@tanstack/react-table"; import { Button } from "../ui/button"; export const columns: ColumnDef[] = [ @@ -16,7 +16,7 @@ export const columns: ColumnDef[] = [ header: "Status", cell: ({ row }) => { const solved = row.getValue("solved") as boolean; - return (solved ?
Solved
: "Unsolved"); + return solved ?
Solved
: "Unsolved"; }, }, { @@ -26,6 +26,8 @@ export const columns: ColumnDef[] = [ const timeCreated = row.getValue("time_created") as Date; return timeCreated.toLocaleString(); }, + enableSorting: true, + sortDescFirst: true, }, { id: "actions", @@ -46,4 +48,4 @@ export const columns: ColumnDef[] = [ ); }, }, -] +]; diff --git a/frontend/src/components/profile/data-table.tsx b/frontend/src/components/profile/data-table.tsx index a9bc6627..da262c2e 100644 --- a/frontend/src/components/profile/data-table.tsx +++ b/frontend/src/components/profile/data-table.tsx @@ -3,7 +3,8 @@ import { flexRender, getCoreRowModel, useReactTable, -} from "@tanstack/react-table" + getSortedRowModel, +} from "@tanstack/react-table"; import { Table, @@ -12,11 +13,11 @@ import { TableHead, TableHeader, TableRow, -} from "@/components/ui/table" +} from "@/components/ui/table"; interface DataTableProps { - columns: ColumnDef[] - data: TData[] + columns: ColumnDef[]; + data: TData[]; } export function DataTable({ @@ -27,7 +28,11 @@ export function DataTable({ data, columns, getCoreRowModel: getCoreRowModel(), - }) + initialState: { + sorting: [{ id: "time_created", desc: true }], + }, + getSortedRowModel: getSortedRowModel(), + }); return (
@@ -41,11 +46,11 @@ export function DataTable({ {header.isPlaceholder ? null : flexRender( - header.column.columnDef.header, - header.getContext() - )} + header.column.columnDef.header, + header.getContext() + )} - ) + ); })} ))} @@ -74,5 +79,5 @@ export function DataTable({
- ) + ); } diff --git a/frontend/src/components/room/code-editor.tsx b/frontend/src/components/room/code-editor.tsx index 4046a112..d9421681 100644 --- a/frontend/src/components/room/code-editor.tsx +++ b/frontend/src/components/room/code-editor.tsx @@ -37,7 +37,7 @@ type CodeEditorProps = { onChange: React.Dispatch>; onCursorChange?: React.Dispatch>; hasRoom?: boolean; - onSubmitClick?: (param: string) => void; + onSubmitClick?: (param: string, solved: boolean) => void; onLeaveRoomClick?: () => void; }; @@ -114,13 +114,13 @@ export default function CodeEditor({ [onChange, onCursorChange, monacoInstance] ); - const handleOnSubmitClick = async () => { + const handleOnSubmitClick = async (solved: boolean) => { if (isSubmitting) { return; // Do nothing if a submission is already in progress. } setIsSubmitting(true); try { - onSubmitClick(monacoInstance?.getValue() ?? value); + onSubmitClick(monacoInstance?.getValue() ?? value, solved); } catch (error) { console.log(error); } @@ -198,14 +198,24 @@ export default function CodeEditor({ Leave Room ) : ( - +
+ + +
)}
diff --git a/frontend/src/pages/questions/[id]/index.tsx b/frontend/src/pages/questions/[id]/index.tsx index 14ac3ac0..f6716dfe 100644 --- a/frontend/src/pages/questions/[id]/index.tsx +++ b/frontend/src/pages/questions/[id]/index.tsx @@ -43,12 +43,12 @@ export default function Questions() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [questionId, authIsReady, currentUser]); - function onSubmitClick(value: string) { + function onSubmitClick(value: string, solved: boolean) { postAttempt({ uid: currentUser ? currentUser.uid : "user", question_id: questionId, answer: value || answer, - solved: true, // assume true + solved: solved, // assume true }) .catch((err: any) => { console.log(err); From 6fcb29ffeb173e801d89276bdd940590138f1893 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Sun, 12 Nov 2023 18:04:40 +0800 Subject: [PATCH 11/36] update code editor to allow for language change --- frontend/src/components/room/code-editor.tsx | 16 +++++++++++----- frontend/src/pages/questions/[id]/index.tsx | 2 ++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/room/code-editor.tsx b/frontend/src/components/room/code-editor.tsx index d9421681..0dbeb2f5 100644 --- a/frontend/src/components/room/code-editor.tsx +++ b/frontend/src/components/room/code-editor.tsx @@ -72,6 +72,7 @@ export default function CodeEditor({ }: CodeEditorProps) { const [open, setOpen] = React.useState(false); const [value, setValue] = React.useState(""); + const [frameWork, setFrameWork] = React.useState(language); // default to python const [isSubmitting, setIsSubmitting] = React.useState(false); const [monacoInstance, setMonacoInstance] = @@ -137,8 +138,8 @@ export default function CodeEditor({ aria-expanded={open} className="w-[240px] justify-between" > - {value - ? languages.find((framework) => framework.value === value) + {frameWork + ? languages.find((framework) => framework.value === frameWork) ?.label : "Select framework..."} @@ -153,14 +154,18 @@ export default function CodeEditor({ { - setValue(currentValue === value ? "" : currentValue); + setFrameWork( + currentValue === frameWork ? "" : currentValue + ); setOpen(false); }} > {framework.label} @@ -183,8 +188,9 @@ export default function CodeEditor({
Date: Sun, 12 Nov 2023 18:19:13 +0800 Subject: [PATCH 12/36] Do not redirect if the router query is undefined --- frontend/src/pages/attempt/[id]/index.tsx | 3 ++- frontend/src/pages/questions/[id]/index.tsx | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/attempt/[id]/index.tsx b/frontend/src/pages/attempt/[id]/index.tsx index 6633ddf1..829b6305 100644 --- a/frontend/src/pages/attempt/[id]/index.tsx +++ b/frontend/src/pages/attempt/[id]/index.tsx @@ -21,7 +21,8 @@ export default function Page() { const [loadingState, setLoadingState] = useState<"loading" | "error" | "success">("loading"); useEffect(() => { - if (attemptId === undefined || Array.isArray(attemptId)) { + if (!attemptId) return; + if (Array.isArray(attemptId)) { router.push("/profile"); return; } diff --git a/frontend/src/pages/questions/[id]/index.tsx b/frontend/src/pages/questions/[id]/index.tsx index 14ac3ac0..8ce9919e 100644 --- a/frontend/src/pages/questions/[id]/index.tsx +++ b/frontend/src/pages/questions/[id]/index.tsx @@ -22,6 +22,10 @@ export default function Questions() { const { user: currentUser, authIsReady } = useContext(AuthContext); useEffect(() => { + if (!authIsReady || !questionId) { + console.log("auth not ready or questionId not found"); + return; + }; if (currentUser) { fetchQuestion(currentUser, questionId) .then((question) => { From 0f83c3635c161598290c86406fd0f29b8481f07d Mon Sep 17 00:00:00 2001 From: chunweii <47494777+chunweii@users.noreply.github.com> Date: Sun, 12 Nov 2023 18:29:11 +0800 Subject: [PATCH 13/36] Remove extra semicolon thanks to linter --- frontend/src/pages/questions/[id]/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/questions/[id]/index.tsx b/frontend/src/pages/questions/[id]/index.tsx index 9b6c20a6..7137f507 100644 --- a/frontend/src/pages/questions/[id]/index.tsx +++ b/frontend/src/pages/questions/[id]/index.tsx @@ -26,7 +26,7 @@ export default function Questions() { if (!authIsReady || !questionId) { console.log("auth not ready or questionId not found"); return; - }; + } if (currentUser) { fetchQuestion(currentUser, questionId) .then((question) => { From defe45f1fe281cd601066b821eeb5dd6ead81751 Mon Sep 17 00:00:00 2001 From: Lee Chun Wei <47494777+chunweii@users.noreply.github.com> Date: Sun, 12 Nov 2023 18:52:46 +0800 Subject: [PATCH 14/36] Fix swap question (#242) Fixes #240 - Now users can swap to another random question. - Requires the other user to acknowledge. --------- Co-authored-by: Ong Jun Xiong --- frontend/src/hooks/useMatch.tsx | 5 +- frontend/src/pages/api/matchHandler.ts | 3 +- frontend/src/pages/api/questionHandler.ts | 10 +-- frontend/src/pages/room/[id].tsx | 39 ++++++++--- .../src/providers/MatchmakingProvider.tsx | 53 +++++++++++++-- .../collaboration-service/src/routes/room.ts | 2 +- services/matching-service/src/app.ts | 3 + .../src/controllers/matchingController.ts | 68 ++++++++++++++++++- services/question-service/src/routes/index.ts | 30 +++++++- 9 files changed, 187 insertions(+), 26 deletions(-) diff --git a/frontend/src/hooks/useMatch.tsx b/frontend/src/hooks/useMatch.tsx index d62b88db..4f5f95b2 100644 --- a/frontend/src/hooks/useMatch.tsx +++ b/frontend/src/hooks/useMatch.tsx @@ -18,10 +18,11 @@ export const useMatch = () => { const updateQuestionIdInMatch = async ( roomId: string, - questionId: string + questionTitle: string, + questionId: string, ) => { if (authIsReady && currentUser) { - await patchMatchQuestionByRoomidApi(currentUser, roomId, questionId); + await patchMatchQuestionByRoomidApi(currentUser, roomId, questionId, questionTitle); } }; diff --git a/frontend/src/pages/api/matchHandler.ts b/frontend/src/pages/api/matchHandler.ts index 5d124e9a..73bd3ad0 100644 --- a/frontend/src/pages/api/matchHandler.ts +++ b/frontend/src/pages/api/matchHandler.ts @@ -44,7 +44,8 @@ export const getMatchByRoomid = async (user: any, roomId: string) => { export const patchMatchQuestionByRoomid = async ( user: any, roomId: string, - questionId: string + questionId: string, + questionTitle: string, ) => { try { const url = `${matchApiPathAddress}match/${roomId}`; diff --git a/frontend/src/pages/api/questionHandler.ts b/frontend/src/pages/api/questionHandler.ts index 206ee7ef..8a2d4e32 100644 --- a/frontend/src/pages/api/questionHandler.ts +++ b/frontend/src/pages/api/questionHandler.ts @@ -10,12 +10,14 @@ export const fetchRandomQuestion = async ( topics: string[] = [] ) => { try { - const url = `${questionApiPathAddress}random-question`; + const url = `${questionApiPathAddress}random-question?`; const idToken = await user.getIdToken(true); - const response = await fetch(url, { - method: "POST", - body: JSON.stringify({ difficulty, topics }), + const response = await fetch(url + new URLSearchParams({ + difficulty: difficulty === "any" ? ["easy", "medium", "hard"][Math.floor(Math.random()*3)] : difficulty, + topics: topics.join(",") + }), { + method: "GET", headers: { "Content-Type": "application/json", "User-Id-Token": idToken, diff --git a/frontend/src/pages/room/[id].tsx b/frontend/src/pages/room/[id].tsx index 118b480a..ccf6c9b2 100644 --- a/frontend/src/pages/room/[id].tsx +++ b/frontend/src/pages/room/[id].tsx @@ -15,6 +15,8 @@ import { useMatchmaking } from "@/hooks/useMatchmaking"; import Solution from "@/components/room/solution"; import { AuthContext } from "@/contexts/AuthContext"; import { toast } from "react-toastify"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; export default function Room() { const router = useRouter(); @@ -43,16 +45,12 @@ export default function Room() { const [loading, setLoading] = useState(true); // to be used later for loading states const { fetchQuestion, fetchRandomQuestion } = useQuestions(); - const { updateQuestionIdInMatch } = useMatch(); - const { match, leaveMatch } = useMatchmaking(); + const { match, leaveMatch, changeQuestion, requestToChangeQuestion, setRequestToChangeQuestion } = useMatchmaking(); useEffect(() => { if (match && match.questionId !== null) { const questionId = match.questionId; setQuestionId(questionId); - } - - if (questionId !== "") { fetchQuestion(questionId).then((fetchQuestion) => { if (fetchQuestion != null) { setQuestion(fetchQuestion); @@ -78,9 +76,8 @@ export default function Room() { fetchRandomQuestion(difficulty) .then((question) => { if (question) { - updateQuestionIdInMatch(roomId, question.id); - setQuestion(question); - setQuestionId(question.id); + changeQuestion(question.id, question.title, roomId); + toast.info("Please wait for other user to accept."); } }) .catch((err) => { @@ -99,6 +96,13 @@ export default function Room() { router.push("/interviews"); } + function replyToChangeRequest(accept: boolean): void { + if (requestToChangeQuestion) { + requestToChangeQuestion.cb(accept); + setRequestToChangeQuestion(null); + } + } + return (
{!router.isReady ? ( @@ -107,6 +111,25 @@ export default function Room() {
) : (
+ {requestToChangeQuestion && ( + + + Do you agree to this change? + + The other user requested to change question to {requestToChangeQuestion.questionTitle}. + + + + replyToChangeRequest(true)}> + Yes + + replyToChangeRequest(false)}> + No + + + + )} +
diff --git a/frontend/src/providers/MatchmakingProvider.tsx b/frontend/src/providers/MatchmakingProvider.tsx index a5b7ea12..0645d624 100644 --- a/frontend/src/providers/MatchmakingProvider.tsx +++ b/frontend/src/providers/MatchmakingProvider.tsx @@ -14,6 +14,11 @@ import { toast } from "react-toastify"; const SERVER_URL = wsMatchProxyGatewayAddress; +interface RequestToChangeQuestion { + questionTitle: string, + cb: (x: boolean) => void, +} + interface MatchmakingContextValue { socket: Socket | null; match: Match | null; @@ -23,6 +28,9 @@ interface MatchmakingContextValue { sendMessage: (message: string) => void; leaveMatch: () => void; cancelLooking: () => void; + changeQuestion: (questionId: string, questionTitle: string, roomId: string) => void; + requestToChangeQuestion: RequestToChangeQuestion | null; + setRequestToChangeQuestion: React.Dispatch>; } export const MatchmakingContext = createContext< @@ -40,6 +48,7 @@ export const MatchmakingProvider: React.FC = ({ const [match, setMatch] = useState(null); const [message, setMessage] = useState(""); const [error, setError] = useState(""); + const [requestToChangeQuestion, setRequestToChangeQuestion] = useState(null); const { user: currentUser, authIsReady } = useContext(AuthContext); const router = useRouter(); @@ -61,16 +70,20 @@ export const MatchmakingProvider: React.FC = ({ "User-Id-Token": token, }, }); - setSocket(newSocket); + setSocket((oldSocket) => { + if (oldSocket) { + oldSocket.close(); + } + return newSocket; + }); newSocket.connect(); console.log("Socket connected"); - - return () => { - newSocket.close(); - }; }); } + return () => { + socket?.close(); + }; }, [currentUser]); useEffect(() => { @@ -120,6 +133,23 @@ export const MatchmakingProvider: React.FC = ({ console.log("Disconnected from server"); }); + socket.on("changeQuestion", (questionTitle: string, cb: (x: boolean) => void) => { + setRequestToChangeQuestion({questionTitle, cb}); + }); + + socket.on("questionChanged", (questionId: string) => { + toast.info("Question is changed"); + setMatch((prevMatch) => { + if (prevMatch) { + return { + ...prevMatch, + questionId: questionId, + }; + } + return prevMatch; + }); + }); + return () => { socket.off("connect"); socket.off("matchFound"); @@ -127,6 +157,8 @@ export const MatchmakingProvider: React.FC = ({ socket.off("receiveMessage"); socket.off("error"); socket.off("disconnect"); + socket.off("changeQuestion"); + socket.off("questionChanged"); }; }, [socket]); @@ -160,7 +192,13 @@ export const MatchmakingProvider: React.FC = ({ socket.emit("cancelLooking"); }, [socket]); - const value = { + const changeQuestion = useCallback((questionId: string, questionTitle: string, roomId: string) => { + if (!socket) return; + + socket.emit("changeQuestion", questionId, questionTitle, roomId); + }, [socket]); + + const value: MatchmakingContextValue = { socket, match, message, @@ -169,6 +207,9 @@ export const MatchmakingProvider: React.FC = ({ sendMessage, leaveMatch, cancelLooking, + changeQuestion, + requestToChangeQuestion, + setRequestToChangeQuestion }; return ( diff --git a/services/collaboration-service/src/routes/room.ts b/services/collaboration-service/src/routes/room.ts index 7b5a30e5..6ce0bbce 100644 --- a/services/collaboration-service/src/routes/room.ts +++ b/services/collaboration-service/src/routes/room.ts @@ -285,7 +285,7 @@ export const roomRouter = (io: Server) => { }); // WebSocket style API - io.on("connection", (socket: Socket) => { + io.once("connection", (socket: Socket) => { console.log("Room.ts: User connected:", socket.id); socket.on(SocketEvents.ROOM_JOIN, (room_id: string, user_id: string) => { diff --git a/services/matching-service/src/app.ts b/services/matching-service/src/app.ts index 8aac8869..308c9089 100644 --- a/services/matching-service/src/app.ts +++ b/services/matching-service/src/app.ts @@ -7,6 +7,7 @@ import { handleDisconnect, handleJoinRoom, handleLooking, + handleChangeQuestion, } from "./controllers/matchingController"; import { handleCancelLooking } from "./controllers/matchingController"; import { handleLeaveMatch } from "./controllers/matchingController"; @@ -57,6 +58,8 @@ io.on("connection", async (socket) => { socket.on("joinRoom", handleJoinRoom(userId, socket)); + socket.on("changeQuestion", handleChangeQuestion(userId, socket, io)); + }); httpServer.listen(port, () => { diff --git a/services/matching-service/src/controllers/matchingController.ts b/services/matching-service/src/controllers/matchingController.ts index 3ea1a409..eb0d8a79 100644 --- a/services/matching-service/src/controllers/matchingController.ts +++ b/services/matching-service/src/controllers/matchingController.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import { Socket } from "socket.io"; +import { Server, Socket } from "socket.io"; import { io } from "../app"; import prisma from "../prismaClient"; import { getRandomQuestionOfDifficulty } from "../questionAdapter"; @@ -307,6 +307,72 @@ export function handleSendMessage( }; } +export function handleChangeQuestion(userId: string, socket: Socket, io: Server) { + return async (questionId: string, questionName: string, room_id: string) => { + if (!questionId || !userId || !questionName || !room_id) { + console.log(`Invalid request from user ${userId}`); + socket.emit("error", "Invalid request"); + return; + } + + console.log(`User ${userId} intends to change the question to ${questionName}`); + + let hasError = false; + const match = await prisma.match + .findFirst({ + where: { + roomId: room_id, + OR: [{ userId1: userId }, { userId2: userId }], // Ensures that the user is really part of the match + }, + }) + .catch((err) => { + console.log(err); + socket.emit("error", "An error occurred in sendMessage."); + hasError = true; + }); + + if (hasError) { + return; + } + + const otherSockets = (await io.in(room_id).fetchSockets()).filter(sock => socket.id !== sock.id); + + console.log(`There are ${otherSockets.length} other sockets in the room`); + + if (otherSockets.length !== 1) { + socket.emit("error", "The number of users in the room is not correct."); + return; + } + + otherSockets[0].timeout(30*1000).emit("changeQuestion", questionName, (err: any, ok: any) => { + if (err) { + socket.emit("error", "Your partner has not provided a valid acknowledgement, you cannot change questions."); + return; + } + if (!ok) { + socket.emit("error", "Your partner does not agree to change questions."); + return; + } + prisma.match.update({ + where: { + roomId: room_id, + }, + data: { + questionId: questionId, + }, + }).then((match) => { + io.to(room_id).emit("questionChanged", questionId); + }).catch((err) => { + console.log(err); + io.to(room_id).emit("error", "Unable to change questions"); + }); + }); + }; +} + +/** + * @deprecated Use the handleChangeQuestion function instead with socket.io + */ export async function updateMatchQuestion(req: Request, res: Response) { const room_id = req.params.room_id as string; diff --git a/services/question-service/src/routes/index.ts b/services/question-service/src/routes/index.ts index 0b96647a..4bdeb692 100644 --- a/services/question-service/src/routes/index.ts +++ b/services/question-service/src/routes/index.ts @@ -1,5 +1,5 @@ import util from "util"; -import express from "express"; +import express, { Response } from "express"; import sanitizeHtml from "sanitize-html"; import { MongoClient, ObjectId, ServerApiVersion } from "mongodb"; import { NewQuestion, isDifficulty } from "../models/new_question.model"; @@ -285,9 +285,14 @@ router.delete("/question/:id", async (req, res, next) => { } }); +/** + * Deprecated: Use GET /random-question instead, with query params: + * - difficulty: string + * - topics: string separated by commas + */ router.post("/random-question", async (req, res, next) => { /** - * #swagger.description = 'Get a random question.' + * #swagger.description = 'Get a random question. Deprecated: Use GET /random-question instead, with query params: difficulty: string, topics: string separated by commas' * #swagger.parameters['difficulty'] = { description: 'Difficulty of the question.', type: 'string' } * #swagger.parameters['topics'] = { description: 'Array of topics of the question to choose.', type: 'array' } */ @@ -297,6 +302,25 @@ router.post("/random-question", async (req, res, next) => { } let difficulty = req.body.difficulty; let topics = req.body.topics ?? []; + await getRandomQuestion(topics, difficulty, res); +}); + +router.get("/random-question", async (req, res, next) => { + /** + * #swagger.description = 'Get a random question.' + * #swagger.parameters['difficulty'] = { description: 'Difficulty of the question.', type: 'string' } + * #swagger.parameters['topics'] = { description: 'Topics of the question to choose, separated by commas', type: 'string' } + */ + if (!isDifficulty(req.query.difficulty as string)) { + res.status(400).send("Invalid difficulty"); + return; + } + let difficulty = req.query.difficulty as string; + let topics = req.query.topics ? (req.query.topics as string).split(",") : []; + await getRandomQuestion(topics, difficulty, res); +}); + +async function getRandomQuestion(topics: string[], difficulty: string, res: Response) { try { let db = mongoClient.db("question_db"); let collection = db.collection("questions"); @@ -319,4 +343,4 @@ router.post("/random-question", async (req, res, next) => { ); res.status(500).send("Failed to get random question"); } -}); +} From d94259c9981ee5bc838c7f12926e1a6f3c2e1707 Mon Sep 17 00:00:00 2001 From: Lee Chun Wei <47494777+chunweii@users.noreply.github.com> Date: Sun, 12 Nov 2023 21:05:21 +0800 Subject: [PATCH 15/36] Fix duplicate sockets and display name in video (#247) Often, the frontend makes so many duplicate socket connections to the backend. Also, the video room display name is currently always set to the user id, which is not human-readable... And we should not allow any unauthorized user to join a room in collaboration service. This PR fixes these problems. --- frontend/src/components/room/video-room.tsx | 24 +++++++++++++------ frontend/src/hooks/useCollaboration.tsx | 23 ++++++++++-------- frontend/src/pages/room/[id].tsx | 15 ++++++++---- .../src/providers/MatchmakingProvider.tsx | 5 +++- .../collaboration-service/src/db/prisma-db.ts | 15 ++++-------- .../collaboration-service/src/routes/room.ts | 24 +++++++++---------- .../src/controllers/matchingController.ts | 3 ++- 7 files changed, 63 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/room/video-room.tsx b/frontend/src/components/room/video-room.tsx index 5a322815..e9b24c4b 100644 --- a/frontend/src/components/room/video-room.tsx +++ b/frontend/src/components/room/video-room.tsx @@ -1,16 +1,18 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { LocalParticipant, LocalVideoTrack, Participant, RemoteParticipant, RemoteAudioTrack, RemoteVideoTrack, Room, Track } from 'twilio-video'; import { Button } from '../ui/button'; import { Mic, MicOff, Video, VideoOff } from 'lucide-react'; +import { useUser } from '@/hooks/useUser'; +import { AuthContext } from '../../contexts/AuthContext'; interface VideoRoomProps { room: Room | null; className?: string; } -function SingleVideoTrack({ track, userId, isLocal, isMute, toggleMute, isCameraOn, toggleCamera }: +function SingleVideoTrack({ track, userId, displayName, isLocal, isMute, toggleMute, isCameraOn, toggleCamera }: { - track: RemoteVideoTrack | LocalVideoTrack, userId: string, isLocal: boolean, + track: RemoteVideoTrack | LocalVideoTrack, userId: string, displayName: string, isLocal: boolean, isMute: boolean, toggleMute: () => void, isCameraOn: boolean, toggleCamera: () => void }) { @@ -31,7 +33,7 @@ function SingleVideoTrack({ track, userId, isLocal, isMute, toggleMute, isCamera
-

{userId}

+

{displayName}

{isLocal ?
- - -
handleOnSubmitClick(false)} disabled={isSubmitting} > - Submit as unsolved + Submit as Unsolved
) From 8314d4ec3068f81cdcfa9b7421c48a66d7032e82 Mon Sep 17 00:00:00 2001 From: Ong Jun Xiong Date: Wed, 15 Nov 2023 03:29:47 +0800 Subject: [PATCH 30/36] implement text op --- frontend/src/components/room/code-editor.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/room/code-editor.tsx b/frontend/src/components/room/code-editor.tsx index ebf679f2..539e54ec 100644 --- a/frontend/src/components/room/code-editor.tsx +++ b/frontend/src/components/room/code-editor.tsx @@ -84,6 +84,8 @@ export default function CodeEditor({ setMonacoInstance(editorL); }; + const [previousText, setPreviousText] = React.useState(text); + const setCursorPosition = React.useCallback( (cursor: number) => { if (!monacoInstance) return; @@ -94,10 +96,23 @@ export default function CodeEditor({ [monacoInstance] ); + const updateCursorPosition = (prevText: any, newText: any) => { + if (!monacoInstance) return; + if (!cursor) return; + if (prevText.slice(0, cursor) !== newText.slice(0, cursor)) { + return true; + } + return false; + }; + React.useEffect(() => { if (cursor !== undefined) { console.log(cursor); - setCursorPosition(cursor); + if (updateCursorPosition(previousText, text)) { + setCursorPosition(cursor + 1); + } else { + setCursorPosition(cursor); + } } monacoInstance?.onDidChangeCursorPosition((e) => { @@ -122,6 +137,7 @@ export default function CodeEditor({ onCursorChange(cursor); } onChange(value); + setPreviousText(value); }, [onChange, onCursorChange, monacoInstance] ); From 1b3fc0938d9c353d6fbf4b7a8bb401b6764cf554 Mon Sep 17 00:00:00 2001 From: Charisma Kausar Date: Wed, 15 Nov 2023 07:00:27 +0800 Subject: [PATCH 31/36] style: increase z index of cancel search button --- frontend/src/components/interviews/loader.tsx | 2 +- frontend/src/pages/interviews/find-match.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/interviews/loader.tsx b/frontend/src/components/interviews/loader.tsx index eeeb6c70..0bd10c34 100644 --- a/frontend/src/components/interviews/loader.tsx +++ b/frontend/src/components/interviews/loader.tsx @@ -1,6 +1,6 @@ export default function Loader() { return ( -
+
diff --git a/frontend/src/pages/interviews/find-match.tsx b/frontend/src/pages/interviews/find-match.tsx index 7550cb6e..5d3e8449 100644 --- a/frontend/src/pages/interviews/find-match.tsx +++ b/frontend/src/pages/interviews/find-match.tsx @@ -58,7 +58,7 @@ export default function FindMatch() { -
From d95a738a8436251d5696fe190a4f3917be50cac6 Mon Sep 17 00:00:00 2001 From: Charisma Kausar <68203159+ckcherry23@users.noreply.github.com> Date: Wed, 15 Nov 2023 07:01:33 +0800 Subject: [PATCH 32/36] feat: add leaderboard api (#258) Co-authored-by: chunweii <47494777+chunweii@users.noreply.github.com> --- .../interviews/leaderboard/columns.tsx | 12 ++-- frontend/src/hooks/useLeaderboard.tsx | 17 +++++ frontend/src/pages/api/leaderboardHandler.ts | 31 ++++++++ frontend/src/pages/interviews/index.tsx | 70 ++++++------------- frontend/src/pages/profile/[id]/index.tsx | 2 +- frontend/src/pages/questions/new.tsx | 2 +- services/user-service/src/db/functions.ts | 46 ++++++++++-- services/user-service/src/routes/index.ts | 14 ++++ 8 files changed, 133 insertions(+), 61 deletions(-) create mode 100644 frontend/src/hooks/useLeaderboard.tsx create mode 100644 frontend/src/pages/api/leaderboardHandler.ts diff --git a/frontend/src/components/interviews/leaderboard/columns.tsx b/frontend/src/components/interviews/leaderboard/columns.tsx index 0f8d7b74..33c34daf 100644 --- a/frontend/src/components/interviews/leaderboard/columns.tsx +++ b/frontend/src/components/interviews/leaderboard/columns.tsx @@ -4,16 +4,17 @@ import { Button } from "@/components/ui/button"; import { ColumnDef } from "@tanstack/react-table"; export type PublicUser = { + uid: string; displayName: string; attempts: number; - photoURL: string; + photoUrl: string; }; const getInitials = (name: string) => { const names = name.split(" "); let initials = ""; names.forEach((n) => { - initials += n[0].toUpperCase(); + initials += n[0]?.toUpperCase() || ""; }); return initials; }; @@ -24,13 +25,14 @@ export const columns: ColumnDef[] = [ header: "User", cell: ({ row }) => { const displayName = row.getValue("displayName") as string; - const photoURL = row.original.photoURL; + const photoURL = row.original.photoUrl; + const uid = row.original.uid; return (
Leaderboard - +
From a1b682395d672e74350d9f9b649ac881e605f613 Mon Sep 17 00:00:00 2001 From: Charisma Kausar Date: Wed, 15 Nov 2023 07:49:02 +0800 Subject: [PATCH 35/36] fix: lower limit test case size to 1 char --- frontend/src/pages/questions/_form.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/questions/_form.tsx b/frontend/src/pages/questions/_form.tsx index a4635e66..dd475b6f 100644 --- a/frontend/src/pages/questions/_form.tsx +++ b/frontend/src/pages/questions/_form.tsx @@ -22,8 +22,8 @@ export const formSchema = z.object({ difficulty: z.enum(['easy', 'medium', 'hard']), topics: z.array(z.string().min(2).max(100)), description: z.string().min(2).max(10000), - testCasesInputs: z.array(z.string().min(2).max(10000)), - testCasesOutputs: z.array(z.string().min(2).max(10000)), + testCasesInputs: z.array(z.string().min(1).max(10000)), + testCasesOutputs: z.array(z.string().min(1).max(10000)), defaultCode: z.object({ "python": z.string().min(0).max(10000), "java": z.string().min(0).max(10000), @@ -109,7 +109,7 @@ export default function QuestionsForm({ type = "add", loading = false, }: QuestionsFormProps) { - const {testCasesInputs, testCasesOutputs} = form.getValues(); + const { testCasesInputs, testCasesOutputs } = form.getValues(); const [, forceUpdate] = useReducer((x) => x + 1, 0); const createTopic = (label: string) => ({ value: label.toLowerCase(), label }); From 222881474daf4293d0aa3eaebd697cbd75f8f7d4 Mon Sep 17 00:00:00 2001 From: Charisma Kausar Date: Wed, 15 Nov 2023 08:29:01 +0800 Subject: [PATCH 36/36] feat: add error messages for invalid test cases --- frontend/src/pages/questions/_form.tsx | 31 ++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/questions/_form.tsx b/frontend/src/pages/questions/_form.tsx index dd475b6f..487c3c06 100644 --- a/frontend/src/pages/questions/_form.tsx +++ b/frontend/src/pages/questions/_form.tsx @@ -45,7 +45,7 @@ if __name__ == "__main__": target = int(input()) print(" ".join(twoSum(nums, target)))`, -'java': `import java.util.*; + 'java': `import java.util.*; class Solution { public int[] twoSum(int[] nums, int target) { @@ -66,7 +66,7 @@ class Solution { } }`, -'c++': `#include + 'c++': `#include #include using namespace std; @@ -105,7 +105,7 @@ interface QuestionsFormProps { export default function QuestionsForm({ form, onSubmit, - onDelete = () => {}, + onDelete = () => { }, type = "add", loading = false, }: QuestionsFormProps) { @@ -179,7 +179,7 @@ export default function QuestionsForm({ Question Description - { + { field.onChange(e); forceUpdate(); }} /> @@ -210,7 +210,7 @@ export default function QuestionsForm({ })}
+ { form.getFieldState('testCasesInputs').invalid &&

Test case inputs cannot be empty

} + { form.getFieldState('testCasesOutputs').invalid &&

Test case outputs cannot be empty

} Default Java Code { - field.onChange(e); - forceUpdate(); - }} /> + field.onChange(e); + forceUpdate(); + }} />
@@ -261,10 +263,10 @@ export default function QuestionsForm({ Default Python Code - { - field.onChange(e); - forceUpdate(); - }} /> + { + field.onChange(e); + forceUpdate(); + }} /> @@ -293,8 +295,9 @@ export default function QuestionsForm({ ) : (