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"}
-
-
-
- } onClick={onCancel}>
- Cancel
-
-
-
+ <>
+
+
+ {isEdit ? "Edit exercise type" : "Select exercise type"}
+ }
+ onClick={() => setImportOpen(true)}
+ >
+ Paste JSON
+
+
+
+
+ }
+ onClick={onCancel}
+ >
+ Cancel
+
+
+
+ setImportOpen(false)}
+ onApply={handleImportApply}
+ />
+ >
);
}
return (
-
+
+ {isEdit && (
+
+ }
+ onClick={() => setImportOpen(true)}
+ >
+ View / Replace JSON
+
+
+ )}
+
+ 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 && (
+
+
+ );
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/importQuestionJson.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/importQuestionJson.spec.ts
new file mode 100644
index 000000000..11324f973
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/importQuestionJson.spec.ts
@@ -0,0 +1,217 @@
+import { describe, expect, it } from "vitest";
+
+import { ExerciseType, QuestionJSON, supportedExerciseTypes } from "@/types/exercises";
+
+import {
+ REQUIRED_FIELDS,
+ RequiredFieldType,
+ parseQuestionJsonInput,
+ validateQuestionJsonForType
+} from "./importQuestionJson";
+
+describe("parseQuestionJsonInput", () => {
+ it("returns an error for empty input", () => {
+ const result = parseQuestionJsonInput(" ");
+
+ expect(result.data).toBeUndefined();
+ expect(result.error).toMatch(/paste the question json/i);
+ });
+
+ it("returns an error for invalid JSON", () => {
+ const result = parseQuestionJsonInput("{ not valid }");
+
+ expect(result.data).toBeUndefined();
+ expect(result.error).toMatch(/not valid json/i);
+ });
+
+ it.each([
+ ["array", "[1, 2, 3]"],
+ ["string", '"just a string"'],
+ ["number", "42"],
+ ["boolean", "true"],
+ ["null", "null"]
+ ])("rejects JSON that is a %s, not an object", (_label, input) => {
+ expect(parseQuestionJsonInput(input).error).toMatch(/must be an object/i);
+ });
+
+ it("parses a valid JSON object", () => {
+ const result = parseQuestionJsonInput('{ "statement": "Hi", "optionList": [] }');
+
+ expect(result.error).toBeUndefined();
+ expect(result.data).toEqual({ statement: "Hi", optionList: [] });
+ });
+
+ it("accepts surrounding whitespace", () => {
+ const result = parseQuestionJsonInput('\n { "iframeSrc": "https://x" } \n');
+
+ expect(result.data).toEqual({ iframeSrc: "https://x" });
+ });
+});
+
+const VALID_PAYLOADS: Record = {
+ mchoice: {
+ statement: "What is the capital of France?",
+ optionList: [
+ { choice: "Paris", feedback: "Correct!", correct: true },
+ { choice: "Berlin", feedback: "No", correct: false }
+ ]
+ },
+ poll: {
+ statement: "How confident are you?",
+ optionList: [{ choice: "Very" }, { choice: "Somewhat" }]
+ },
+ shortanswer: { statement: "Explain polymorphism.", attachment: false },
+ activecode: {
+ language: "python",
+ prefix_code: "",
+ starter_code: "pass",
+ suffix_code: "",
+ instructions: "Write code",
+ stdin: "",
+ enableCodeTailor: false,
+ enableCodelens: true
+ },
+ parsonsprob: {
+ instructions: "Arrange the blocks",
+ language: "python",
+ blocks: [
+ { id: "b1", content: "def greet():", indent: 0 },
+ { id: "b2", content: "print('hi')", indent: 1 }
+ ],
+ adaptive: true,
+ numbered: "left",
+ noindent: false
+ },
+ dragndrop: {
+ statement: "Match the items",
+ left: [{ id: "a", label: "Python" }],
+ right: [{ id: "x", label: "Dynamic" }],
+ correctAnswers: [["a", "x"]],
+ feedback: "Try again"
+ },
+ matching: {
+ statement: "Match the items",
+ left: [{ id: "a", label: "Hash Table" }],
+ right: [{ id: "x", label: "O(1)" }],
+ correctAnswers: [["a", "x"]],
+ feedback: "Try again"
+ },
+ fillintheblank: {
+ questionText: "Binary search is O(___).",
+ blanks: [
+ { id: "blank-1", graderType: "string", exactMatch: "log n" }
+ ] as unknown as QuestionJSON["blanks"]
+ },
+ clickablearea: {
+ statement: "Click the errors",
+ questionText: "x = 10
",
+ feedback: "Look again"
+ },
+ selectquestion: {
+ questionList: ["q-101", "q-102"],
+ questionLabels: { "q-101": "Easy", "q-102": "Hard" },
+ abExperimentName: "",
+ toggleOptions: [],
+ dataLimitBasecourse: true
+ },
+ iframe: { iframeSrc: "https://example.com/sim" }
+};
+
+const ALL_TYPES = [...supportedExerciseTypes];
+
+const wrongValueFor = (type: RequiredFieldType): unknown =>
+ type === "array" ? "not-an-array" : 123;
+
+const expectedTypeWord = (type: RequiredFieldType): RegExp =>
+ type === "array" ? /must be an array/i : /must be a string/i;
+
+describe("validateQuestionJsonForType", () => {
+ it("covers every supported exercise type", () => {
+ expect(Object.keys(REQUIRED_FIELDS).sort()).toEqual([...ALL_TYPES].sort());
+ expect(Object.keys(VALID_PAYLOADS).sort()).toEqual([...ALL_TYPES].sort());
+ });
+
+ describe.each(ALL_TYPES)("type: %s", (type) => {
+ const required = REQUIRED_FIELDS[type];
+
+ it("accepts a fully valid payload", () => {
+ expect(validateQuestionJsonForType(type, VALID_PAYLOADS[type])).toEqual([]);
+ });
+
+ it("ignores unknown/extra optional fields", () => {
+ const payload = {
+ ...VALID_PAYLOADS[type],
+ someUnexpectedField: "whatever",
+ another: 123
+ } as unknown as QuestionJSON;
+
+ expect(validateQuestionJsonForType(type, payload)).toEqual([]);
+ });
+
+ it("reports one error per required field when the payload is empty", () => {
+ const errors = validateQuestionJsonForType(type, {});
+
+ expect(errors).toHaveLength(required.length);
+ });
+
+ if (required.length > 0) {
+ it.each(required)("rejects when required field $field is missing", ({ field }) => {
+ const payload = { ...VALID_PAYLOADS[type] } as Record;
+
+ delete payload[field];
+
+ const errors = validateQuestionJsonForType(type, payload as unknown as QuestionJSON);
+
+ expect(errors.some((message) => message.includes(field))).toBe(true);
+ expect(errors.some((message) => /missing required field/i.test(message))).toBe(true);
+ });
+
+ it.each(required)("rejects when required field $field is null", ({ field }) => {
+ const payload = { ...VALID_PAYLOADS[type], [field]: null } as unknown as QuestionJSON;
+
+ const errors = validateQuestionJsonForType(type, payload);
+
+ expect(errors.some((message) => message.includes(field))).toBe(true);
+ });
+
+ it.each(required)(
+ "rejects when required field $field has the wrong type ($type expected)",
+ ({ field, type: fieldType }) => {
+ const payload = {
+ ...VALID_PAYLOADS[type],
+ [field]: wrongValueFor(fieldType)
+ } as unknown as QuestionJSON;
+
+ const errors = validateQuestionJsonForType(type, payload);
+
+ const fieldErrors = errors.filter((message) => message.includes(field));
+
+ expect(fieldErrors).toHaveLength(1);
+ expect(fieldErrors[0]).toMatch(expectedTypeWord(fieldType));
+ }
+ );
+ }
+ });
+
+ it("aggregates multiple errors for a type with several required fields", () => {
+ const errors = validateQuestionJsonForType("dragndrop", {
+ left: "bad"
+ } as unknown as QuestionJSON);
+
+ expect(errors).toHaveLength(4);
+ expect(errors.some((message) => message.includes("statement"))).toBe(true);
+ expect(errors.find((message) => message.includes("left"))).toMatch(/must be an array/i);
+ expect(errors.some((message) => message.includes("right"))).toBe(true);
+ expect(errors.some((message) => message.includes("correctAnswers"))).toBe(true);
+ });
+
+ it("treats an empty required array as valid (presence + type only)", () => {
+ expect(validateQuestionJsonForType("mchoice", { statement: "Q", optionList: [] })).toEqual([]);
+ expect(validateQuestionJsonForType("parsonsprob", { blocks: [] })).toEqual([]);
+ });
+
+ it("treats an empty required string as valid (presence + type only)", () => {
+ expect(validateQuestionJsonForType("shortanswer", { statement: "" })).toEqual([]);
+ expect(validateQuestionJsonForType("iframe", { iframeSrc: "" })).toEqual([]);
+ });
+});
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/importQuestionJson.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/importQuestionJson.ts
new file mode 100644
index 000000000..8d1b544ce
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/importQuestionJson.ts
@@ -0,0 +1,106 @@
+import { ExerciseType, QuestionJSON } from "@/types/exercises";
+
+export interface ParsedQuestionJsonResult {
+ data?: QuestionJSON;
+ error?: string;
+}
+
+export type RequiredFieldType = "string" | "array" | "object";
+
+export interface RequiredFieldRule {
+ field: string;
+ type: RequiredFieldType;
+}
+
+export const REQUIRED_FIELDS: Record = {
+ mchoice: [
+ { field: "statement", type: "string" },
+ { field: "optionList", type: "array" }
+ ],
+ poll: [
+ { field: "statement", type: "string" },
+ { field: "optionList", type: "array" }
+ ],
+ shortanswer: [{ field: "statement", type: "string" }],
+ activecode: [{ field: "language", type: "string" }],
+ parsonsprob: [{ field: "blocks", type: "array" }],
+ dragndrop: [
+ { field: "statement", type: "string" },
+ { field: "left", type: "array" },
+ { field: "right", type: "array" },
+ { field: "correctAnswers", type: "array" }
+ ],
+ matching: [
+ { field: "statement", type: "string" },
+ { field: "left", type: "array" },
+ { field: "right", type: "array" },
+ { field: "correctAnswers", type: "array" }
+ ],
+ fillintheblank: [
+ { field: "questionText", type: "string" },
+ { field: "blanks", type: "array" }
+ ],
+ clickablearea: [
+ { field: "statement", type: "string" },
+ { field: "questionText", type: "string" }
+ ],
+ selectquestion: [{ field: "questionList", type: "array" }],
+ iframe: [{ field: "iframeSrc", type: "string" }]
+};
+
+export const parseQuestionJsonInput = (input: string): ParsedQuestionJsonResult => {
+ const trimmed = input.trim();
+
+ if (!trimmed) {
+ return { error: "Paste the question JSON before importing." };
+ }
+
+ let parsed: unknown;
+
+ try {
+ parsed = JSON.parse(trimmed);
+ } catch {
+ return {
+ error: "This is not valid JSON. Check for missing quotes, commas, or brackets."
+ };
+ }
+
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ return { error: "The JSON must be an object containing the question fields." };
+ }
+
+ return { data: parsed as QuestionJSON };
+};
+
+const matchesType = (value: unknown, type: RequiredFieldType): boolean => {
+ if (type === "array") {
+ return Array.isArray(value);
+ }
+ if (type === "object") {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+ }
+ return typeof value === "string";
+};
+
+export const validateQuestionJsonForType = (type: ExerciseType, data: QuestionJSON): string[] => {
+ const rules = REQUIRED_FIELDS[type] ?? [];
+ const record = data as Record;
+ const errors: string[] = [];
+
+ for (const rule of rules) {
+ const value = record[rule.field];
+
+ if (value === undefined || value === null) {
+ errors.push(`Missing required field "${rule.field}" for this question type.`);
+ continue;
+ }
+
+ if (!matchesType(value, rule.type)) {
+ errors.push(
+ `Field "${rule.field}" must be ${rule.type === "array" ? "an array" : `a ${rule.type}`}.`
+ );
+ }
+ }
+
+ return errors;
+};