diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CopyExercise/CopyExerciseModal.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CopyExercise/CopyExerciseModal.tsx index 3832b25f..94bb392a 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CopyExercise/CopyExerciseModal.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CopyExercise/CopyExerciseModal.tsx @@ -12,6 +12,7 @@ import toast from "react-hot-toast"; import { useSelectedAssignment } from "@/hooks/useSelectedAssignment"; import { Exercise, supportedExerciseTypesToEdit } from "@/types/exercises"; +import { regenerateHtmlSrc } from "@/utils/htmlRegeneration"; interface CopyExerciseModalProps { visible: boolean; @@ -89,11 +90,22 @@ export const CopyExerciseModal = ({ if (!exercise || !isValid) return; try { + // Generate new HTML source with the new name if the exercise type is supported + let newHtmlSrc: string | undefined; + if (exercise.question_json && supportedExerciseTypesToEdit.includes(exercise.question_type)) { + try { + newHtmlSrc = regenerateHtmlSrc(exercise, newName.trim()); + } catch (htmlError) { + console.error("Failed to regenerate HTML source:", htmlError); + } + } + const result = await copyQuestion({ original_question_id: exercise.question_id ?? exercise.id, new_name: newName.trim(), assignment_id: copyToAssignment ? selectedAssignment?.id : undefined, - copy_to_assignment: copyToAssignment + copy_to_assignment: copyToAssignment, + htmlsrc: newHtmlSrc }).unwrap(); if (setCurrentEditExercise && setViewMode && copyToAssignment && result.detail.question_id) { diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignmentExercise/assignmentExercise.logic.api.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignmentExercise/assignmentExercise.logic.api.ts index 05efbbb7..fc6ae308 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignmentExercise/assignmentExercise.logic.api.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignmentExercise/assignmentExercise.logic.api.ts @@ -174,6 +174,7 @@ export const assignmentExerciseApi = createApi({ new_name: string; assignment_id?: number; copy_to_assignment: boolean; + htmlsrc?: string; } >({ query: (body) => ({ diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/htmlRegeneration.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/htmlRegeneration.ts new file mode 100644 index 00000000..ade01014 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/htmlRegeneration.ts @@ -0,0 +1,118 @@ +import { Exercise, QuestionJSON } from "@/types/exercises"; +import { generateMultiChoicePreview } from "@/utils/preview/multichoice"; +import { generateFillInTheBlankPreview } from "@/utils/preview/fillInTheBlank"; +import { generateParsonsPreview } from "@/utils/preview/parsonsPreview"; +import { generateActiveCodePreview } from "@/utils/preview/activeCode"; +import { generateShortAnswerPreview } from "@/utils/preview/shortAnswer"; +import { generateMatchingPreview } from "@/utils/preview/matchingPreview"; +import { generateDragAndDropPreview } from "@/utils/preview/dndPreview"; +import { generatePollPreview } from "@/utils/preview/poll"; +import { safeJsonParse } from "@/utils/json"; + +/** + * Regenerates HTML source for a copied exercise with the new name + * This ensures that IDs and labels in the HTML match the new exercise name + */ +export const regenerateHtmlSrc = (exercise: Exercise, newName: string): string => { + try { + console.log(exercise); + const questionJson: QuestionJSON = exercise.question_json + ? (safeJsonParse(exercise.question_json) as QuestionJSON) + : {}; + + switch (exercise.question_type) { + case "mchoice": + return generateMultiChoicePreview( + questionJson.statement || "", + questionJson.optionList || [], + newName, + questionJson.forceCheckboxes + ); + + case "fillintheblank": + return generateFillInTheBlankPreview({ + questionText: questionJson.questionText || "", + blanks: questionJson.blanks || [], + name: newName + }); + + case "parsonsprob": + return generateParsonsPreview({ + instructions: questionJson.instructions || questionJson.questionText || "", + blocks: questionJson.blocks || [], + name: newName + }); + + case "activecode": + console.log(questionJson); + return generateActiveCodePreview( + questionJson.instructions || "", + questionJson.language || "python", + questionJson.prefix_code || "", + questionJson.starter_code || "", + questionJson.suffix_code || "", + newName, + questionJson.stdin + ); + + case "shortanswer": + return generateShortAnswerPreview( + questionJson.questionText || "", + questionJson.attachment || false, + newName + ); + + case "matching": + return generateMatchingPreview({ + left: questionJson.left || [], + right: questionJson.right || [], + correctAnswers: questionJson.correctAnswers || [], + feedback: questionJson.feedback || "", + name: newName, + statement: questionJson.statement || questionJson.questionText || "" + }); + + case "dragndrop": + return generateDragAndDropPreview({ + left: questionJson.left || [], + right: questionJson.right || [], + correctAnswers: questionJson.correctAnswers || [], + feedback: questionJson.feedback || "", + name: newName, + statement: questionJson.statement || questionJson.questionText || "" + }); + + case "poll": + return generatePollPreview( + questionJson.questionText || "", + questionJson.optionList?.map((opt) => opt.choice) || [], + newName, + questionJson.poll_type + ); + + default: + // For unsupported types, try to update the name in the existing HTML + return updateNameInHtml(exercise.htmlsrc, exercise.name, newName); + } + } catch (error) { + console.error("Error regenerating HTML source:", error); + return updateNameInHtml(exercise.htmlsrc, exercise.name, newName); + } +}; + +/** + * Fallback method to update question name/ID in existing HTML + * This is used when we can't regenerate the HTML completely + */ +const updateNameInHtml = (htmlSrc: string, oldName: string, newName: string): string => { + if (!htmlSrc || !oldName || !newName) { + return htmlSrc; + } + + return htmlSrc + .replace(new RegExp(`id="${oldName}"`, "g"), `id="${newName}"`) + .replace(new RegExp(`data-component="${oldName}"`, "g"), `data-component="${newName}"`) + .replace(new RegExp(`data-question="${oldName}"`, "g"), `data-question="${newName}"`) + .replace(new RegExp(`name="${oldName}"`, "g"), `name="${newName}"`) + .replace(new RegExp(`name='${oldName}'`, "g"), `name='${newName}'`); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/poll.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/poll.tsx index 7bf99e18..8fa03f80 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/poll.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/poll.tsx @@ -4,7 +4,7 @@ export const generatePollPreview = ( questionTitle: string, options: string[], questionName: string, - pollType: "options" | "scale" = "options" + pollType: string = "options" ): string => { const safeId = sanitizeId(questionName); // Function to strip paragraph tags and clean HTML diff --git a/bases/rsptx/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index 8d32dafd..0a40edf8 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -1470,7 +1470,8 @@ async def copy_question_endpoint( original_question_id=request_data.original_question_id, new_name=request_data.new_name, new_owner=user.username, - assignment_id=assignment_id + assignment_id=assignment_id, + htmlsrc=request_data.htmlsrc ) return make_json_response( diff --git a/components/rsptx/db/crud/question.py b/components/rsptx/db/crud/question.py index 851ec3d0..0f1fb24c 100644 --- a/components/rsptx/db/crud/question.py +++ b/components/rsptx/db/crud/question.py @@ -707,7 +707,8 @@ async def copy_question( original_question_id: int, new_name: str, new_owner: str, - assignment_id: Optional[int] = None + assignment_id: Optional[int] = None, + htmlsrc: Optional[str] = None ) -> QuestionValidator: """ Copy a question to create a new one with the same content but different name and owner. @@ -716,6 +717,7 @@ async def copy_question( :param new_name: str, the name for the new question :param new_owner: str, the username of the new owner :param assignment_id: Optional[int], the assignment ID if copying to an assignment + :param htmlsrc: Optional[str], the HTML source to use for the new question (if provided, overrides original) :return: QuestionValidator, the newly created question """ async with async_session() as session: @@ -727,6 +729,9 @@ async def copy_question( if not original_question: raise ValueError(f"Original question with ID {original_question_id} not found") + # Use provided htmlsrc or fall back to original + question_htmlsrc = htmlsrc if htmlsrc is not None else original_question.htmlsrc + # Create new question with copied data new_question = Question( base_course=original_question.base_course, @@ -738,7 +743,7 @@ async def copy_question( timestamp=canonical_utcnow(), question_type=original_question.question_type, is_private=original_question.is_private, - htmlsrc=original_question.htmlsrc, + htmlsrc=question_htmlsrc, autograde=original_question.autograde, practice=original_question.practice, topic=original_question.topic, diff --git a/components/rsptx/validation/schemas.py b/components/rsptx/validation/schemas.py index 82657e5d..4bdbb172 100644 --- a/components/rsptx/validation/schemas.py +++ b/components/rsptx/validation/schemas.py @@ -379,4 +379,5 @@ class CopyQuestionRequest(BaseModel): original_question_id: int new_name: str assignment_id: Optional[int] = None - copy_to_assignment: bool = False \ No newline at end of file + copy_to_assignment: bool = False + htmlsrc: Optional[str] = None