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,7 +12,8 @@ import {
ShortAnswerExercise,
MultiChoiceExercise,
MatchingExercise,
SelectQuestionExercise
SelectQuestionExercise,
IframeExercise
} from ".";

export const ExerciseFactory: FC<ExerciseComponentProps> = (props) => {
Expand All @@ -37,6 +38,8 @@ export const ExerciseFactory: FC<ExerciseComponentProps> = (props) => {
return <MatchingExercise {...props} />;
case "selectquestion":
return <SelectQuestionExercise {...props} />;
case "iframe":
return <IframeExercise {...props} />;
default:
throw new Error(`Unknown exercise type: ${props.type}`);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CreateExerciseFormType> => ({
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<CreateExerciseFormType>): string => {
return generateIframePreview(data.iframeSrc || "", data.name || "");
};

export const IframeExercise: FC<ExerciseComponentProps> = ({
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<CreateExerciseFormType>) => Promise<void>,
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 (
<IframeUrlInput
iframeSrc={formData.iframeSrc || ""}
onChange={(url: string) => updateFormData("iframeSrc", url)}
/>
);

case 1: // Settings
return <IframeExerciseSettings formData={formData} onChange={handleSettingsChange} />;

case 2: // Preview
return <IframePreview iframeSrc={formData.iframeSrc || ""} name={formData.name || ""} />;

default:
return null;
}
};

return (
<ExerciseLayout
title="iFrame Exercise"
exerciseType="iframe"
isEdit={isEdit}
steps={IFRAME_STEPS}
activeStep={activeStep}
isCurrentStepValid={isCurrentStepValid}
isSaving={isSaving}
stepsValidity={stepsValidity}
onCancel={onCancel}
onBack={goToPrevStep}
onNext={handleNext}
onSave={handleSave}
onStepSelect={handleStepSelect}
validation={validation}
>
{renderStepContent()}
</ExerciseLayout>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FC } from "react";

import { CreateExerciseFormType } from "@/types/exercises";

import {
BaseExerciseSettings,
BaseExerciseSettingsContent
} from "../../shared/BaseExerciseSettingsContent";

interface IframeExerciseSettingsProps {
formData: Partial<CreateExerciseFormType>;
onChange: (settings: Partial<CreateExerciseFormType>) => void;
}

export const IframeExerciseSettings: FC<IframeExerciseSettingsProps> = ({ formData, onChange }) => {
return (
<BaseExerciseSettingsContent<BaseExerciseSettings>
initialData={formData}
onSettingsChange={onChange}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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<IframePreviewProps> = ({ iframeSrc, name }) => {
return (
<div style={{ display: "flex", alignItems: "start", justifyContent: "center" }}>
<ExercisePreview htmlsrc={generateIframePreview(iframeSrc || "", name || "")} />
</div>
);
};
Original file line number Diff line number Diff line change
@@ -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<IframeUrlInputProps> = ({ iframeSrc, onChange }) => {
const { shouldShowValidation } = useValidation();
const isEmpty = !iframeSrc?.trim();
const isInvalidUrl = iframeSrc?.trim() && !isValidUrl(iframeSrc);
const shouldShowError = (isEmpty || isInvalidUrl) && shouldShowValidation;

return (
<>
<div className={styles.formField}>
<label htmlFor="iframeSrc" className="font-medium block mb-2">
iFrame Source URL *
</label>
<InputText
id="iframeSrc"
value={iframeSrc}
onChange={(e) => onChange(e.target.value)}
placeholder="https://example.com/exercise"
className={`w-full ${shouldShowError ? "p-invalid" : ""}`}
/>
{shouldShowError && isEmpty && (
<small className="p-error mt-1 block">iFrame URL is required</small>
)}
{shouldShowError && isInvalidUrl && (
<small className="p-error mt-1 block">Please enter a valid URL</small>
)}
</div>

<div className={styles.questionTips}>
<i className="pi pi-lightbulb" style={{ marginRight: "4px" }}></i>
<span>
Tip: Enter a valid URL that can be embedded in an iframe (e.g., videos, interactive
content, external tools).
</span>
</div>

{iframeSrc && isValidUrl(iframeSrc) && (
<div className="mt-4">
<label className="font-medium block mb-2">Preview</label>
<div
style={{
border: "1px solid var(--surface-border)",
borderRadius: "6px",
overflow: "hidden"
}}
>
<iframe
src={iframeSrc}
title="iFrame Preview"
style={{ width: "100%", height: "400px", border: "none" }}
allowFullScreen
/>
</div>
</div>
)}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { IframeUrlInput } from "./IframeUrlInput";
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { IframeExercise } from "./IframeExercise";
export { IframeExerciseSettings } from "./IframeExerciseSettings";
export { IframePreview } from "./IframePreview";
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export { ShortAnswerExercise } from "./ShortAnswerExercise/ShortAnswerExercise";
export * from "./MultiChoiceExercise";
export { MatchingExercise } from "./MatchingExercise/MatchingExercise";
export { SelectQuestionExercise } from "./SelectQuestionExercise/SelectQuestionExercise";
export { IframeExercise } from "./IframeExercise/IframeExercise";
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,20 @@ const stepConfigs: Record<string, ExerciseStepConfig> = {
title: "Preview",
description: "Preview the exercise as students will see it"
}
},
iframe: {
0: {
title: "iFrame URL",
description: "Enter the URL of the content to embed"
},
1: {
title: "Exercise Settings",
description: "Configure exercise settings such as name, points, etc."
},
2: {
title: "Preview",
description: "Preview the exercise as students will see it"
}
}
};

Expand Down Expand Up @@ -736,3 +750,54 @@ export const CLICKABLE_AREA_STEP_VALIDATORS: StepValidator<Partial<CreateExercis
// Step 2: Preview
() => []
];

// Helper function to validate URL
const isValidUrl = (url: string): boolean => {
if (!url?.trim()) return false;
try {
new URL(url);
return true;
} catch {
return false;
}
};

// iFrame/SPLICE Exercise Step Validators
export const IFRAME_STEP_VALIDATORS: StepValidator<Partial<CreateExerciseFormType>>[] = [
// Step 0: iFrame URL
(data) => {
const errors: string[] = [];

if (!data.iframeSrc?.trim()) {
errors.push("iFrame URL is required");
} else if (!isValidUrl(data.iframeSrc)) {
errors.push("Please enter a valid URL");
}

return errors;
},
// Step 1: Settings
(data) => {
const errors: string[] = [];

if (!data.name?.trim()) {
errors.push("Exercise name is required");
}
if (!data.chapter) {
errors.push("Chapter is required");
}
if (!data.subchapter) {
errors.push("Section is required");
}
if (data.points === undefined || data.points <= 0) {
errors.push("Points must be greater than 0");
}
if (data.difficulty === undefined) {
errors.push("Difficulty is required");
}

return errors;
},
// Step 2: Preview
() => []
];
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const supportedExerciseTypesToEdit = [
"parsonsprob",
"matching",
"fillintheblank",
"clickablearea"
"clickablearea",
"iframe"
];

export const supportedExerciseTypes = [
Expand All @@ -26,7 +27,8 @@ export const supportedExerciseTypes = [
"poll",
"shortanswer",
"matching",
"selectquestion"
"selectquestion",
"iframe"
] as const;

export type ExerciseType = (typeof supportedExerciseTypes)[number];
Expand Down Expand Up @@ -105,6 +107,7 @@ export type QuestionJSON = Partial<{
enableCodeTailor: boolean;
parsonspersonalize: "solution-level" | "block-and-solution" | "";
parsonsexample: string;
iframeSrc: string;
}>;

export type CreateExerciseFormType = Omit<Exercise, "question_json"> & QuestionJSON;
Expand Down
Loading
Loading