diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/CreateExercise.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/CreateExercise.spec.tsx index 7c010c45c..d5ad4c8ff 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/CreateExercise.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/CreateExercise.spec.tsx @@ -15,8 +15,23 @@ vi.mock("@components/routes/AssignmentBuilder/hooks/useAssignmentRouting", () => }) })); +vi.mock("react-redux", () => ({ + useSelector: () => [{ value: "python", label: "Python" }] +})); + vi.mock("./components/ExerciseFactory", () => ({ - ExerciseFactory: ({ type }: { type: string }) =>
{type}
+ ExerciseFactory: ({ + type, + initialData + }: { + type: string; + initialData?: { statement?: string }; + }) => ( +
+ {type} + {initialData?.statement ?? ""} +
+ ) })); vi.mock("./components/ExerciseTypeSelect", () => ({ @@ -27,6 +42,21 @@ vi.mock("./components/ExerciseTypeSelect", () => ({ ) })); +vi.mock("./components/ImportQuestionJsonModal", () => ({ + ImportQuestionJsonModal: ({ + opened, + onApply + }: { + opened: boolean; + onApply: (type: string, data: { statement: string }) => void; + }) => + opened ? ( + + ) : null +})); + beforeEach(() => { routedType = null; updateExerciseType.mockClear(); @@ -72,4 +102,34 @@ describe("CreateExercise", () => { expect(screen.getByTestId("factory")).toHaveTextContent("activecode"); }); + + it("shows the Paste JSON button on the type-selection view", () => { + renderWithMantine(); + + expect(screen.getByRole("button", { name: "Paste JSON" })).toBeInTheDocument(); + }); + + it("importing JSON renders the factory pre-filled with the chosen type", () => { + renderWithMantine(); + + fireEvent.click(screen.getByRole("button", { name: "Paste JSON" })); + fireEvent.click(screen.getByRole("button", { name: "apply-import" })); + + expect(updateExerciseType).toHaveBeenCalledWith("mchoice"); + expect(screen.getByTestId("factory")).toHaveTextContent("mchoice"); + expect(screen.getByTestId("factory-statement")).toHaveTextContent("from-json"); + }); + + it("offers a View / Replace JSON button in edit mode", () => { + renderWithMantine( + + ); + + expect(screen.getByRole("button", { name: "View / Replace JSON" })).toBeInTheDocument(); + }); }); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/CreateExercise.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/CreateExercise.tsx index 74ec08cb5..6c8ab6e00 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/CreateExercise.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/CreateExercise.tsx @@ -1,12 +1,16 @@ import { useAssignmentRouting } from "@components/routes/AssignmentBuilder/hooks/useAssignmentRouting"; -import { Button, Card, Group, Title } from "@mantine/core"; -import { useState, useEffect } from "react"; +import { Button, Card, Group, Stack, Title } from "@mantine/core"; +import { datasetSelectors } from "@store/dataset/dataset.logic"; +import { useState, useEffect, useMemo } from "react"; +import { useSelector } from "react-redux"; import { Icon } from "@/components/ui/Icon"; -import { CreateExerciseFormType, ExerciseType } from "@/types/exercises"; +import { CreateExerciseFormType, ExerciseType, QuestionJSON } from "@/types/exercises"; +import { buildQuestionJson, mergeQuestionJsonWithDefaults } from "@/utils/questionJson"; import { ExerciseFactory } from "./components/ExerciseFactory"; import { ExerciseTypeSelect } from "./components/ExerciseTypeSelect"; +import { ImportQuestionJsonModal } from "./components/ImportQuestionJsonModal"; interface CreateExerciseProps { onCancel: () => void; @@ -26,6 +30,7 @@ export const CreateExercise = ({ isEdit = false }: CreateExerciseProps) => { const { exerciseType, updateExerciseType, updateExerciseViewMode } = useAssignmentRouting(); + const languageOptions = useSelector(datasetSelectors.getLanguageOptions); // If editing and there's initial data with question_type, use that type const [selectedType, setSelectedType] = useState( @@ -34,6 +39,10 @@ export const CreateExercise = ({ : (exerciseType as ExerciseType | null) ); + const [importedData, setImportedData] = useState | undefined>(); + const [factoryKey, setFactoryKey] = useState(0); + const [importOpen, setImportOpen] = useState(false); + useEffect(() => { if (!isEdit) { if (exerciseType) { @@ -48,14 +57,15 @@ export const CreateExercise = ({ useEffect(() => { if (resetForm && onFormReset) { setSelectedType(null); + setImportedData(undefined); onFormReset(); } }, [resetForm, onFormReset]); const handleTypeSelect = (type: string) => { - const exerciseType = type as ExerciseType; + const nextType = type as ExerciseType; - setSelectedType(exerciseType); + setSelectedType(nextType); if (!isEdit) { updateExerciseType(type); } @@ -70,31 +80,103 @@ export const CreateExercise = ({ } }; + const currentJson = useMemo(() => { + if (!isEdit || !initialData) { + return ""; + } + try { + return JSON.stringify( + JSON.parse(buildQuestionJson(initialData as CreateExerciseFormType)), + null, + 2 + ); + } catch { + return ""; + } + }, [isEdit, initialData]); + + const handleImportApply = (type: ExerciseType, data: QuestionJSON) => { + const merged = mergeQuestionJsonWithDefaults(languageOptions, data); + const next = { + ...(initialData ?? {}), + ...merged, + question_type: type + } as Partial; + + setImportedData(next); + setSelectedType(type); + setFactoryKey((key) => key + 1); + if (!isEdit) { + updateExerciseType(type); + } + }; + + const effectiveInitialData = importedData ?? initialData; + if (!selectedType) { return ( - - - {isEdit ? "Edit exercise type" : "Select exercise type"} - - - - - - + <> + + + {isEdit ? "Edit exercise type" : "Select exercise type"} + + + + + + + + setImportOpen(false)} + onApply={handleImportApply} + /> + ); } return ( - + + {isEdit && ( + + + + )} + + setImportOpen(false)} + onApply={handleImportApply} + lockedType={isEdit ? selectedType : undefined} + initialJson={isEdit ? currentJson : undefined} + /> + ); }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ImportQuestionJsonModal.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ImportQuestionJsonModal.spec.tsx new file mode 100644 index 000000000..9848088a6 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ImportQuestionJsonModal.spec.tsx @@ -0,0 +1,84 @@ +import { renderWithMantine } from "@/test/renderWithMantine"; +import { fireEvent, screen } from "@testing-library/react"; + +import { ImportQuestionJsonModal } from "./ImportQuestionJsonModal"; + +vi.mock("@/hooks/useExerciseTypes", () => ({ + useExerciseTypes: () => [ + { value: "mchoice", label: "Multiple Choice", color: {}, tag: "mchoice" }, + { value: "iframe", label: "iFrame", color: {}, tag: "iframe" } + ] +})); + +describe("ImportQuestionJsonModal", () => { + it("requires a question type before applying in create mode", () => { + const onApply = vi.fn(); + + renderWithMantine(); + + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(screen.getByText(/select a question type first/i)).toBeInTheDocument(); + expect(onApply).not.toHaveBeenCalled(); + }); + + it("shows a parse error for invalid JSON", () => { + const onApply = vi.fn(); + + renderWithMantine( + + ); + + fireEvent.change(screen.getByPlaceholderText(/statement/i), { + target: { value: "{ broken" } + }); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(screen.getByText(/not valid json/i)).toBeInTheDocument(); + expect(onApply).not.toHaveBeenCalled(); + }); + + it("shows validation errors when required fields are missing", () => { + const onApply = vi.fn(); + + renderWithMantine( + + ); + + fireEvent.change(screen.getByPlaceholderText(/statement/i), { + target: { value: "{}" } + }); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(screen.getByText(/iframeSrc/)).toBeInTheDocument(); + expect(onApply).not.toHaveBeenCalled(); + }); + + it("applies valid JSON for the locked type", () => { + const onApply = vi.fn(); + const onClose = vi.fn(); + + renderWithMantine( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(onApply).toHaveBeenCalledWith("iframe", { iframeSrc: "https://example.com" }); + expect(onClose).toHaveBeenCalled(); + }); + + it("hides the type selector when a type is locked", () => { + renderWithMantine( + + ); + + expect(screen.queryByText("Question type")).not.toBeInTheDocument(); + }); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ImportQuestionJsonModal.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ImportQuestionJsonModal.tsx new file mode 100644 index 000000000..d068355dd --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/exercises/components/CreateExercise/components/ImportQuestionJsonModal.tsx @@ -0,0 +1,128 @@ +import { Alert, Button, Group, Modal, Select, Stack, Text, Textarea } from "@mantine/core"; +import { useEffect, useMemo, useState } from "react"; + +import { Icon } from "@/components/ui/Icon"; +import { useExerciseTypes } from "@/hooks/useExerciseTypes"; +import { ExerciseType, QuestionJSON, supportedExerciseTypes } from "@/types/exercises"; +import { parseQuestionJsonInput, validateQuestionJsonForType } from "@/utils/importQuestionJson"; + +interface ImportQuestionJsonModalProps { + opened: boolean; + onClose: () => void; + onApply: (type: ExerciseType, data: QuestionJSON) => void; + lockedType?: ExerciseType; + initialJson?: string; +} + +export const ImportQuestionJsonModal = ({ + opened, + onClose, + onApply, + lockedType, + initialJson +}: ImportQuestionJsonModalProps) => { + const exerciseTypes = useExerciseTypes(); + + const typeOptions = useMemo( + () => + exerciseTypes + .filter((type) => supportedExerciseTypes.includes(type.value as ExerciseType)) + .map((type) => ({ value: type.value, label: type.label })), + [exerciseTypes] + ); + + const [selectedType, setSelectedType] = useState(lockedType ?? null); + const [jsonText, setJsonText] = useState(initialJson ?? ""); + const [errors, setErrors] = useState([]); + + useEffect(() => { + if (opened) { + setSelectedType(lockedType ?? null); + setJsonText(initialJson ?? ""); + setErrors([]); + } + }, [opened, lockedType, initialJson]); + + const handleApply = () => { + if (!selectedType) { + setErrors(["Select a question type first."]); + return; + } + + const { data, error } = parseQuestionJsonInput(jsonText); + + if (error || !data) { + setErrors([error ?? "The JSON could not be parsed."]); + return; + } + + const validationErrors = validateQuestionJsonForType(selectedType, data); + + if (validationErrors.length > 0) { + setErrors(validationErrors); + return; + } + + onApply(selectedType, data); + onClose(); + }; + + return ( + + + {!lockedType && ( +