From 6495b7cc006a66b45e2ec5914350c6ce43e4391d Mon Sep 17 00:00:00 2001 From: andreimarozau Date: Sat, 21 Mar 2026 23:22:44 +0300 Subject: [PATCH] Refactor: enhance SmartSearchExercises and CopyExerciseModal to support editing and improve UX --- .../AssignmentExercisesContainer.tsx | 11 +- .../AssignmentExercisesTable.tsx | 1 - .../CopyExercise/CopyExerciseModal.tsx | 107 +++++++++++++----- .../SearchExercises/SmartSearchExercises.tsx | 13 ++- .../routers/instructor.py | 9 +- components/rsptx/db/crud/question.py | 47 ++++++-- 6 files changed, 141 insertions(+), 47 deletions(-) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesContainer.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesContainer.tsx index 53fa9191..0ce965a6 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesContainer.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/AssignmentExercisesList/AssignmentExercisesContainer.tsx @@ -18,7 +18,7 @@ import { EditView } from "./EditView"; import { ErrorDisplay } from "./ErrorDisplay"; import { ExerciseListView } from "./ExerciseListView"; import { ExerciseSuccessDialog } from "./ExerciseSuccessDialog"; -import { AssignmentExercisesComponentProps, ViewMode } from "./types"; +import { AssignmentExercisesComponentProps } from "./types"; export const AssignmentExercisesContainer = ({ startItemId, @@ -100,7 +100,14 @@ export const AssignmentExercisesContainer = ({ {viewMode === "browse" && } - {viewMode === "search" && } + {viewMode === "search" && ( + + updateExerciseViewMode(mode) + } + /> + )} {viewMode === "create" && ( 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 94bb392a..4d3b3ff8 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 @@ -18,7 +18,6 @@ interface CopyExerciseModalProps { visible: boolean; onHide: () => void; exercise: Exercise | null; - copyToAssignment?: boolean; setCurrentEditExercise?: (exercise: Exercise | null) => void; setViewMode?: (mode: "list" | "browse" | "search" | "create" | "edit") => void; } @@ -27,7 +26,6 @@ export const CopyExerciseModal = ({ visible, onHide, exercise, - copyToAssignment = false, setCurrentEditExercise, setViewMode }: CopyExerciseModalProps) => { @@ -44,6 +42,15 @@ export const CopyExerciseModal = ({ skip: !selectedAssignment?.id }); + // Determine if the exercise type supports direct editing + const canEditDirectly = + exercise && + supportedExerciseTypesToEdit.includes(exercise.question_type) && + !!exercise.question_json; + + // Determine if we have the edit infrastructure available + const hasEditSupport = !!setCurrentEditExercise && !!setViewMode; + useEffect(() => { if (exercise && visible) { setNewName(exercise.name || ""); @@ -92,6 +99,7 @@ export const CopyExerciseModal = ({ 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()); @@ -103,75 +111,116 @@ export const CopyExerciseModal = ({ 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, + assignment_id: selectedAssignment?.id, + copy_to_assignment: true, htmlsrc: newHtmlSrc }).unwrap(); - if (setCurrentEditExercise && setViewMode && copyToAssignment && result.detail.question_id) { - const exercises = await refetchExercises(); - const newExercise = exercises.data?.find( - (ex) => ex.question_id === result.detail.question_id - ); - const canEdit = - newExercise && - supportedExerciseTypesToEdit.includes(newExercise.question_type) && - !!newExercise.question_json; - - if (canEdit) { - setCurrentEditExercise(newExercise); - setViewMode("edit"); - } + // Refetch the exercises list to pick up the newly added copy + const exercises = await refetchExercises(); + const newExercise = exercises.data?.find( + (ex) => ex.question_id === result.detail.question_id + ); + + const canEdit = + newExercise && + supportedExerciseTypesToEdit.includes(newExercise.question_type) && + !!newExercise.question_json; + + if (canEdit && hasEditSupport) { + toast.success("Exercise copied and added to assignment. Opening editor…"); + setCurrentEditExercise!(newExercise); + setViewMode!("edit"); + } else if (newExercise) { + toast.success("Exercise copied and added to your assignment. You are now the owner.", { + duration: 5000 + }); + } else { + toast.success("Exercise copied successfully! You are now the owner."); } - toast.success("Exercise copied successfully!"); - onHide(); + handleClose(); } catch (error) { toast.error("Error copying exercise"); } }; - const handleCancel = () => { + const handleClose = () => { setNewName(""); setValidationMessage(null); setIsValid(false); onHide(); }; + const getPrimaryButtonLabel = () => { + if (isCopying) return "Copying…"; + if (canEditDirectly && hasEditSupport) return "Copy, Add & Edit"; + return "Copy & Add to Assignment"; + }; + + const getPrimaryButtonIcon = () => { + if (canEditDirectly && hasEditSupport) return "pi pi-pencil"; + return "pi pi-plus"; + }; + return (
+ +
setNewName(e.target.value)} - placeholder="Enter new exercise name" + placeholder="Enter a unique name for the copy" className="w-full" invalid={validationMessage !== null} /> {validationMessage && ( - + )}
-
-
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/SearchExercises/SmartSearchExercises.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/SearchExercises/SmartSearchExercises.tsx index ce6b190b..c4272b3e 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/SearchExercises/SmartSearchExercises.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/SearchExercises/SmartSearchExercises.tsx @@ -29,7 +29,15 @@ import styles from "./SmartSearchExercises.module.css"; /** * Smart exercise search component with fixed layout and enhanced UX */ -export const SmartSearchExercises = () => { +interface SmartSearchExercisesProps { + setCurrentEditExercise?: (exercise: Exercise | null) => void; + setViewMode?: (mode: "list" | "browse" | "search" | "create" | "edit") => void; +} + +export const SmartSearchExercises = ({ + setCurrentEditExercise, + setViewMode +}: SmartSearchExercisesProps) => { const dispatch = useDispatch(); const selectedExercises = useSelector(searchExercisesSelectors.getSelectedExercises); const exerciseTypes = useSelector(datasetSelectors.getQuestionTypeOptions); @@ -440,7 +448,8 @@ export const SmartSearchExercises = () => { visible={copyModalVisible} onHide={handleCopyModalHide} exercise={selectedExerciseForCopy} - copyToAssignment={false} + setCurrentEditExercise={setCurrentEditExercise} + setViewMode={setViewMode} /> ); diff --git a/bases/rsptx/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index 5a7bb62f..b5dc7154 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -1505,11 +1505,17 @@ async def validate_question_name( @router.post("/copy_question") @instructor_role_required() +@with_course() async def copy_question_endpoint( - request: Request, request_data: CopyQuestionRequest, user=Depends(auth_manager) + request: Request, + request_data: CopyQuestionRequest, + user=Depends(auth_manager), + course=None, ): """ Copy a question with a new name and owner. + The user making the copy becomes the owner and author. + The base_course is updated to the current user's course base_course. Optionally copy it to an assignment as well. """ try: @@ -1523,6 +1529,7 @@ async def copy_question_endpoint( new_owner=user.username, assignment_id=assignment_id, htmlsrc=request_data.htmlsrc, + new_base_course=course.base_course, ) return make_json_response( diff --git a/components/rsptx/db/crud/question.py b/components/rsptx/db/crud/question.py index f099a21f..a9a1e578 100644 --- a/components/rsptx/db/crud/question.py +++ b/components/rsptx/db/crud/question.py @@ -744,6 +744,7 @@ async def copy_question( new_owner: str, assignment_id: Optional[int] = None, htmlsrc: Optional[str] = None, + new_base_course: Optional[str] = None, ) -> QuestionValidator: """ Copy a question to create a new one with the same content but different name and owner. @@ -753,6 +754,7 @@ async def copy_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) + :param new_base_course: Optional[str], the base course for the new question (if provided, overrides original) :return: QuestionValidator, the newly created question """ async with async_session() as session: @@ -769,13 +771,20 @@ async def copy_question( # Use provided htmlsrc or fall back to original question_htmlsrc = htmlsrc if htmlsrc is not None else original_question.htmlsrc + # Use provided base_course or fall back to original + question_base_course = ( + new_base_course + if new_base_course is not None + else original_question.base_course + ) + # Create new question with copied data new_question = Question( - base_course=original_question.base_course, + base_course=question_base_course, name=new_name, chapter=original_question.chapter, subchapter=original_question.subchapter, - author=original_question.author, + author=new_owner, question=original_question.question, timestamp=canonical_utcnow(), question_type=original_question.question_type, @@ -802,9 +811,9 @@ async def copy_question( await session.flush() await session.refresh(new_question) - # If assignment_id is provided, also copy the assignment question + # If assignment_id is provided, also add the new question to the assignment if assignment_id: - # Get the original assignment question + # Try to get the original assignment question for copying settings original_aq_query = select(AssignmentQuestion).where( (AssignmentQuestion.question_id == original_question_id) & (AssignmentQuestion.assignment_id == assignment_id) @@ -812,14 +821,15 @@ async def copy_question( original_aq_result = await session.execute(original_aq_query) original_aq = original_aq_result.scalars().first() - if original_aq: - # Get the next sorting priority - max_priority_query = select( - func.max(AssignmentQuestion.sorting_priority) - ).where(AssignmentQuestion.assignment_id == assignment_id) - max_priority_result = await session.execute(max_priority_query) - max_priority = max_priority_result.scalar() or 0 + # Get the next sorting priority + max_priority_query = select( + func.max(AssignmentQuestion.sorting_priority) + ).where(AssignmentQuestion.assignment_id == assignment_id) + max_priority_result = await session.execute(max_priority_query) + max_priority = max_priority_result.scalar() or 0 + if original_aq: + # Copy settings from the original assignment question new_assignment_question = AssignmentQuestion( assignment_id=assignment_id, question_id=new_question.id, @@ -831,7 +841,20 @@ async def copy_question( sorting_priority=max_priority + 1, activities_required=original_aq.activities_required, ) - session.add(new_assignment_question) + else: + # Original question wasn't in this assignment; add with defaults + new_assignment_question = AssignmentQuestion( + assignment_id=assignment_id, + question_id=new_question.id, + points=1, + timed=False, + autograde="pct_correct", + which_to_grade="best_answer", + reading_assignment=False, + sorting_priority=max_priority + 1, + activities_required=0, + ) + session.add(new_assignment_question) await session.commit() return QuestionValidator.from_orm(new_question)