@@ -45,7 +47,7 @@ export function FormActions({
export function Form({ children }: { children: ReactNode }) {
return (
-
+
{children}
);
diff --git a/components/GenerateQuizModal.tsx b/components/GenerateQuizModal.tsx
new file mode 100644
index 00000000..388a7b76
--- /dev/null
+++ b/components/GenerateQuizModal.tsx
@@ -0,0 +1,260 @@
+"use client";
+import { GenerateQuizModalMediaQuery } from "@/__generated__/GenerateQuizModalMediaQuery.graphql";
+import {
+ AiGenQuestionContext,
+ GenerateQuizModalMutation,
+} from "@/__generated__/GenerateQuizModalMutation.graphql";
+import { FormDivider } from "@/components/Form";
+import {
+ Alert,
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogTitle,
+ Tab,
+ Tabs,
+ Typography,
+} from "@mui/material";
+import { useMemo, useState } from "react";
+import { graphql, useLazyLoadQuery, useMutation } from "react-relay";
+import {
+ CapabilitiesTabPanel,
+ EducationalObjective,
+} from "./quiz/CapabilitiesTabPanel";
+import { LectureMaterialsTabPanel } from "./quiz/LectureMaterialsTabPanel";
+import { QuestionsTabPanel } from "./quiz/QuestionsTabPanel";
+
+interface TabPanelProps {
+ children?: React.ReactNode;
+ index: number;
+ value: number;
+}
+
+function TabPanel(props: TabPanelProps) {
+ const { children, value, index, ...other } = props;
+
+ return (
+
+ {value === index && (
+
+ {children}
+
+ )}
+
+ );
+}
+
+export type CapabilityInput = {
+ objectives: EducationalObjective[];
+ keywords: string[];
+ relationship: string;
+};
+
+const defaultCapability = {
+ objectives: [],
+ keywords: [""],
+ relationship: "",
+};
+
+const defaultQuestionAmount = {
+ multipleChoiceAmount: 0,
+ clozeAmount: 0,
+ associationAmount: 0,
+};
+
+export function GenerateQuizModal({
+ onClose: _onClose,
+ courseId,
+ isOpen,
+ quizId,
+}: {
+ onClose: () => void;
+ isOpen: boolean;
+ courseId: string;
+ quizId: string;
+}) {
+ const [tabIndex, setTabIndex] = useState(0);
+
+ const [capabilities, setCapabilities] =
+ useState
(defaultCapability);
+
+ const [materialIds, setMaterialIds] = useState([]);
+
+ const [questionAmount, setQuestionAmount] = useState<{
+ multipleChoiceAmount: number;
+ clozeAmount: number;
+ associationAmount: number;
+ }>(defaultQuestionAmount);
+
+ const [error, setError] = useState(null);
+
+ const valid = useMemo(() => {
+ const validCapabilities =
+ capabilities.objectives.length !== 0 &&
+ capabilities.relationship !== "" &&
+ capabilities.keywords.every((kw) => kw.trim() !== "");
+
+ const validMaterials =
+ materialIds.length !== 0 && materialIds.every((m) => m !== "");
+ const validQuestionAmount = Object.values(questionAmount).some(
+ (amount) => amount !== 0
+ );
+ return (
+ (tabIndex === 0 && validCapabilities) ||
+ (tabIndex === 1 && validMaterials) ||
+ (tabIndex === 2 && validQuestionAmount)
+ );
+ }, [capabilities, materialIds, questionAmount, tabIndex]);
+
+ const data = useLazyLoadQuery(
+ graphql`
+ query GenerateQuizModalMediaQuery {
+ mediaRecords {
+ id
+ name
+ type
+ courseIds
+ }
+ }
+ `,
+ { courseId }
+ );
+
+ const mediaRecords = data.mediaRecords.filter((item) => {
+ return item.courseIds.includes(courseId);
+ });
+
+ const [generate] = useMutation(graphql`
+ mutation GenerateQuizModalMutation(
+ $context: AiGenQuestionContext!
+ $assessmentId: UUID!
+ ) {
+ mutateQuiz(assessmentId: $assessmentId) {
+ aiGenerateQuestionAsync(context: $context) {
+ quiz {
+ assessmentId
+ }
+ }
+ }
+ }
+ `);
+
+ function handleSubmit() {
+ const context: AiGenQuestionContext = {
+ description:
+ "Use the following keywords as context to generate the questions:\n" +
+ capabilities.keywords.join(", "),
+ maxAnswersPerQuestion: 5,
+ maxExactQuestions: 0,
+ minExactQuestions: 0,
+ maxFreeTextQuestions: 0,
+ minFreeTextQuestions: 0,
+ maxMultipleChoiceQuestions: questionAmount.multipleChoiceAmount,
+ minMultipleChoiceQuestions: 0,
+ maxNumericQuestions: 0,
+ minNumericQuestions: 0,
+ mediaRecordIds: materialIds,
+ };
+ generate({
+ variables: { context, assessmentId: quizId },
+ onError: setError,
+
+ onCompleted() {
+ alert(
+ "Generation of questions was started successfully!" +
+ "\n Please come back later to review the generated questions!"
+ );
+ closeModal();
+ },
+ });
+ }
+
+ function closeModal() {
+ setCapabilities(defaultCapability);
+ setMaterialIds([]);
+ setQuestionAmount(defaultQuestionAmount);
+ setTabIndex(0);
+ setError(null);
+ _onClose();
+ }
+
+ function handleNext() {
+ if (!valid) return;
+ if (tabIndex != 2) {
+ setTabIndex(tabIndex + 1);
+ } else {
+ handleSubmit();
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/components/quiz/CapabilitiesTabPanel.tsx b/components/quiz/CapabilitiesTabPanel.tsx
new file mode 100644
index 00000000..0f04f702
--- /dev/null
+++ b/components/quiz/CapabilitiesTabPanel.tsx
@@ -0,0 +1,166 @@
+import {
+ Button,
+ Checkbox,
+ FormControl,
+ IconButton,
+ InputLabel,
+ ListItemText,
+ MenuItem,
+ Select,
+ TextField,
+} from "@mui/material";
+import { Form, FormSection } from "../Form";
+import { Add, Delete } from "@mui/icons-material";
+import { useCallback, useEffect, useState } from "react";
+import { SkillType } from "@/__generated__/QuizModalEditMutation.graphql";
+import { CapabilityInput } from "../GenerateQuizModal";
+
+export type EducationalObjective = Exclude;
+
+const skillTypeLabel: Record = {
+ ANALYZE: "Analyze",
+ APPLY: "Apply",
+ REMEMBER: "Remember",
+ UNDERSTAND: "Understand",
+ "%future added value": "Unknown",
+};
+
+export function CapabilitiesTabPanel({
+ onChange,
+ capabilities,
+}: {
+ onChange: (capabilities: CapabilityInput) => void;
+ capabilities: CapabilityInput;
+}) {
+ const [skillTypes, setSkillTypes] = useState(
+ capabilities.objectives
+ );
+ const [relationship, setRelationship] = useState(
+ capabilities.relationship
+ );
+ const [keywords, setKeywords] = useState(capabilities.keywords);
+
+ const updateKeywordAt = useCallback(
+ (index: number, keywordText: string) => {
+ setKeywords((oldValue) =>
+ oldValue.map((item, i) => (index === i ? keywordText : item))
+ );
+ },
+ [setKeywords]
+ );
+
+ const deleteQuestionAnswerAt = useCallback(
+ (index: number) => {
+ setKeywords((oldValue) => oldValue.filter((_, i) => i !== index));
+ },
+ [setKeywords]
+ );
+
+ const addEmptyKeyword = useCallback(
+ () => setKeywords((oldValue) => [...oldValue, ""]),
+ [setKeywords]
+ );
+
+ useEffect(() => {
+ onChange({
+ objectives: skillTypes,
+ keywords: keywords,
+ relationship: relationship,
+ });
+ }, [skillTypes, keywords, relationship, onChange]);
+
+ return (
+
+ );
+}
diff --git a/components/quiz/LectureMaterialsTabPanel.tsx b/components/quiz/LectureMaterialsTabPanel.tsx
new file mode 100644
index 00000000..8da14ecd
--- /dev/null
+++ b/components/quiz/LectureMaterialsTabPanel.tsx
@@ -0,0 +1,111 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Form, FormSection } from "../Form";
+import {
+ Alert,
+ AlertTitle,
+ Button,
+ IconButton,
+ MenuItem,
+ Select,
+} from "@mui/material";
+import { Add, Delete } from "@mui/icons-material";
+import { GenerateQuizModalMediaQuery$data } from "@/__generated__/GenerateQuizModalMediaQuery.graphql";
+
+export function LectureMaterialsTabPanel({
+ mediaRecords,
+ materialIds,
+ onChange,
+}: {
+ mediaRecords: GenerateQuizModalMediaQuery$data["mediaRecords"];
+ materialIds: string[];
+ onChange: (materialIds: string[]) => void;
+}) {
+ const [selectedMediaIds, setSelectedMediaIds] =
+ useState(materialIds);
+
+ const noMediaToPick = useMemo(() => {
+ const test = mediaRecords.filter((item) => {
+ return !!item.id && !!item.name;
+ });
+ return test.length === 0;
+ }, [mediaRecords]);
+
+ const addMaterial = useCallback(
+ () => setSelectedMediaIds((oldValue) => [...oldValue, ""]),
+ [setSelectedMediaIds]
+ );
+
+ const updateMaterialAt = useCallback(
+ (index: number, materialID: string) => {
+ setSelectedMediaIds((oldValue) =>
+ oldValue.map((item, i) => (index === i ? materialID : item))
+ );
+ },
+ [setSelectedMediaIds]
+ );
+
+ const deleteMaterialAt = useCallback(
+ (index: number) => {
+ setSelectedMediaIds((oldValue) => oldValue.filter((_, i) => i !== index));
+ },
+ [setSelectedMediaIds]
+ );
+
+ useEffect(() => {
+ onChange(selectedMediaIds);
+ });
+
+ return (
+
+ );
+}
diff --git a/components/quiz/QuestionPreview.tsx b/components/quiz/QuestionPreview.tsx
index 8a82b8c3..7367211c 100644
--- a/components/quiz/QuestionPreview.tsx
+++ b/components/quiz/QuestionPreview.tsx
@@ -4,8 +4,8 @@ import {
BloomLevel,
QuestionPreviewFragment$key,
} from "@/__generated__/QuestionPreviewFragment.graphql";
-import { Edit } from "@mui/icons-material";
-import { Button } from "@mui/material";
+import { AutoAwesome, Edit } from "@mui/icons-material";
+import { Button, Tooltip } from "@mui/material";
import { useParams } from "next/navigation";
import {
useCallback,
@@ -31,6 +31,7 @@ import { MultipleChoiceQuestionPreview } from "./MultipleChoiceQuestionPreview";
const QuestionFragment = graphql`
fragment QuestionPreviewFragment on Question {
__typename
+ aiGenerated
hint
number
itemId
@@ -113,7 +114,7 @@ const QuestionPreview = ({
}: Props) => {
const { quizId } = useParams();
const data = useFragment(QuestionFragment, question);
-
+ console.log(data);
// Destructuring necessary due to `readonly` types from relay
const item = useMemo- (
() => ({
@@ -164,7 +165,16 @@ const QuestionPreview = ({
return (
<>
-
+
+ {data.aiGenerated && (
+
+
+
+ )}
{data.text ? (
void;
+}) {
+ const [input, setInput] = useState(questionAmounts);
+
+ useEffect(() => {
+ onChange(input);
+ });
+
+ return (
+
+ );
+}
diff --git a/components/quiz/QuizHeader.tsx b/components/quiz/QuizHeader.tsx
index 47f9bdd5..4fce5921 100644
--- a/components/quiz/QuizHeader.tsx
+++ b/components/quiz/QuizHeader.tsx
@@ -1,7 +1,7 @@
import { QuizHeaderDeleteQuizMutation } from "@/__generated__/QuizHeaderDeleteQuizMutation.graphql";
import { QuizHeaderFragment$key } from "@/__generated__/QuizHeaderFragment.graphql";
import { updaterSetDelete } from "@/src/relay-helpers/common";
-import { Delete, Edit } from "@mui/icons-material";
+import { AutoAwesome, Delete, Edit } from "@mui/icons-material";
import { Button, CircularProgress } from "@mui/material";
import { useParams, useRouter } from "next/navigation";
import { useCallback } from "react";
@@ -32,9 +32,14 @@ const metadataFragment = graphql`
interface Props {
content: QuizHeaderFragment$key;
openEditQuizModal: () => void;
+ openGenerateQuizModal: () => void;
}
-const QuizHeader = ({ content, openEditQuizModal }: Props) => {
+const QuizHeader = ({
+ content,
+ openEditQuizModal,
+ openGenerateQuizModal,
+}: Props) => {
const { courseId, quizId } = useParams();
const router = useRouter();
@@ -63,6 +68,13 @@ const QuizHeader = ({ content, openEditQuizModal }: Props) => {
title={metadata.name}
action={
+ }
+ onClick={openGenerateQuizModal}
+ >
+ AI Generate
+
}
diff --git a/src/schema.graphql b/src/schema.graphql
index ec054eca..498ca48b 100644
--- a/src/schema.graphql
+++ b/src/schema.graphql
@@ -639,7 +639,6 @@ type ExternalAssignment {
type ExternalCourse {
courseTitle: String!
- organizationName: String!
url: String!
}
@@ -1153,6 +1152,7 @@ type PublicUserInfo {
}
type Query {
+ _empty: String
achievementsByCourseId(courseId: UUID!): [Achievement!]!
achievementsByUserId(userId: UUID): [Achievement!]!
contentsByChapterIds(chapterIds: [UUID!]!): [[Content!]!]!