diff --git a/src/AppLayout.tsx b/src/AppLayout.tsx index 5fb95ea..fdc0507 100644 --- a/src/AppLayout.tsx +++ b/src/AppLayout.tsx @@ -20,6 +20,7 @@ import { forceUpdateSearchUserPage } from './pages/searchUserPage'; import { forceUpdateUserInfoPage } from './pages/userInfoPage'; import { forceUpdateSearchExamPage } from './pages/searchExamPage'; import { forceUpdateChangePasswordPage } from './pages/changePasswordPage'; +import { forceUpdateExamHallPage } from './pages/examHallPage'; /** * This function forces a re-render of all the pages in the app. @@ -37,6 +38,7 @@ const forceUpdateWholePage = () => { forceUpdateCreateTopicPage(); forceUpdateCreateUserPage(); forceUpdateDashboardPage(); + forceUpdateExamHallPage(); forceUpdateExamInfoPage(); forceUpdateLoginPage(); forceUpdateSearchCoursePage(); diff --git a/src/api/api.ts b/src/api/api.ts index d440baa..8761da9 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -669,6 +669,141 @@ export interface CreateExamData { */ 'price'?: string; } +/** + * + * @export + * @interface CreateExamQuestionData + */ +export interface CreateExamQuestionData { + /** + * + * @type {string} + * @memberof CreateExamQuestionData + */ + 'description'?: string; + /** + * + * @type {number} + * @memberof CreateExamQuestionData + */ + 'exam_id'?: number; + /** + * + * @type {string} + * @memberof CreateExamQuestionData + */ + 'option1'?: string; + /** + * + * @type {string} + * @memberof CreateExamQuestionData + */ + 'option2'?: string; + /** + * + * @type {string} + * @memberof CreateExamQuestionData + */ + 'option3'?: string; + /** + * + * @type {string} + * @memberof CreateExamQuestionData + */ + 'option4'?: string; + /** + * + * @type {string} + * @memberof CreateExamQuestionData + */ + 'question_title'?: string; +} +/** + * + * @export + * @interface CreateExamQuestionResult + */ +export interface CreateExamQuestionResult { + /** + * + * @type {string} + * @memberof CreateExamQuestionResult + */ + 'created_at'?: string; + /** + * + * @type {string} + * @memberof CreateExamQuestionResult + */ + 'description'?: string; + /** + * + * @type {number} + * @memberof CreateExamQuestionResult + */ + 'exam_id'?: number; + /** + * + * @type {string} + * @memberof CreateExamQuestionResult + */ + 'option1'?: string; + /** + * + * @type {string} + * @memberof CreateExamQuestionResult + */ + 'option2'?: string; + /** + * + * @type {string} + * @memberof CreateExamQuestionResult + */ + 'option3'?: string; + /** + * + * @type {string} + * @memberof CreateExamQuestionResult + */ + 'option4'?: string; + /** + * + * @type {number} + * @memberof CreateExamQuestionResult + */ + 'question_id'?: number; + /** + * + * @type {string} + * @memberof CreateExamQuestionResult + */ + 'question_title'?: string; +} +/** + * + * @export + * @interface CreateExamQuestionV1200Response + */ +export interface CreateExamQuestionV1200Response { + /** + * + * @type {EndpointError} + * @memberof CreateExamQuestionV1200Response + */ + 'error'?: EndpointError; + /** + * + * @type {CreateExamQuestionResult} + * @memberof CreateExamQuestionV1200Response + */ + 'result'?: CreateExamQuestionResult; + /** + * + * @type {boolean} + * @memberof CreateExamQuestionV1200Response + */ + 'success'?: boolean; +} /** * * @export @@ -1098,6 +1233,147 @@ export interface EditExamData { */ 'price'?: string; } +/** + * + * @export + * @interface EditExamQuestionData + */ +export interface EditExamQuestionData { + /** + * + * @type {string} + * @memberof EditExamQuestionData + */ + 'description'?: string; + /** + * + * @type {number} + * @memberof EditExamQuestionData + */ + 'exam_id'?: number; + /** + * + * @type {string} + * @memberof EditExamQuestionData + */ + 'option1'?: string; + /** + * + * @type {string} + * @memberof EditExamQuestionData + */ + 'option2'?: string; + /** + * + * @type {string} + * @memberof EditExamQuestionData + */ + 'option3'?: string; + /** + * + * @type {string} + * @memberof EditExamQuestionData + */ + 'option4'?: string; + /** + * + * @type {number} + * @memberof EditExamQuestionData + */ + 'question_id'?: number; + /** + * + * @type {string} + * @memberof EditExamQuestionData + */ + 'question_title'?: string; +} +/** + * + * @export + * @interface EditExamQuestionResult + */ +export interface EditExamQuestionResult { + /** + * + * @type {string} + * @memberof EditExamQuestionResult + */ + 'created_at'?: string; + /** + * + * @type {string} + * @memberof EditExamQuestionResult + */ + 'description'?: string; + /** + * + * @type {number} + * @memberof EditExamQuestionResult + */ + 'exam_id'?: number; + /** + * + * @type {string} + * @memberof EditExamQuestionResult + */ + 'option1'?: string; + /** + * + * @type {string} + * @memberof EditExamQuestionResult + */ + 'option2'?: string; + /** + * + * @type {string} + * @memberof EditExamQuestionResult + */ + 'option3'?: string; + /** + * + * @type {string} + * @memberof EditExamQuestionResult + */ + 'option4'?: string; + /** + * + * @type {number} + * @memberof EditExamQuestionResult + */ + 'question_id'?: number; + /** + * + * @type {string} + * @memberof EditExamQuestionResult + */ + 'question_title'?: string; +} +/** + * + * @export + * @interface EditExamQuestionV1200Response + */ +export interface EditExamQuestionV1200Response { + /** + * + * @type {EndpointError} + * @memberof EditExamQuestionV1200Response + */ + 'error'?: EndpointError; + /** + * + * @type {EditExamQuestionResult} + * @memberof EditExamQuestionV1200Response + */ + 'result'?: EditExamQuestionResult; + /** + * + * @type {boolean} + * @memberof EditExamQuestionV1200Response + */ + 'success'?: boolean; +} /** * * @export @@ -1649,6 +1925,18 @@ export interface GetCreatedCoursesV1200Response { * @interface GetExamInfoResult */ export interface GetExamInfoResult { + /** + * + * @type {boolean} + * @memberof GetExamInfoResult + */ + 'can_add_others_to_exam'?: boolean; + /** + * + * @type {boolean} + * @memberof GetExamInfoResult + */ + 'can_edit_question'?: boolean; /** * * @type {boolean} @@ -1789,6 +2077,18 @@ export interface GetExamQuestionsData { * @memberof GetExamQuestionsData */ 'exam_id'?: number; + /** + * + * @type {number} + * @memberof GetExamQuestionsData + */ + 'limit'?: number; + /** + * + * @type {number} + * @memberof GetExamQuestionsData + */ + 'offset'?: number; } /** * @@ -3801,6 +4101,49 @@ export const ExamApiAxiosParamCreator = function (configuration?: Configuration) + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(data, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Allows the user to create a new question for an exam. + * @summary Create a new question for an exam + * @param {string} authorization Authorization token + * @param {CreateExamQuestionData} data Data needed to create a new question for an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createExamQuestionV1: async (authorization: string, data: CreateExamQuestionData, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'authorization' is not null or undefined + assertParamExists('createExamQuestionV1', 'authorization', authorization) + // verify required parameter 'data' is not null or undefined + assertParamExists('createExamQuestionV1', 'data', data) + const localVarPath = `/api/v1/exam/createQuestion`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (authorization != null) { + localVarHeaderParameter['Authorization'] = String(authorization); + } + + + localVarHeaderParameter['Content-Type'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -3844,6 +4187,49 @@ export const ExamApiAxiosParamCreator = function (configuration?: Configuration) + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(data, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Allows the user to edit a question of an exam. + * @summary Edit a question of an exam + * @param {string} authorization Authorization token + * @param {EditExamQuestionData} data Data needed to edit a question of an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editExamQuestionV1: async (authorization: string, data: EditExamQuestionData, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'authorization' is not null or undefined + assertParamExists('editExamQuestionV1', 'authorization', authorization) + // verify required parameter 'data' is not null or undefined + assertParamExists('editExamQuestionV1', 'data', data) + const localVarPath = `/api/v1/exam/editQuestion`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (authorization != null) { + localVarHeaderParameter['Authorization'] = String(authorization); + } + + + localVarHeaderParameter['Content-Type'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -4267,6 +4653,20 @@ export const ExamApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ExamApi.answerExamQuestionV1']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Allows the user to create a new question for an exam. + * @summary Create a new question for an exam + * @param {string} authorization Authorization token + * @param {CreateExamQuestionData} data Data needed to create a new question for an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createExamQuestionV1(authorization: string, data: CreateExamQuestionData, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createExamQuestionV1(authorization, data, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExamApi.createExamQuestionV1']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Allows the user to create a new exam. * @summary Create a new exam @@ -4281,6 +4681,20 @@ export const ExamApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['ExamApi.createExamV1']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Allows the user to edit a question of an exam. + * @summary Edit a question of an exam + * @param {string} authorization Authorization token + * @param {EditExamQuestionData} data Data needed to edit a question of an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async editExamQuestionV1(authorization: string, data: EditExamQuestionData, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.editExamQuestionV1(authorization, data, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['ExamApi.editExamQuestionV1']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Allows the user to edit an exam. * @summary Edit an exam @@ -4428,6 +4842,17 @@ export const ExamApiFactory = function (configuration?: Configuration, basePath? answerExamQuestionV1(authorization: string, data: AnswerQuestionData, options?: any): AxiosPromise { return localVarFp.answerExamQuestionV1(authorization, data, options).then((request) => request(axios, basePath)); }, + /** + * Allows the user to create a new question for an exam. + * @summary Create a new question for an exam + * @param {string} authorization Authorization token + * @param {CreateExamQuestionData} data Data needed to create a new question for an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createExamQuestionV1(authorization: string, data: CreateExamQuestionData, options?: any): AxiosPromise { + return localVarFp.createExamQuestionV1(authorization, data, options).then((request) => request(axios, basePath)); + }, /** * Allows the user to create a new exam. * @summary Create a new exam @@ -4439,6 +4864,17 @@ export const ExamApiFactory = function (configuration?: Configuration, basePath? createExamV1(authorization: string, data: CreateExamData, options?: any): AxiosPromise { return localVarFp.createExamV1(authorization, data, options).then((request) => request(axios, basePath)); }, + /** + * Allows the user to edit a question of an exam. + * @summary Edit a question of an exam + * @param {string} authorization Authorization token + * @param {EditExamQuestionData} data Data needed to edit a question of an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editExamQuestionV1(authorization: string, data: EditExamQuestionData, options?: any): AxiosPromise { + return localVarFp.editExamQuestionV1(authorization, data, options).then((request) => request(axios, basePath)); + }, /** * Allows the user to edit an exam. * @summary Edit an exam @@ -4561,6 +4997,19 @@ export class ExamApi extends BaseAPI { return ExamApiFp(this.configuration).answerExamQuestionV1(authorization, data, options).then((request) => request(this.axios, this.basePath)); } + /** + * Allows the user to create a new question for an exam. + * @summary Create a new question for an exam + * @param {string} authorization Authorization token + * @param {CreateExamQuestionData} data Data needed to create a new question for an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExamApi + */ + public createExamQuestionV1(authorization: string, data: CreateExamQuestionData, options?: RawAxiosRequestConfig) { + return ExamApiFp(this.configuration).createExamQuestionV1(authorization, data, options).then((request) => request(this.axios, this.basePath)); + } + /** * Allows the user to create a new exam. * @summary Create a new exam @@ -4574,6 +5023,19 @@ export class ExamApi extends BaseAPI { return ExamApiFp(this.configuration).createExamV1(authorization, data, options).then((request) => request(this.axios, this.basePath)); } + /** + * Allows the user to edit a question of an exam. + * @summary Edit a question of an exam + * @param {string} authorization Authorization token + * @param {EditExamQuestionData} data Data needed to edit a question of an exam + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ExamApi + */ + public editExamQuestionV1(authorization: string, data: EditExamQuestionData, options?: RawAxiosRequestConfig) { + return ExamApiFp(this.configuration).editExamQuestionV1(authorization, data, options).then((request) => request(this.axios, this.basePath)); + } + /** * Allows the user to edit an exam. * @summary Edit an exam diff --git a/src/apiClient.ts b/src/apiClient.ts index abe0c81..11af80d 100644 --- a/src/apiClient.ts +++ b/src/apiClient.ts @@ -43,6 +43,15 @@ import { ChangePasswordData, ChangePasswordResult, ConfirmChangePasswordData, + GetExamQuestionsData, + GetExamQuestionsResult, + ExamQuestionInfo, + CreateExamQuestionData, + CreateExamQuestionResult, + AnswerQuestionData, + AnswerQuestionResult, + EditExamQuestionData, + EditExamQuestionResult, } from './api'; import { canParseAsNumber } from './utils/textUtils'; import { SupportedTranslations } from './translations/translationSwitcher'; @@ -121,7 +130,7 @@ class ExamSphereAPIClient extends UserApi { this.configuration.basePath = this.basePath; } - + public getCurrentUserId(): string | null { return this.currentUserInfo?.user_id ?? null; } @@ -285,6 +294,38 @@ class ExamSphereAPIClient extends UserApi { return examInfo; } + public async getExamQuestions(data: GetExamQuestionsData): Promise { + if (!this.isLoggedIn()) { + throw new Error("Not logged in"); + } + + let examQuestions = (await this.examApi.getExamQuestionsV1(`Bearer ${this.accessToken}`, data))?.data.result; + if (!examQuestions) { + // we shouldn't reach here, because if there is an error somewhere, + // it should have already been thrown by the API client + throw new Error("Failed to get exam questions"); + } + + return examQuestions; + } + + /** + * Gets the question options from the question info. + * The question options are the options that the user can choose from. + * @todo This method should be moved to a more appropriate place. + * Perhaps dynamically load this from api in future? + * @param questionInfo The question info. + * @returns the question options. + */ + public getQuestionOptions(questionInfo: ExamQuestionInfo): string[] { + return [ + questionInfo.option1!, + questionInfo.option2!, + questionInfo.option3!, + questionInfo.option4!, + ]; + } + public async getCourseInfo(courseId: number): Promise { if (!this.isLoggedIn()) { throw new Error("Not logged in"); @@ -594,6 +635,86 @@ class ExamSphereAPIClient extends UserApi { return createExamResult; } + public async createExamQuestion(data: CreateExamQuestionData): Promise { + if (!this.isLoggedIn()) { + throw new Error("Not logged in"); + } + + if (typeof data.exam_id !== 'number') { + if (canParseAsNumber(data.exam_id)) { + data.exam_id = parseInt(data.exam_id as any); + } else { + throw new Error("Invalid exam ID"); + } + } + + let createExamQuestionResult = (await this.examApi.createExamQuestionV1( + `Bearer ${this.accessToken}`, data))?.data.result; + if (!createExamQuestionResult) { + // we shouldn't reach here, because if there is an error somewhere, + // it should have already been thrown by the API client + throw new Error("Failed to create exam question"); + } + + return createExamQuestionResult; + } + + public async answerExamQuestion(data: AnswerQuestionData): Promise { + if (!this.isLoggedIn()) { + throw new Error("Not logged in"); + } + + if (typeof data.exam_id !== 'number') { + if (canParseAsNumber(data.exam_id)) { + data.exam_id = parseInt(data.exam_id as any); + } else { + throw new Error("Invalid exam ID"); + } + } + + let answerQuestionResult = (await this.examApi.answerExamQuestionV1( + `Bearer ${this.accessToken}`, data))?.data.result; + if (!answerQuestionResult) { + // we shouldn't reach here, because if there is an error somewhere, + // it should have already been thrown by the API client + throw new Error("Failed to answer question"); + } + + return answerQuestionResult; + } + + public async editExamQuestion(data: EditExamQuestionData): Promise { + if (!this.isLoggedIn()) { + throw new Error("Not logged in"); + } + + if (typeof data.exam_id !== 'number') { + if (canParseAsNumber(data.exam_id)) { + data.exam_id = parseInt(data.exam_id as any); + } else { + throw new Error("Invalid exam ID"); + } + } + + if (typeof data.question_id !== 'number') { + if (canParseAsNumber(data.question_id)) { + data.question_id = parseInt(data.question_id as any); + } else { + throw new Error("Invalid question ID"); + } + } + + let editExamQuestionResult = (await this.examApi.editExamQuestionV1( + `Bearer ${this.accessToken}`, data))?.data.result; + if (!editExamQuestionResult) { + // we shouldn't reach here, because if there is an error somewhere, + // it should have already been thrown by the API client + throw new Error("Failed to edit exam question"); + } + + return editExamQuestionResult; + } + /** * Returns true if we are considered as "logged in" by the API client, * This method only checks if the access token is present, it doesn't @@ -641,6 +762,10 @@ class ExamSphereAPIClient extends UserApi { return this.isOwner() || this.isAdmin(); } + public canEditUserInfo(): boolean { + return this.isOwner() || this.isAdmin(); + } + public canChangeOthersPassword(): boolean { return this.isOwner() || this.isAdmin(); } diff --git a/src/components/menus/sideMenu.tsx b/src/components/menus/sideMenu.tsx index 7112c9b..b5cfd0a 100644 --- a/src/components/menus/sideMenu.tsx +++ b/src/components/menus/sideMenu.tsx @@ -31,7 +31,10 @@ const RenderProfileMenu = () => { return ( - + ) }; @@ -39,16 +42,18 @@ const RenderProfileMenu = () => { const RenderManageUserMenu = () => { return ( - - + )} = ({ ...props }) => { {RenderProfileMenu()} - {RenderManageUserMenu()} {RenderManageExamsMenu()} {RenderCommonMenus()} diff --git a/src/components/rendering/RenderAllQuestions.tsx b/src/components/rendering/RenderAllQuestions.tsx new file mode 100644 index 0000000..d54d509 --- /dev/null +++ b/src/components/rendering/RenderAllQuestions.tsx @@ -0,0 +1,128 @@ +import React from "react"; +import { ExamQuestionInfo } from "../../api"; +import { Box, Button, Container, Paper, TextField, Typography } from "@mui/material"; +import apiClient from "../../apiClient"; +import { CurrentAppTranslation } from "../../translations/appTranslation"; + + + +interface RenderQuestionsListProps { + questions: ExamQuestionInfo[]; + editingId: number | null; + + /** + * Whether the user is participating in the exam. + * This is used to determine if the user can submit answers. + * If the user is not participating, the user can only view the questions. + */ + isParticipating: boolean; + + /** + * Whether the user should be able to edit the questions. + */ + canEditQuestions: boolean; + + handleEdit: (qId: number) => void; + handleSubmit: (qId: number) => void; + handleInputChange: (qId: number, field: keyof ExamQuestionInfo, value: string) => void; + handleChosenOptionChange: (qId: number, value: string) => void; + handleAnswerTextChange: (qId: number, value: string) => void; +} + +const RenderAllQuestions: React.FC = ({ ...props }) => { + return ( + + {props.questions.map((question) => ( + + + {props.editingId === question.question_id && props.canEditQuestions ? ( + + {question.question_title} + + ) : ( + props.handleInputChange(question.question_id!, "question_title", e.target.value)} + disabled={props.editingId !== question.question_id} + /> + )} + + + {props.editingId === question.question_id && props.canEditQuestions ? ( + + {question.description} + ) : ( + props.handleInputChange(question.question_id!, "description", e.target.value)} + disabled={props.editingId !== question.question_id} + margin="normal" + /> + )} + {apiClient.getQuestionOptions(question).map((option, index) => ( + props.editingId === question.question_id ? + ({`${index + 1}. ${option}`}) : + ( props.handleInputChange( + question.question_id!, + `option${index}` as keyof ExamQuestionInfo, + e.target.value + )} + disabled={props.editingId !== question.question_id} + margin="normal" />) + ))} + {!props.canEditQuestions && ( + props.handleChosenOptionChange(question.question_id!, e.target.value)} + disabled={props.editingId !== question.question_id} + margin="normal" + />)} + {!props.canEditQuestions && ( + props.handleAnswerTextChange(question.question_id!, e.target.value)} + disabled={props.editingId !== question.question_id!} + margin="normal" + />)} + {props.editingId === question.question_id && ( + + )} + + ))} + + ); +}; + +export default RenderAllQuestions; diff --git a/src/pages/examHallPage.tsx b/src/pages/examHallPage.tsx new file mode 100644 index 0000000..6511fcc --- /dev/null +++ b/src/pages/examHallPage.tsx @@ -0,0 +1,278 @@ +import React, { useEffect, useReducer, useState } from 'react'; +import { Typography, Button, TextField, Paper, Box, Container, Pagination, CircularProgress } from '@mui/material'; +import { AnswerQuestionData, CreateExamQuestionData, ExamQuestionInfo, GetExamInfoResult } from '../api'; +import { extractErrorDetails } from '../utils/errorUtils'; +import useAppSnackbar from '../components/snackbars/useAppSnackbars'; +import apiClient from '../apiClient'; +import { DashboardContainer } from '../components/containers/dashboardContainer'; +import RenderAllQuestions from '../components/rendering/RenderAllQuestions'; +import { autoSetWindowTitle } from '../utils/commonUtils'; + +const PageLimit = 10; + +export var forceUpdateExamHallPage = () => { }; + +export default function Component() { + const urlSearch = new URLSearchParams(window.location.search); + const examId = parseInt(urlSearch.get('examId')!); + const providedPage = urlSearch.get('page'); + const [examInfo, setExamInfo] = useState(null); + const [page, setPage] = useState(providedPage ? parseInt(providedPage) - 1 : 0); + const [totalPages, setTotalPages] = useState(page + 1); + const [isLoading, setIsLoading] = useState(false); + const [questions, setQuestions] = useState([]); + const [editingId, setEditingId] = useState(null); + const [answerQuestionData, setAnswerQuestionData] = useState({}); + const [newExamQuestion, setNewExamQuestion] = useState(null); + const [, setForceUpdate] = useReducer(x => x + 1, 0); + + const snackbar = useAppSnackbar(); + + forceUpdateExamHallPage = () => setForceUpdate(); + + const fetchQuestions = async () => { + try { + const result = await apiClient.getExamQuestions({ + exam_id: examId!, + offset: page * PageLimit, + limit: PageLimit, + }); + setQuestions(result.questions!); + return result; + } catch (error: any) { + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed to get examQuestions (${errCode}): ${errMessage}`); + } + }; + + const fetchExamInfo = async () => { + if (!examId || isNaN(examId)) { + window.location.href = '/dashboard'; + return; + } + + if (isLoading) { + return; + } + + setIsLoading(true); + try { + const result = await apiClient.getExamInfo(examId!); + setExamInfo(result); + } catch (error: any) { + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed to get examInfo (${errCode}): ${errMessage}`); + } + + await fetchQuestions(); + setIsLoading(false); + }; + + const handleNextPage = async (newPage: number) => { + window.history.pushState( + `examHall_page_${newPage + 1}`, + "Exam Hall", + `${window.location.pathname}?examId=${examId}&page=${newPage + 1}`, + ); + + setIsLoading(true); + + try { + const result = await apiClient.getExamQuestions({ + exam_id: examId!, + offset: newPage * PageLimit, + limit: PageLimit, + }); + + if (!result || !result.questions) { + setIsLoading(false); + setQuestions([]); + return; + } + + // we need to do setTotalPages dynamically, e.g. if the limit is reached, + // we should add one more page. if the amount of results returned is less than + // the limit, we shouldn't increment the total pages. + const newTotalPages = result.questions.length < PageLimit ? (newPage + 1) : newPage + 2; + setTotalPages(newTotalPages); + + setPage(newPage); + setQuestions(result.questions); + } catch (error: any) { + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed to get examQuestions (${errCode}): ${errMessage}`); + } + }; + + const handleEdit = (id: number) => { + // this function will be called when the user clicks on the edit button + // for a question. if the question is already being edited, we will cancel + // the edit mode. otherwise, we will set the editingId to the question_id. + if (id == editingId) { + setEditingId(null); + } else { + setEditingId(id); + } + }; + + const handleSubmit = async (id: number) => { + // we can have 3 operations here: + // 1. create a new question + // 2. edit an existing question + // 3. submit an answer + if (id === -1 && newExamQuestion) { + setIsLoading(true); + try { + apiClient.createExamQuestion(newExamQuestion); + } catch (error: any) { + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed to create examQuestion (${errCode}): ${errMessage}`); + } + + // fetch the questions again to get the new question(s) + await fetchQuestions(); + setIsLoading(false); + setNewExamQuestion(null); + } else if (!examInfo?.can_edit_question && answerQuestionData) { + try { + apiClient.answerExamQuestion(answerQuestionData); + } catch (error: any) { + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed to submit answer (${errCode}): ${errMessage}`); + } + + setAnswerQuestionData(null); + } else if (examInfo?.can_edit_question && id !== -1) { + // we are editing an existing question + try { + const question = questions.find(q => q.question_id === id); + if (!question) { + snackbar.error('Failed to find the question to edit'); + return; + } + + apiClient.editExamQuestion({ + exam_id: examId!, + question_id: question.question_id, + question_title: question.question_title, + description: question.description, + option1: question.option1, + option2: question.option2, + option3: question.option3, + option4: question.option4, + }); + } catch (error: any) { + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed to edit examQuestion (${errCode}): ${errMessage}`); + } + + // fetch the questions again to get the new question(s) + await fetchQuestions(); + setEditingId(null); + } + setEditingId(null); + }; + + const handleInputChange = (id: number, field: keyof ExamQuestionInfo, value: string) => { + setQuestions(questions.map(q => q.question_id === id ? { ...q, [field]: value } : q)); + }; + + const handleChosenOptionChange = (qId: number, value: string) => { + if (!qId || isNaN(qId)) { + return; + } + + setAnswerQuestionData( + { + ...answerQuestionData, + chosen_option: value, + } + ) + }; + + const handleAnswerTextChange = (qId: number, value: string) => { + if (!qId || isNaN(qId)) { + return; + } + + setAnswerQuestionData( + { + ...answerQuestionData, + answer_text: value, + } + ) + } + + const handleAddNewQuestion = () => { + setQuestions([...questions, { + question_id: -1, + question_title: '', + description: '', + user_answer: { + chosen_option: '', + answer: '', + }, + }]); + setNewExamQuestion( + { + exam_id: examId!, + question_title: '', + description: '', + option1: '', + option2: '', + option3: '', + option4: '', + } + ); + setEditingId(-1); + } + + useEffect(() => { + fetchExamInfo(); + autoSetWindowTitle(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + + {isLoading ? ( + + + + ) : } + + + + handleNextPage(newPage - 1)} /> + + + + ); + // return ( + // + // Online Exam + + // + // ); +} \ No newline at end of file diff --git a/src/pages/searchCoursePage.tsx b/src/pages/searchCoursePage.tsx index 1bff2df..320f0c9 100644 --- a/src/pages/searchCoursePage.tsx +++ b/src/pages/searchCoursePage.tsx @@ -18,6 +18,10 @@ import { DashboardContainer } from '../components/containers/dashboardContainer' import { timeAgo } from '../utils/timeUtils'; import { CurrentAppTranslation } from '../translations/appTranslation'; import { autoSetWindowTitle } from '../utils/commonUtils'; +import useAppSnackbar from '../components/snackbars/useAppSnackbars'; +import { extractErrorDetails } from '../utils/errorUtils'; + +const PageLimit = 10; export var forceUpdateSearchCoursePage = () => {}; @@ -81,7 +85,7 @@ const SearchCoursePage = () => { const [, setForceUpdate] = useReducer(x => x + 1, 0); const [page, setPage] = useState(providedPage ? parseInt(providedPage) - 1 : 0); const [totalPages, setTotalPages] = useState(page + 1); - const limit = 10; + const snackbar = useAppSnackbar(); forceUpdateSearchCoursePage = () => setForceUpdate(); @@ -93,25 +97,34 @@ const SearchCoursePage = () => { ); setIsLoading(true); - const results = await apiClient.searchCourse({ - course_name: query, - offset: newPage * limit, - limit: limit, - }) - if (!results || !results.courses) { + try { + const results = await apiClient.searchCourse({ + course_name: query, + offset: newPage * PageLimit, + limit: PageLimit, + }) + + if (!results || !results.courses) { + setCourses([]); + setIsLoading(false); + return; + } + + // we need to do setTotalPages dynamically, e.g. if the limit is reached, + // we should add one more page. if the amount of results returned is less than + // the limit, we shouldn't increment the total pages. + const newTotalPages = results.courses.length < PageLimit ? (newPage + 1) : newPage + 2; + setTotalPages(newTotalPages); + + setPage(newPage); + setCourses(results.courses!); + } catch (error: any) { + const [errCode, errMessage] = extractErrorDetails(error); + snackbar.error(`Failed to search course (${errCode}): ${errMessage}`); setIsLoading(false); return; } - - // we need to do setTotalPages dynamically, e.g. if the limit is reached, - // we should add one more page. if the amount of results returned is less than - // the limit, we shouldn't increment the total pages. - const newTotalPages = results.courses.length < limit ? (newPage + 1) : newPage + 2; - setTotalPages(newTotalPages); - - setPage(newPage); - setCourses(results.courses!); setIsLoading(false); }; diff --git a/src/translations/appTranslation.ts b/src/translations/appTranslation.ts index fc6437d..74734a7 100644 --- a/src/translations/appTranslation.ts +++ b/src/translations/appTranslation.ts @@ -45,6 +45,8 @@ export class AppTranslationBase { SearchCoursesText: string = "Search Course"; SearchCourseText: string = "Search Courses"; SearchExamText: string = "Search Exams"; + QuestionTitleText: string = "Question Title"; + DescriptionText: string = "Description"; EditCourseText: string = "Edit Course"; ManageExamsText: string = "Manage Exams"; AddExamText: string = "Add Exam"; @@ -58,6 +60,9 @@ export class AppTranslationBase { SaveText: string = "Save"; ParticipateText: string = "Participate"; ConfirmText: string = "Confirm"; + SubmitText: string = "Submit"; + ChosenOptionText: string = "Chosen Option"; + OptionText: string = "Option"; EditText: string = "Edit"; UserInformationText: string = "User Information"; CourseInformationText: string = "Course Information"; diff --git a/src/translations/faTranslation.ts b/src/translations/faTranslation.ts index 6597532..eefa388 100644 --- a/src/translations/faTranslation.ts +++ b/src/translations/faTranslation.ts @@ -43,6 +43,8 @@ class FaTranslation extends AppTranslationBase { SearchCoursesText: string = "جستجوی دوره ها"; SearchCourseText: string = "جستجوی دوره"; SearchExamText: string = "جستجوی آزمون ها"; + QuestionTitleText: string = "عنوان سوال"; + DescriptionText: string = "توضیحات"; EditCourseText: string = "ویرایش دوره"; ManageExamsText: string = "مدیریت آزمون ها"; AddExamText: string = "افزودن آزمون"; @@ -55,6 +57,9 @@ class FaTranslation extends AppTranslationBase { SaveText: string = "ذخیره"; ParticipateText: string = "شرکت در آزمون"; ConfirmText: string = "تایید"; + SubmitText: string = "ثبت"; + ChosenOptionText: string = "گزینه انتخابی"; + OptionText: string = "گزینه"; EditText: string = "ویرایش"; UserInformationText: string = "اطلاعات کاربر"; CourseInformationText: string = "اطلاعات دوره";