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 @@ -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 }) => <div data-testid="factory">{type}</div>
ExerciseFactory: ({
type,
initialData
}: {
type: string;
initialData?: { statement?: string };
}) => (
<div data-testid="factory">
{type}
<span data-testid="factory-statement">{initialData?.statement ?? ""}</span>
</div>
)
}));

vi.mock("./components/ExerciseTypeSelect", () => ({
Expand All @@ -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 ? (
<button type="button" onClick={() => onApply("mchoice", { statement: "from-json" })}>
apply-import
</button>
) : null
}));

beforeEach(() => {
routedType = null;
updateExerciseType.mockClear();
Expand Down Expand Up @@ -72,4 +102,34 @@ describe("CreateExercise", () => {

expect(screen.getByTestId("factory")).toHaveTextContent("activecode");
});

it("shows the Paste JSON button on the type-selection view", () => {
renderWithMantine(<CreateExercise onCancel={vi.fn()} onSave={vi.fn()} />);

expect(screen.getByRole("button", { name: "Paste JSON" })).toBeInTheDocument();
});

it("importing JSON renders the factory pre-filled with the chosen type", () => {
renderWithMantine(<CreateExercise onCancel={vi.fn()} onSave={vi.fn()} />);

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(
<CreateExercise
onCancel={vi.fn()}
onSave={vi.fn()}
isEdit
initialData={{ question_type: "mchoice" }}
/>
);

expect(screen.getByRole("button", { name: "View / Replace JSON" })).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<ExerciseType | null>(
Expand All @@ -34,6 +39,10 @@ export const CreateExercise = ({
: (exerciseType as ExerciseType | null)
);

const [importedData, setImportedData] = useState<Partial<CreateExerciseFormType> | undefined>();
const [factoryKey, setFactoryKey] = useState(0);
const [importOpen, setImportOpen] = useState(false);

useEffect(() => {
if (!isEdit) {
if (exerciseType) {
Expand All @@ -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);
}
Expand All @@ -70,31 +80,103 @@ export const CreateExercise = ({
}
};
Comment on lines 65 to 81

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<CreateExerciseFormType>;

setImportedData(next);
setSelectedType(type);
setFactoryKey((key) => key + 1);
if (!isEdit) {
updateExerciseType(type);
}
};

const effectiveInitialData = importedData ?? initialData;

if (!selectedType) {
return (
<Card withBorder radius="md" padding="lg">
<Title order={4} mb="md">
{isEdit ? "Edit exercise type" : "Select exercise type"}
</Title>
<ExerciseTypeSelect selectedType={selectedType} onSelect={handleTypeSelect} />
<Group justify="flex-end" gap="sm" mt="md">
<Button variant="subtle" leftSection={<Icon name="times" size={16} />} onClick={onCancel}>
Cancel
</Button>
</Group>
</Card>
<>
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="center" mb="md">
<Title order={4}>{isEdit ? "Edit exercise type" : "Select exercise type"}</Title>
<Button
variant="light"
leftSection={<Icon name="code" size={16} />}
onClick={() => setImportOpen(true)}
>
Paste JSON
</Button>
</Group>
<ExerciseTypeSelect selectedType={selectedType} onSelect={handleTypeSelect} />
<Group justify="flex-end" gap="sm" mt="md">
<Button
variant="subtle"
leftSection={<Icon name="times" size={16} />}
onClick={onCancel}
>
Cancel
</Button>
</Group>
</Card>
<ImportQuestionJsonModal
opened={importOpen}
onClose={() => setImportOpen(false)}
onApply={handleImportApply}
/>
</>
);
}

return (
<ExerciseFactory
type={selectedType}
onCancel={handleCancel}
onSave={onSave}
resetForm={resetForm}
onFormReset={onFormReset}
initialData={initialData}
isEdit={isEdit}
/>
<Stack gap="sm">
{isEdit && (
<Group justify="flex-end">
<Button
variant="light"
leftSection={<Icon name="code" size={16} />}
onClick={() => setImportOpen(true)}
>
View / Replace JSON
</Button>
</Group>
)}
<ExerciseFactory
key={factoryKey}
type={selectedType}
onCancel={handleCancel}
onSave={onSave}
resetForm={resetForm}
onFormReset={onFormReset}
initialData={effectiveInitialData}
isEdit={isEdit}
/>
<ImportQuestionJsonModal
opened={importOpen}
onClose={() => setImportOpen(false)}
onApply={handleImportApply}
lockedType={isEdit ? selectedType : undefined}
initialJson={isEdit ? currentJson : undefined}
/>
</Stack>
);
};
Original file line number Diff line number Diff line change
@@ -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(<ImportQuestionJsonModal opened onClose={vi.fn()} onApply={onApply} />);

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(
<ImportQuestionJsonModal opened onClose={vi.fn()} onApply={onApply} lockedType="iframe" />
);

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(
<ImportQuestionJsonModal opened onClose={vi.fn()} onApply={onApply} lockedType="iframe" />
);

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(
<ImportQuestionJsonModal
opened
onClose={onClose}
onApply={onApply}
lockedType="iframe"
initialJson='{ "iframeSrc": "https://example.com" }'
/>
);

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(
<ImportQuestionJsonModal opened onClose={vi.fn()} onApply={vi.fn()} lockedType="iframe" />
);

expect(screen.queryByText("Question type")).not.toBeInTheDocument();
});
});
Loading
Loading