diff --git a/backend/question-service/src/config/multer.ts b/backend/question-service/src/config/multer.ts index 40e5e1919a..c3ec6c1e95 100644 --- a/backend/question-service/src/config/multer.ts +++ b/backend/question-service/src/config/multer.ts @@ -2,5 +2,9 @@ import multer from "multer"; const storage = multer.memoryStorage(); const upload = multer({ storage }).array("images[]"); +const uploadTestcaseFiles = multer({ storage }).fields([ + { name: "testcaseInputFile", maxCount: 1 }, + { name: "testcaseOutputFile", maxCount: 1 }, +]); -export { upload }; +export { upload, uploadTestcaseFiles }; diff --git a/backend/question-service/src/controllers/questionController.ts b/backend/question-service/src/controllers/questionController.ts index ccce137ba6..56eb302d90 100644 --- a/backend/question-service/src/controllers/questionController.ts +++ b/backend/question-service/src/controllers/questionController.ts @@ -20,10 +20,17 @@ import { MONGO_OBJ_ID_MALFORMED_MESSAGE, } from "../utils/constants.ts"; -import { upload } from "../config/multer.ts"; +import { upload, uploadTestcaseFiles } from "../config/multer.ts"; import { uploadFileToFirebase } from "../utils/utils"; import { QnListSearchFilterParams, RandomQnCriteria } from "../utils/types.ts"; +const FIREBASE_TESTCASE_FILES_FOLDER_NAME = "testcaseFiles/"; + +enum TestcaseFilesUploadRequestTypes { + CREATE = "create", + UPDATE = "update", +} + export const createQuestion = async ( req: Request, res: Response, @@ -34,6 +41,8 @@ export const createQuestion = async ( description, complexity, category, + testcaseInputFileUrl, + testcaseOutputFileUrl, pythonTemplate, javaTemplate, cTemplate, @@ -59,6 +68,8 @@ export const createQuestion = async ( description, complexity, category, + testcaseInputFileUrl, + testcaseOutputFileUrl, }); await newQuestion.save(); @@ -77,6 +88,7 @@ export const createQuestion = async ( question: formatQuestionIndivResponse(newQuestion, newQuestionTemplate), }); } catch (error) { + console.log(error); res.status(500).json({ message: SERVER_ERROR_MESSAGE, error }); } }; @@ -110,6 +122,72 @@ export const createImageLink = async ( }); }; +export const createFileLink = async ( + req: Request, + res: Response, +): Promise => { + uploadTestcaseFiles(req, res, async (err) => { + if (err) { + return res.status(500).json({ + message: "Failed to upload testcase files", + error: err.message, + }); + } + + const isQuestionCreation = + req.body.requestType === TestcaseFilesUploadRequestTypes.CREATE; + + const tcFiles = req.files as { + testcaseInputFile?: Express.Multer.File[]; + testcaseOutputFile?: Express.Multer.File[]; + }; + + if ( + isQuestionCreation && + (!tcFiles || !tcFiles.testcaseInputFile || !tcFiles.testcaseOutputFile) + ) { + return res + .status(400) + .json({ message: "Missing one or both testcase file(s)" }); + } + + try { + const uploadPromises = []; + + if (tcFiles.testcaseInputFile) { + const inputFile = tcFiles.testcaseInputFile[0] as Express.Multer.File; + uploadPromises.push( + uploadFileToFirebase(inputFile, FIREBASE_TESTCASE_FILES_FOLDER_NAME), + ); + } else { + uploadPromises.push(Promise.resolve(null)); + } + + if (tcFiles.testcaseOutputFile) { + const outputFile = tcFiles.testcaseOutputFile[0] as Express.Multer.File; + uploadPromises.push( + uploadFileToFirebase(outputFile, FIREBASE_TESTCASE_FILES_FOLDER_NAME), + ); + } else { + uploadPromises.push(Promise.resolve(null)); + } + + const [tcInputFileUrl, tcOutputFileUrl] = + await Promise.all(uploadPromises); + + return res.status(200).json({ + message: "Files uploaded successfully", + urls: { + testcaseInputFileUrl: tcInputFileUrl || "", + testcaseOutputFileUrl: tcOutputFileUrl || "", + }, + }); + } catch (error) { + return res.status(500).json({ message: "Server error", error }); + } + }); +}; + export const updateQuestion = async ( req: Request, res: Response, @@ -156,9 +234,9 @@ export const updateQuestion = async ( const updatedQuestionTemplate = await QuestionTemplate.findOneAndUpdate( { questionId: id }, { - ...(pythonTemplate !== undefined && { pythonTemplate }), - ...(javaTemplate !== undefined && { javaTemplate }), - ...(cTemplate !== undefined && { cTemplate }), + pythonTemplate, + javaTemplate, + cTemplate, }, { new: true }, ); @@ -304,9 +382,18 @@ export const readRandomQuestion = async ( return; } + const chosenQuestion = randomQuestion[0]; + + const questionTemplate = await QuestionTemplate.findOne({ + questionId: chosenQuestion._id, + }); + res.status(200).json({ message: QN_RETRIEVED_MESSAGE, - question: formatQuestionResponse(randomQuestion[0]), + question: formatQuestionIndivResponse( + chosenQuestion, + questionTemplate as IQuestionTemplate, + ), }); } catch (error) { res.status(500).json({ message: SERVER_ERROR_MESSAGE, error }); @@ -356,6 +443,8 @@ const formatQuestionIndivResponse = ( description: question.description, complexity: question.complexity, categories: question.category, + testcaseInputFileUrl: question.testcaseInputFileUrl, + testcaseOutputFileUrl: question.testcaseOutputFileUrl, pythonTemplate: questionTemplate ? questionTemplate.pythonTemplate : "", javaTemplate: questionTemplate ? questionTemplate.javaTemplate : "", cTemplate: questionTemplate ? questionTemplate.cTemplate : "", diff --git a/backend/question-service/src/models/Question.ts b/backend/question-service/src/models/Question.ts index 40e86f6fb3..c2dd19d491 100644 --- a/backend/question-service/src/models/Question.ts +++ b/backend/question-service/src/models/Question.ts @@ -5,6 +5,8 @@ export interface IQuestion extends Document { description: string; complexity: string; category: string[]; + testcaseInputFileUrl: string; + testcaseOutputFileUrl: string; createdAt: Date; updatedAt: Date; } @@ -18,10 +20,9 @@ const questionSchema: Schema = new mongoose.Schema( enum: ["Easy", "Medium", "Hard"], required: true, }, - category: { - type: [String], - required: true, - }, + category: { type: [String], required: true }, + testcaseInputFileUrl: { type: String, required: true }, + testcaseOutputFileUrl: { type: String, required: true }, }, { timestamps: true }, ); diff --git a/backend/question-service/src/routes/questionRoutes.ts b/backend/question-service/src/routes/questionRoutes.ts index 318f168620..b2d5f1c949 100644 --- a/backend/question-service/src/routes/questionRoutes.ts +++ b/backend/question-service/src/routes/questionRoutes.ts @@ -8,6 +8,7 @@ import { readQuestionIndiv, readCategories, readRandomQuestion, + createFileLink, } from "../controllers/questionController.ts"; import { verifyAdminToken } from "../middlewares/basicAccessControl.ts"; @@ -17,6 +18,8 @@ router.post("/", verifyAdminToken, createQuestion); router.post("/images", verifyAdminToken, createImageLink); +router.post("/tcfiles", verifyAdminToken, createFileLink); + router.put("/:id", verifyAdminToken, updateQuestion); router.get("/categories", readCategories); diff --git a/backend/question-service/src/utils/utils.ts b/backend/question-service/src/utils/utils.ts index 8e93776d23..4bccc53823 100644 --- a/backend/question-service/src/utils/utils.ts +++ b/backend/question-service/src/utils/utils.ts @@ -21,9 +21,10 @@ export const checkIsExistingQuestion = async ( export const uploadFileToFirebase = async ( file: Express.Multer.File, + folderName: string = "", ): Promise => { return new Promise((resolve, reject) => { - const fileName = uuidv4(); + const fileName = folderName + uuidv4(); const ref = bucket.file(fileName); const blobStream = ref.createWriteStream({ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eaafd17673..f685f42ad5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,6 +27,7 @@ "react-router-dom": "^6.3.0", "react-toastify": "^10.0.5", "socket.io-client": "^4.8.0", + "uuid": "^11.0.2", "vite-plugin-svgr": "^4.2.0" }, "devDependencies": { @@ -12966,6 +12967,19 @@ "requires-port": "^1.0.0" } }, + "node_modules/uuid": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.2.tgz", + "integrity": "sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 829b474cb8..7bd11112c0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "react-router-dom": "^6.3.0", "react-toastify": "^10.0.5", "socket.io-client": "^4.8.0", + "uuid": "^11.0.2", "vite-plugin-svgr": "^4.2.0" }, "devDependencies": { diff --git a/frontend/src/components/QuestionCodeTemplates/index.tsx b/frontend/src/components/QuestionCodeTemplates/index.tsx new file mode 100644 index 0000000000..c1506b6118 --- /dev/null +++ b/frontend/src/components/QuestionCodeTemplates/index.tsx @@ -0,0 +1,132 @@ +import { HelpOutlined } from "@mui/icons-material"; +import { + Box, + IconButton, + Stack, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, +} from "@mui/material"; +import { useState } from "react"; +import { CODE_TEMPLATES_TOOLTIP_MESSAGE } from "../../utils/constants"; + +interface QuestionCodeTemplatesProps { + codeTemplates: { + [key: string]: string; + }; + setCodeTemplates: React.Dispatch< + React.SetStateAction<{ + [key: string]: string; + }> + >; +} + +const QuestionCodeTemplates: React.FC = ({ + codeTemplates, + setCodeTemplates, +}) => { + const [selectedLanguage, setSelectedLanguage] = useState("python"); + + const handleLanguageChange = ( + _: React.MouseEvent, + language: string + ) => { + if (language) { + setSelectedLanguage(language); + } + }; + + const handleCodeChange = (event: React.ChangeEvent) => { + const { value } = event.target; + setCodeTemplates((prevTemplates) => ({ + ...prevTemplates, + [selectedLanguage]: value, + })); + }; + + const handleTabKeys = (event: any) => { + const { value } = event.target; + + if (event.key === "Tab") { + event.preventDefault(); + + const cursorPosition = event.target.selectionStart; + const cursorEndPosition = event.target.selectionEnd; + const tab = "\t"; + + event.target.value = + value.substring(0, cursorPosition) + + tab + + value.substring(cursorEndPosition); + + event.target.selectionStart = cursorPosition + 1; + event.target.selectionEnd = cursorPosition + 1; + } + }; + + return ( + + + Code Templates + + + + } + placement="right" + arrow + > + + + + + + + Python + Java + C + + + + + ); +}; + +export default QuestionCodeTemplates; diff --git a/frontend/src/components/QuestionFileContainer/index.tsx b/frontend/src/components/QuestionFileContainer/index.tsx new file mode 100644 index 0000000000..8374dc18ff --- /dev/null +++ b/frontend/src/components/QuestionFileContainer/index.tsx @@ -0,0 +1,78 @@ +import FileUploadIcon from "@mui/icons-material/FileUpload"; +import { Button, styled } from "@mui/material"; +import { toast } from "react-toastify"; + +interface QuestionFileContainerProps { + fileUploadMessage: string; + marginLeft?: number; + marginRight?: number; + file: File | null; + setFile: React.Dispatch>; +} + +const FileUploadInput = styled("input")({ + height: 1, + overflow: "hidden", + position: "absolute", + bottom: 0, + left: 0, + width: 1, +}); + +const QuestionFileContainer: React.FC = ({ + fileUploadMessage, + marginLeft, + marginRight, + file, + setFile, +}) => { + const handleFileUpload = (event: React.ChangeEvent) => { + if (!event.target.files) { + return; + } + + const file = event.target.files[0]; + if (!file.type.startsWith("text/")) { + toast.error(`${file.name} is not a text file`); + return; + } + + if (!file.size) { + toast.error(`${file.name} is empty. Please upload another text file.`); + return; + } + + setFile(file); + }; + + return ( + + ); +}; + +export default QuestionFileContainer; diff --git a/frontend/src/components/QuestionImageContainer/index.tsx b/frontend/src/components/QuestionImageContainer/index.tsx index 1b48bb5e13..9bbc5c38d3 100644 --- a/frontend/src/components/QuestionImageContainer/index.tsx +++ b/frontend/src/components/QuestionImageContainer/index.tsx @@ -41,7 +41,7 @@ const QuestionImageContainer: React.FC = ({ }; const handleImageUpload = async ( - event: React.ChangeEvent, + event: React.ChangeEvent ) => { if (!event.target.files) { return; diff --git a/frontend/src/components/QuestionTestCases/index.tsx b/frontend/src/components/QuestionTestCases/index.tsx new file mode 100644 index 0000000000..65d444d40e --- /dev/null +++ b/frontend/src/components/QuestionTestCases/index.tsx @@ -0,0 +1,151 @@ +import { HelpOutlined } from "@mui/icons-material"; +import { + Box, + Button, + IconButton, + Stack, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { Dispatch, SetStateAction } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { ADD_QUESTION_TEST_CASE_TOOLTIP_MESSAGE } from "../../utils/constants"; + +interface QuestionTestCasesProps { + testCases: TestCase[]; + setTestCases: Dispatch>; +} + +export interface TestCase { + id: string; + input: string; + expectedOutput: string; +} + +const QuestionTestCases: React.FC = ({ + testCases, + setTestCases, +}) => { + const handleAddTestCase = () => { + if (testCases.length < 3) { + setTestCases((testCases) => [ + ...testCases, + { id: uuidv4(), input: "", expectedOutput: "" }, + ]); + } + }; + + const handleDeleteTestCase = (testCaseId: string) => { + setTestCases((testCases) => + testCases.filter((testCase) => testCase.id !== testCaseId) + ); + }; + + const handleInputChange = ( + testCaseId: string, + field: keyof TestCase, + value: string + ) => { + setTestCases((testCases) => + testCases.map((testCase) => + testCase.id === testCaseId ? { ...testCase, [field]: value } : testCase + ) + ); + }; + + return ( + + {testCases.map((testCase, i) => ( + + + + Test Case {i + 1} + {i === 0 ? ( + + + + } + placement="right" + arrow + > + + + + + ) : ( + <> + )} + + + {i === testCases.length - 1 && testCases.length < 3 ? ( + <> + {i === 0 ? ( + <> + ) : ( + + )} + + + ) : ( + + )} + + + + handleInputChange(testCase.id, "input", e.target.value) + } + fullWidth + margin="normal" + /> + + handleInputChange(testCase.id, "expectedOutput", e.target.value) + } + fullWidth + margin="normal" + /> + + ))} + + ); +}; + +export default QuestionTestCases; diff --git a/frontend/src/components/QuestionTestCasesFileUpload/index.tsx b/frontend/src/components/QuestionTestCasesFileUpload/index.tsx new file mode 100644 index 0000000000..f2963ea9e1 --- /dev/null +++ b/frontend/src/components/QuestionTestCasesFileUpload/index.tsx @@ -0,0 +1,62 @@ +import { Box, IconButton, Stack, Tooltip, Typography } from "@mui/material"; +import { HelpOutlined } from "@mui/icons-material"; +import QuestionFileContainer from "../QuestionFileContainer"; +import { ADD_TEST_CASE_FILES_TOOLTIP_MESSAGE } from "../../utils/constants"; + +interface QuestionTestCasesFileUploadProps { + testcaseInputFile: File | null; + setTestcaseInputFile: React.Dispatch>; + testcaseOutputFile: File | null; + setTestcaseOutputFile: React.Dispatch>; +} + +const QuestionTestCasesFileUpload: React.FC< + QuestionTestCasesFileUploadProps +> = ({ + testcaseInputFile, + setTestcaseInputFile, + testcaseOutputFile, + setTestcaseOutputFile, +}) => { + return ( + + + Test Cases File Upload + + + + } + placement="right" + arrow + > + + + + + + + + + + + + ); +}; + +export default QuestionTestCasesFileUpload; diff --git a/frontend/src/pages/NewQuestion/index.tsx b/frontend/src/pages/NewQuestion/index.tsx index fe029ed9b7..e53a890b69 100644 --- a/frontend/src/pages/NewQuestion/index.tsx +++ b/frontend/src/pages/NewQuestion/index.tsx @@ -16,9 +16,12 @@ import { toast } from "react-toastify"; import { ABORT_CREATE_OR_EDIT_QUESTION_CONFIRMATION_MESSAGE, + C_CODE_TEMPLATE, complexityList, FAILED_QUESTION_CREATE, FILL_ALL_FIELDS, + JAVA_CODE_TEMPLATE, + PYTHON_CODE_TEMPLATE, SUCCESS_QUESTION_CREATE, } from "../../utils/constants"; import AppMargin from "../../components/AppMargin"; @@ -26,6 +29,8 @@ import QuestionMarkdown from "../../components/QuestionMarkdown"; import QuestionImageContainer from "../../components/QuestionImageContainer"; import QuestionCategoryAutoComplete from "../../components/QuestionCategoryAutoComplete"; import QuestionDetail from "../../components/QuestionDetail"; +import QuestionTestCasesFileUpload from "../../components/QuestionTestCasesFileUpload"; +import QuestionCodeTemplates from "../../components/QuestionCodeTemplates"; const NewQuestion = () => { const navigate = useNavigate(); @@ -41,9 +46,18 @@ const NewQuestion = () => { const [uploadedImagesUrl, setUploadedImagesUrl] = useState([]); const [isPreviewQuestion, setIsPreviewQuestion] = useState(false); - const [pythonTemplate, setPythonTemplate] = useState(""); - const [javaTemplate, setJavaTemplate] = useState(""); - const [cTemplate, setCTemplate] = useState(""); + const [testcaseInputFile, setTestcaseInputFile] = useState(null); + const [testcaseOutputFile, setTestcaseOutputFile] = useState( + null + ); + + const [codeTemplates, setCodeTemplates] = useState<{ [key: string]: string }>( + { + python: PYTHON_CODE_TEMPLATE, + java: JAVA_CODE_TEMPLATE, + c: C_CODE_TEMPLATE, + } + ); const handleBack = () => { if ( @@ -64,7 +78,10 @@ const NewQuestion = () => { !title || !markdownText || !selectedComplexity || - selectedCategories.length === 0 + selectedCategories.length === 0 || + testcaseInputFile === null || + testcaseOutputFile === null || + Object.values(codeTemplates).some((value) => value === "") ) { toast.error(FILL_ALL_FIELDS); return; @@ -76,9 +93,13 @@ const NewQuestion = () => { description: markdownText, complexity: selectedComplexity, categories: selectedCategories, - pythonTemplate, - javaTemplate, - cTemplate, + pythonTemplate: codeTemplates.python, + javaTemplate: codeTemplates.java, + cTemplate: codeTemplates.c, + }, + { + testcaseInputFile: testcaseInputFile, + testcaseOutputFile: testcaseOutputFile, }, dispatch ); @@ -142,21 +163,16 @@ const NewQuestion = () => { setMarkdownText={setMarkdownText} /> - {/* for the FE ppl to redesign... */} - setPythonTemplate(e.target.value)} + - setJavaTemplate(e.target.value)} - /> - setCTemplate(e.target.value)} + + )} diff --git a/frontend/src/pages/QuestionEdit/index.tsx b/frontend/src/pages/QuestionEdit/index.tsx index b522c5144f..c3c9aa46cf 100644 --- a/frontend/src/pages/QuestionEdit/index.tsx +++ b/frontend/src/pages/QuestionEdit/index.tsx @@ -28,6 +28,8 @@ import QuestionMarkdown from "../../components/QuestionMarkdown"; import QuestionImageContainer from "../../components/QuestionImageContainer"; import QuestionCategoryAutoComplete from "../../components/QuestionCategoryAutoComplete"; import QuestionDetail from "../../components/QuestionDetail"; +import QuestionTestCasesFileUpload from "../../components/QuestionTestCasesFileUpload"; +import QuestionCodeTemplates from "../../components/QuestionCodeTemplates"; const QuestionEdit = () => { const navigate = useNavigate(); @@ -37,10 +39,22 @@ const QuestionEdit = () => { const [title, setTitle] = useState(""); const [markdownText, setMarkdownText] = useState(""); - const [selectedComplexity, setselectedComplexity] = useState( + const [selectedComplexity, setSelectedComplexity] = useState( null ); const [selectedCategories, setSelectedCategories] = useState([]); + const [testcaseInputFile, setTestcaseInputFile] = useState(null); + const [testcaseOutputFile, setTestcaseOutputFile] = useState( + null + ); + + const [codeTemplates, setCodeTemplates] = useState<{ [key: string]: string }>( + { + python: "", + java: "", + c: "", + } + ); const [uploadedImagesUrl, setUploadedImagesUrl] = useState([]); const [isPreviewQuestion, setIsPreviewQuestion] = useState(false); @@ -56,8 +70,13 @@ const QuestionEdit = () => { if (state.selectedQuestion) { setTitle(state.selectedQuestion.title); setMarkdownText(state.selectedQuestion.description); - setselectedComplexity(state.selectedQuestion.complexity); + setSelectedComplexity(state.selectedQuestion.complexity); setSelectedCategories(state.selectedQuestion.categories); + setCodeTemplates({ + python: state.selectedQuestion.pythonTemplate, + java: state.selectedQuestion.javaTemplate, + c: state.selectedQuestion.cTemplate, + }); } }, [state.selectedQuestion]); @@ -77,7 +96,12 @@ const QuestionEdit = () => { title === state.selectedQuestion.title && markdownText === state.selectedQuestion.description && selectedComplexity === state.selectedQuestion.complexity && - selectedCategories === state.selectedQuestion.categories + selectedCategories === state.selectedQuestion.categories && + codeTemplates.python === state.selectedQuestion.pythonTemplate && + codeTemplates.java === state.selectedQuestion.javaTemplate && + codeTemplates.c === state.selectedQuestion.cTemplate && + testcaseInputFile === null && + testcaseOutputFile === null ) { toast.error(NO_QUESTION_CHANGES); return; @@ -87,7 +111,8 @@ const QuestionEdit = () => { !title || !markdownText || !selectedComplexity || - selectedCategories.length === 0 + selectedCategories.length === 0 || + Object.values(codeTemplates).some((value) => value === "") ) { toast.error(FILL_ALL_FIELDS); return; @@ -100,6 +125,15 @@ const QuestionEdit = () => { description: markdownText, complexity: selectedComplexity, categories: selectedCategories, + testcaseInputFileUrl: state.selectedQuestion.testcaseInputFileUrl, + testcaseOutputFileUrl: state.selectedQuestion.testcaseOutputFileUrl, + pythonTemplate: codeTemplates.python, + javaTemplate: codeTemplates.java, + cTemplate: codeTemplates.c, + }, + { + testcaseInputFile: testcaseInputFile, + testcaseOutputFile: testcaseOutputFile, }, dispatch ); @@ -142,7 +176,7 @@ const QuestionEdit = () => { sx={{ marginTop: 2 }} value={selectedComplexity} onChange={(_e, newcomplexitySelected) => { - setselectedComplexity(newcomplexitySelected); + setSelectedComplexity(newcomplexitySelected); }} renderInput={(params) => ( @@ -163,6 +197,18 @@ const QuestionEdit = () => { markdownText={markdownText} setMarkdownText={setMarkdownText} /> + + + + )} diff --git a/frontend/src/reducers/questionReducer.ts b/frontend/src/reducers/questionReducer.ts index b7e57a7063..69f38025d0 100644 --- a/frontend/src/reducers/questionReducer.ts +++ b/frontend/src/reducers/questionReducer.ts @@ -2,6 +2,16 @@ import { Dispatch } from "react"; import { questionClient } from "../utils/api"; import { isString, isStringArray } from "../utils/typeChecker"; +type TestcaseFiles = { + testcaseInputFile: File | null; + testcaseOutputFile: File | null; +}; + +export const enum TestcaseFilesUploadRequestTypes { + CREATE = "create", + UPDATE = "update", +} + type QuestionDetail = { id: string; title: string; @@ -13,6 +23,11 @@ type QuestionDetail = { cTemplate: string; }; +type QuestionDetailWithUrl = QuestionDetail & { + testcaseInputFileUrl: string; + testcaseOutputFileUrl: string; +}; + type QuestionListDetail = { id: string; title: string; @@ -41,21 +56,26 @@ enum QuestionActionTypes { type QuestionActions = { type: QuestionActionTypes; - payload: QuestionList | QuestionDetail | string[] | string; + payload: + | QuestionList + | QuestionDetail + | QuestionDetailWithUrl + | string[] + | string; }; type QuestionsState = { questionCategories: Array; questions: Array; questionCount: number; - selectedQuestion: QuestionDetail | null; + selectedQuestion: QuestionDetailWithUrl | null; questionCategoriesError: string | null; questionListError: string | null; selectedQuestionError: string | null; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -const isQuestion = (question: any): question is QuestionDetail => { +const isQuestion = (question: any): question is QuestionDetailWithUrl => { if (!question || typeof question !== "object") { return false; } @@ -93,10 +113,55 @@ export const initialState: QuestionsState = { selectedQuestionError: null, }; +export const uploadTestcaseFiles = async ( + data: TestcaseFiles, + requestType: TestcaseFilesUploadRequestTypes +): Promise<{ + message: string; + urls: { + testcaseInputFileUrl: string; + testcaseOutputFileUrl: string; + }; +} | null> => { + const formData = new FormData(); + formData.append("testcaseInputFile", data.testcaseInputFile ?? ""); + formData.append("testcaseOutputFile", data.testcaseOutputFile ?? ""); + formData.append("requestType", requestType); + + try { + const accessToken = localStorage.getItem("token"); + const res = await questionClient.post("/tcfiles", formData, { + headers: { + "Content-Type": "multipart/form-data", + Authorization: `Bearer ${accessToken}`, + }, + }); + return res.data; + } catch { + return null; + } +}; + export const createQuestion = async ( question: Omit, + testcaseFiles: TestcaseFiles, dispatch: Dispatch ): Promise => { + const uploadResult = await uploadTestcaseFiles( + testcaseFiles, + TestcaseFilesUploadRequestTypes.CREATE + ); + + if (!uploadResult) { + dispatch({ + type: QuestionActionTypes.ERROR_CREATING_QUESTION, + payload: "Failed to upload test case files.", + }); + return false; + } + + const { testcaseInputFileUrl, testcaseOutputFileUrl } = uploadResult.urls; + const accessToken = localStorage.getItem("token"); return questionClient .post( @@ -106,6 +171,8 @@ export const createQuestion = async ( description: question.description, complexity: question.complexity, category: question.categories, + testcaseInputFileUrl, + testcaseOutputFileUrl, pythonTemplate: question.pythonTemplate, cTemplate: question.cTemplate, javaTemplate: question.javaTemplate, @@ -124,6 +191,8 @@ export const createQuestion = async ( return true; }) .catch((err) => { + console.log(err.response?.data.message || err.message); + dispatch({ type: QuestionActionTypes.ERROR_CREATING_QUESTION, payload: err.response?.data.message || err.message, @@ -203,9 +272,34 @@ export const getQuestionById = ( export const updateQuestionById = async ( questionId: string, - question: Omit, + question: Omit, + testcaseFiles: TestcaseFiles, dispatch: Dispatch ): Promise => { + let urls = {}; + + if (Object.values(testcaseFiles).some((file) => file !== null)) { + const uploadResult = await uploadTestcaseFiles( + testcaseFiles, + TestcaseFilesUploadRequestTypes.UPDATE + ); + + if (!uploadResult) { + dispatch({ + type: QuestionActionTypes.ERROR_CREATING_QUESTION, + payload: "Failed to upload test case file(s).", + }); + return false; + } + + const { testcaseInputFileUrl, testcaseOutputFileUrl } = uploadResult.urls; + + urls = { + ...(testcaseInputFileUrl ? { testcaseInputFileUrl } : {}), + ...(testcaseOutputFileUrl ? { testcaseOutputFileUrl } : {}), + }; + } + const accessToken = localStorage.getItem("token"); return questionClient .put( @@ -215,6 +309,12 @@ export const updateQuestionById = async ( description: question.description, complexity: question.complexity, category: question.categories, + testcaseInputFileUrl: question.testcaseInputFileUrl, + testcaseOutputFileUrl: question.testcaseOutputFileUrl, + ...urls, + pythonTemplate: question.pythonTemplate, + javaTemplate: question.javaTemplate, + cTemplate: question.cTemplate, }, { headers: { diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index dad0b7a81e..eacb2e5b2e 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -113,3 +113,13 @@ export const FIND_MATCH_FORM_PATH = "/find_match_form.png"; export const MATCH_FOUND_PATH = "/match_found.png"; export const QUESTIONS_LIST_PATH = "/questions_list.png"; export const COLLABORATIVE_EDITOR_PATH = "/collaborative_editor.png"; + +/* Tooltips */ +export const ADD_QUESTION_TEST_CASE_TOOLTIP_MESSAGE = `Add at least 1 and at most 3 test cases.
This will be displayed to users.`; +export const ADD_TEST_CASE_FILES_TOOLTIP_MESSAGE = `Upload files for executing test cases backend when user submits code.

This is a required field.
Only text files accepted.`; +export const CODE_TEMPLATES_TOOLTIP_MESSAGE = `This is a required field.
Fill in a code template for each language.`; + +/* Code Templates */ +export const PYTHON_CODE_TEMPLATE = `# Please do not modify the main function\ndef main():\n\tprint(convert_to_string_format(solution()))\n\n\n# Write your code here\ndef solution():\n\treturn None\n\n\nif __name__ == "__main__":\n\tmain()\n`; +export const JAVA_CODE_TEMPLATE = `public class Main {\n\t// Please do not modify the main function\n\tpublic static void main(String[] args) {\n\t\tSystem.out.println(convert_to_string_format(solution()));\n\t}\n\n\t// Write your code here and return the appropriate type\n\tpublic static String solution() {\n\t\treturn null;\n\t}\n}\n`; +export const C_CODE_TEMPLATE = `#include \n\n// Write your code here and return the appropriate type\nconst char* solution() {\n\treturn "";\n}\n\n// Please do not modify the main function\nint main() {\n\tprintf("%s\\n", convert_to_string_format(solution()));\n\treturn 0;\n}\n`;