From eb59a20d5d8c018fafd0b1c0e226ae9799424483 Mon Sep 17 00:00:00 2001 From: Kevin Tjan Date: Fri, 27 Sep 2024 13:17:21 +0800 Subject: [PATCH 01/60] Add Assessment and Question Independent of Google Added: Frontend: - New Assessment type: Internal assessments - Will completely replace the google versions - Question list of question cards - Assessment release Backend: - Internal Assessment entity and API - Question entity and API --- backend/app.ts | 2 + backend/controllers/courseController.ts | 36 + .../internalAssessmentController.ts | 238 ++++++ backend/models/Course.ts | 4 +- backend/models/InternalAssessment.ts | 45 ++ backend/models/Question.ts | 27 + backend/models/QuestionTypes.ts | 138 ++++ backend/routes/courseRoutes.ts | 5 + backend/routes/internalAssessmentRoutes.ts | 30 + backend/services/courseService.ts | 20 + backend/services/internalAssessmentService.ts | 683 ++++++++++++++++++ multi-git-dashboard/src/components/Navbar.tsx | 2 +- .../cards/AssessmentQuestionCard.tsx | 642 ++++++++++++++++ .../cards/InternalAssessmentCard.tsx | 68 ++ .../src/components/forms/AssessmentForm.tsx | 185 ----- .../components/forms/CreateAssessmentForm.tsx | 66 ++ .../forms/CreateGoogleAssessmentForm.tsx | 107 +++ .../forms/CreateInternalAssessmentForm.tsx | 123 ++++ .../components/forms/UpdateAssessmentForm.tsx | 100 ++- .../forms/UpdateAssessmentGoogleForm.tsx | 121 ++++ .../forms/UpdateAssessmentInternalForm.tsx | 105 +++ .../forms/UploadGoogleAssessmentFormCsv.tsx | 35 + .../forms/UploadInternalAssessmentFormCsv.tsx | 35 + .../views/AssessmentGoogleOverview.tsx | 238 ++++++ .../views/AssessmentGoogleResults.tsx | 124 ++++ .../views/AssessmentInternalForm.tsx | 142 ++++ .../views/AssessmentInternalOverview.tsx | 178 +++++ .../views/AssessmentInternalResults.tsx | 121 ++++ .../src/components/views/AssessmentLists.tsx | 112 +++ .../src/components/views/AssessmentsInfo.tsx | 132 +++- .../[id]/assessments/[assessmentId].tsx | 144 ++-- .../pages/courses/[id]/assessments/index.tsx | 30 + .../internal-assessments/[assessmentId].tsx | 231 ++++++ shared/types/Course.ts | 2 + shared/types/InternalAssessment.ts | 22 + shared/types/Question.ts | 74 ++ 36 files changed, 4000 insertions(+), 367 deletions(-) create mode 100644 backend/controllers/internalAssessmentController.ts create mode 100644 backend/models/InternalAssessment.ts create mode 100644 backend/models/Question.ts create mode 100644 backend/models/QuestionTypes.ts create mode 100644 backend/routes/internalAssessmentRoutes.ts create mode 100644 backend/services/internalAssessmentService.ts create mode 100644 multi-git-dashboard/src/components/cards/AssessmentQuestionCard.tsx create mode 100644 multi-git-dashboard/src/components/cards/InternalAssessmentCard.tsx delete mode 100644 multi-git-dashboard/src/components/forms/AssessmentForm.tsx create mode 100644 multi-git-dashboard/src/components/forms/CreateAssessmentForm.tsx create mode 100644 multi-git-dashboard/src/components/forms/CreateGoogleAssessmentForm.tsx create mode 100644 multi-git-dashboard/src/components/forms/CreateInternalAssessmentForm.tsx create mode 100644 multi-git-dashboard/src/components/forms/UpdateAssessmentGoogleForm.tsx create mode 100644 multi-git-dashboard/src/components/forms/UpdateAssessmentInternalForm.tsx create mode 100644 multi-git-dashboard/src/components/forms/UploadGoogleAssessmentFormCsv.tsx create mode 100644 multi-git-dashboard/src/components/forms/UploadInternalAssessmentFormCsv.tsx create mode 100644 multi-git-dashboard/src/components/views/AssessmentGoogleOverview.tsx create mode 100644 multi-git-dashboard/src/components/views/AssessmentGoogleResults.tsx create mode 100644 multi-git-dashboard/src/components/views/AssessmentInternalForm.tsx create mode 100644 multi-git-dashboard/src/components/views/AssessmentInternalOverview.tsx create mode 100644 multi-git-dashboard/src/components/views/AssessmentInternalResults.tsx create mode 100644 multi-git-dashboard/src/components/views/AssessmentLists.tsx create mode 100644 multi-git-dashboard/src/pages/courses/[id]/internal-assessments/[assessmentId].tsx create mode 100644 shared/types/InternalAssessment.ts create mode 100644 shared/types/Question.ts diff --git a/backend/app.ts b/backend/app.ts index cb57c472..ff87b65c 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -5,6 +5,7 @@ import setupGitHubJob from './jobs/githubJob'; import setupJiraJob from './jobs/jiraJob'; import accountRoutes from './routes/accountRoutes'; import assessmentRoutes from './routes/assessmentRoutes'; +import internalAssessmentRoutes from './routes/internalAssessmentRoutes'; import courseRoutes from './routes/courseRoutes'; import githubRoutes from './routes/githubRoutes'; import jiraRoutes from './routes/jiraRoutes'; @@ -37,6 +38,7 @@ app.use('/api/accounts', accountRoutes); app.use('/api/teams', teamRoutes); app.use('/api/teamsets', teamSetRoutes); app.use('/api/assessments', assessmentRoutes); +app.use('/api/internal-assessments', internalAssessmentRoutes); app.use('/api/jira', jiraRoutes); app.use('/api/metrics', metricRoutes); app.use('/api/user', userRoutes); diff --git a/backend/controllers/courseController.ts b/backend/controllers/courseController.ts index d7d3a6d0..d1abe707 100644 --- a/backend/controllers/courseController.ts +++ b/backend/controllers/courseController.ts @@ -15,6 +15,7 @@ import { getCourseTeachingTeam, getCourseTimeline, getCoursesForUser, + getInternalAssessmentsFromCourse, getPeopleFromCourse, getProjectManagementBoardFromCourse, getTeamSetNamesFromCourse, @@ -35,6 +36,7 @@ import { import { addStudentsToTeam, addTAsToTeam } from '../services/teamService'; import { createTeamSet } from '../services/teamSetService'; import { getAccountId } from '../utils/auth'; +import { addInternalAssessmentsToCourse } from 'services/internalAssessmentService'; /*----------------------------------------Course----------------------------------------*/ export const createCourse = async (req: Request, res: Response) => { @@ -496,6 +498,40 @@ export const getAssessments = async (req: Request, res: Response) => { } }; +/*----------------------------------------Internal-Assessment----------------------------------------*/ +export const addInternalAssessments = async (req: Request, res: Response) => { + const courseId = req.params.id; + const assessments = req.body.items; + try { + await addInternalAssessmentsToCourse(courseId, assessments); + res.status(201).json({ message: 'Assessments added successfully' }); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof BadRequestError) { + res.status(400).json({ error: error.message }); + } else { + console.error('Error adding assessments:', error); + res.status(500).json({ error: 'Failed to add assessments' }); + } + } +}; + +export const getInternalAssessments = async (req: Request, res: Response) => { + const courseId = req.params.id; + try { + const assessments = await getInternalAssessmentsFromCourse(courseId); + res.status(200).json(assessments); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else { + console.error('Error getting assessments:', error); + res.status(500).json({ error: 'Failed to get assessments' }); + } + } +}; + /*------------------------------------Project Management------------------------------------*/ export const getProjectManagementBoard = async ( req: Request, diff --git a/backend/controllers/internalAssessmentController.ts b/backend/controllers/internalAssessmentController.ts new file mode 100644 index 00000000..fe3eea49 --- /dev/null +++ b/backend/controllers/internalAssessmentController.ts @@ -0,0 +1,238 @@ +import { Request, Response } from 'express'; +import { BadRequestError, NotFoundError, MissingAuthorizationError } from '../services/errors'; +import { getAccountId } from '../utils/auth'; +import { + getInternalAssessmentById, + updateInternalAssessmentById, + deleteInternalAssessmentById, + uploadInternalAssessmentResultsById, + updateInternalAssessmentResultMarkerById, + addQuestionToAssessment, + deleteQuestionById, + getQuestionsByAssessmentId, + updateQuestionById, + releaseInternalAssessmentById, + recallInternalAssessmentById, +} from 'services/internalAssessmentService'; + +export const getInternalAssessment = async (req: Request, res: Response) => { + try { + const accountId = await getAccountId(req); + const { assessmentId } = req.params; + const assessment = await getInternalAssessmentById(assessmentId, accountId); + res.status(200).json(assessment); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof MissingAuthorizationError) { + res.status(400).json({ error: 'Missing authorization' }); + } else { + console.error('Error retrieving assessment:', error); + res.status(500).json({ error: 'Failed to retrieve assessment' }); + } + } +}; + +export const updateInternalAssessment = async (req: Request, res: Response) => { + const { assessmentId } = req.params; + const updateData = req.body; + try { + const accountId = await getAccountId(req); + await updateInternalAssessmentById(assessmentId, accountId, updateData); + res.status(200).json({ message: 'Assessment updated successfully' }); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof BadRequestError) { + res.status(400).json({ error: error.message }); + } else { + console.error('Error updating assessment:', error); + res.status(500).json({ error: 'Failed to update assessment' }); + } + } +}; + +export const deleteInternalAssessment = async (req: Request, res: Response) => { + const { assessmentId } = req.params; + try { + await deleteInternalAssessmentById(assessmentId); + res.status(200).json({ message: 'Assessment deleted successfully' }); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else { + console.error('Error deleting assessment:', error); + res.status(500).json({ error: 'Failed to delete assessment' }); + } + } +}; + +export const uploadInternalResults = async (req: Request, res: Response) => { + try { + const { assessmentId } = req.params; + const results = req.body.items; + await uploadInternalAssessmentResultsById(assessmentId, results); + res.status(200).json({ message: 'Results uploaded successfully' }); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else { + console.error('Error uploading results:', error); + res.status(500).json({ error: 'Failed to upload results' }); + } + } +}; + +export const updateInternalResultMarker = async (req: Request, res: Response) => { + try { + const { assessmentId, resultId } = req.params; + const { markerId } = req.body; + await updateInternalAssessmentResultMarkerById(assessmentId, resultId, markerId); + res.status(200).json({ message: 'Marker updated successfully' }); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else { + console.error('Error updating result marker:', error); + res.status(500).json({ error: 'Failed to update result marker' }); + } + } +}; + +/*--------------------------Questions---------------------------------------------*/ +// Add a question to an internal assessment +export const addQuestionToAssessmentController = async (req: Request, res: Response) => { + try { + const accountId = await getAccountId(req); + const { assessmentId } = req.params; + const questionData = req.body; + + const question = await addQuestionToAssessment(assessmentId, questionData, accountId); + + res.status(201).json(question); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof BadRequestError) { + res.status(400).json({ error: error.message }); + } else if (error instanceof MissingAuthorizationError) { + res.status(401).json({ error: 'Missing authorization' }); + } else { + console.error('Error adding question:', error); + res.status(500).json({ error: 'Failed to add question' }); + } + } +}; + +// Get all questions for an internal assessment +export const getQuestionsByAssessmentIdController = async (req: Request, res: Response) => { + try { + const accountId = await getAccountId(req); + const { assessmentId } = req.params; + + const questions = await getQuestionsByAssessmentId(assessmentId, accountId); + + res.status(200).json(questions); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof MissingAuthorizationError) { + res.status(401).json({ error: 'Missing authorization' }); + } else { + console.error('Error retrieving questions:', error); + res.status(500).json({ error: 'Failed to retrieve questions' }); + } + } +}; + +// Update a question +export const updateQuestionByIdController = async (req: Request, res: Response) => { + try { + const accountId = await getAccountId(req); + const { questionId } = req.params; + const updateData = req.body; + + const updatedQuestion = await updateQuestionById(questionId, updateData, accountId); + + res.status(200).json(updatedQuestion); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof BadRequestError) { + res.status(400).json({ error: error.message }); + } else if (error instanceof MissingAuthorizationError) { + res.status(401).json({ error: 'Missing authorization' }); + } else { + console.error('Error updating question:', error); + res.status(500).json({ error: 'Failed to update question' }); + } + } +}; + +// Delete a question +export const deleteQuestionByIdController = async (req: Request, res: Response) => { + try { + const accountId = await getAccountId(req); + const { assessmentId, questionId } = req.params; + + await deleteQuestionById(assessmentId, questionId, accountId); + + res.status(204).send(); // No Content + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof BadRequestError) { + res.status(400).json({ error: error.message }); + } else if (error instanceof MissingAuthorizationError) { + res.status(401).json({ error: 'Missing authorization' }); + } else { + console.error('Error deleting question:', error); + res.status(500).json({ error: 'Failed to delete question' }); + } + } +}; + +/*----------------------------Release-Form--------------------------*/ +export const releaseInternalAssessment = async (req: Request, res: Response) => { + try { + const { assessmentId } = req.params; + const accountId = await getAccountId(req); + + await releaseInternalAssessmentById(assessmentId, accountId); + + res.status(200).json({ message: 'Assessment released successfully' }); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof BadRequestError) { + res.status(400).json({ error: error.message }); + } else if (error instanceof MissingAuthorizationError) { + res.status(401).json({ error: 'Missing authorization' }); + } else { + console.error('Error releasing assessment:', error); + res.status(500).json({ error: 'Failed to release assessment' }); + } + } +}; + +export const recallInternalAssessment = async (req: Request, res: Response) => { + try { + const { assessmentId } = req.params; + const accountId = await getAccountId(req); + + await recallInternalAssessmentById(assessmentId, accountId); + + res.status(200).json({ message: 'Assessment recalled successfully' }); + } catch (error) { + if (error instanceof NotFoundError) { + res.status(404).json({ error: error.message }); + } else if (error instanceof BadRequestError) { + res.status(400).json({ error: error.message }); + } else if (error instanceof MissingAuthorizationError) { + res.status(401).json({ error: 'Missing authorization' }); + } else { + console.error('Error recalling assessment:', error); + res.status(500).json({ error: 'Failed to recall assessment' }); + } + } +}; diff --git a/backend/models/Course.ts b/backend/models/Course.ts index c6516936..8ce187f1 100644 --- a/backend/models/Course.ts +++ b/backend/models/Course.ts @@ -4,7 +4,7 @@ import mongoose, { Schema, Types } from 'mongoose'; export interface Course extends Omit< SharedCourse, - '_id' | 'faculty' | 'TAs' | 'students' | 'teamSets' | 'assessments' + '_id' | 'faculty' | 'TAs' | 'students' | 'teamSets' | 'assessments' | 'internalAssessments' >, Document { _id: Types.ObjectId; @@ -13,6 +13,7 @@ export interface Course students: Types.ObjectId[]; teamSets: Types.ObjectId[]; assessments: Types.ObjectId[]; + internalAssessments: Types.ObjectId[]; } export const courseSchema = new Schema({ @@ -25,6 +26,7 @@ export const courseSchema = new Schema({ students: [{ type: Schema.Types.ObjectId, ref: 'User' }], teamSets: [{ type: Schema.Types.ObjectId, ref: 'TeamSet' }], assessments: [{ type: Schema.Types.ObjectId, ref: 'Assessment' }], + internalAssessments: [{ type: Schema.Types.ObjectId, ref: 'InternalAssessment' }], sprints: [ { number: { type: Number, required: true }, diff --git a/backend/models/InternalAssessment.ts b/backend/models/InternalAssessment.ts new file mode 100644 index 00000000..d1da2f1c --- /dev/null +++ b/backend/models/InternalAssessment.ts @@ -0,0 +1,45 @@ +import { InternalAssessment as SharedInternalAssessment } from '@shared/types/InternalAssessment'; +import mongoose, { Schema, Types } from 'mongoose'; + +// Define the InternalAssessment interface +export interface InternalAssessment + extends Omit< + SharedInternalAssessment, + '_id' | 'course' | 'results' | 'teamSet' | 'gradedBy' | 'questions' + >, + mongoose.Document { + _id: Types.ObjectId; + course: Types.ObjectId; + results: Types.ObjectId[]; + teamSet?: Types.ObjectId; + gradedBy?: Types.ObjectId; + questions: Types.ObjectId[]; +} + +// Schema definition for InternalAssessment +const internalAssessmentSchema = new Schema({ + course: { type: Schema.Types.ObjectId, ref: 'Course', required: true }, + assessmentName: { type: String, required: true }, + description: { type: String, required: true }, + startDate: { type: Date, required: true }, + endDate: { type: Date, required: false }, + maxMarks: { type: Number, required: false }, + granularity: { + type: String, + enum: ['individual', 'team'], + required: true, + }, + teamSet: { type: Schema.Types.ObjectId, ref: 'TeamSet', required: false }, + gradedBy: { type: Schema.Types.ObjectId, ref: 'User', required: false }, + results: [{ type: Schema.Types.ObjectId, ref: 'Result', required: false }], + isReleased: { type: Schema.Types.Boolean, ref: 'IsReleased', required: true}, + questions: [{ type: Schema.Types.ObjectId, ref: 'Question', required: false}] +}); + +// Creating and exporting the InternalAssessmentModel +const InternalAssessmentModel = mongoose.model( + 'InternalAssessment', + internalAssessmentSchema +); + +export default InternalAssessmentModel; diff --git a/backend/models/Question.ts b/backend/models/Question.ts new file mode 100644 index 00000000..5572ad57 --- /dev/null +++ b/backend/models/Question.ts @@ -0,0 +1,27 @@ +// models/Question.ts + +import mongoose, { Schema, Document } from 'mongoose'; + +export interface Question extends Document { + text: string; + type: string; + customInstruction?: string; + isLocked?: boolean; // Indicates if the question is locked +} + +const options = { discriminatorKey: 'type', timestamps: true }; + +const QuestionSchema = new Schema( + { + text: { type: String, required: true }, + type: { type: String, required: true }, + customInstruction: { type: String }, + isLocked: { type: Boolean, default: false }, + }, + options +); + +const QuestionModel = mongoose.model('Question', QuestionSchema); + +export default QuestionModel; + diff --git a/backend/models/QuestionTypes.ts b/backend/models/QuestionTypes.ts new file mode 100644 index 00000000..28762363 --- /dev/null +++ b/backend/models/QuestionTypes.ts @@ -0,0 +1,138 @@ +import { Document, Schema } from 'mongoose'; +import QuestionModel from './Question'; + +export interface BaseQuestion extends Document { + text: string; + type: string; + customInstruction?: string; + isLocked: boolean; +} + +export interface MultipleChoiceQuestion extends BaseQuestion { + type: 'Multiple Choice'; + options: string[]; +} + +const MultipleChoiceSchema = new Schema({ + options: { type: [String], required: true }, +}); + +export const MultipleChoiceQuestionModel = QuestionModel.discriminator( + 'Multiple Choice', + MultipleChoiceSchema +); + +export interface MultipleResponseQuestion extends BaseQuestion { + type: 'Multiple Response'; + options: string[]; +} + +const MultipleResponseSchema = new Schema({ + options: { type: [String], required: true }, +}); + +export const MultipleResponseQuestionModel = QuestionModel.discriminator( + 'Multiple Response', + MultipleResponseSchema +); + +export interface ScaleQuestion extends BaseQuestion { + type: 'Scale'; + scaleMax: number; + labelMin: string; + labelMax: string; +} + +const ScaleSchema = new Schema({ + scaleMax: { type: Number, required: true }, + labelMin: { type: String, required: true }, + labelMax: { type: String, required: true }, +}); + +export const ScaleQuestionModel = QuestionModel.discriminator( + 'Scale', + ScaleSchema +); + +export interface ShortResponseQuestion extends BaseQuestion { + type: 'Short Response'; + shortResponsePlaceholder: string; +} + +const ShortResponseSchema = new Schema({ + shortResponsePlaceholder: { type: String, required: true }, +}); + +export const ShortResponseQuestionModel = QuestionModel.discriminator( + 'Short Response', + ShortResponseSchema +); + +export interface LongResponseQuestion extends BaseQuestion { + type: 'Long Response'; + longResponsePlaceholder: string; +} + +const LongResponseSchema = new Schema({ + longResponsePlaceholder: { type: String, required: true }, +}); + +export const LongResponseQuestionModel = QuestionModel.discriminator( + 'Long Response', + LongResponseSchema +); + +export interface DateQuestion extends BaseQuestion { + type: 'Date'; + isRange: boolean; + datePickerPlaceholder?: string; + minDate?: Date; + maxDate?: Date; +} + +const DateQuestionSchema = new Schema({ + isRange: { type: Boolean, required: true }, + datePickerPlaceholder: { type: String, required: false }, + minDate: { type: Date, required: false }, + maxDate: { type: Date, required: false }, +}); + +export const DateQuestionModel = QuestionModel.discriminator( + 'Date', + DateQuestionSchema +); + +export interface NumberQuestion extends BaseQuestion { + type: 'Number'; + maxNumber: number; +} + +const NumberSchema = new Schema({ + maxNumber: { type: Number, required: true }, +}); + +export const NumberQuestionModel = QuestionModel.discriminator( + 'Number', + NumberSchema +); + +export interface UndecidedQuestion extends BaseQuestion { + type: 'Undecided'; +} + +const UndecidedSchema = new Schema({}); + +export const UndecidedQuestionModel = QuestionModel.discriminator( + 'Undecided', + UndecidedSchema +); + +export type QuestionUnion = + | MultipleChoiceQuestion + | MultipleResponseQuestion + | ScaleQuestion + | ShortResponseQuestion + | LongResponseQuestion + | DateQuestion + | NumberQuestion + | UndecidedQuestion; diff --git a/backend/routes/courseRoutes.ts b/backend/routes/courseRoutes.ts index c5a9c84a..a09b0d5d 100644 --- a/backend/routes/courseRoutes.ts +++ b/backend/routes/courseRoutes.ts @@ -29,7 +29,10 @@ import { updateTAs, getProjectManagementBoard, getCourseJiraRegistrationStatus, + addInternalAssessments, + getInternalAssessments, } from '../controllers/courseController'; + import { noCache } from '../middleware/noCache'; const router = express.Router(); @@ -63,5 +66,7 @@ router.post('/:id/sprints', addSprint); router.post('/:id/assessments', addAssessments); router.get('/:id/project-management', getProjectManagementBoard); router.get('/:id/jira-registration-status', getCourseJiraRegistrationStatus); +router.get('/:id/internal-assessments', getInternalAssessments); +router.post('/:id/internal-assessments', addInternalAssessments); export default router; diff --git a/backend/routes/internalAssessmentRoutes.ts b/backend/routes/internalAssessmentRoutes.ts new file mode 100644 index 00000000..fb107ce7 --- /dev/null +++ b/backend/routes/internalAssessmentRoutes.ts @@ -0,0 +1,30 @@ +import express from 'express'; +import { + getInternalAssessment, + updateInternalAssessment, + deleteInternalAssessment, + uploadInternalResults, + updateInternalResultMarker, + addQuestionToAssessmentController, + getQuestionsByAssessmentIdController, + updateQuestionByIdController, + deleteQuestionByIdController, + recallInternalAssessment, + releaseInternalAssessment, +} from '../controllers/internalAssessmentController'; + +const router = express.Router(); + +router.get('/:assessmentId', getInternalAssessment); +router.patch('/:assessmentId', updateInternalAssessment); +router.delete('/:assessmentId', deleteInternalAssessment); +router.post('/:assessmentId/results/', uploadInternalResults); +router.patch('/:assessmentId/results/:resultId/marker', updateInternalResultMarker); +router.post('/:assessmentId/questions', addQuestionToAssessmentController); +router.get('/:assessmentId/questions', getQuestionsByAssessmentIdController); +router.patch('/:assessmentId/questions/:questionId', updateQuestionByIdController); +router.delete('/:assessmentId/questions/:questionId', deleteQuestionByIdController); +router.post('/:assessmentId/release', releaseInternalAssessment); +router.post('/:assessmentId/recall', recallInternalAssessment); + +export default router; diff --git a/backend/services/courseService.ts b/backend/services/courseService.ts index 93640b5c..8b0bfec3 100644 --- a/backend/services/courseService.ts +++ b/backend/services/courseService.ts @@ -7,6 +7,7 @@ import UserModel, { User } from '@models/User'; import Role from '@shared/types/auth/Role'; import { Types } from 'mongoose'; import { NotFoundError } from './errors'; +import { InternalAssessment } from '@shared/types/InternalAssessment'; /*----------------------------------------Course----------------------------------------*/ export const createNewCourse = async (courseData: any, accountId: string) => { @@ -554,6 +555,25 @@ export const getAssessmentsFromCourse = async (courseId: string) => { return course.assessments; }; +/*----------------------------------------Internal Assessments----------------------------------------*/ +export const getInternalAssessmentsFromCourse = async (courseId: string) => { + const course = await CourseModel.findById(courseId).populate<{ + assessments: InternalAssessment[]; + }>({ + path: 'internalAssessments', + populate: [ + { + path: 'teamSet', + model: 'TeamSet', + }, + ], + }); + if (!course) { + throw new NotFoundError('Course not found'); + } + return course.internalAssessments; +}; + /*------------------------------------Project Management------------------------------------*/ export const getProjectManagementBoardFromCourse = async ( accountId: string, diff --git a/backend/services/internalAssessmentService.ts b/backend/services/internalAssessmentService.ts new file mode 100644 index 00000000..504cf831 --- /dev/null +++ b/backend/services/internalAssessmentService.ts @@ -0,0 +1,683 @@ +import { ObjectId } from 'mongodb'; +import InternalAssessmentModel from '../models/InternalAssessment'; +import AccountModel from '../models/Account'; +import ResultModel, { Result } from '../models/Result'; +import CourseModel from '../models/Course'; +import { NotFoundError, BadRequestError } from './errors'; +import { Team } from '@models/Team'; +import mongoose, { Types } from 'mongoose'; +import TeamSetModel from '@models/TeamSet'; +import QuestionModel from '@models/Question'; +import { DateQuestionModel, LongResponseQuestionModel, MultipleChoiceQuestionModel, MultipleResponseQuestionModel, NumberQuestionModel, QuestionUnion, ScaleQuestionModel, ShortResponseQuestionModel, UndecidedQuestionModel } from '@models/QuestionTypes'; + +export const getInternalAssessmentById = async ( + assessmentId: string, + accountId: string +) => { + const account = await AccountModel.findById(accountId); + if (!account) { + throw new NotFoundError('Account not found'); + } + + const assessment = await InternalAssessmentModel.findById(assessmentId) + .populate<{ + results: Result[]; + }>({ + path: 'results', + populate: [ + { + path: 'team', + model: 'Team', + populate: { + path: 'members', + model: 'User', + }, + }, + { + path: 'marker', + model: 'User', + }, + ], + }) + .populate({ + path: 'teamSet', + populate: { + path: 'teams', + model: 'Team', + }, + }); + + if (!assessment) { + throw new NotFoundError('Assessment not found'); + } + + // Filtering results based on the role and assigned marker for teaching assistants + if (account.role === 'Teaching assistant') { + const userId = account.user; + assessment.results = assessment.results.filter(result => + result.marker?.equals(userId) + ); + } + + // Sorting logic for individual or team-based assessments + if (assessment.granularity === 'individual') { + assessment.results.sort((a, b) => + a.marks[0].name.localeCompare(b.marks[0].name) + ); + } else if (assessment.granularity === 'team') { + assessment.results.sort((a, b) => { + const teamA = a.team as unknown as Team; + const teamB = b.team as unknown as Team; + if (!teamA && !teamB) return 0; + if (!teamA) return -1; + if (!teamB) return 1; + return teamA.number - teamB.number; + }); + assessment.results.forEach(result => { + result.marks.sort((a, b) => a.name.localeCompare(b.name)); + }); + } + + return assessment; +}; + +export const updateInternalAssessmentById = async ( + assessmentId: string, + accountId: string, + updateData: Record +) => { + const account = await AccountModel.findById(accountId); + if (!account) { + throw new NotFoundError('Account not found'); + } + + // Only admins or faculty members are allowed to update assessments. + if (account.role !== 'admin' && account.role !== 'Faculty member') { + throw new BadRequestError('Unauthorized'); + } + + const updatedAssessment = await InternalAssessmentModel.findByIdAndUpdate( + assessmentId, + updateData, + { new: true } + ); + + if (!updatedAssessment) { + throw new NotFoundError('Assessment not found'); + } + + return updatedAssessment; +}; + +export const deleteInternalAssessmentById = async (assessmentId: string) => { + const assessment = await InternalAssessmentModel.findById(assessmentId); + if (!assessment) { + throw new NotFoundError('Assessment not found'); + } + + // Delete associated results + await ResultModel.deleteMany({ assessment: assessmentId }); + + // Remove the assessment from the associated course + const course = await CourseModel.findById(assessment.course); + if (course && course.assessments) { + const index = course.assessments.findIndex(id => id.equals(assessment._id)); + if (index !== -1) { + course.assessments.splice(index, 1); + await course.save(); + } + } + + // Delete the assessment + await InternalAssessmentModel.findByIdAndDelete(assessmentId); +}; + +export const uploadInternalAssessmentResultsById = async ( + assessmentId: string, + results: { studentId: string; mark: number }[] +) => { + const assessment = await InternalAssessmentModel.findById(assessmentId).populate('results'); + + if (!assessment) { + throw new NotFoundError('Assessment not found'); + } + + const resultMap: Record = {}; + results.forEach(({ studentId, mark }) => { + resultMap[studentId] = mark; + }); + + // Update marks for each result in the assessment + for (const result of assessment.results as unknown as Result[]) { + const userId = new ObjectId(result.marks[0]?.user); // Ensure userId is an ObjectId + const mark = resultMap[userId.toString()]; + if (mark !== undefined) { + result.marks[0].mark = mark; + await result.save(); + } + } +}; + +export const updateInternalAssessmentResultMarkerById = async ( + assessmentId: string, + resultId: string, + markerId: string +) => { + const assessment = await InternalAssessmentModel.findById(assessmentId) + .populate('results'); + + if (!assessment) { + throw new NotFoundError('Assessment not found'); + } + + const resultToUpdate = await ResultModel.findById(resultId); + if (!resultToUpdate || !resultToUpdate.assessment.equals(new ObjectId(assessment._id))) { + throw new NotFoundError('Result not found'); + } + + // Update the marker (ensure ObjectId conversion) + resultToUpdate.marker = new ObjectId(markerId); + await resultToUpdate.save(); +}; + +interface InternalAssessmentData { + assessmentName: string; + description: string; + startDate: Date; + endDate?: Date; + maxMarks?: number; + granularity: string; + teamSetName: string; + gradedBy?: string; +} + +export const addInternalAssessmentsToCourse = async ( + courseId: string, + assessmentsData: InternalAssessmentData[] +) => { + if (!Array.isArray(assessmentsData) || assessmentsData.length === 0) { + throw new BadRequestError('Invalid or empty internal assessments data'); + } + + const course = await CourseModel.findById(courseId).populate('students'); + if (!course) { + throw new NotFoundError('Course not found'); + } + + const newAssessments: mongoose.Document[] = []; + + for (const data of assessmentsData) { + const { + assessmentName, + description, + startDate, + endDate, + maxMarks, + granularity, + teamSetName, + gradedBy, + } = data; + + const existingAssessment = await InternalAssessmentModel.findOne({ + course: courseId, + assessmentName, + }); + + if (existingAssessment) { + continue; // Skip if assessment already exists + } + + const assessment = new InternalAssessmentModel({ + course: courseId, + assessmentName, + description, + startDate, + endDate, + maxMarks, + granularity, + teamSet: null, + gradedBy: gradedBy ? new ObjectId(gradedBy) : null, + results: [], + isReleased: false, + questions: [], + }); + // Add locked questions + const nusnetIdQuestion = new ShortResponseQuestionModel({ + text: 'Student NUSNET ID (EXXXXXXX)', + type: 'Short Response', + shortResponsePlaceholder: 'E1234567', + customInstruction: 'Enter your NUSNET ID starting with E followed by 7 digits.', + isLocked: true, + }); + + const nusnetEmailQuestion = new ShortResponseQuestionModel({ + text: 'Student NUSNET Email', + type: 'Short Response', + shortResponsePlaceholder: 'e1234567@u.nus.edu', + customInstruction: 'Enter your NUSNET email address.', + isLocked: true, + }); + + await nusnetIdQuestion.save(); + await nusnetEmailQuestion.save(); + assessment.questions = [nusnetIdQuestion._id, nusnetEmailQuestion._id]; + + await assessment.save(); + const results: mongoose.Document[] = []; + + const teamSet = await TeamSetModel.findOne({ + course: courseId, + name: teamSetName, + }).populate({ path: 'teams', populate: ['members', 'TA'] }); + + if (granularity === 'team') { + if (!teamSet) { + continue; + } + assessment.teamSet = teamSet._id; + teamSet.teams.forEach((team: any) => { + const initialMarks = team.members.map((member: any) => ({ + user: member.identifier, + name: member.name, + mark: 0, + })); + const result = new ResultModel({ + assessment: assessment._id, + team: team._id, + marker: team.TA?._id, + marks: initialMarks, + }); + results.push(result); + }); + } else { + if (teamSet) { + assessment.teamSet = teamSet._id; + course.students.forEach((student: any) => { + const teams: Team[] = teamSet.teams as unknown as Team[]; + const team = teams.find(t => + t?.members?.some(member => member._id.equals(student._id)) + ); + const marker = team?.TA?._id || null; + const result = new ResultModel({ + assessment: assessment._id, + team: team?._id, + marker, + marks: [{ user: student.identifier, name: student.name, mark: 0 }], + }); + results.push(result); + }); + } else { + course.students.forEach((student: any) => { + const result = new ResultModel({ + assessment: assessment._id, + team: null, + marker: null, + marks: [ + { + user: student.identifier, + name: student.name, + mark: 0, + }, + ], + }); + results.push(result); + }); + } + } + + assessment.results = results.map(result => result._id); + course.internalAssessments.push(assessment._id); + newAssessments.push(assessment); + await Promise.all(results.map(result => result.save())); + } + + if (newAssessments.length === 0) { + throw new BadRequestError('Failed to add any internal assessments'); + } + + await course.save(); + await Promise.all(newAssessments.map(assessment => assessment.save())); +}; + +/*--------------------------Questions---------------------------------------------*/ +// Add a question to an internal assessment +export const addQuestionToAssessment = async ( + assessmentId: string, + questionData: Partial, + accountId: string +): Promise => { + const account = await AccountModel.findById(accountId); + if (!account) { + throw new NotFoundError('Account not found'); + } + + // Only admins or faculty members are allowed to add questions + if (account.role !== 'admin' && account.role !== 'Faculty member') { + throw new BadRequestError('Unauthorized'); + } + + const assessment = await InternalAssessmentModel.findById(assessmentId); + if (!assessment) { + throw new NotFoundError('Assessment not found'); + } + + // Remove the temporary _id before saving + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { _id, ...validQuestionData } = questionData; + + // Ensure the required fields are present + if (!validQuestionData.type || !validQuestionData.text) { + throw new BadRequestError('Both type and text fields are required'); + } + + // Determine which model to use based on the question type + let QuestionTypeModel; + switch (validQuestionData.type) { + case 'Multiple Choice': + QuestionTypeModel = MultipleChoiceQuestionModel; + break; + case 'Multiple Response': + QuestionTypeModel = MultipleResponseQuestionModel; + break; + case 'Scale': + QuestionTypeModel = ScaleQuestionModel; + break; + case 'Short Response': + QuestionTypeModel = ShortResponseQuestionModel; + break; + case 'Long Response': + QuestionTypeModel = LongResponseQuestionModel; + break; + case 'Date': + QuestionTypeModel = DateQuestionModel; + break; + case 'Number': + QuestionTypeModel = NumberQuestionModel; + break; + case 'Undecided': + QuestionTypeModel = UndecidedQuestionModel; + break; + default: + QuestionTypeModel = QuestionModel; + break; + } + + // Create a new question using the appropriate model + const question = new QuestionTypeModel({ + ...validQuestionData, + customInstruction: validQuestionData.customInstruction || '', + isLocked: validQuestionData.isLocked || false, + }); + + // Save the question + await question.save(); + + // Add the question to the assessment + assessment.questions = assessment.questions || []; + assessment.questions.push(question._id); + await assessment.save(); + + let savedQuestion: QuestionUnion | null; + switch (validQuestionData.type) { + case 'Multiple Choice': + savedQuestion = await MultipleChoiceQuestionModel.findById(question._id); + break; + case 'Multiple Response': + savedQuestion = await MultipleResponseQuestionModel.findById(question._id); + break; + case 'Scale': + savedQuestion = await ScaleQuestionModel.findById(question._id); + break; + case 'Short Response': + savedQuestion = await ShortResponseQuestionModel.findById(question._id); + break; + case 'Long Response': + savedQuestion = await LongResponseQuestionModel.findById(question._id); + break; + case 'Date': + savedQuestion = await DateQuestionModel.findById(question._id); + break; + case 'Number': + savedQuestion = await NumberQuestionModel.findById(question._id); + break; + case 'Undecided': + savedQuestion = await UndecidedQuestionModel.findById(question._id); + break; + default: + // If type is 'Undecided' or unrecognized, use the base model + savedQuestion = await QuestionModel.findById(question._id); + break; + } + + if (!savedQuestion) { + throw new NotFoundError('Question not found after saving'); + } + + return savedQuestion as QuestionUnion; +}; + +// Get all questions for an internal assessment +export const getQuestionsByAssessmentId = async ( + assessmentId: string, + accountId: string +) => { + const account = await AccountModel.findById(accountId); + if (!account) { + throw new NotFoundError('Account not found'); + } + + const assessment = await InternalAssessmentModel.findById(assessmentId).populate('questions'); + if (!assessment) { + throw new NotFoundError('Assessment not found'); + } + + return assessment.questions; +}; + +// Update a question by its ID +export const updateQuestionById = async ( + questionId: string, + updateData: Partial, // Use the QuestionUnion type for update data + accountId: string +): Promise => { + const account = await AccountModel.findById(accountId); + if (!account) { + throw new NotFoundError('Account not found'); + } + + // Only admins or faculty members are allowed to update questions + if (account.role !== 'admin' && account.role !== 'Faculty member') { + throw new BadRequestError('Unauthorized'); + } + + const existingQuestion = await QuestionModel.findById(questionId); + if (!existingQuestion) { + throw new NotFoundError('Question not found'); + } + + // Prevent updates to locked questions + if (existingQuestion.isLocked) { + throw new BadRequestError('Cannot modify a locked question'); + } + + let updatedQuestion: QuestionUnion | null; + switch (existingQuestion.type) { + case 'Multiple Choice': + updatedQuestion = await MultipleChoiceQuestionModel.findByIdAndUpdate( + questionId, + { ...updateData }, + { new: true } + ); + break; + + case 'Multiple Response': + updatedQuestion = await MultipleResponseQuestionModel.findByIdAndUpdate( + questionId, + { ...updateData }, + { new: true } + ); + break; + + case 'Scale': + updatedQuestion = await ScaleQuestionModel.findByIdAndUpdate( + questionId, + { ...updateData }, + { new: true } + ); + break; + + case 'Short Response': + updatedQuestion = await ShortResponseQuestionModel.findByIdAndUpdate( + questionId, + { ...updateData }, + { new: true } + ); + break; + + case 'Long Response': + updatedQuestion = await LongResponseQuestionModel.findByIdAndUpdate( + questionId, + { ...updateData }, + { new: true } + ); + break; + + case 'Date': + updatedQuestion = await DateQuestionModel.findByIdAndUpdate( + questionId, + { ...updateData }, + { new: true } + ); + break; + + case 'Number': + updatedQuestion = await NumberQuestionModel.findByIdAndUpdate( + questionId, + { ...updateData }, + { new: true } + ); + break; + + case 'Undecided': + updatedQuestion = await UndecidedQuestionModel.findByIdAndUpdate( + questionId, + { ...updateData }, + { new: true } + ); + break; + + default: + // Handle unknown types + updatedQuestion = await QuestionModel.findByIdAndUpdate( + questionId, + { ...updateData }, + { new: true } + ); + break; + } + + if (!updatedQuestion) { + throw new NotFoundError('Question not found after update'); + } + + return updatedQuestion as QuestionUnion; +}; + +// Delete a question from an internal assessment +export const deleteQuestionById = async ( + assessmentId: string, + questionId: string, + accountId: string +) => { + const account = await AccountModel.findById(accountId); + if (!account) { + throw new NotFoundError('Account not found'); + } + + // Only admins or faculty members are allowed to delete questions + if (account.role !== 'admin' && account.role !== 'Faculty member') { + throw new BadRequestError('Unauthorized'); + } + + const assessment = await InternalAssessmentModel.findById(assessmentId); + if (!assessment) { + throw new NotFoundError('Assessment not found'); + } + + const question = await QuestionModel.findById(questionId); + if (!question) { + throw new NotFoundError('Question not found'); + } + + if (question.isLocked) { + throw new BadRequestError('Cannot delete a locked question'); + } + + const questionIndex = assessment.questions?.findIndex( + (qId: Types.ObjectId) => qId.toString() === questionId + ); + if (questionIndex === undefined || questionIndex === -1) { + throw new NotFoundError('Question not associated with this assessment'); + } + + assessment.questions?.splice(questionIndex, 1); + await assessment.save(); + + await QuestionModel.findByIdAndDelete(questionId); +}; + +/*------------------------------Release-Form-------------------------------*/ +export const releaseInternalAssessmentById = async ( + assessmentId: string, + accountId: string +) => { + const account = await AccountModel.findById(accountId); + if (!account) { + throw new NotFoundError('Account not found'); + } + + // Only admins or faculty members are allowed to release assessments + if (account.role !== 'admin' && account.role !== 'Faculty member') { + throw new BadRequestError('Unauthorized'); + } + + const updatedAssessment = await InternalAssessmentModel.findByIdAndUpdate( + assessmentId, + { isReleased: true }, + { new: true } + ); + + if (!updatedAssessment) { + throw new NotFoundError('Assessment not found'); + } + + return updatedAssessment; +}; + +export const recallInternalAssessmentById = async ( + assessmentId: string, + accountId: string +) => { + const account = await AccountModel.findById(accountId); + if (!account) { + throw new NotFoundError('Account not found'); + } + + // Only admins or faculty members are allowed to recall assessments + if (account.role !== 'admin' && account.role !== 'Faculty member') { + throw new BadRequestError('Unauthorized'); + } + + const updatedAssessment = await InternalAssessmentModel.findByIdAndUpdate( + assessmentId, + { isReleased: false }, + { new: true } + ); + + if (!updatedAssessment) { + throw new NotFoundError('Assessment not found'); + } + + return updatedAssessment; +}; + diff --git a/multi-git-dashboard/src/components/Navbar.tsx b/multi-git-dashboard/src/components/Navbar.tsx index e50b5d99..717c5ef1 100644 --- a/multi-git-dashboard/src/components/Navbar.tsx +++ b/multi-git-dashboard/src/components/Navbar.tsx @@ -94,7 +94,7 @@ const Navbar: React.FC = () => { return 'Teams'; } else if (path.startsWith('/courses/[id]/timeline')) { return 'Timeline'; - } else if (path.startsWith('/courses/[id]/assessments')) { + } else if (path.startsWith('/courses/[id]/assessments') || path.startsWith('/courses/[id]/internal-assessments')) { return 'Assessments'; } else if (path.startsWith('/courses/[id]/project-management')) { return 'Project Management'; diff --git a/multi-git-dashboard/src/components/cards/AssessmentQuestionCard.tsx b/multi-git-dashboard/src/components/cards/AssessmentQuestionCard.tsx new file mode 100644 index 00000000..73bc182a --- /dev/null +++ b/multi-git-dashboard/src/components/cards/AssessmentQuestionCard.tsx @@ -0,0 +1,642 @@ +// AssessmentQuestionCard.tsx + +import React, { useState, useEffect } from 'react'; +import { + Box, + Button, + TextInput, + Select, + Text, + Group, + Badge, + Slider, + Radio, + Checkbox, + Textarea, +} from '@mantine/core'; +import { DatePicker, DatesRangeValue, DateValue } from '@mantine/dates'; +import { + QuestionUnion, + MultipleChoiceQuestion, + MultipleResponseQuestion, + ScaleQuestion, + ShortResponseQuestion, + LongResponseQuestion, + DateQuestion, + NumberQuestion, + UndecidedQuestion, +} from '@shared/types/Question'; +import { IconTrash, IconPencil } from '@tabler/icons-react'; + +interface AssessmentQuestionCardProps { + questionData: QuestionUnion; + onDelete: () => void; + onSave: (updatedQuestion: QuestionUnion) => void; + isLocked: boolean; +} + +const AssessmentQuestionCard: React.FC = ({ + questionData, + onDelete, + onSave, + isLocked, +}) => { + const isQuestionLocked = isLocked || questionData.isLocked || false; + + // Determine if this is a new question (temporary _id starting with 'temp-') + const isNewQuestion = questionData._id.startsWith('temp-'); + + // Start in edit mode if it's a new question or question type is 'Undecided' + const [isEditing, setIsEditing] = useState(isNewQuestion || questionData.type === 'Undecided'); + + const [questionText, setQuestionText] = useState(questionData.text || ''); + const [questionType, setQuestionType] = useState(questionData.type); + const [customInstruction, setCustomInstruction] = useState(questionData.customInstruction || ''); + + // Additional state based on question type + const [options, setOptions] = useState([]); + const [selectedOptions, setSelectedOptions] = useState([]); + const [scaleMax, setScaleMax] = useState(5); + const [scaleValue, setScaleValue] = useState(1); + const [labelMin, setLabelMin] = useState('Low'); + const [labelMax, setLabelMax] = useState('High'); + const [shortResponseValue, setShortResponseValue] = useState(''); // For submission + const [shortResponsePlaceholder, setShortResponsePlaceholder] = useState(''); // For editing the placeholder + const [longResponseValue, setLongResponseValue] = useState(''); // For Long Response submission + const [dateValue, setDateValue] = useState(null); // For Date Question (view mode) + const [datesRangeValue, setDatesRangeValue] = useState(); // For Date Question (view mode) + const [isRange, setIsRange] = useState((questionData as DateQuestion).isRange || false); // Edit mode toggle + const [datePickerPlaceholder, setDatePickerPlaceholder] = useState( + (questionData as DateQuestion).datePickerPlaceholder || '' + ); + const [minDate, setMinDate] = useState((questionData as DateQuestion).minDate || null); // Edit mode + const [maxDate, setMaxDate] = useState((questionData as DateQuestion).maxDate || null); // Edit mode + const [numberValue, setNumberValue] = useState((questionData as NumberQuestion).maxNumber || undefined); // Editable number value + + // Initialize state based on question type + useEffect(() => { + switch (questionType) { + case 'Multiple Choice': + case 'Multiple Response': + if ('options' in questionData && questionData.options) { + setOptions(questionData.options); + } else { + setOptions([]); + } + break; + case 'Scale': + if ('scaleMax' in questionData && 'labelMin' in questionData && 'labelMax' in questionData) { + setScaleMax(questionData.scaleMax); + setLabelMin(questionData.labelMin); + setLabelMax(questionData.labelMax); + } else { + setScaleMax(5); + setLabelMin('Low'); + setLabelMax('High'); + } + break; + case 'Short Response': + setShortResponsePlaceholder((questionData as ShortResponseQuestion).shortResponsePlaceholder || ''); + setShortResponseValue(''); // Future submission handling + break; + case 'Long Response': + setShortResponsePlaceholder((questionData as LongResponseQuestion).longResponsePlaceholder || ''); + setLongResponseValue(''); // Future submission handling + break; + case 'Date': + // eslint-disable-next-line no-case-declarations + const dateQuestion = questionData as DateQuestion; + setIsRange(dateQuestion.isRange); + setDatePickerPlaceholder(dateQuestion.datePickerPlaceholder || ''); + setMinDate(dateQuestion.minDate || null); + setMaxDate(dateQuestion.maxDate || null); + break; + case 'Number': + setNumberValue((questionData as NumberQuestion).maxNumber || undefined); + break; + default: + break; + } + }, [questionType, questionData]); + + const handleAddOption = () => { + setOptions([...options, '']); + }; + + const handleOptionChange = (index: number, value: string) => { + const newOptions = [...options]; + newOptions[index] = value; + setOptions(newOptions); + }; + + const handleDeleteOption = (index: number) => { + const newOptions = options.filter((_, i) => i !== index); + setOptions(newOptions); + }; + + const toggleOption = (index: number) => { + if (questionType === 'Multiple Choice') { + setSelectedOptions(selectedOptions.includes(index) ? [] : [index]); // Only one can be selected at a time for MCQ + } else if (questionType === 'Multiple Response') { + setSelectedOptions((prev) => + prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index] + ); // Toggle selection for MRQ + } + }; + + const saveQuestion = () => { + let updatedQuestion: QuestionUnion; + + switch (questionType) { + case 'Multiple Choice': + updatedQuestion = { + ...questionData, + text: questionText, + type: questionType, + options, + customInstruction: customInstruction || getDefaultInstruction(), + isLocked: questionData.isLocked || false, + } as MultipleChoiceQuestion; + break; + case 'Multiple Response': + updatedQuestion = { + ...questionData, + text: questionText, + type: questionType, + options, + customInstruction: customInstruction || getDefaultInstruction(), + isLocked: questionData.isLocked || false, + } as MultipleResponseQuestion; + break; + case 'Scale': + updatedQuestion = { + ...questionData, + text: questionText, + type: questionType, + scaleMax, + labelMin, + labelMax, + customInstruction: customInstruction || getDefaultInstruction(), + isLocked: questionData.isLocked || false, + } as ScaleQuestion; + break; + case 'Short Response': + updatedQuestion = { + ...questionData, + text: questionText, + type: questionType, + shortResponsePlaceholder, + customInstruction: customInstruction || getDefaultInstruction(), + isLocked: questionData.isLocked || false, + } as ShortResponseQuestion; + break; + case 'Long Response': + updatedQuestion = { + ...questionData, + text: questionText, + type: questionType, + longResponsePlaceholder: shortResponsePlaceholder, + customInstruction: customInstruction || getDefaultInstruction(), + isLocked: questionData.isLocked || false, + } as LongResponseQuestion; + break; + case 'Date': + updatedQuestion = { + ...questionData, + text: questionText, + type: questionType, + isRange, + datePickerPlaceholder, + minDate: minDate || undefined, + maxDate: maxDate || undefined, + customInstruction: customInstruction || getDefaultInstruction(), + isLocked: questionData.isLocked || false, + } as DateQuestion; + break; + case 'Number': + updatedQuestion = { + ...questionData, + text: questionText, + type: questionType, + maxNumber: numberValue || 100, + customInstruction: customInstruction || getDefaultInstruction(), + isLocked: questionData.isLocked || false, + } as NumberQuestion; + break; + case 'Undecided': + updatedQuestion = { + ...questionData, + text: questionText, + type: questionType, + customInstruction: customInstruction || '', + isLocked: questionData.isLocked || false, + } as UndecidedQuestion; + break; + default: + updatedQuestion = { + ...questionData, + text: questionText, + type: questionType, + isLocked: questionData.isLocked || false, + } as QuestionUnion; + break; + } + + onSave(updatedQuestion); + setIsEditing(false); // Switch to view mode after saving + }; + + const getDefaultInstruction = () => { + switch (questionType) { + case 'Multiple Choice': + return 'Select the most appropriate answer.'; + case 'Multiple Response': + return 'Select ALL appropriate answers.'; + case 'Scale': + return 'Select the most accurate score.'; + case 'Short Response': + case 'Long Response': + return 'Provide a brief answer in the text box below.'; + case 'Date': + return isRange ? 'Select a date range.' : 'Select a single date.'; + case 'Number': + return `Enter a number (maximum: ${numberValue || 100}).`; + default: + return ''; + } + }; + + if (!isEditing) { + // View mode + return ( + + {/* Question type badge */} + + {isQuestionLocked ? 'Locked' : questionType} + + + {/* Question instruction */} + + {customInstruction || getDefaultInstruction()} + + + {/* Question text */} + {questionText} + + {/* Render based on question type */} + {questionType === 'Multiple Choice' || questionType === 'Multiple Response' ? ( + + {options.map((option, index) => ( + + {questionType === 'Multiple Choice' ? ( + toggleOption(index)} + style={{ width: '100%' }} + /> + ) : ( + toggleOption(index)} + style={{ width: '100%' }} + /> + )} + + ))} + + ) : null} + + {questionType === 'Scale' ? ( + <> + + + + ) : null} + + {questionType === 'Short Response' && ( + setShortResponseValue(e.currentTarget.value)} + style={{ marginBottom: '16px' }} + /> + )} + + {questionType === 'Long Response' && ( +