Skip to content

Commit 5b31c06

Browse files
committed
Merge branch 'feature-1035' of github.com:morozov-av/rs into morozov-av-feature-1035
# Conflicts: # bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts
2 parents a5e00bd + f2d3db3 commit 5b31c06

File tree

14 files changed

+356
-3
lines changed

14 files changed

+356
-3
lines changed

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ExerciseFactory.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import {
1212
ShortAnswerExercise,
1313
MultiChoiceExercise,
1414
MatchingExercise,
15-
SelectQuestionExercise
15+
SelectQuestionExercise,
16+
IframeExercise
1617
} from ".";
1718

1819
export const ExerciseFactory: FC<ExerciseComponentProps> = (props) => {
@@ -37,6 +38,8 @@ export const ExerciseFactory: FC<ExerciseComponentProps> = (props) => {
3738
return <MatchingExercise {...props} />;
3839
case "selectquestion":
3940
return <SelectQuestionExercise {...props} />;
41+
case "iframe":
42+
return <IframeExercise {...props} />;
4043
default:
4144
throw new Error(`Unknown exercise type: ${props.type}`);
4245
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { ExerciseComponentProps } from "@components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/types/ExerciseTypes";
2+
import { FC } from "react";
3+
4+
import { CreateExerciseFormType } from "@/types/exercises";
5+
import { createExerciseId } from "@/utils/exercise";
6+
import { generateIframePreview } from "@/utils/preview/iframePreview";
7+
8+
import { IFRAME_STEP_VALIDATORS } from "../../config/stepConfigs";
9+
import { useBaseExercise } from "../../hooks/useBaseExercise";
10+
import { useExerciseStepNavigation } from "../../hooks/useExerciseStepNavigation";
11+
import { ExerciseLayout } from "../../shared/ExerciseLayout";
12+
import { validateCommonFields } from "../../utils/validation";
13+
14+
import { IframeExerciseSettings } from "./IframeExerciseSettings";
15+
import { IframePreview } from "./IframePreview";
16+
import { IframeUrlInput } from "./components/IframeUrlInput";
17+
18+
const IFRAME_STEPS = [{ label: "iFrame URL" }, { label: "Settings" }, { label: "Preview" }];
19+
20+
// Define the default form data
21+
const getDefaultFormData = (): Partial<CreateExerciseFormType> => ({
22+
name: createExerciseId(),
23+
author: "",
24+
topic: "",
25+
chapter: "",
26+
subchapter: "",
27+
tags: "",
28+
points: 1,
29+
difficulty: 3,
30+
htmlsrc: "",
31+
question_type: "iframe",
32+
iframeSrc: ""
33+
});
34+
35+
// Create a wrapper for generateIframePreview to match the expected type
36+
const generatePreview = (data: Partial<CreateExerciseFormType>): string => {
37+
return generateIframePreview(data.iframeSrc || "", data.name || "");
38+
};
39+
40+
export const IframeExercise: FC<ExerciseComponentProps> = ({
41+
initialData,
42+
onSave,
43+
onCancel,
44+
resetForm,
45+
onFormReset,
46+
isEdit = false
47+
}) => {
48+
const {
49+
formData,
50+
activeStep,
51+
isSaving,
52+
updateFormData,
53+
handleSettingsChange,
54+
isCurrentStepValid,
55+
goToNextStep,
56+
goToPrevStep,
57+
handleSave: baseHandleSave,
58+
setActiveStep
59+
} = useBaseExercise({
60+
initialData,
61+
steps: IFRAME_STEPS,
62+
exerciseType: "iframe",
63+
generatePreview,
64+
validateStep: (step, data) => {
65+
const errors = IFRAME_STEP_VALIDATORS[step](data);
66+
67+
return errors.length === 0;
68+
},
69+
validateForm: validateCommonFields,
70+
getDefaultFormData,
71+
onSave: onSave as (data: Partial<CreateExerciseFormType>) => Promise<void>,
72+
onCancel,
73+
resetForm,
74+
onFormReset,
75+
isEdit
76+
});
77+
78+
// Use our centralized navigation and validation hook
79+
const { validation, handleNext, handleStepSelect, handleSave, stepsValidity } =
80+
useExerciseStepNavigation({
81+
data: formData,
82+
activeStep,
83+
setActiveStep,
84+
stepValidators: IFRAME_STEP_VALIDATORS,
85+
goToNextStep,
86+
goToPrevStep,
87+
steps: IFRAME_STEPS,
88+
handleBaseSave: baseHandleSave,
89+
generateHtmlSrc: generatePreview,
90+
updateFormData
91+
});
92+
93+
// Render step content
94+
const renderStepContent = () => {
95+
switch (activeStep) {
96+
case 0: // iFrame URL
97+
return (
98+
<IframeUrlInput
99+
iframeSrc={formData.iframeSrc || ""}
100+
onChange={(url: string) => updateFormData("iframeSrc", url)}
101+
/>
102+
);
103+
104+
case 1: // Settings
105+
return <IframeExerciseSettings formData={formData} onChange={handleSettingsChange} />;
106+
107+
case 2: // Preview
108+
return <IframePreview iframeSrc={formData.iframeSrc || ""} name={formData.name || ""} />;
109+
110+
default:
111+
return null;
112+
}
113+
};
114+
115+
return (
116+
<ExerciseLayout
117+
title="iFrame Exercise"
118+
exerciseType="iframe"
119+
isEdit={isEdit}
120+
steps={IFRAME_STEPS}
121+
activeStep={activeStep}
122+
isCurrentStepValid={isCurrentStepValid}
123+
isSaving={isSaving}
124+
stepsValidity={stepsValidity}
125+
onCancel={onCancel}
126+
onBack={goToPrevStep}
127+
onNext={handleNext}
128+
onSave={handleSave}
129+
onStepSelect={handleStepSelect}
130+
validation={validation}
131+
>
132+
{renderStepContent()}
133+
</ExerciseLayout>
134+
);
135+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { FC } from "react";
2+
3+
import { CreateExerciseFormType } from "@/types/exercises";
4+
5+
import {
6+
BaseExerciseSettings,
7+
BaseExerciseSettingsContent
8+
} from "../../shared/BaseExerciseSettingsContent";
9+
10+
interface IframeExerciseSettingsProps {
11+
formData: Partial<CreateExerciseFormType>;
12+
onChange: (settings: Partial<CreateExerciseFormType>) => void;
13+
}
14+
15+
export const IframeExerciseSettings: FC<IframeExerciseSettingsProps> = ({ formData, onChange }) => {
16+
return (
17+
<BaseExerciseSettingsContent<BaseExerciseSettings>
18+
initialData={formData}
19+
onSettingsChange={onChange}
20+
/>
21+
);
22+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ExercisePreview } from "@components/routes/AssignmentBuilder/components/exercises/components/ExercisePreview/ExercisePreview";
2+
import { FC } from "react";
3+
4+
import { generateIframePreview } from "@/utils/preview/iframePreview";
5+
6+
interface IframePreviewProps {
7+
iframeSrc: string;
8+
name: string;
9+
}
10+
11+
export const IframePreview: FC<IframePreviewProps> = ({ iframeSrc, name }) => {
12+
return (
13+
<div style={{ display: "flex", alignItems: "start", justifyContent: "center" }}>
14+
<ExercisePreview htmlsrc={generateIframePreview(iframeSrc || "", name || "")} />
15+
</div>
16+
);
17+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { InputText } from "primereact/inputtext";
2+
import { FC } from "react";
3+
4+
import { useValidation } from "../../../shared/ExerciseLayout";
5+
import styles from "../../../shared/styles/CreateExercise.module.css";
6+
7+
interface IframeUrlInputProps {
8+
iframeSrc: string;
9+
onChange: (url: string) => void;
10+
}
11+
12+
const isValidUrl = (url: string): boolean => {
13+
if (!url.trim()) return false;
14+
try {
15+
new URL(url);
16+
return true;
17+
} catch {
18+
return false;
19+
}
20+
};
21+
22+
export const IframeUrlInput: FC<IframeUrlInputProps> = ({ iframeSrc, onChange }) => {
23+
const { shouldShowValidation } = useValidation();
24+
const isEmpty = !iframeSrc?.trim();
25+
const isInvalidUrl = iframeSrc?.trim() && !isValidUrl(iframeSrc);
26+
const shouldShowError = (isEmpty || isInvalidUrl) && shouldShowValidation;
27+
28+
return (
29+
<>
30+
<div className={styles.formField}>
31+
<label htmlFor="iframeSrc" className="font-medium block mb-2">
32+
iFrame Source URL *
33+
</label>
34+
<InputText
35+
id="iframeSrc"
36+
value={iframeSrc}
37+
onChange={(e) => onChange(e.target.value)}
38+
placeholder="https://example.com/exercise"
39+
className={`w-full ${shouldShowError ? "p-invalid" : ""}`}
40+
/>
41+
{shouldShowError && isEmpty && (
42+
<small className="p-error mt-1 block">iFrame URL is required</small>
43+
)}
44+
{shouldShowError && isInvalidUrl && (
45+
<small className="p-error mt-1 block">Please enter a valid URL</small>
46+
)}
47+
</div>
48+
49+
<div className={styles.questionTips}>
50+
<i className="pi pi-lightbulb" style={{ marginRight: "4px" }}></i>
51+
<span>
52+
Tip: Enter a valid URL that can be embedded in an iframe (e.g., videos, interactive
53+
content, external tools).
54+
</span>
55+
</div>
56+
57+
{iframeSrc && isValidUrl(iframeSrc) && (
58+
<div className="mt-4">
59+
<label className="font-medium block mb-2">Preview</label>
60+
<div
61+
style={{
62+
border: "1px solid var(--surface-border)",
63+
borderRadius: "6px",
64+
overflow: "hidden"
65+
}}
66+
>
67+
<iframe
68+
src={iframeSrc}
69+
title="iFrame Preview"
70+
style={{ width: "100%", height: "400px", border: "none" }}
71+
allowFullScreen
72+
/>
73+
</div>
74+
</div>
75+
)}
76+
</>
77+
);
78+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { IframeUrlInput } from "./IframeUrlInput";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { IframeExercise } from "./IframeExercise";
2+
export { IframeExerciseSettings } from "./IframeExerciseSettings";
3+
export { IframePreview } from "./IframePreview";

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export { ShortAnswerExercise } from "./ShortAnswerExercise/ShortAnswerExercise";
1010
export * from "./MultiChoiceExercise";
1111
export { MatchingExercise } from "./MatchingExercise/MatchingExercise";
1212
export { SelectQuestionExercise } from "./SelectQuestionExercise/SelectQuestionExercise";
13+
export { IframeExercise } from "./IframeExercise/IframeExercise";

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/config/stepConfigs.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,20 @@ const stepConfigs: Record<string, ExerciseStepConfig> = {
229229
title: "Preview",
230230
description: "Preview the exercise as students will see it"
231231
}
232+
},
233+
iframe: {
234+
0: {
235+
title: "iFrame URL",
236+
description: "Enter the URL of the content to embed"
237+
},
238+
1: {
239+
title: "Exercise Settings",
240+
description: "Configure exercise settings such as name, points, etc."
241+
},
242+
2: {
243+
title: "Preview",
244+
description: "Preview the exercise as students will see it"
245+
}
232246
}
233247
};
234248

@@ -736,3 +750,54 @@ export const CLICKABLE_AREA_STEP_VALIDATORS: StepValidator<Partial<CreateExercis
736750
// Step 2: Preview
737751
() => []
738752
];
753+
754+
// Helper function to validate URL
755+
const isValidUrl = (url: string): boolean => {
756+
if (!url?.trim()) return false;
757+
try {
758+
new URL(url);
759+
return true;
760+
} catch {
761+
return false;
762+
}
763+
};
764+
765+
// iFrame/SPLICE Exercise Step Validators
766+
export const IFRAME_STEP_VALIDATORS: StepValidator<Partial<CreateExerciseFormType>>[] = [
767+
// Step 0: iFrame URL
768+
(data) => {
769+
const errors: string[] = [];
770+
771+
if (!data.iframeSrc?.trim()) {
772+
errors.push("iFrame URL is required");
773+
} else if (!isValidUrl(data.iframeSrc)) {
774+
errors.push("Please enter a valid URL");
775+
}
776+
777+
return errors;
778+
},
779+
// Step 1: Settings
780+
(data) => {
781+
const errors: string[] = [];
782+
783+
if (!data.name?.trim()) {
784+
errors.push("Exercise name is required");
785+
}
786+
if (!data.chapter) {
787+
errors.push("Chapter is required");
788+
}
789+
if (!data.subchapter) {
790+
errors.push("Section is required");
791+
}
792+
if (data.points === undefined || data.points <= 0) {
793+
errors.push("Points must be greater than 0");
794+
}
795+
if (data.difficulty === undefined) {
796+
errors.push("Difficulty is required");
797+
}
798+
799+
return errors;
800+
},
801+
// Step 2: Preview
802+
() => []
803+
];

bases/rsptx/assignment_server_api/assignment_builder/src/types/exercises.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export const supportedExerciseTypesToEdit = [
1414
"matching",
1515
"fillintheblank",
1616
"clickablearea",
17-
"selectquestion"
17+
"selectquestion",
18+
"iframe"
1819
];
1920

2021
export const supportedExerciseTypes = [
@@ -27,7 +28,8 @@ export const supportedExerciseTypes = [
2728
"poll",
2829
"shortanswer",
2930
"matching",
30-
"selectquestion"
31+
"selectquestion",
32+
"iframe"
3133
] as const;
3234

3335
export type ExerciseType = (typeof supportedExerciseTypes)[number];
@@ -107,6 +109,7 @@ export type QuestionJSON = Partial<{
107109
enableCodeTailor: boolean;
108110
parsonspersonalize: "solution-level" | "block-and-solution" | "";
109111
parsonsexample: string;
112+
iframeSrc: string;
110113
}>;
111114

112115
export type CreateExerciseFormType = Omit<Exercise, "question_json"> & QuestionJSON;

0 commit comments

Comments
 (0)