From f4a18a12378c0bc8551e3e4f86115ee69f6a516e Mon Sep 17 00:00:00 2001 From: Nhung Date: Sat, 21 Jun 2025 19:33:34 +0700 Subject: [PATCH 01/10] feat (Quiz Set Form): add quiz set form successfully for back and front --- apps/upskii/messages/en.json | 82 ++- apps/upskii/messages/vi.json | 90 ++- .../[setId]/take/before-taking-quiz-whole.tsx | 2 - .../[moduleId]/quiz-sets/create/page.tsx | 18 + .../modules/[moduleId]/quiz-sets/form.tsx | 588 ++++++++++++++++-- .../quiz-sets/instruction-editor.tsx | 47 ++ .../modules/[moduleId]/quiz-sets/page.tsx | 4 +- .../v1/workspaces/[wsId]/quiz-sets/route.ts | 6 +- .../components/ui/custom/feature-summary.tsx | 2 + 9 files changed, 765 insertions(+), 74 deletions(-) create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/instruction-editor.tsx diff --git a/apps/upskii/messages/en.json b/apps/upskii/messages/en.json index 8dbc853b53..b3cde3a0ba 100644 --- a/apps/upskii/messages/en.json +++ b/apps/upskii/messages/en.json @@ -3830,12 +3830,84 @@ "plural": "Quiz Sets", "singular": "Quiz Set", "description": "Quiz sets are used to group quizzes together for learning purposes.", - "create": "Create quiz set", "create_description": "Create a new quiz set", - "name": "Set name", - "edit": "Edit set", "link-quizzes": "Link Quizzes", - "link-modules": "Link modules" + "link-modules": "Link modules", + "editing": "Updating...", + "creating": "Creating...", + "create": "Create Quiz Set", + "edit": "Edit Quiz Set", + "form-sections": { + "basic": { + "title": "Basic Information", + "subtitle": "Set up the fundamental details of your quiz" + }, + "timing-limit": { + "title": "Basic Information", + "subtitle": "Set up the fundamental details of your quiz" + }, + "schedule": { + "title": "Schedule", + "subtitle": "Set availability and due dates for the quiz" + }, + "settings": { + "title": "Quiz Settings", + "subtitle": "Configure quiz behavior and student permissions" + } + }, + "form-description": "Configure your quiz settings, timing, and permissions to create an engaging learning experience for your students.", + "required-badge": "Required", + "form-fields": { + "name": { + "title": "Set Name", + "description": "Choose a clear, descriptive name for your quiz", + "placeholder": "e.g., Chapter 5 Assessment" + }, + "attempt_limit": { + "title": "Attempt Limit", + "description": "Maximum number of times a user can attempt the quiz. Leave 0 for unlimited.", + "placeholder": "e.g., 3" + }, + "time_limit_minutes": { + "title": "Time Limit (minutes)", + "description": "The time allowed to complete the quiz in minutes. Leave 0 for no time limit.", + "placeholder": "e.g, 60" + }, + "available_date": { + "title": "Available From", + "description": "Date and time when the quiz becomes available to participants." + }, + "due_date": { + "title": "Due Date", + "description": "Deadline for submitting the quiz." + }, + "explanation_mode": { + "title": "Explanation Mode", + "description": "Choose when and how students can see answer explanations", + "placeholder": "Select explanation timing", + "select_never": "Never show answer explanations", + "select_correct_answer": "Only show explanation of correct answers", + "select_all_answer": "Show explanation of all answers" + }, + "instruction": { + "title": "Instructions", + "description": "Provide detailed instructions for students using the rich text editor, displayed before starting the quiz.", + "save": "Save Instruction", + "saving": "Saving..." + }, + "allow_view_results": { + "title": "Allow Immediate Results", + "description": "Enable users to see their score immediately upon submission." + }, + "results_released": { + "title": "Results Released", + "description": "Mark quiz results as released, allowing detailed feedback and explanations." + }, + "allow_view_old_attempts": { + "title": "Allow Review Old Attempts", + "description": "Revisit users' previous quiz attempts. Before results are released, users can view only their submitted answers. Once results are released, they'll see their answers along with detailed results and feedback." + } + } }, "ws-quizzes": { "plural": "Quizzes", @@ -4311,4 +4383,4 @@ "users": "Users", "teams": "Teams" } -} +} \ No newline at end of file diff --git a/apps/upskii/messages/vi.json b/apps/upskii/messages/vi.json index 63ba3e665b..7247b05e7d 100644 --- a/apps/upskii/messages/vi.json +++ b/apps/upskii/messages/vi.json @@ -3828,15 +3828,85 @@ "create_description": "Tạo một hàng chờ mới" }, "ws-quiz-sets": { - "plural": "Bộ trắc nghiệm", - "singular": "Bộ trắc nghiệm", - "description": "Quản lý các bộ trắc nghiệm trong không gian làm việc của bạn bao gồm các câu hỏi nhiều lựa chọn dành cho mục đích học tập.", - "create": "Tạo bộ trắc nghiệm", - "create_description": "Tạo bộ trắc nghiệm mới", - "name": "Tên bộ trắc nghiệm", - "edit": "Chỉnh sửa bộ", - "link-quizzes": "Liên kết câu hỏi", - "link-modules": "Liên kết mô-đun" + "plural": "Bộ câu hỏi", + "singular": "Bộ câu hỏi", + "description": "Bộ câu hỏi được sử dụng để nhóm các câu quiz lại với nhau cho mục đích học tập.", + "create_description": "Tạo một bộ câu hỏi mới", + "link-quizzes": "Liên kết bài kiểm tra", + "link-modules": "Liên kết học phần", + "editing": "Đang cập nhật...", + "creating": "Đang tạo...", + "create": "Tạo bộ câu hỏi", + "edit": "Chỉnh sửa bộ câu hỏi", + "form-sections": { + "basic": { + "title": "Thông tin cơ bản", + "subtitle": "Thiết lập các chi tiết cơ bản cho bài kiểm tra của bạn" + }, + "timing-limit": { + "title": "Thông tin cơ bản", + "subtitle": "Thiết lập các chi tiết cơ bản cho bài kiểm tra của bạn" + }, + "schedule": { + "title": "Lịch trình", + "subtitle": "Đặt thời gian khả dụng và thời hạn cho bài kiểm tra" + }, + "settings": { + "title": "Cài đặt bài kiểm tra", + "subtitle": "Cấu hình hành vi bài kiểm tra và quyền của sinh viên" + } + }, + "form-description": "Cấu hình cài đặt, thời gian và quyền của bài kiểm tra để tạo trải nghiệm học tập hấp dẫn cho sinh viên của bạn.", + "required-badge": "Bắt buộc", + "form-fields": { + "name": { + "title": "Tên bộ câu hỏi", + "description": "Chọn một tên rõ ràng, mô tả cho bài kiểm tra của bạn", + "placeholder": "Ví dụ: Kiểm tra Chương 5" + }, + "attempt_limit": { + "title": "Giới hạn số lần làm bài", + "description": "Số lần tối đa người dùng có thể làm bài kiểm tra. Để 0 nếu không giới hạn.", + "placeholder": "Ví dụ: 3" + }, + "time_limit_minutes": { + "title": "Giới hạn thời gian (phút)", + "description": "Thời gian cho phép hoàn thành bài kiểm tra tính bằng phút. Để 0 nếu không giới hạn thời gian.", + "placeholder": "Ví dụ: 60" + }, + "available_date": { + "title": "Có hiệu lực từ", + "description": "Ngày và giờ bài kiểm tra có sẵn cho người tham gia." + }, + "due_date": { + "title": "Ngày hết hạn", + "description": "Thời hạn nộp bài kiểm tra." + }, + "explanation_mode": { + "title": "Chế độ giải thích", + "description": "Chọn thời điểm và cách học sinh có thể xem giải thích câu trả lời", + "placeholder": "Chọn thời điểm giải thích", + "select_never": "Không bao giờ hiển thị giải thích câu trả lời", + "select_correct_answer": "Chỉ hiển thị giải thích các câu trả lời đúng", + "select_all_answer": "Hiển thị giải thích của tất cả các câu trả lời" + }, + "instruction": { + "title": "Hướng dẫn", + "description": "Cung cấp hướng dẫn chi tiết cho học sinh bằng trình soạn thảo văn bản đa dạng thức, hiển thị trước khi bắt đầu bài kiểm tra." + }, + "allow_view_results": { + "title": "Cho phép xem kết quả ngay lập tức", + "description": "Cho phép người dùng xem điểm của họ ngay sau khi nộp bài." + }, + "results_released": { + "title": "Đã công bố kết quả", + "description": "Đánh dấu kết quả bài kiểm tra đã công bố, cho phép xem phản hồi chi tiết và giải thích." + }, + "allow_view_old_attempts": { + "title": "Cho phép xem lại các lần làm bài cũ", + "description": "Cho phép người dùng xem lại các lần làm bài kiểm tra trước đó của họ. Trước khi kết quả được công bố, người dùng chỉ có thể xem các câu trả lời đã nộp của mình. Khi kết quả được công bố, họ sẽ thấy các câu trả lời cùng với kết quả chi tiết và phản hồi." + } + } }, "ws-quizzes": { "plural": "Câu hỏi trắc nghiệm", @@ -4308,4 +4378,4 @@ "browse_courses": "Xem các khóa học", "certified": "Được chứng nhận" } -} +} \ No newline at end of file diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx index 9ca924fcc7..f82c8341fd 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/take/before-taking-quiz-whole.tsx @@ -118,8 +118,6 @@ export default function BeforeTakingQuizWhole({ const canViewTotalPointsOnly = quizData.resultsReleased; - console.log('Test', quizData.attempts[0]); - // const canViewOldAttemptsResults = quizData.resultsReleased; // can view attempts with points in detailed explanation const canViewOldAttemptsResults = diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx new file mode 100644 index 0000000000..6d0fa3806c --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx @@ -0,0 +1,18 @@ +import QuizSetForm from '../form'; + +interface Props { + params: Promise<{ + wsId: string; + courseId: string; + moduleId: string; + }>; +} + +export default async function Page({ params }: Props) { + const { wsId, courseId, moduleId } = await params; + return ( +
+ +
+ ); +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx index bdc2554fc8..1606482585 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx @@ -1,10 +1,21 @@ 'use client'; -import { type WorkspaceQuizSet } from '@tuturuuu/types/db'; +import InstructionEditor from './instruction-editor'; +import type { WorkspaceQuizSet } from '@tuturuuu/types/db'; +import { Badge } from '@tuturuuu/ui/badge'; import { Button } from '@tuturuuu/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@tuturuuu/ui/card'; +import { Checkbox } from '@tuturuuu/ui/checkbox'; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -14,6 +25,21 @@ import { useForm } from '@tuturuuu/ui/hooks/use-form'; import { toast } from '@tuturuuu/ui/hooks/use-toast'; import { Input } from '@tuturuuu/ui/input'; import { zodResolver } from '@tuturuuu/ui/resolvers'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@tuturuuu/ui/select'; +import { + Calendar, + Clock, + Eye, + FileText, + RotateCcw, + Settings, +} from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import * as z from 'zod'; @@ -22,95 +48,551 @@ interface Props { wsId: string; moduleId: string; data?: WorkspaceQuizSet; - // eslint-disable-next-line no-unused-vars onFinish?: (data: z.infer) => void; } const FormSchema = z.object({ id: z.string().optional(), - name: z.string().min(1), + name: z.string().nonempty(), moduleId: z.string(), + attemptLimit: z.number().nullable(), + timeLimitMinutes: z.number().nullable(), + allowViewResults: z.boolean(), + dueDate: z.string(), + availableDate: z.string(), + explanationMode: z.number().int(), + instruction: z.any().optional(), // Changed to z.any() to handle JSONContent + resultsReleased: z.boolean(), + allowViewOldAttempts: z.boolean(), }); -export default function CourseModuleForm({ - wsId, - moduleId, - data, - onFinish, -}: Props) { +export default function QuizSetForm({ wsId, moduleId, data, onFinish }: Props) { const t = useTranslations(); const router = useRouter(); - const form = useForm({ resolver: zodResolver(FormSchema), values: { id: data?.id, - name: data?.name || '', + name: data?.name ?? '', moduleId, + attemptLimit: data?.attempt_limit ?? null, + timeLimitMinutes: data?.time_limit_minutes ?? null, + allowViewResults: data?.allow_view_results ?? true, + availableDate: data?.available_date + ? data.available_date.toString().slice(0, 16) + : '', + dueDate: data?.due_date ? data.due_date.toString().slice(0, 16) : '', + explanationMode: data?.explanation_mode ?? 0, + instruction: data?.instruction ?? null, // Changed to handle JSONContent directly + resultsReleased: data?.results_released ?? false, + allowViewOldAttempts: data?.allow_view_old_attempts ?? true, }, }); - const isDirty = form.formState.isDirty; - const isValid = form.formState.isValid; - const isSubmitting = form.formState.isSubmitting; - + const { isDirty, isValid, isSubmitting } = form.formState; const disabled = !isDirty || !isValid || isSubmitting; - const onSubmit = async (data: z.infer) => { + const onSubmit = async (values: z.infer) => { try { + const { timeLimitMinutes, attemptLimit, instruction } = values; + const payload = { + name: values.name.trim(), + moduleId: moduleId, + allow_view_results: values.allowViewResults, + due_date: values.dueDate, + available_date: values.availableDate, + explanation_mode: values.explanationMode, + results_released: values.resultsReleased, + allow_view_old_attempts: values.allowViewOldAttempts, + + time_limit_minutes: timeLimitMinutes + ? timeLimitMinutes <= 0 + ? null + : timeLimitMinutes + : null, + attempt_limit: attemptLimit + ? attemptLimit <= 0 + ? null + : attemptLimit + : null, + instruction: instruction, // No need to JSON.parse since it's already JSONContent + }; + // console.log('Payload', payload, values.timeLimitMinutes != null && values.timeLimitMinutes <=0, "hello"); const res = await fetch( - data.id - ? `/api/v1/workspaces/${wsId}/quiz-sets/${data.id}` + values.id + ? `/api/v1/workspaces/${wsId}/quiz-sets/${values.id}` : `/api/v1/workspaces/${wsId}/quiz-sets`, { - method: data.id ? 'PUT' : 'POST', - body: JSON.stringify(data), + method: values.id ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), } ); - if (res.ok) { - onFinish?.(data); + onFinish?.(values); router.refresh(); } else { - const data = await res.json(); - toast({ - title: `Failed to ${data.id ? 'edit' : 'create'} course module`, - description: data.message, - }); + const err = await res.json(); + toast({ title: t('error_saving'), description: err.message }); } } catch (error) { toast({ - title: `Failed to ${data.id ? 'edit' : 'create'} course module`, + title: t('error_saving'), description: error instanceof Error ? error.message : String(error), }); } }; return ( -
- - ( - - {t('ws-quiz-sets.name')} - - - - - - )} - /> - - - - +
+
+ {/* Header */} +
+

+ {data?.id ? t('ws-quiz-sets.edit') : t('ws-quiz-sets.create')} +

+

+ {t('ws-quiz-sets.form-description')} +

+
+ +
+ + {/* Hidden moduleId */} + + + {/* Basic Information Card */} + + + + + {t('ws-quiz-sets.form-sections.basic.title')} + + + {t('ws-quiz-sets.form-sections.basic.subtitle')} + + + +
+ {/* Name */} + ( + + + {t('ws-quiz-sets.form-fields.name.title')} + + {t('ws-quiz-sets.required-badge')} + + + + {t('ws-quiz-sets.form-fields.name.description')} + + + + + + + )} + /> + + {/* Instruction */} + ( + + + {t('ws-quiz-sets.form-fields.instruction.title')} + + + {t( + 'ws-quiz-sets.form-fields.instruction.description' + )} + + + + + + + )} + /> +
+
+
+ + {/* Timing & Limits Card */} + + + + + {t('ws-quiz-sets.form-sections.timing-limit.title')} + + + {t('ws-quiz-sets.form-sections.timing-limit.subtitle')} + + + +
+ {/* Attempt Limit */} + ( + + + {t('ws-quiz-sets.form-fields.attempt_limit.title')} + + + {t( + 'ws-quiz-sets.form-fields.attempt_limit.description' + )} + + + + field.onChange( + e.target.value === '' + ? null + : Number(e.target.value) + ) + } + placeholder={t( + 'ws-quiz-sets.form-fields.attempt_limit.placeholder' + )} + className="border-dynamic-purple/30 focus:border-dynamic-purple focus:ring-dynamic-purple/20" + /> + + + + )} + /> + + {/* Time Limit Minutes */} + ( + + + {t( + 'ws-quiz-sets.form-fields.time_limit_minutes.title' + )} + + + {t( + 'ws-quiz-sets.form-fields.time_limit_minutes.description' + )} + + + + field.onChange( + e.target.value === '' + ? null + : Number(e.target.value) + ) + } + value={field.value ?? ''} + placeholder={t( + 'ws-quiz-sets.form-fields.time_limit_minutes.placeholder' + )} + className="border-dynamic-purple/30 focus:border-dynamic-purple focus:ring-dynamic-purple/20" + /> + + + + )} + /> +
+
+
+ + {/* Schedule Card */} + + + + + {t('ws-quiz-sets.form-sections.schedule.title')} + + + {t('ws-quiz-sets.form-sections.schedule.subtitle')} + + + +
+ {/* Available Date */} + ( + + + {t('ws-quiz-sets.form-fields.available_date.title')} + + + {t( + 'ws-quiz-sets.form-fields.available_date.description' + )} + + + + + + + )} + /> + + {/* Due Date */} + ( + + + {t('ws-quiz-sets.form-fields.due_date.title')} + + + {t('ws-quiz-sets.form-fields.due_date.description')} + + + + + + + )} + /> +
+
+
+ + {/* Settings Card */} + + + + + {t('ws-quiz-sets.form-sections.settings.title')} + + + {t('ws-quiz-sets.form-sections.settings.subtitle')} + + + +
+ {/* Explanation Mode */} + ( + + + {t('ws-quiz-sets.form-fields.explanation_mode.title')} + + + {t( + 'ws-quiz-sets.form-fields.explanation_mode.description' + )} + + + + + )} + /> + + {/* Permission Checkboxes */} +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> +
+
+
+
+ + {/* Submit Button */} +
+ +
+
+ +
+
); } diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/instruction-editor.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/instruction-editor.tsx new file mode 100644 index 0000000000..dd09dda4d6 --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/instruction-editor.tsx @@ -0,0 +1,47 @@ +"use client" + +import { useState } from "react" +import { useTranslations } from "next-intl" +import { Button } from "@tuturuuu/ui/button" +import { RichTextEditor } from '@tuturuuu/ui/text-editor/editor'; +import type { JSONContent } from "@tiptap/react" + +interface InstructionEditorProps { + quizSetId: string + instruction: JSONContent | null + setInstruction: (instruction: JSONContent) => void +} + +export default function InstructionEditor({ quizSetId, instruction, setInstruction }: InstructionEditorProps) { + const t = useTranslations() + const [saving, setSaving] = useState(false) + + const INSTRUCTION_EDITOR_KEY = `instruction-quiz-set-${quizSetId}` + + const handleSave = async () => { + setSaving(true) + if (instruction) { + localStorage.setItem(INSTRUCTION_EDITOR_KEY, JSON.stringify(instruction)) + } + setSaving(false) + } + + return ( +
+
+ +
+ +
+ +
+
+ ) +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/page.tsx index 4ea80f71fe..350f9ba1cb 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/page.tsx @@ -1,5 +1,4 @@ import { getQuizSetColumns } from './columns'; -import CourseModuleForm from './form'; import { CustomDataTable } from '@/components/custom-data-table'; import { createClient } from '@tuturuuu/supabase/next/server'; import { type WorkspaceQuizSet } from '@tuturuuu/types/db'; @@ -46,7 +45,8 @@ export default async function WorkspaceCoursesPage({ singularTitle={t('ws-quiz-sets.singular')} createTitle={t('ws-quiz-sets.create')} createDescription={t('ws-quiz-sets.create_description')} - form={} + // form={} + href={`/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/create`} /> d.name); const baseName = formattedName; - let suffix = 2; - let newName = `${baseName} ${suffix}`; + let suffix = 1; + let newName = baseName; + + // Keep incrementing suffix until we find a unique name while (existingNames.includes(newName)) { suffix++; newName = `${baseName} ${suffix}`; diff --git a/packages/ui/src/components/ui/custom/feature-summary.tsx b/packages/ui/src/components/ui/custom/feature-summary.tsx index f5dc661f40..2443ea2a7c 100644 --- a/packages/ui/src/components/ui/custom/feature-summary.tsx +++ b/packages/ui/src/components/ui/custom/feature-summary.tsx @@ -2,6 +2,7 @@ import { Button } from '../button'; import ModifiableDialogTrigger from './modifiable-dialog-trigger'; import { cn } from '@tuturuuu/utils/format'; import { Cog, Plus } from 'lucide-react'; +import Link from 'next/link'; import { type ReactElement, ReactNode } from 'react'; interface FormProps { @@ -94,6 +95,7 @@ export default function FeatureSummary({ )} + {href && !form && {primaryTrigger}} {(form || action || showDefaultFormAsSecondary || From cdb0f8efce84a55e4dec2c753dc480328989fc0c Mon Sep 17 00:00:00 2001 From: Nhung Date: Sat, 21 Jun 2025 19:45:03 +0700 Subject: [PATCH 02/10] feat(Quiz Set Form UI): add navigation to detailed page when finishing creating or editing quiz set --- .../modules/[moduleId]/quiz-sets/create/page.tsx | 3 +-- .../[courseId]/modules/[moduleId]/quiz-sets/form.tsx | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx index 6d0fa3806c..542c14816e 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/create/page.tsx @@ -3,13 +3,12 @@ import QuizSetForm from '../form'; interface Props { params: Promise<{ wsId: string; - courseId: string; moduleId: string; }>; } export default async function Page({ params }: Props) { - const { wsId, courseId, moduleId } = await params; + const { wsId, moduleId } = await params; return (
diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx index 1606482585..3aaeba6860 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/form.tsx @@ -131,6 +131,14 @@ export default function QuizSetForm({ wsId, moduleId, data, onFinish }: Props) { if (res.ok) { onFinish?.(values); router.refresh(); + + // navigate to set detail page if created or edited successfully + if (!values.id) { + const newSet = await res.json(); + router.push(`/${wsId}/quiz-sets/${newSet.setId}`); + } else { + router.push(`/${wsId}/quiz-sets/${values.id}`); + } } else { const err = await res.json(); toast({ title: t('error_saving'), description: err.message }); From 6bf58307e6c532183c61b72928517476a142c939 Mon Sep 17 00:00:00 2001 From: Nhung Date: Sat, 21 Jun 2025 20:20:41 +0700 Subject: [PATCH 03/10] feat (Quiz Set Form UI): add edit quiz set page --- .../quiz-sets/[setId]/edit/page.tsx | 44 +++++++++++++++++++ .../modules/[moduleId]/quiz-sets/columns.tsx | 1 + .../[moduleId]/quiz-sets/row-actions.tsx | 12 ++++- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/edit/page.tsx diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/edit/page.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/edit/page.tsx new file mode 100644 index 0000000000..2863c54feb --- /dev/null +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/[setId]/edit/page.tsx @@ -0,0 +1,44 @@ +import QuizSetForm from '../../form'; +import { createClient } from '@tuturuuu/supabase/next/server'; + +interface Props { + params: Promise<{ + wsId: string; + // courseId: string; + moduleId: string; + setId: string; + }>; +} + +export default async function Page({ params }: Props) { + const { wsId, moduleId, setId } = await params; + const setDetails = await getQuizSetDetails(setId); + + if (!setDetails) { + return
Error: Quiz set not found
; + } + + return ( +
+ +
+ ); +} + +async function getQuizSetDetails(setId: string) { + const supabase = await createClient(); + + const queryBuilder = supabase + .from('workspace_quiz_sets') + .select('*') + .eq('id', setId) + .single(); + const { data, error } = await queryBuilder; + + if (error) { + console.error('Error fetching quiz set details:', error); + return null; + } + + return data; +} diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/columns.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/columns.tsx index ab5264bfa4..becc572208 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/columns.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/columns.tsx @@ -86,6 +86,7 @@ export const getQuizSetColumns = ( ), diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/row-actions.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/row-actions.tsx index ec7c2c45d4..8cb2f7730b 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/row-actions.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/row-actions.tsx @@ -15,17 +15,20 @@ import { import { toast } from '@tuturuuu/ui/hooks/use-toast'; import { Ellipsis } from '@tuturuuu/ui/icons'; import { useTranslations } from 'next-intl'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; interface QuizSetRowActionsProps { wsId: string; + courseId: string; moduleId: string; row: Row; } export function QuizSetRowActions({ wsId, + courseId, moduleId, row, }: QuizSetRowActionsProps) { @@ -76,7 +79,14 @@ export function QuizSetRowActions({ - setShowEditDialog(true)}> + {/* setShowEditDialog(true)}> */} + { + router.push( + `/${wsId}/courses/${courseId}/modules/${moduleId}/quiz-sets/${data.id}/edit` + ); + }} + > {t('common.edit')} From 6025724f6677970dbbfd0376f42377ea5658dc54 Mon Sep 17 00:00:00 2001 From: Nhung Date: Sun, 22 Jun 2025 15:40:13 +0700 Subject: [PATCH 04/10] ops(Quiz Option Form): add create multi-quizzes at once form --- .../[moduleId]/quiz-sets/row-actions.tsx | 1 - .../[wsId]/quiz-sets/[setId]/form.tsx | 707 +++++++++++++----- .../[wsId]/quiz-sets/[setId]/page.tsx | 4 +- .../quiz-sets/[setId]/quiz-create/page.tsx | 17 + .../api/v1/workspaces/[wsId]/quizzes/route.ts | 110 ++- 5 files changed, 595 insertions(+), 244 deletions(-) create mode 100644 apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/quiz-create/page.tsx diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/row-actions.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/row-actions.tsx index 8cb2f7730b..3e03986584 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/row-actions.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/courses/[courseId]/modules/[moduleId]/quiz-sets/row-actions.tsx @@ -15,7 +15,6 @@ import { import { toast } from '@tuturuuu/ui/hooks/use-toast'; import { Ellipsis } from '@tuturuuu/ui/icons'; import { useTranslations } from 'next-intl'; -import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; diff --git a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/form.tsx b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/form.tsx index c8f68b31f0..c4a9247ed8 100644 --- a/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/form.tsx +++ b/apps/upskii/src/app/[locale]/(dashboard)/[wsId]/quiz-sets/[setId]/form.tsx @@ -1,9 +1,16 @@ 'use client'; -import { WorkspaceQuiz } from '@tuturuuu/types/db'; +import type { WorkspaceQuiz } from '@tuturuuu/types/db'; +import { Badge } from '@tuturuuu/ui/badge'; import { Button } from '@tuturuuu/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@tuturuuu/ui/card'; import { Checkbox } from '@tuturuuu/ui/checkbox'; -import { AutosizeTextarea } from '@tuturuuu/ui/custom/autosize-textarea'; import { Form, FormControl, @@ -14,11 +21,26 @@ import { } from '@tuturuuu/ui/form'; import { useFieldArray, useForm } from '@tuturuuu/ui/hooks/use-form'; import { toast } from '@tuturuuu/ui/hooks/use-toast'; -import { Loader, Pencil, Plus, PlusCircle, Wand } from '@tuturuuu/ui/icons'; import { Input } from '@tuturuuu/ui/input'; import { zodResolver } from '@tuturuuu/ui/resolvers'; import { ScrollArea } from '@tuturuuu/ui/scroll-area'; import { Separator } from '@tuturuuu/ui/separator'; +import { Textarea } from '@tuturuuu/ui/textarea'; +import { + Check, + CheckCheck, + CheckCircle, + Copy, + HelpCircle, + Loader2, + Plus, + PlusCircle, + Save, + Trash2, + Wand2, + X, + XCircle, +} from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { Fragment, useState } from 'react'; @@ -28,39 +50,43 @@ interface Props { wsId: string; moduleId?: string; setId?: string; - data?: Partial< - WorkspaceQuiz & { - quiz_options: ( - | { - id?: string; - value?: string; - is_correct?: boolean; - explanation?: string | null; - } - | undefined - )[]; - } + data?: Array< + Partial< + WorkspaceQuiz & { + quiz_options: Array<{ + id?: string; + value?: string; + is_correct?: boolean; + explanation?: string | null; + }>; + } + > >; - // eslint-disable-next-line no-unused-vars onFinish?: (data: z.infer) => void; } const QuizOptionSchema = z.object({ id: z.string().optional(), - value: z.string().min(1), + value: z.string().min(1, 'Option value is required'), is_correct: z.boolean(), - explanation: z.string().optional(), // Add explanation field + explanation: z.string().optional(), }); -const FormSchema = z.object({ +const QuizSchema = z.object({ id: z.string().optional(), + question: z.string().min(1, 'Question is required'), + quiz_options: z + .array(QuizOptionSchema) + .min(2, 'At least 2 options are required'), +}); + +const FormSchema = z.object({ moduleId: z.string().optional(), setId: z.string().optional(), - question: z.string().min(1), - quiz_options: z.array(QuizOptionSchema).min(1), + quizzes: z.array(QuizSchema).min(1, 'At least one quiz is required'), }); -export default function QuizForm({ +export default function MultiQuizForm({ wsId, moduleId, setId, @@ -69,239 +95,510 @@ export default function QuizForm({ }: Props) { const t = useTranslations(); const router = useRouter(); - const [loadingIndex, setLoadingIndex] = useState(null); + const [loadingStates, setLoadingStates] = useState>( + {} + ); const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { - id: data?.id, moduleId, setId, - question: data?.question || '', - quiz_options: data?.quiz_options?.map((option) => ({ - id: option?.id, - value: option?.value || '', - is_correct: option?.is_correct || false, - explanation: option?.explanation || '', - })) || [{ value: '', is_correct: false, explanation: '' }], // Ensure explanation values are included + quizzes: + data && data.length > 0 + ? data.map((quiz) => ({ + id: quiz.id, + question: quiz.question || '', + quiz_options: quiz.quiz_options?.map((option) => ({ + id: option.id, + value: option.value || '', + is_correct: option.is_correct || false, + explanation: option.explanation || '', + })) || [ + { value: '', is_correct: false, explanation: '' }, + { value: '', is_correct: false, explanation: '' }, + ], + })) + : [ + { + question: '', + quiz_options: [ + { value: '', is_correct: false, explanation: '' }, + { value: '', is_correct: false, explanation: '' }, + ], + }, + ], }, }); - const { fields, append, remove } = useFieldArray({ + const { + fields: quizFields, + append: appendQuiz, + remove: removeQuiz, + } = useFieldArray({ control: form.control, - name: 'quiz_options', + name: 'quizzes', }); - const isDirty = form.formState.isDirty; - const isValid = form.formState.isValid; - const isSubmitting = form.formState.isSubmitting; - + const { isDirty, isValid, isSubmitting } = form.formState; const disabled = !isDirty || !isValid || isSubmitting; - const onSubmit = async (data: z.infer) => { + const onSubmit = async (formData: z.infer) => { try { - const res = await fetch( - data.id - ? `/api/v1/workspaces/${wsId}/quizzes/${data.id}` - : `/api/v1/workspaces/${wsId}/quizzes`, - { - method: data.id ? 'PUT' : 'POST', - body: JSON.stringify(data), + const promises = formData.quizzes.map(async (quiz) => { + const res = await fetch( + quiz.id + ? `/api/v1/workspaces/${wsId}/quizzes/${quiz.id}` + : `/api/v1/workspaces/${wsId}/quizzes`, + { + method: quiz.id ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...quiz, + moduleId: formData.moduleId, + setId: formData.setId, + }), + } + ); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error( + errorData.message || + `Failed to ${quiz.id ? 'update' : 'create'} quiz` + ); } - ); - if (res.ok) { - onFinish?.(data); - router.refresh(); - } else { - const resData = await res.json(); - toast({ - title: `Failed to ${data.id ? 'edit' : 'create'} quiz`, - description: resData.message, - }); - } + return res.json(); + }); + + await Promise.all(promises); + + toast({ + title: 'Success', + description: `Successfully ${data && data.length > 0 ? 'updated' : 'created'} ${formData.quizzes.length} quiz${ + formData.quizzes.length > 1 ? 'es' : '' + }`, + }); + + onFinish?.(formData); + router.refresh(); } catch (error) { toast({ - title: `Failed to ${data.id ? 'edit' : 'create'} quiz`, - description: error instanceof Error ? error.message : String(error), + title: 'Error', + description: + error instanceof Error + ? error.message + : 'An unexpected error occurred', + variant: 'destructive', }); } }; - const generateExplanation = async (index: number) => { - const question = form.getValues('question'); - const option = form.getValues(`quiz_options.${index}`); + const generateExplanation = async ( + quizIndex: number, + optionIndex: number + ) => { + const question = form.getValues(`quizzes.${quizIndex}.question`); + const option = form.getValues( + `quizzes.${quizIndex}.quiz_options.${optionIndex}` + ); - setLoadingIndex(index); + const loadingKey = `${quizIndex}-${optionIndex}`; + setLoadingStates((prev) => ({ ...prev, [loadingKey]: true })); try { const res = await fetch('/api/ai/objects/quizzes/explanation', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ wsId, question, option }), }); if (res.ok) { const { explanation } = await res.json(); - form.setValue(`quiz_options.${index}.explanation`, explanation); - } else { + form.setValue( + `quizzes.${quizIndex}.quiz_options.${optionIndex}.explanation`, + explanation + ); toast({ - title: t('common.error'), - description: t('ws-quizzes.generation_error'), + title: 'Success', + description: 'Explanation generated successfully', }); + } else { + throw new Error('Failed to generate explanation'); } } catch (error) { toast({ - title: t('common.error'), - description: error instanceof Error ? error.message : String(error), + title: 'Error', + description: + error instanceof Error + ? error.message + : 'Failed to generate explanation', + variant: 'destructive', }); } finally { - setLoadingIndex(null); + setLoadingStates((prev) => ({ ...prev, [loadingKey]: false })); } }; + const addQuizOption = (quizIndex: number) => { + const currentOptions = form.getValues(`quizzes.${quizIndex}.quiz_options`); + form.setValue(`quizzes.${quizIndex}.quiz_options`, [ + ...currentOptions, + { value: '', is_correct: false, explanation: '' }, + ]); + }; + + const removeQuizOption = (quizIndex: number, optionIndex: number) => { + const currentOptions = form.getValues(`quizzes.${quizIndex}.quiz_options`); + if (currentOptions.length > 2) { + const newOptions = currentOptions.filter( + (_, index) => index !== optionIndex + ); + form.setValue(`quizzes.${quizIndex}.quiz_options`, newOptions); + } + }; + + const duplicateQuiz = (quizIndex: number) => { + const quizToDuplicate = form.getValues(`quizzes.${quizIndex}`); + const duplicatedQuiz = { + ...quizToDuplicate, + id: undefined, // Remove ID so it creates a new quiz + question: `${quizToDuplicate.question} (Copy)`, + quiz_options: quizToDuplicate.quiz_options.map((option) => ({ + ...option, + id: undefined, // Remove ID so it creates new options + })), + }; + appendQuiz(duplicatedQuiz); + }; + + const getCorrectAnswersCount = (quizIndex: number) => { + const options = form.watch(`quizzes.${quizIndex}.quiz_options`); + return options.filter((option) => option.is_correct).length; + }; + return ( -
- - ( - - {t('common.question')} - - - - - - )} - /> - - - - -
- {fields.map((field, index) => ( - -
- ( - +
+
+ {/* Header */} +
+

+ {data && data.length > 0 + ? 'Edit Quiz Questions' + : 'Create Quiz Questions'} +

+

+ Create multiple quiz questions at once. Each question can have + multiple options with explanations. +

+
+ + {quizFields.length} Question{quizFields.length !== 1 ? 's' : ''} + + + {quizFields.reduce((total, _, index) => { + const options = form.watch(`quizzes.${index}.quiz_options`); + return total + (options?.length || 0); + }, 0)}{' '} + Total Options + +
+
+ + + + {/* Quiz Questions */} +
+ {quizFields.map((field, quizIndex) => { + const correctAnswers = getCorrectAnswersCount(quizIndex); + const options = + form.watch(`quizzes.${quizIndex}.quiz_options`) || []; + + return ( + + +
+
+ +
+ Question {quizIndex + 1} + + {correctAnswers === 0 && ( + + + No correct answer selected + + )} + {correctAnswers === 1 && ( + + + Single correct answer + + )} + {correctAnswers > 1 && ( + + + Multiple correct answers ({correctAnswers}) + + )} + +
+
+
+ + {quizFields.length > 1 && ( + + )} +
+
+
+ +
+ {/* Question Input */} ( - - - {t('common.correct')} + + + Question + + +