From f2d3db309db014c1c68f4b54021a4c3ff7a7e9f7 Mon Sep 17 00:00:00 2001 From: andreimarozau Date: Tue, 13 Jan 2026 13:31:16 +0300 Subject: [PATCH] feature-1035 Add iFrame exercise type and related components --- .../components/ExerciseFactory.tsx | 5 +- .../IframeExercise/IframeExercise.tsx | 135 ++++++++++++++++++ .../IframeExercise/IframeExerciseSettings.tsx | 22 +++ .../IframeExercise/IframePreview.tsx | 17 +++ .../components/IframeUrlInput.tsx | 78 ++++++++++ .../IframeExercise/components/index.ts | 1 + .../components/IframeExercise/index.ts | 3 + .../CreateExercise/components/index.ts | 1 + .../CreateExercise/config/stepConfigs.ts | 65 +++++++++ .../assignment_builder/src/types/exercises.ts | 7 +- .../src/utils/htmlRegeneration.ts | 4 + .../src/utils/preview/iframePreview.ts | 13 ++ .../src/utils/questionJson.ts | 3 + components/rsptx/data_types/question_type.py | 5 + 14 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframeExercise.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframeExerciseSettings.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframePreview.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/components/IframeUrlInput.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/components/index.ts create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/index.ts create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/utils/preview/iframePreview.ts diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ExerciseFactory.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ExerciseFactory.tsx index bc21b7f91..73ac11110 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ExerciseFactory.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ExerciseFactory.tsx @@ -12,7 +12,8 @@ import { ShortAnswerExercise, MultiChoiceExercise, MatchingExercise, - SelectQuestionExercise + SelectQuestionExercise, + IframeExercise } from "."; export const ExerciseFactory: FC = (props) => { @@ -37,6 +38,8 @@ export const ExerciseFactory: FC = (props) => { return ; case "selectquestion": return ; + case "iframe": + return ; default: throw new Error(`Unknown exercise type: ${props.type}`); } diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframeExercise.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframeExercise.tsx new file mode 100644 index 000000000..e1394f416 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframeExercise.tsx @@ -0,0 +1,135 @@ +import { ExerciseComponentProps } from "@components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/types/ExerciseTypes"; +import { FC } from "react"; + +import { CreateExerciseFormType } from "@/types/exercises"; +import { createExerciseId } from "@/utils/exercise"; +import { generateIframePreview } from "@/utils/preview/iframePreview"; + +import { IFRAME_STEP_VALIDATORS } from "../../config/stepConfigs"; +import { useBaseExercise } from "../../hooks/useBaseExercise"; +import { useExerciseStepNavigation } from "../../hooks/useExerciseStepNavigation"; +import { ExerciseLayout } from "../../shared/ExerciseLayout"; +import { validateCommonFields } from "../../utils/validation"; + +import { IframeExerciseSettings } from "./IframeExerciseSettings"; +import { IframePreview } from "./IframePreview"; +import { IframeUrlInput } from "./components/IframeUrlInput"; + +const IFRAME_STEPS = [{ label: "iFrame URL" }, { label: "Settings" }, { label: "Preview" }]; + +// Define the default form data +const getDefaultFormData = (): Partial => ({ + name: createExerciseId(), + author: "", + topic: "", + chapter: "", + subchapter: "", + tags: "", + points: 1, + difficulty: 3, + htmlsrc: "", + question_type: "iframe", + iframeSrc: "" +}); + +// Create a wrapper for generateIframePreview to match the expected type +const generatePreview = (data: Partial): string => { + return generateIframePreview(data.iframeSrc || "", data.name || ""); +}; + +export const IframeExercise: FC = ({ + initialData, + onSave, + onCancel, + resetForm, + onFormReset, + isEdit = false +}) => { + const { + formData, + activeStep, + isSaving, + updateFormData, + handleSettingsChange, + isCurrentStepValid, + goToNextStep, + goToPrevStep, + handleSave: baseHandleSave, + setActiveStep + } = useBaseExercise({ + initialData, + steps: IFRAME_STEPS, + exerciseType: "iframe", + generatePreview, + validateStep: (step, data) => { + const errors = IFRAME_STEP_VALIDATORS[step](data); + + return errors.length === 0; + }, + validateForm: validateCommonFields, + getDefaultFormData, + onSave: onSave as (data: Partial) => Promise, + onCancel, + resetForm, + onFormReset, + isEdit + }); + + // Use our centralized navigation and validation hook + const { validation, handleNext, handleStepSelect, handleSave, stepsValidity } = + useExerciseStepNavigation({ + data: formData, + activeStep, + setActiveStep, + stepValidators: IFRAME_STEP_VALIDATORS, + goToNextStep, + goToPrevStep, + steps: IFRAME_STEPS, + handleBaseSave: baseHandleSave, + generateHtmlSrc: generatePreview, + updateFormData + }); + + // Render step content + const renderStepContent = () => { + switch (activeStep) { + case 0: // iFrame URL + return ( + updateFormData("iframeSrc", url)} + /> + ); + + case 1: // Settings + return ; + + case 2: // Preview + return ; + + default: + return null; + } + }; + + return ( + + {renderStepContent()} + + ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframeExerciseSettings.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframeExerciseSettings.tsx new file mode 100644 index 000000000..b37901260 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframeExerciseSettings.tsx @@ -0,0 +1,22 @@ +import { FC } from "react"; + +import { CreateExerciseFormType } from "@/types/exercises"; + +import { + BaseExerciseSettings, + BaseExerciseSettingsContent +} from "../../shared/BaseExerciseSettingsContent"; + +interface IframeExerciseSettingsProps { + formData: Partial; + onChange: (settings: Partial) => void; +} + +export const IframeExerciseSettings: FC = ({ formData, onChange }) => { + return ( + + initialData={formData} + onSettingsChange={onChange} + /> + ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframePreview.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframePreview.tsx new file mode 100644 index 000000000..def3288d1 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/IframePreview.tsx @@ -0,0 +1,17 @@ +import { ExercisePreview } from "@components/routes/AssignmentBuilder/components/exercises/components/ExercisePreview/ExercisePreview"; +import { FC } from "react"; + +import { generateIframePreview } from "@/utils/preview/iframePreview"; + +interface IframePreviewProps { + iframeSrc: string; + name: string; +} + +export const IframePreview: FC = ({ iframeSrc, name }) => { + return ( +
+ +
+ ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/components/IframeUrlInput.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/components/IframeUrlInput.tsx new file mode 100644 index 000000000..c48566b5a --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/IframeExercise/components/IframeUrlInput.tsx @@ -0,0 +1,78 @@ +import { InputText } from "primereact/inputtext"; +import { FC } from "react"; + +import { useValidation } from "../../../shared/ExerciseLayout"; +import styles from "../../../shared/styles/CreateExercise.module.css"; + +interface IframeUrlInputProps { + iframeSrc: string; + onChange: (url: string) => void; +} + +const isValidUrl = (url: string): boolean => { + if (!url.trim()) return false; + try { + new URL(url); + return true; + } catch { + return false; + } +}; + +export const IframeUrlInput: FC = ({ iframeSrc, onChange }) => { + const { shouldShowValidation } = useValidation(); + const isEmpty = !iframeSrc?.trim(); + const isInvalidUrl = iframeSrc?.trim() && !isValidUrl(iframeSrc); + const shouldShowError = (isEmpty || isInvalidUrl) && shouldShowValidation; + + return ( + <> +
+ + onChange(e.target.value)} + placeholder="https://example.com/exercise" + className={`w-full ${shouldShowError ? "p-invalid" : ""}`} + /> + {shouldShowError && isEmpty && ( + iFrame URL is required + )} + {shouldShowError && isInvalidUrl && ( + Please enter a valid URL + )} +
+ +
+ + + Tip: Enter a valid URL that can be embedded in an iframe (e.g., videos, interactive + content, external tools). + +
+ + {iframeSrc && isValidUrl(iframeSrc) && ( +
+ +
+ +
+
+ `; +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts index 0b26663bb..5e4a74949 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/questionJson.ts @@ -62,6 +62,9 @@ export const buildQuestionJson = (data: CreateExerciseFormType) => { questionText: data.questionText, statement: data.statement, feedback: data.feedback + }), + ...(data.question_type === "iframe" && { + iframeSrc: data.iframeSrc }) }; diff --git a/components/rsptx/data_types/question_type.py b/components/rsptx/data_types/question_type.py index b43aa0d68..90bbcb5af 100644 --- a/components/rsptx/data_types/question_type.py +++ b/components/rsptx/data_types/question_type.py @@ -55,6 +55,11 @@ class QuestionType(Enum): ) PAGE = ("page", "Page", "") WEBWORK = ("webwork", "WeBWorK", "Create a WeBWorK problem for students to solve") + IFRAME = ( + "iframe", + "iFrame", + "Embed external content using an iFrame", + ) def to_dict(self): return {