Module Update
This commit is contained in:
140
src/modules/questions/actions.ts
Normal file
140
src/modules/questions/actions.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
||||
import { CreateQuestionInput, CreateQuestionSchema } from "./schema";
|
||||
import { ActionState } from "@/shared/types/action-state";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { ZodError } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
// --- Mock Auth Helper (Replace with actual Auth.js call) ---
|
||||
async function getCurrentUser() {
|
||||
// In production: const session = await auth(); return session?.user;
|
||||
// Mocking a teacher user for this demonstration
|
||||
return {
|
||||
id: "user_teacher_123",
|
||||
role: "teacher", // or "admin"
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureTeacher() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user || (user.role !== "teacher" && user.role !== "admin")) {
|
||||
throw new Error("Unauthorized: Only teachers can perform this action.");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// --- Recursive Insert Helper ---
|
||||
// We pass 'tx' to ensure all operations run within the same transaction
|
||||
async function insertQuestionWithRelations(
|
||||
tx: any, // using any or strict Drizzle Transaction type if imported
|
||||
input: CreateQuestionInput,
|
||||
authorId: string,
|
||||
parentId: string | null = null
|
||||
) {
|
||||
// We generate ID explicitly here.
|
||||
const newQuestionId = createId();
|
||||
|
||||
await tx.insert(questions).values({
|
||||
id: newQuestionId,
|
||||
content: input.content,
|
||||
type: input.type,
|
||||
difficulty: input.difficulty,
|
||||
authorId: authorId,
|
||||
parentId: parentId,
|
||||
});
|
||||
|
||||
// 2. Link Knowledge Points
|
||||
if (input.knowledgePointIds && input.knowledgePointIds.length > 0) {
|
||||
await tx.insert(questionsToKnowledgePoints).values(
|
||||
input.knowledgePointIds.map((kpId) => ({
|
||||
questionId: newQuestionId,
|
||||
knowledgePointId: kpId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Handle Sub-Questions (Recursion)
|
||||
if (input.subQuestions && input.subQuestions.length > 0) {
|
||||
for (const subQ of input.subQuestions) {
|
||||
await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId);
|
||||
}
|
||||
}
|
||||
|
||||
return newQuestionId;
|
||||
}
|
||||
|
||||
// --- Main Server Action ---
|
||||
|
||||
export async function createNestedQuestion(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData | CreateQuestionInput // Support both FormData and JSON input
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
// 1. Auth Check
|
||||
const user = await ensureTeacher();
|
||||
|
||||
// 2. Parse Input
|
||||
// If formData is actual FormData, we need to convert it.
|
||||
// For complex nested structures, frontend usually sends JSON string or pure JSON object if using `useServerAction` with arguments.
|
||||
// Here we assume the client might send a raw object (if using direct function call) or we parse FormData.
|
||||
let rawInput: any = formData;
|
||||
|
||||
if (formData instanceof FormData) {
|
||||
// Parsing complex nested JSON from FormData is messy.
|
||||
// We assume one field 'data' contains the JSON, or we expect direct object usage (common in modern Next.js RPC).
|
||||
const jsonString = formData.get("json");
|
||||
if (typeof jsonString === "string") {
|
||||
rawInput = JSON.parse(jsonString);
|
||||
} else {
|
||||
return { success: false, message: "Invalid submission format. Expected JSON." };
|
||||
}
|
||||
}
|
||||
|
||||
const validatedFields = CreateQuestionSchema.safeParse(rawInput);
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Validation failed",
|
||||
errors: validatedFields.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const input = validatedFields.data;
|
||||
|
||||
// 3. Database Transaction
|
||||
await db.transaction(async (tx) => {
|
||||
await insertQuestionWithRelations(tx, input, user.id, null);
|
||||
});
|
||||
|
||||
// 4. Revalidate Cache
|
||||
revalidatePath("/questions");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Question created successfully",
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to create question:", error);
|
||||
|
||||
// Drizzle/DB Error Handling (Generic)
|
||||
if (error instanceof Error) {
|
||||
// Check for specific DB errors (constraints, etc.)
|
||||
// e.g., if (error.message.includes("Duplicate entry")) ...
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || "Database error occurred",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: "An unexpected error occurred",
|
||||
};
|
||||
}
|
||||
}
|
||||
20
src/modules/questions/components/create-question-button.tsx
Normal file
20
src/modules/questions/components/create-question-button.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { CreateQuestionDialog } from "./create-question-dialog"
|
||||
|
||||
export function CreateQuestionButton() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Question
|
||||
</Button>
|
||||
<CreateQuestionDialog open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
286
src/modules/questions/components/create-question-dialog.tsx
Normal file
286
src/modules/questions/components/create-question-dialog.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useForm, type SubmitHandler } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/components/ui/form"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { BaseQuestionSchema } from "../schema"
|
||||
import { createNestedQuestion } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { Question } from "../types"
|
||||
|
||||
// Extend schema for form usage (e.g. handling options for choice questions)
|
||||
const QuestionFormSchema = BaseQuestionSchema.extend({
|
||||
difficulty: z.number().min(1).max(5),
|
||||
content: z.string().min(1, "Question content is required"),
|
||||
options: z.array(z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
isCorrect: z.boolean().default(false)
|
||||
})).optional(),
|
||||
})
|
||||
|
||||
type QuestionFormValues = z.input<typeof QuestionFormSchema>
|
||||
|
||||
interface CreateQuestionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
initialData?: Question | null
|
||||
}
|
||||
|
||||
export function CreateQuestionDialog({ open, onOpenChange, initialData }: CreateQuestionDialogProps) {
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const isEdit = !!initialData
|
||||
|
||||
const form = useForm<QuestionFormValues>({
|
||||
resolver: zodResolver(QuestionFormSchema),
|
||||
defaultValues: {
|
||||
type: initialData?.type || "single_choice",
|
||||
difficulty: initialData?.difficulty || 1,
|
||||
content: (typeof initialData?.content === 'string' ? initialData.content : "") || "",
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Reset form when initialData changes
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
form.reset({
|
||||
type: initialData.type,
|
||||
difficulty: initialData.difficulty,
|
||||
content: typeof initialData.content === 'string' ? initialData.content : JSON.stringify(initialData.content),
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
]
|
||||
})
|
||||
} else {
|
||||
form.reset({
|
||||
type: "single_choice",
|
||||
difficulty: 1,
|
||||
content: "",
|
||||
options: [
|
||||
{ label: "Option A", value: "A", isCorrect: true },
|
||||
{ label: "Option B", value: "B", isCorrect: false },
|
||||
]
|
||||
})
|
||||
}
|
||||
}, [initialData, form])
|
||||
|
||||
const questionType = form.watch("type")
|
||||
|
||||
const onSubmit: SubmitHandler<QuestionFormValues> = async (data) => {
|
||||
setIsPending(true)
|
||||
try {
|
||||
const payload = {
|
||||
type: data.type,
|
||||
difficulty: data.difficulty,
|
||||
content: data.content,
|
||||
knowledgePointIds: [],
|
||||
}
|
||||
const fd = new FormData()
|
||||
fd.set("json", JSON.stringify(payload))
|
||||
const res = await createNestedQuestion(undefined, fd)
|
||||
if (res.success) {
|
||||
toast.success(isEdit ? "Updated question" : "Created question")
|
||||
onOpenChange(false)
|
||||
if (!isEdit) {
|
||||
form.reset()
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message || "Operation failed")
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Unexpected error")
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit Question" : "Create New Question"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? "Update question details." : "Add a new question to the bank. Fill in the details below."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Question Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="difficulty"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Difficulty (1-5)</FormLabel>
|
||||
<Select
|
||||
onValueChange={(val) => field.onChange(parseInt(val))}
|
||||
defaultValue={String(field.value)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select difficulty" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<SelectItem key={level} value={String(level)}>
|
||||
{level} - {level === 1 ? "Easy" : level === 5 ? "Hard" : "Medium"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Question Content</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter the question text here..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Supports basic text. Rich text editor coming soon.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(questionType === "single_choice" || questionType === "multiple_choice") && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Options</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const currentOptions = form.getValues("options") || [];
|
||||
form.setValue("options", [
|
||||
...currentOptions,
|
||||
{ label: `Option ${String.fromCharCode(65 + currentOptions.length)}`, value: String.fromCharCode(65 + currentOptions.length), isCorrect: false }
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-3 w-3" /> Add Option
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{form.watch("options")?.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...(form.getValues("options") || [])];
|
||||
newOptions[index].label = e.target.value;
|
||||
form.setValue("options", newOptions);
|
||||
}}
|
||||
placeholder={`Option ${index + 1}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
onClick={() => {
|
||||
const newOptions = [...(form.getValues("options") || [])];
|
||||
newOptions.splice(index, 1);
|
||||
form.setValue("options", newOptions);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Creating..." : "Create Question"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
178
src/modules/questions/components/question-actions.tsx
Normal file
178
src/modules/questions/components/question-actions.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { MoreHorizontal, Pencil, Trash, Eye, Copy } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
|
||||
import { Question } from "../types"
|
||||
import { CreateQuestionDialog } from "./create-question-dialog"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface QuestionActionsProps {
|
||||
question: Question
|
||||
}
|
||||
|
||||
export function QuestionActions({ question }: QuestionActionsProps) {
|
||||
const [showEditDialog, setShowEditDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const copyId = () => {
|
||||
navigator.clipboard.writeText(question.id)
|
||||
toast.success("Question ID copied to clipboard")
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
// Simulate API call
|
||||
console.log("Deleting question:", question.id)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
toast.success("Question deleted successfully")
|
||||
setShowDeleteDialog(false)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to delete question")
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={copyId}>
|
||||
<Copy className="mr-2 h-4 w-4" /> Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setShowViewDialog(true)}>
|
||||
<Eye className="mr-2 h-4 w-4" /> View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowEditDialog(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<CreateQuestionDialog
|
||||
open={showEditDialog}
|
||||
onOpenChange={setShowEditDialog}
|
||||
initialData={question}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the question
|
||||
and remove it from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleDelete()
|
||||
}}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* View Details Dialog (Simple Read-only View) */}
|
||||
<Dialog open={showViewDialog} onOpenChange={setShowViewDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Question Details</DialogTitle>
|
||||
<DialogDescription>ID: {question.id}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Type:</span>
|
||||
<span className="col-span-3 capitalize">{question.type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Difficulty:</span>
|
||||
<span className="col-span-3">{question.difficulty}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-start gap-4">
|
||||
<span className="font-medium pt-1">Content:</span>
|
||||
<div className="col-span-3 rounded-md bg-muted p-2 text-sm">
|
||||
{typeof question.content === 'string' ? question.content : JSON.stringify(question.content, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Show Author if exists */}
|
||||
{question.author && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Author:</span>
|
||||
<span className="col-span-3">{question.author.name || "Unknown"}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Show Knowledge Points */}
|
||||
{question.knowledgePoints && question.knowledgePoints.length > 0 && (
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<span className="font-medium">Tags:</span>
|
||||
<div className="col-span-3 flex flex-wrap gap-1">
|
||||
{question.knowledgePoints.map(kp => (
|
||||
<span key={kp.id} className="rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
|
||||
{kp.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
144
src/modules/questions/components/question-columns.tsx
Normal file
144
src/modules/questions/components/question-columns.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Question, QuestionType } from "../types"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { QuestionActions } from "./question-actions"
|
||||
|
||||
// Helper for Type Colors
|
||||
const getTypeColor = (type: QuestionType) => {
|
||||
switch (type) {
|
||||
case "single_choice":
|
||||
return "default"; // Primary
|
||||
case "multiple_choice":
|
||||
return "secondary";
|
||||
case "judgment":
|
||||
return "outline";
|
||||
case "text":
|
||||
return "secondary"; // Changed from 'accent' which might not be a valid badge variant key in standard shadcn, usually it's default, secondary, destructive, outline.
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: QuestionType) => {
|
||||
switch (type) {
|
||||
case "single_choice": return "Single Choice";
|
||||
case "multiple_choice": return "Multiple Choice";
|
||||
case "judgment": return "True/False";
|
||||
case "text": return "Short Answer";
|
||||
case "composite": return "Composite";
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<Question>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue("type") as QuestionType
|
||||
return (
|
||||
<Badge variant={getTypeColor(type)} className="whitespace-nowrap">
|
||||
{getTypeLabel(type)}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "content",
|
||||
header: "Content",
|
||||
cell: ({ row }) => {
|
||||
const content = row.getValue("content");
|
||||
let preview = "";
|
||||
if (typeof content === 'string') {
|
||||
preview = content;
|
||||
} else if (content && typeof content === 'object') {
|
||||
preview = JSON.stringify(content).slice(0, 50);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[400px] truncate font-medium" title={preview}>
|
||||
{preview}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "difficulty",
|
||||
header: "Difficulty",
|
||||
cell: ({ row }) => {
|
||||
const diff = row.getValue("difficulty") as number;
|
||||
// 1-5 scale
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span className={cn("font-medium",
|
||||
diff <= 2 ? "text-green-600" :
|
||||
diff === 3 ? "text-yellow-600" : "text-red-600"
|
||||
)}>
|
||||
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">({diff})</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "knowledgePoints",
|
||||
header: "Knowledge Points",
|
||||
cell: ({ row }) => {
|
||||
const kps = row.original.knowledgePoints;
|
||||
if (!kps || kps.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{kps.slice(0, 2).map(kp => (
|
||||
<Badge key={kp.id} variant="outline" className="text-xs">
|
||||
{kp.name}
|
||||
</Badge>
|
||||
))}
|
||||
{kps.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">+{kps.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{new Date(row.getValue("createdAt")).toLocaleDateString()}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => <QuestionActions question={row.original} />,
|
||||
},
|
||||
]
|
||||
134
src/modules/questions/components/question-data-table.tsx
Normal file
134
src/modules/questions/components/question-data-table.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
getFilteredRowModel,
|
||||
RowSelectionState,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
}
|
||||
|
||||
export function QuestionDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
src/modules/questions/components/question-filters.tsx
Normal file
80
src/modules/questions/components/question-filters.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export function QuestionFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
|
||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withDefault("all"))
|
||||
|
||||
// Debounce could be added here for search, but for simplicity we rely on 'Enter' or blur or just let it update (nuqs handles some updates well, but for text input usually we want debounce or on change).
|
||||
// Actually nuqs with shallow: false (default) triggers server re-render.
|
||||
// For text input, it's better to use local state and update URL on debounce or enter.
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="relative flex-1 md:max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search questions..."
|
||||
className="pl-8"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficulty} onValueChange={(val) => setDifficulty(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Difficulty" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Any Difficulty</SelectItem>
|
||||
<SelectItem value="1">Easy (1)</SelectItem>
|
||||
<SelectItem value="2">Easy-Med (2)</SelectItem>
|
||||
<SelectItem value="3">Medium (3)</SelectItem>
|
||||
<SelectItem value="4">Med-Hard (4)</SelectItem>
|
||||
<SelectItem value="5">Hard (5)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(search || type !== "all" || difficulty !== "all") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setType(null)
|
||||
setDifficulty(null)
|
||||
}}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
src/modules/questions/data-access.ts
Normal file
93
src/modules/questions/data-access.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'server-only';
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
||||
import { and, eq, inArray, count, desc, sql } from "drizzle-orm";
|
||||
import { cache } from "react";
|
||||
|
||||
// Types for filters
|
||||
export type GetQuestionsParams = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
knowledgePointId?: string;
|
||||
difficulty?: number;
|
||||
};
|
||||
|
||||
// Cached Data Access Function
|
||||
// Using React's cache() to deduplicate requests if called multiple times in one render pass
|
||||
export const getQuestions = cache(async ({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
knowledgePointId,
|
||||
difficulty,
|
||||
}: GetQuestionsParams = {}) => {
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// Build Where Conditions
|
||||
const conditions = [];
|
||||
|
||||
if (difficulty) {
|
||||
conditions.push(eq(questions.difficulty, difficulty));
|
||||
}
|
||||
|
||||
// Filter by Knowledge Point (using subquery pattern for Many-to-Many)
|
||||
if (knowledgePointId) {
|
||||
const subQuery = db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId));
|
||||
|
||||
conditions.push(inArray(questions.id, subQuery));
|
||||
}
|
||||
|
||||
// Only fetch top-level questions (parent questions)
|
||||
// Assuming we only want to list "root" questions, not sub-questions
|
||||
conditions.push(sql`${questions.parentId} IS NULL`);
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// 1. Get Total Count (for Pagination)
|
||||
// Optimization: separate count query is often faster than fetching all data
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(questions)
|
||||
.where(whereClause);
|
||||
|
||||
const total = totalResult?.count ?? 0;
|
||||
|
||||
// 2. Get Data with Relations
|
||||
const data = await db.query.questions.findMany({
|
||||
where: whereClause,
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
orderBy: [desc(questions.createdAt)],
|
||||
with: {
|
||||
// Preload Knowledge Points
|
||||
questionsToKnowledgePoints: {
|
||||
with: {
|
||||
knowledgePoint: true,
|
||||
},
|
||||
},
|
||||
// Preload Author
|
||||
author: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
// Preload Child Questions (first level)
|
||||
children: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
});
|
||||
124
src/modules/questions/mock-data.ts
Normal file
124
src/modules/questions/mock-data.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Question } from "./types";
|
||||
|
||||
export const MOCK_QUESTIONS: Question[] = [
|
||||
{
|
||||
id: "q-001",
|
||||
content: "What is the capital of France?",
|
||||
type: "single_choice",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-01"),
|
||||
updatedAt: new Date("2023-11-01"),
|
||||
author: { id: "u-1", name: "Alice Teacher", image: null },
|
||||
knowledgePoints: [{ id: "kp-1", name: "Geography" }, { id: "kp-2", name: "Europe" }],
|
||||
},
|
||||
{
|
||||
id: "q-002",
|
||||
content: "Explain the theory of relativity in simple terms.",
|
||||
type: "text",
|
||||
difficulty: 5,
|
||||
createdAt: new Date("2023-11-02"),
|
||||
updatedAt: new Date("2023-11-02"),
|
||||
author: { id: "u-2", name: "Bob Physicist", image: null },
|
||||
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
|
||||
},
|
||||
{
|
||||
id: "q-003",
|
||||
content: "True or False: The earth is flat.",
|
||||
type: "judgment",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-03"),
|
||||
updatedAt: new Date("2023-11-03"),
|
||||
author: { id: "u-1", name: "Alice Teacher", image: null },
|
||||
knowledgePoints: [{ id: "kp-1", name: "Geography" }],
|
||||
},
|
||||
{
|
||||
id: "q-004",
|
||||
content: "Select all prime numbers below 10.",
|
||||
type: "multiple_choice",
|
||||
difficulty: 2,
|
||||
createdAt: new Date("2023-11-04"),
|
||||
updatedAt: new Date("2023-11-04"),
|
||||
author: { id: "u-3", name: "Charlie Math", image: null },
|
||||
knowledgePoints: [{ id: "kp-4", name: "Math" }],
|
||||
},
|
||||
{
|
||||
id: "q-005",
|
||||
content: "Write a function to reverse a string in JavaScript.",
|
||||
type: "text",
|
||||
difficulty: 3,
|
||||
createdAt: new Date("2023-11-05"),
|
||||
updatedAt: new Date("2023-11-05"),
|
||||
author: { id: "u-4", name: "Dave Coder", image: null },
|
||||
knowledgePoints: [{ id: "kp-5", name: "Programming" }, { id: "kp-6", name: "JavaScript" }],
|
||||
},
|
||||
{
|
||||
id: "q-006",
|
||||
content: "Which of the following are fruits? (Apple, Carrot, Banana, Potato)",
|
||||
type: "multiple_choice",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-06"),
|
||||
updatedAt: new Date("2023-11-06"),
|
||||
author: { id: "u-1", name: "Alice Teacher", image: null },
|
||||
knowledgePoints: [{ id: "kp-7", name: "Biology" }],
|
||||
},
|
||||
{
|
||||
id: "q-007",
|
||||
content: "Water boils at 100 degrees Celsius at sea level.",
|
||||
type: "judgment",
|
||||
difficulty: 2,
|
||||
createdAt: new Date("2023-11-07"),
|
||||
updatedAt: new Date("2023-11-07"),
|
||||
author: { id: "u-2", name: "Bob Physicist", image: null },
|
||||
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
|
||||
},
|
||||
{
|
||||
id: "q-008",
|
||||
content: "What is the powerhouse of the cell?",
|
||||
type: "single_choice",
|
||||
difficulty: 2,
|
||||
createdAt: new Date("2023-11-08"),
|
||||
updatedAt: new Date("2023-11-08"),
|
||||
author: { id: "u-5", name: "Eve Biologist", image: null },
|
||||
knowledgePoints: [{ id: "kp-7", name: "Biology" }],
|
||||
},
|
||||
{
|
||||
id: "q-009",
|
||||
content: "Solve for x: 2x + 5 = 15",
|
||||
type: "single_choice",
|
||||
difficulty: 2,
|
||||
createdAt: new Date("2023-11-09"),
|
||||
updatedAt: new Date("2023-11-09"),
|
||||
author: { id: "u-3", name: "Charlie Math", image: null },
|
||||
knowledgePoints: [{ id: "kp-4", name: "Math" }],
|
||||
},
|
||||
{
|
||||
id: "q-010",
|
||||
content: "Describe the impact of the Industrial Revolution.",
|
||||
type: "text",
|
||||
difficulty: 4,
|
||||
createdAt: new Date("2023-11-10"),
|
||||
updatedAt: new Date("2023-11-10"),
|
||||
author: { id: "u-6", name: "Frank Historian", image: null },
|
||||
knowledgePoints: [{ id: "kp-8", name: "History" }],
|
||||
},
|
||||
{
|
||||
id: "q-011",
|
||||
content: "Light travels faster than sound.",
|
||||
type: "judgment",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-11"),
|
||||
updatedAt: new Date("2023-11-11"),
|
||||
author: { id: "u-2", name: "Bob Physicist", image: null },
|
||||
knowledgePoints: [{ id: "kp-3", name: "Physics" }],
|
||||
},
|
||||
{
|
||||
id: "q-012",
|
||||
content: "Which element has the chemical symbol 'O'?",
|
||||
type: "single_choice",
|
||||
difficulty: 1,
|
||||
createdAt: new Date("2023-11-12"),
|
||||
updatedAt: new Date("2023-11-12"),
|
||||
author: { id: "u-7", name: "Grace Chemist", image: null },
|
||||
knowledgePoints: [{ id: "kp-9", name: "Chemistry" }],
|
||||
},
|
||||
];
|
||||
21
src/modules/questions/schema.ts
Normal file
21
src/modules/questions/schema.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Enum for Question Types matching DB
|
||||
export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]);
|
||||
|
||||
// Base Question Schema
|
||||
export const BaseQuestionSchema = z.object({
|
||||
content: z.any().describe("JSON content for the question (e.g. Slate nodes)"), // Using any for JSON flexibility, strict schemas can be applied if structure is known
|
||||
type: QuestionTypeEnum,
|
||||
difficulty: z.number().min(1).max(5).default(1),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// Recursive Schema for Nested Questions (e.g. Composite -> Sub Questions)
|
||||
export type CreateQuestionInput = z.infer<typeof BaseQuestionSchema> & {
|
||||
subQuestions?: CreateQuestionInput[];
|
||||
};
|
||||
|
||||
export const CreateQuestionSchema: z.ZodType<CreateQuestionInput> = BaseQuestionSchema.extend({
|
||||
subQuestions: z.lazy(() => CreateQuestionSchema.array().optional()),
|
||||
});
|
||||
27
src/modules/questions/types.ts
Normal file
27
src/modules/questions/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import { QuestionTypeEnum } from "./schema";
|
||||
|
||||
// Infer types from Zod Schema
|
||||
export type QuestionType = z.infer<typeof QuestionTypeEnum>;
|
||||
|
||||
// UI Model for Question (matching the structure returned by data-access or mock)
|
||||
export interface Question {
|
||||
id: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
content: any; // Rich text content
|
||||
type: QuestionType;
|
||||
difficulty: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
author: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
knowledgePoints: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
// For UI display
|
||||
childrenCount?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user