Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export const assignmentExerciseApi = createApi({
new_name: string;
assignment_id?: number;
copy_to_assignment: boolean;
htmlsrc?: string;
}
>({
query: (body) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -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}'`);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion bases/rsptx/assignment_server_api/routers/instructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
9 changes: 7 additions & 2 deletions components/rsptx/db/crud/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion components/rsptx/validation/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,4 +379,5 @@ class CopyQuestionRequest(BaseModel):
original_question_id: int
new_name: str
assignment_id: Optional[int] = None
copy_to_assignment: bool = False
copy_to_assignment: bool = False
htmlsrc: Optional[str] = None