@tinyweb_dev/oe-exam-sdk 0.1.30 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/exams/ExamPreviewDialog.d.ts +9 -3
- package/dist/components/exams/ExamPreviewDialog.js +23 -5
- package/dist/components/exams/ExamPreviewPartContent.d.ts +2 -1
- package/dist/components/exams/ExamPreviewPartContent.js +3 -2
- package/dist/components/exams/ExamPreviewSidebar.d.ts +3 -1
- package/dist/components/exams/ExamPreviewSidebar.js +7 -2
- package/dist/components/exams/exam-preview.mode.d.ts +4 -0
- package/dist/components/exams/exam-preview.mode.js +5 -0
- package/dist/components/exams/index.d.ts +1 -0
- package/dist/components/exams/index.js +1 -0
- package/dist/components/exams/take/components/QuestionRenderer.js +5 -1
- package/dist/components/exams/take/utils/answer-transformers.js +2 -1
- package/dist/components/exams/take/utils/question-transformers.d.ts +6 -1
- package/dist/components/exams/take/utils/question-transformers.js +33 -0
- package/dist/components/questions/_shared/config/question-types.config.js +6 -0
- package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +32 -0
- package/dist/components/questions/_shared/types/answer-the-question-group.type.js +1 -0
- package/dist/components/questions/creator/question-type-registry.js +2 -0
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.d.ts +2 -0
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +13 -0
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.d.ts +2 -0
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +183 -0
- package/dist/components/questions/types/answer-the-question-group/index.d.ts +5 -0
- package/dist/components/questions/types/answer-the-question-group/index.js +5 -0
- package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.d.ts +4 -0
- package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +83 -0
- package/dist/components/questions/types/answer-the-question-group/register.d.ts +2 -0
- package/dist/components/questions/types/answer-the-question-group/register.js +36 -0
- package/dist/components/questions/types/answer-the-question-group/transform.d.ts +2 -0
- package/dist/components/questions/types/answer-the-question-group/transform.js +72 -0
- package/dist/components/questions/types/speaking-conversation/SpeakingConversationPreviewClient.js +1 -1
- package/dist/components/questions/viewer/QuestionViewer.js +2 -2
- package/dist/components/questions/viewer/SpontaneousQaGroupViewer.js +4 -4
- package/dist/components/results/ReviewQuestionRenderer.js +1 -0
- package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.d.ts +19 -0
- package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.js +27 -0
- package/dist/components/results/renderers/review-question-body-dedicated.d.ts +1 -0
- package/dist/components/results/renderers/review-question-body-dedicated.js +47 -2
- package/dist/components/results/review-data.js +12 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/constants/question-skills.js +2 -0
- package/dist/shared/lib/i18n/messages/en/admin.d.ts +5 -0
- package/dist/shared/lib/i18n/messages/en/admin.js +5 -0
- package/dist/shared/lib/i18n/messages/en.d.ts +5 -0
- package/dist/shared/lib/i18n/messages/vi/admin.d.ts +5 -0
- package/dist/shared/lib/i18n/messages/vi/admin.js +5 -0
- package/dist/shared/lib/i18n/messages/vi.d.ts +5 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +39 -0
- package/dist/shared/lib/utils/question-transform.js +2 -0
- package/dist/shared/types/common.types.d.ts +1 -1
- package/dist/shared/types/questions/answer-the-question-group.d.ts +39 -0
- package/dist/shared/types/questions/answer-the-question-group.js +1 -0
- package/dist/shared/types/questions/index.d.ts +1 -0
- package/dist/shared/types/questions/index.js +2 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExamQuestionsSdkApi } from '../../api/exam-questions.types';
|
|
2
|
+
import { ExamPreviewMode } from './exam-preview.mode';
|
|
2
3
|
export interface ExamPreviewDialogProps {
|
|
3
4
|
open: boolean;
|
|
4
5
|
onOpenChange: (open: boolean) => void;
|
|
@@ -8,12 +9,17 @@ export interface ExamPreviewDialogProps {
|
|
|
8
9
|
api: ExamQuestionsSdkApi;
|
|
9
10
|
/** Fallback heading shown until the exam name loads. */
|
|
10
11
|
title?: string;
|
|
12
|
+
/** Initial preview mode. Default is student view without answers. */
|
|
13
|
+
defaultMode?: ExamPreviewMode;
|
|
11
14
|
}
|
|
12
15
|
/**
|
|
13
16
|
* Read-only popup that previews an exam by id.
|
|
14
17
|
*
|
|
18
|
+
* Supports two modes:
|
|
19
|
+
* - 'student' (default): interactive student taking view without revealing answers
|
|
20
|
+
* - 'answers': review view with correct answers highlighted and explanations
|
|
21
|
+
*
|
|
15
22
|
* UI mirrors the host app: one Part per screen (all questions of that part),
|
|
16
|
-
* Next/Prev navigates between parts.
|
|
17
|
-
* {@link ExamQuestionsSdkApi}. Questions render via QuestionViewer in review mode.
|
|
23
|
+
* Next/Prev navigates between parts. Data comes from {@link ExamQuestionsSdkApi}.
|
|
18
24
|
*/
|
|
19
|
-
export declare function ExamPreviewDialog({ open, onOpenChange, examId, api, title, }: ExamPreviewDialogProps): import("react").JSX.Element;
|
|
25
|
+
export declare function ExamPreviewDialog({ open, onOpenChange, examId, api, title, defaultMode, }: ExamPreviewDialogProps): import("react").JSX.Element;
|
|
@@ -1,30 +1,42 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
-
import { Eye } from 'lucide-react';
|
|
4
|
+
import { CheckCircle2, Eye, GraduationCap } from 'lucide-react';
|
|
5
5
|
import { Button } from '../../components/ui/button';
|
|
6
6
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
|
7
7
|
import { useT } from '../../shared/lib/i18n';
|
|
8
|
+
import { cn } from '../../shared/lib/utils';
|
|
9
|
+
import { ExamPreviewMode } from './exam-preview.mode';
|
|
8
10
|
import { ExamPreviewPartContent } from './ExamPreviewPartContent';
|
|
9
11
|
import { ExamPreviewSidebar } from './ExamPreviewSidebar';
|
|
10
12
|
import { getQuestionNumber, getQuestionPoints, groupQuestionsByPart, sumQuestionPoints, unwrapApiData, } from './exam-preview.types';
|
|
11
13
|
/**
|
|
12
14
|
* Read-only popup that previews an exam by id.
|
|
13
15
|
*
|
|
16
|
+
* Supports two modes:
|
|
17
|
+
* - 'student' (default): interactive student taking view without revealing answers
|
|
18
|
+
* - 'answers': review view with correct answers highlighted and explanations
|
|
19
|
+
*
|
|
14
20
|
* UI mirrors the host app: one Part per screen (all questions of that part),
|
|
15
|
-
* Next/Prev navigates between parts.
|
|
16
|
-
* {@link ExamQuestionsSdkApi}. Questions render via QuestionViewer in review mode.
|
|
21
|
+
* Next/Prev navigates between parts. Data comes from {@link ExamQuestionsSdkApi}.
|
|
17
22
|
*/
|
|
18
|
-
export function ExamPreviewDialog({ open, onOpenChange, examId, api, title, }) {
|
|
23
|
+
export function ExamPreviewDialog({ open, onOpenChange, examId, api, title, defaultMode = ExamPreviewMode.STUDENT, }) {
|
|
19
24
|
const t = useT();
|
|
20
25
|
const [exam, setExam] = useState(null);
|
|
21
26
|
const [questions, setQuestions] = useState([]);
|
|
22
27
|
const [currentPartIndex, setCurrentPartIndex] = useState(0);
|
|
23
28
|
const [showPartsDetail, setShowPartsDetail] = useState(false);
|
|
29
|
+
const [previewMode, setPreviewMode] = useState(defaultMode);
|
|
24
30
|
const [loading, setLoading] = useState(false);
|
|
25
31
|
const [error, setError] = useState(null);
|
|
26
32
|
const [reloadKey, setReloadKey] = useState(0);
|
|
27
33
|
const contentRef = useRef(null);
|
|
34
|
+
// Sync / reset previewMode whenever dialog opens
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (open) {
|
|
37
|
+
setPreviewMode(defaultMode);
|
|
38
|
+
}
|
|
39
|
+
}, [open, defaultMode]);
|
|
28
40
|
// Fetch exam + questions whenever the dialog opens for an exam.
|
|
29
41
|
useEffect(() => {
|
|
30
42
|
if (!open || !examId) {
|
|
@@ -117,5 +129,11 @@ export function ExamPreviewDialog({ open, onOpenChange, examId, api, title, }) {
|
|
|
117
129
|
scrollToTop();
|
|
118
130
|
}
|
|
119
131
|
};
|
|
120
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[95vh] max-w-7xl overflow-y-auto", children: [
|
|
132
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[95vh] max-w-7xl overflow-y-auto", children: [_jsxs(DialogHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b pb-3 pr-8", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2 text-lg font-bold", children: [_jsx(Eye, { className: "h-5 w-5 text-blue-600" }), t('admin.exams.preview.title', { name: heading })] }), _jsxs("div", { className: "inline-flex items-center rounded-lg border border-gray-200 bg-gray-100 p-1 shadow-inner", children: [_jsxs("button", { type: "button", onClick: () => setPreviewMode(ExamPreviewMode.STUDENT), className: cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-all', previewMode === ExamPreviewMode.STUDENT
|
|
133
|
+
? 'bg-white text-blue-700 shadow-sm ring-1 ring-black/5'
|
|
134
|
+
: 'text-gray-600 hover:bg-gray-200/70 hover:text-gray-900'), children: [_jsx(GraduationCap, { className: "h-4 w-4" }), _jsx("span", { children: t('admin.exams.preview.mode.student') })] }), _jsxs("button", { type: "button", onClick: () => setPreviewMode(ExamPreviewMode.ANSWERS), className: cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-all', previewMode === ExamPreviewMode.ANSWERS
|
|
135
|
+
? 'bg-emerald-600 text-white shadow-sm'
|
|
136
|
+
: 'text-gray-600 hover:bg-gray-200/70 hover:text-gray-900'), children: [_jsx(CheckCircle2, { className: "h-4 w-4" }), _jsx("span", { children: t('admin.exams.preview.mode.answers') })] })] })] }), _jsxs("div", { className: "flex h-[80vh] flex-col gap-4 sm:flex-row", children: [_jsx(ExamPreviewSidebar, { examName: heading, duration: duration, totalPoints: totalPoints, questions: questions, questionsByPart: questionsByPart, currentPart: currentPart, currentPartIndex: currentPartIndex, totalParts: totalParts, progress: progress, loading: loading, showPartsDetail: showPartsDetail, previewMode: previewMode, onTogglePartsDetail: () => setShowPartsDetail((value) => !value), onGoToPart: goToPart }), _jsx(ExamPreviewPartContent, { loading: loading, error: error, questions: questions, currentPart: currentPart, currentPartQuestions: currentPartQuestions, currentPartIndex: currentPartIndex, totalParts: totalParts, partDisplayName: partDisplayName, partTotalPoints: partTotalPoints, contentRef: contentRef, isReviewMode: previewMode === ExamPreviewMode.ANSWERS, onRetry: () => setReloadKey((key) => key + 1), onPreviousPart: goToPreviousPart, onNextPart: goToNextPart })] }), _jsx("div", { className: "border-t pt-4", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("div", { className: "text-sm text-gray-600", children: previewMode === ExamPreviewMode.ANSWERS
|
|
137
|
+
? t('admin.exams.preview.footerNoteAnswers')
|
|
138
|
+
: t('admin.exams.preview.footerNoteStudent') }), _jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), className: "border-gray-300", children: t('admin.exams.preview.close') })] }) })] }) }));
|
|
121
139
|
}
|
|
@@ -11,8 +11,9 @@ export interface ExamPreviewPartContentProps {
|
|
|
11
11
|
partDisplayName: string;
|
|
12
12
|
partTotalPoints: number;
|
|
13
13
|
contentRef: RefObject<HTMLDivElement | null>;
|
|
14
|
+
isReviewMode?: boolean;
|
|
14
15
|
onRetry: () => void;
|
|
15
16
|
onPreviousPart: () => void;
|
|
16
17
|
onNextPart: () => void;
|
|
17
18
|
}
|
|
18
|
-
export declare function ExamPreviewPartContent({ loading, error, questions, currentPart, currentPartQuestions, currentPartIndex, totalParts, partDisplayName, partTotalPoints, contentRef, onRetry, onPreviousPart, onNextPart, }: ExamPreviewPartContentProps): import("react").JSX.Element;
|
|
19
|
+
export declare function ExamPreviewPartContent({ loading, error, questions, currentPart, currentPartQuestions, currentPartIndex, totalParts, partDisplayName, partTotalPoints, contentRef, isReviewMode, onRetry, onPreviousPart, onNextPart, }: ExamPreviewPartContentProps): import("react").JSX.Element;
|
|
@@ -5,8 +5,9 @@ import { Badge } from '../../components/ui/badge';
|
|
|
5
5
|
import { Button } from '../../components/ui/button';
|
|
6
6
|
import { QuestionViewer } from '../questions/viewer/QuestionViewer';
|
|
7
7
|
import { useT } from '../../shared/lib/i18n';
|
|
8
|
+
import { ExamPreviewMode } from './exam-preview.mode';
|
|
8
9
|
import { difficultyBadgeClass, getQuestionDifficulty, getQuestionNumber, getQuestionPoints, } from './exam-preview.types';
|
|
9
|
-
export function ExamPreviewPartContent({ loading, error, questions, currentPart, currentPartQuestions, currentPartIndex, totalParts, partDisplayName, partTotalPoints, contentRef, onRetry, onPreviousPart, onNextPart, }) {
|
|
10
|
+
export function ExamPreviewPartContent({ loading, error, questions, currentPart, currentPartQuestions, currentPartIndex, totalParts, partDisplayName, partTotalPoints, contentRef, isReviewMode = false, onRetry, onPreviousPart, onNextPart, }) {
|
|
10
11
|
const t = useT();
|
|
11
12
|
return (_jsx("div", { className: "flex-1 overflow-y-auto", children: _jsxs("div", { className: "flex h-full flex-col", children: [_jsxs("div", { className: "flex items-center justify-between border-b pb-4", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-semibold", children: loading ? (_jsx("span", { className: "text-gray-400", children: t('admin.exams.preview.loading') })) : totalParts > 0 ? (t('admin.exams.preview.partIndex', {
|
|
12
13
|
index: String(currentPartIndex + 1),
|
|
@@ -22,6 +23,6 @@ export function ExamPreviewPartContent({ loading, error, questions, currentPart,
|
|
|
22
23
|
number: String(qNumber),
|
|
23
24
|
}) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Badge, { variant: "outline", children: t('admin.exams.preview.points', {
|
|
24
25
|
count: String(getQuestionPoints(question)),
|
|
25
|
-
}) }), _jsx(Badge, { className: difficultyBadgeClass(difficulty), children: difficulty })] })] }), _jsx(QuestionViewer, { question: question, isReviewMode:
|
|
26
|
+
}) }), _jsx(Badge, { className: difficultyBadgeClass(difficulty), children: difficulty })] })] }), _jsx(QuestionViewer, { question: question, isReviewMode: isReviewMode }, `${question.id}-${isReviewMode ? ExamPreviewMode.ANSWERS : ExamPreviewMode.STUDENT}`)] }, question.id));
|
|
26
27
|
}) })) : null }), !loading && totalParts > 0 && (_jsxs("div", { className: "mt-auto flex items-center justify-between border-t border-gray-200 bg-gray-50 px-4 py-3", children: [_jsxs(Button, { variant: "outline", onClick: onPreviousPart, disabled: currentPartIndex === 0, className: "flex h-9 items-center gap-2 px-3 text-sm", children: [_jsx(ArrowLeft, { className: "h-4 w-4" }), _jsx("span", { className: "hidden sm:inline", children: t('admin.exams.preview.prevPart') }), _jsx("span", { className: "sm:hidden", children: t('admin.exams.preview.prev') })] }), _jsxs("div", { className: "text-sm text-gray-600", children: [currentPartIndex + 1, " / ", totalParts] }), _jsxs(Button, { variant: "outline", onClick: onNextPart, disabled: currentPartIndex === totalParts - 1, className: "flex h-9 items-center gap-2 px-3 text-sm", children: [_jsx("span", { className: "hidden sm:inline", children: t('admin.exams.preview.nextPart') }), _jsx("span", { className: "sm:hidden", children: t('admin.exams.preview.next') }), _jsx(ArrowRight, { className: "h-4 w-4" })] })] }))] }) }));
|
|
27
28
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ExamPreviewMode } from './exam-preview.mode';
|
|
1
2
|
import { type PreviewQuestion, type QuestionsByPart } from './exam-preview.types';
|
|
2
3
|
export interface ExamPreviewSidebarProps {
|
|
3
4
|
examName: string;
|
|
@@ -11,7 +12,8 @@ export interface ExamPreviewSidebarProps {
|
|
|
11
12
|
progress: number;
|
|
12
13
|
loading: boolean;
|
|
13
14
|
showPartsDetail: boolean;
|
|
15
|
+
previewMode?: ExamPreviewMode;
|
|
14
16
|
onTogglePartsDetail: () => void;
|
|
15
17
|
onGoToPart: (index: number) => void;
|
|
16
18
|
}
|
|
17
|
-
export declare function ExamPreviewSidebar({ examName, duration, totalPoints, questions, questionsByPart, currentPart, currentPartIndex, totalParts, progress, loading, showPartsDetail, onTogglePartsDetail, onGoToPart, }: ExamPreviewSidebarProps): import("react").JSX.Element;
|
|
19
|
+
export declare function ExamPreviewSidebar({ examName, duration, totalPoints, questions, questionsByPart, currentPart, currentPartIndex, totalParts, progress, loading, showPartsDetail, previewMode, onTogglePartsDetail, onGoToPart, }: ExamPreviewSidebarProps): import("react").JSX.Element;
|
|
@@ -6,10 +6,15 @@ import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/ca
|
|
|
6
6
|
import { Progress } from '../../components/ui/progress';
|
|
7
7
|
import { Loader2 } from 'lucide-react';
|
|
8
8
|
import { useT } from '../../shared/lib/i18n';
|
|
9
|
+
import { ExamPreviewMode } from './exam-preview.mode';
|
|
9
10
|
import { getQuestionNumber, } from './exam-preview.types';
|
|
10
|
-
export function ExamPreviewSidebar({ examName, duration, totalPoints, questions, questionsByPart, currentPart, currentPartIndex, totalParts, progress, loading, showPartsDetail, onTogglePartsDetail, onGoToPart, }) {
|
|
11
|
+
export function ExamPreviewSidebar({ examName, duration, totalPoints, questions, questionsByPart, currentPart, currentPartIndex, totalParts, progress, loading, showPartsDetail, previewMode = ExamPreviewMode.STUDENT, onTogglePartsDetail, onGoToPart, }) {
|
|
11
12
|
const t = useT();
|
|
12
|
-
return (_jsx("div", { className: "w-full overflow-y-auto border-b border-gray-200 sm:w-80 sm:border-b-0 sm:border-r", children: _jsxs("div", { className: "space-y-4 p-4", children: [_jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-3", children: _jsx(CardTitle, { className: "text-base", children: t('admin.exams.preview.examInfo') }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.
|
|
13
|
+
return (_jsx("div", { className: "w-full overflow-y-auto border-b border-gray-200 sm:w-80 sm:border-b-0 sm:border-r", children: _jsxs("div", { className: "space-y-4 p-4", children: [_jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-3", children: _jsx(CardTitle, { className: "text-base", children: t('admin.exams.preview.examInfo') }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.mode.label') }), _jsx(Badge, { variant: "outline", className: previewMode === ExamPreviewMode.ANSWERS
|
|
14
|
+
? 'border-emerald-300 bg-emerald-50 text-emerald-700 font-semibold'
|
|
15
|
+
: 'border-blue-300 bg-blue-50 text-blue-700 font-semibold', children: previewMode === ExamPreviewMode.ANSWERS
|
|
16
|
+
? t('admin.exams.preview.mode.answers')
|
|
17
|
+
: t('admin.exams.preview.mode.student') })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.duration') }), _jsx("span", { className: "text-sm font-medium", children: t('admin.exams.view.minutes', { count: String(duration) }) })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.questionCount') }), _jsx("span", { className: "text-sm font-medium", children: loading
|
|
13
18
|
? '...'
|
|
14
19
|
: t('admin.exams.preview.questions', { count: String(questions.length) }) })] }), _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm text-gray-600", children: t('admin.exams.preview.totalPoints') }), _jsx("span", { className: "text-sm font-medium", children: t('admin.exams.preview.points', { count: String(totalPoints) }) })] })] })] }), !loading && totalParts > 0 && (_jsx(Card, { children: _jsx(CardContent, { className: "p-4", children: _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex justify-between text-sm", children: [_jsx("span", { children: t('admin.exams.preview.progress') }), _jsxs("span", { className: "font-medium", children: [currentPartIndex + 1, "/", totalParts] })] }), _jsx(Progress, { value: progress, className: "h-2" })] }) }) })), loading ? (_jsx("div", { className: "flex items-center justify-center py-4", children: _jsxs("div", { className: "flex items-center gap-2 text-gray-500", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), _jsx("span", { className: "text-sm", children: t('admin.exams.preview.loading') })] }) })) : (totalParts > 0 && (_jsxs("div", { className: "space-y-3", children: [_jsx("h4", { className: "text-sm font-medium text-gray-700", children: t('admin.exams.preview.questionNavigation') }), _jsx("div", { className: "flex flex-wrap gap-2", children: questionsByPart.map((part, index) => {
|
|
15
20
|
const isCurrent = index === currentPartIndex;
|
|
@@ -32,5 +32,6 @@ export { EXAM_DIFFICULTY, ExamViewDialog } from './ExamViewDialog';
|
|
|
32
32
|
export type { ExamDifficulty, ExamViewDialogExam, ExamViewDialogProps } from './ExamViewDialog';
|
|
33
33
|
export { ExamPreviewDialog } from './ExamPreviewDialog';
|
|
34
34
|
export type { ExamPreviewDialogProps } from './ExamPreviewDialog';
|
|
35
|
+
export { ExamPreviewMode } from './exam-preview.mode';
|
|
35
36
|
export type { PreviewQuestion, QuestionsByPart } from './exam-preview.types';
|
|
36
37
|
export { QuestionStatus, clearQuestionStatuses, getQuestionStatus, initializeQuestionStatusesFromAPI, setQuestionStatus } from './exam-question-status';
|
|
@@ -16,4 +16,5 @@ export { UnsavedChangesDialog } from './UnsavedChangesDialog';
|
|
|
16
16
|
export { CompletionDialog } from './CompletionDialog';
|
|
17
17
|
export { EXAM_DIFFICULTY, ExamViewDialog } from './ExamViewDialog';
|
|
18
18
|
export { ExamPreviewDialog } from './ExamPreviewDialog';
|
|
19
|
+
export { ExamPreviewMode } from './exam-preview.mode';
|
|
19
20
|
export { QuestionStatus, clearQuestionStatuses, getQuestionStatus, initializeQuestionStatusesFromAPI, setQuestionStatus } from './exam-question-status';
|
|
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
*/
|
|
11
11
|
import { MoversListenAndWriteRenderer, MoversChooseBestAnswerRenderer, MoversChooseAdjectiveRenderer, MoversWritingRenderer, MoversColoringRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversLabelThePictureRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversTrueFalseRenderer, MoversCrossOutWordGroupRenderer, MoversAnswerTheQuestionRenderer, MoversWriteSentencesRenderer, MoversWordOrderingRenderer, MoversWordFillParagraphRenderer, MoversMatchByWritingAnswerRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderAndMatchGroupRenderer, } from './question-renderers';
|
|
12
12
|
import { SpeakingQuestionRenderer } from './speaking/renderers';
|
|
13
|
-
import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformLabelPicture, transformFillWordHint, transformGridFill, transformAnswerTheQuestion, transformTrueFalse, transformTrueFalseGroup, transformWordOrdering, transformWordFillParagraph, transformMatchByWritingAnswer, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, } from '../utils/question-transformers';
|
|
13
|
+
import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformLabelPicture, transformFillWordHint, transformGridFill, transformAnswerTheQuestion, transformAnswerTheQuestionGroup, transformTrueFalse, transformTrueFalseGroup, transformWordOrdering, transformWordFillParagraph, transformMatchByWritingAnswer, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, } from '../utils/question-transformers';
|
|
14
14
|
import { transformCrossOutWordGroup } from '../utils/cross-out-word-group.transformer';
|
|
15
15
|
import { normalizeColoringAssignments } from '../utils/coloring-assignments';
|
|
16
16
|
export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode = false, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, }) {
|
|
@@ -137,6 +137,10 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
|
|
|
137
137
|
const data = transformAnswerTheQuestion(questions);
|
|
138
138
|
return (_jsx(MoversAnswerTheQuestionRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, imageUrl: data.imageUrl, audioUrl: part.audioUrl, title: data.title, groupContent: data.groupContent, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
139
139
|
}
|
|
140
|
+
case 'ANSWER_THE_QUESTION_GROUP': {
|
|
141
|
+
const data = transformAnswerTheQuestionGroup(questions);
|
|
142
|
+
return (_jsx(MoversAnswerTheQuestionRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, groupContent: data.groupContent, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
143
|
+
}
|
|
140
144
|
case 'TRUE_FALSE_GROUP': {
|
|
141
145
|
const data = transformTrueFalseGroup(questions);
|
|
142
146
|
return (_jsx(MoversTrueFalseRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, imageUrl: data.imageUrl, groupContent: data.groupContent, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
@@ -88,7 +88,8 @@ function findParentQuestion(subQuestionId, parts) {
|
|
|
88
88
|
part.questionType === 'CROSS_OUT_WORD_GROUP' ||
|
|
89
89
|
part.questionType === 'TRUE_FALSE_GROUP' ||
|
|
90
90
|
part.questionType === 'WORD_ORDER_AND_MATCH_GROUP' ||
|
|
91
|
-
part.questionType === 'SPONTANEOUS_QA_GROUP'
|
|
91
|
+
part.questionType === 'SPONTANEOUS_QA_GROUP' ||
|
|
92
|
+
part.questionType === 'ANSWER_THE_QUESTION_GROUP') {
|
|
92
93
|
const parent = part.questions.find((question) => subQuestionId.startsWith(`${question.id}-item-`));
|
|
93
94
|
if (parent) {
|
|
94
95
|
return {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Each transformer handles specific question type data structure.
|
|
6
6
|
*/
|
|
7
7
|
import type { FillBlankQuestion, ChooseBestAnswerQuestion, PictureChooseSubQuestion, PictureFillBlankSubQuestion, ReadingPassageQuestion, LabelThePictureLabel, FillWordHintSubQuestion, ColoringHintItem, ColoringRegion } from '../types';
|
|
8
|
-
export type QuestionType = 'FILL_IN_BLANK' | 'WRITE_CORRECT_VERB_FORM' | 'CHOOSE_THE_CORRECT_ANSWER' | 'CHOOSE_THE_CORRECT_ANSWER_GROUP' | 'CHOOSE_CORRECT_ADJECTIVE' | 'WRITE_A_SHORT_LETTER' | 'READ_AND_COLOR_OBJECTS' | 'LOOK_PICTURE_CHOOSE_CORRECT_ANSWER' | 'LOOK_PICTURE_FILL_BLANK_CHOOSE_ANSWER' | 'READ_PASSAGE_AND_ANSWER_QUESTIONS' | 'LABEL_THE_PICTURE' | 'LOOK_PICTURE_FILL_WORD_HINT' | 'FILL_MISSING_WORDS_IN_GRID' | 'IMAGE_OBJECT_MATCHING' | 'ANSWER_THE_QUESTION' | 'TRUE_FALSE' | 'TRUE_FALSE_GROUP' | 'CROSS_OUT_WORD_GROUP' | 'WORD_ORDERING' | 'WORD_FILL_PARAGRAPH' | 'MATCH_BY_WRITING_ANSWER' | 'WRITE_SENTENCES' | 'WORD_FILL_STRUCTURED_FORM' | 'WORD_ORDER_AND_MATCH_GROUP' | 'LISTEN_AND_CHOOSE_OBJECTS_IN_SCENE' | 'LISTEN_AND_CHOOSE_FROM_ANSWER_GROUP' | 'LISTEN_AND_DRAG_OBJECTS_INTO_SCENE' | 'LISTEN_AND_SPEAK_ANSWER' | 'LISTEN_AND_SPEAK_COMPARE_IMAGES' | 'LISTEN_AND_SPEAK_WITH_STORY_IMAGES' | 'LISTEN_AND_SPEAK_IMAGE_GROUP' | 'LISTEN_AND_SPEAK_ODD_ONE_OUT' | 'LISTEN_AND_SPEAK_QUESTION_LIST' | 'LISTEN_AND_SPEAK_INFO_EXCHANGE' | 'READ_DISPLAYED_CONTENT' | 'SPEAKING_DESCRIBE_IMAGE' | 'SPONTANEOUS_QA' | 'SPONTANEOUS_QA_GROUP' | 'SPEAKING_CONVERSATION' | 'GN_SPEAKING_INTERVIEW' | 'SPEAKING_CUE_CARD';
|
|
8
|
+
export type QuestionType = 'FILL_IN_BLANK' | 'WRITE_CORRECT_VERB_FORM' | 'CHOOSE_THE_CORRECT_ANSWER' | 'CHOOSE_THE_CORRECT_ANSWER_GROUP' | 'CHOOSE_CORRECT_ADJECTIVE' | 'WRITE_A_SHORT_LETTER' | 'READ_AND_COLOR_OBJECTS' | 'LOOK_PICTURE_CHOOSE_CORRECT_ANSWER' | 'LOOK_PICTURE_FILL_BLANK_CHOOSE_ANSWER' | 'READ_PASSAGE_AND_ANSWER_QUESTIONS' | 'LABEL_THE_PICTURE' | 'LOOK_PICTURE_FILL_WORD_HINT' | 'FILL_MISSING_WORDS_IN_GRID' | 'IMAGE_OBJECT_MATCHING' | 'ANSWER_THE_QUESTION' | 'ANSWER_THE_QUESTION_GROUP' | 'TRUE_FALSE' | 'TRUE_FALSE_GROUP' | 'CROSS_OUT_WORD_GROUP' | 'WORD_ORDERING' | 'WORD_FILL_PARAGRAPH' | 'MATCH_BY_WRITING_ANSWER' | 'WRITE_SENTENCES' | 'WORD_FILL_STRUCTURED_FORM' | 'WORD_ORDER_AND_MATCH_GROUP' | 'LISTEN_AND_CHOOSE_OBJECTS_IN_SCENE' | 'LISTEN_AND_CHOOSE_FROM_ANSWER_GROUP' | 'LISTEN_AND_DRAG_OBJECTS_INTO_SCENE' | 'LISTEN_AND_SPEAK_ANSWER' | 'LISTEN_AND_SPEAK_COMPARE_IMAGES' | 'LISTEN_AND_SPEAK_WITH_STORY_IMAGES' | 'LISTEN_AND_SPEAK_IMAGE_GROUP' | 'LISTEN_AND_SPEAK_ODD_ONE_OUT' | 'LISTEN_AND_SPEAK_QUESTION_LIST' | 'LISTEN_AND_SPEAK_INFO_EXCHANGE' | 'READ_DISPLAYED_CONTENT' | 'SPEAKING_DESCRIBE_IMAGE' | 'SPONTANEOUS_QA' | 'SPONTANEOUS_QA_GROUP' | 'SPEAKING_CONVERSATION' | 'GN_SPEAKING_INTERVIEW' | 'SPEAKING_CUE_CARD';
|
|
9
9
|
export interface ApiQuestion {
|
|
10
10
|
id: string;
|
|
11
11
|
content: any;
|
|
@@ -244,6 +244,11 @@ export declare function transformAnswerTheQuestion(questions: ApiQuestion[]): {
|
|
|
244
244
|
title?: string;
|
|
245
245
|
groupContent?: string;
|
|
246
246
|
};
|
|
247
|
+
export declare function transformAnswerTheQuestionGroup(questions: ApiQuestion[]): {
|
|
248
|
+
questions: AnswerTheQuestionQuestion[];
|
|
249
|
+
instruction: string;
|
|
250
|
+
groupContent?: string;
|
|
251
|
+
};
|
|
247
252
|
export interface TrueFalseQuestion {
|
|
248
253
|
id: string;
|
|
249
254
|
questionNumber: number;
|
|
@@ -804,6 +804,39 @@ export function transformAnswerTheQuestion(questions) {
|
|
|
804
804
|
groupContent: groupPayload?.groupContent || firstContent?.groupContent,
|
|
805
805
|
};
|
|
806
806
|
}
|
|
807
|
+
// ============================================
|
|
808
|
+
// ANSWER_THE_QUESTION_GROUP Transformer
|
|
809
|
+
// ============================================
|
|
810
|
+
export function transformAnswerTheQuestionGroup(questions) {
|
|
811
|
+
const parent = questions[0];
|
|
812
|
+
const content = parent?.content;
|
|
813
|
+
const items = Array.isArray(content?.items) ? content.items : [];
|
|
814
|
+
const parentCorrect = Array.isArray(parent?.correct_answer?.answer)
|
|
815
|
+
? parent.correct_answer.answer
|
|
816
|
+
: [];
|
|
817
|
+
const transformed = items.map((item, itemIndex) => {
|
|
818
|
+
const isExample = item.isExample === true;
|
|
819
|
+
const fallbackAnswers = Array.isArray(parentCorrect[itemIndex]?.expectedAnswers)
|
|
820
|
+
? parentCorrect[itemIndex].expectedAnswers
|
|
821
|
+
: [];
|
|
822
|
+
const nestedAnswers = Array.isArray(item.correctAnswer?.expectedAnswers)
|
|
823
|
+
? item.correctAnswer.expectedAnswers
|
|
824
|
+
: [];
|
|
825
|
+
const exampleAnswer = (nestedAnswers[0] || fallbackAnswers[0] || '');
|
|
826
|
+
return {
|
|
827
|
+
id: `${parent.id}-item-${itemIndex}`,
|
|
828
|
+
questionNumber: item.questionNumber || itemIndex + 1,
|
|
829
|
+
question: item.content?.question || '',
|
|
830
|
+
isExample,
|
|
831
|
+
exampleAnswer: isExample ? exampleAnswer : undefined,
|
|
832
|
+
};
|
|
833
|
+
});
|
|
834
|
+
return {
|
|
835
|
+
questions: transformed,
|
|
836
|
+
instruction: content?.meta?.instruction || 'Read the text again and answer the questions.',
|
|
837
|
+
groupContent: content?.meta?.passage,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
807
840
|
export function transformTrueFalse(questions) {
|
|
808
841
|
const groupPayload = questions[0]?.questionGroup?.payload;
|
|
809
842
|
const transformed = questions.map((q) => {
|
|
@@ -207,6 +207,11 @@ export const QUESTION_TYPE_CONFIG = {
|
|
|
207
207
|
requiresContent: true,
|
|
208
208
|
hasDedicatedComponent: true,
|
|
209
209
|
},
|
|
210
|
+
ANSWER_THE_QUESTION_GROUP: {
|
|
211
|
+
label: 'Đọc và trả lời câu hỏi (AI)',
|
|
212
|
+
requiresContent: true,
|
|
213
|
+
hasDedicatedComponent: true,
|
|
214
|
+
},
|
|
210
215
|
WORD_ORDERING: {
|
|
211
216
|
label: 'Sắp xếp từ',
|
|
212
217
|
requiresContent: true,
|
|
@@ -333,6 +338,7 @@ export const QUESTION_TYPES_WITH_DEDICATED_COMPONENTS = [
|
|
|
333
338
|
'WRITE_CORRECT_VERB_FORM',
|
|
334
339
|
'CHOOSE_CORRECT_ADJECTIVE',
|
|
335
340
|
'ANSWER_THE_QUESTION',
|
|
341
|
+
'ANSWER_THE_QUESTION_GROUP',
|
|
336
342
|
'TRUE_FALSE',
|
|
337
343
|
'WORD_ORDERING',
|
|
338
344
|
'WORD_FILL_PARAGRAPH',
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { MutableRefObject } from 'react';
|
|
2
|
+
export interface AnswerTheQuestionGroupItemData {
|
|
3
|
+
question: string;
|
|
4
|
+
expectedAnswers: string[];
|
|
5
|
+
isExample?: boolean;
|
|
6
|
+
points: number;
|
|
7
|
+
questionNumber: number;
|
|
8
|
+
}
|
|
9
|
+
export interface AnswerTheQuestionGroupData {
|
|
10
|
+
instruction: string;
|
|
11
|
+
passage?: string;
|
|
12
|
+
items: AnswerTheQuestionGroupItemData[];
|
|
13
|
+
explanation?: string;
|
|
14
|
+
points?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface AnswerTheQuestionGroupCreatorProps {
|
|
17
|
+
initialData?: AnswerTheQuestionGroupData;
|
|
18
|
+
onSave?: (data: AnswerTheQuestionGroupData) => void;
|
|
19
|
+
onCancel?: () => void;
|
|
20
|
+
onChange?: (data: AnswerTheQuestionGroupData) => void;
|
|
21
|
+
externalErrors?: string[];
|
|
22
|
+
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
|
|
23
|
+
validationRef?: MutableRefObject<{
|
|
24
|
+
triggerValidation: () => Promise<boolean>;
|
|
25
|
+
getFormData?: () => AnswerTheQuestionGroupData;
|
|
26
|
+
} | null>;
|
|
27
|
+
}
|
|
28
|
+
export interface AnswerTheQuestionGroupClientProps {
|
|
29
|
+
questionData: AnswerTheQuestionGroupData;
|
|
30
|
+
isReviewMode?: boolean;
|
|
31
|
+
userAnswers?: string[];
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -18,6 +18,7 @@ import { writeAShortLetterRegistration } from '../types/write-a-short-letter/reg
|
|
|
18
18
|
import { writeCorrectVerbFormRegistration } from '../types/write-correct-verb-form/register';
|
|
19
19
|
import { chooseCorrectAdjectiveRegistration } from '../types/choose-correct-adjective/register';
|
|
20
20
|
import { answerTheQuestionRegistration } from '../types/answer-the-question/register';
|
|
21
|
+
import { answerTheQuestionGroupRegistration } from '../types/answer-the-question-group/register';
|
|
21
22
|
import { trueFalseRegistration } from '../types/true-false/register';
|
|
22
23
|
import { wordOrderingRegistration } from '../types/word-ordering/register';
|
|
23
24
|
import { wordFillParagraphRegistration } from '../types/word-fill-paragraph/register';
|
|
@@ -64,6 +65,7 @@ export const questionTypeRegistry = {
|
|
|
64
65
|
WRITE_CORRECT_VERB_FORM: writeCorrectVerbFormRegistration,
|
|
65
66
|
CHOOSE_CORRECT_ADJECTIVE: chooseCorrectAdjectiveRegistration,
|
|
66
67
|
ANSWER_THE_QUESTION: answerTheQuestionRegistration,
|
|
68
|
+
ANSWER_THE_QUESTION_GROUP: answerTheQuestionGroupRegistration,
|
|
67
69
|
TRUE_FALSE: trueFalseRegistration,
|
|
68
70
|
WORD_ORDERING: wordOrderingRegistration,
|
|
69
71
|
WORD_FILL_PARAGRAPH: wordFillParagraphRegistration, // Will need to handle viewMode mapping either here or in wrapper
|
package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { BookOpen, CircleHelp, Lightbulb, Sparkles, Star } from 'lucide-react';
|
|
4
|
+
export function AnswerTheQuestionGroupClient({ questionData, isReviewMode = false, userAnswers = [], }) {
|
|
5
|
+
const items = questionData.items || [];
|
|
6
|
+
return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
|
|
7
|
+
const userValue = userAnswers[itemIndex] || '';
|
|
8
|
+
const expectedAnswers = Array.isArray(item.expectedAnswers)
|
|
9
|
+
? item.expectedAnswers.filter((answer) => answer.trim() !== '')
|
|
10
|
+
: [];
|
|
11
|
+
return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] })), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700", children: [_jsx(Sparkles, { className: "h-3 w-3" }), "AI"] })] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-4", children: [_jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), _jsx("div", { className: "rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700", children: userValue || (isReviewMode ? '(Chưa trả lời)' : 'Học sinh sẽ nhập câu trả lời tại đây') }), isReviewMode && expectedAnswers.length > 0 && (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs font-medium text-gray-500", children: "\u0110\u00E1p \u00E1n m\u1EABu (AI)" }), _jsx("ul", { className: "list-disc space-y-1 pl-5 text-sm text-gray-700", children: expectedAnswers.map((answer) => (_jsx("li", { children: answer }, answer))) })] }))] })] }, `${item.questionNumber}-${itemIndex}`));
|
|
12
|
+
}), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
|
|
13
|
+
}
|
package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { AnswerTheQuestionGroupCreatorProps } from '../../_shared/types/answer-the-question-group.type';
|
|
2
|
+
export declare function AnswerTheQuestionGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: AnswerTheQuestionGroupCreatorProps): import("react").JSX.Element;
|
package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Button } from '../../../../components/ui/button';
|
|
5
|
+
import { Label } from '../../../../components/ui/label';
|
|
6
|
+
import { Input } from '../../../../components/ui/input';
|
|
7
|
+
import { Textarea } from '../../../../components/ui/textarea';
|
|
8
|
+
import { PointsInput } from '../../../../components/ui/points-input';
|
|
9
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
|
|
10
|
+
import { Switch } from '../../../../components/ui/switch';
|
|
11
|
+
import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
|
|
12
|
+
import { useDebouncedCallback } from '../../../../shared/lib/hooks';
|
|
13
|
+
import { Eye, Pencil, Plus, Sparkles, Star, Trash2 } from 'lucide-react';
|
|
14
|
+
import { AnswerTheQuestionGroupClient } from './AnswerTheQuestionGroupClient';
|
|
15
|
+
const DEFAULT_INSTRUCTION = 'Read the text again and answer the questions.';
|
|
16
|
+
function createEmptyItem(questionNumber) {
|
|
17
|
+
return {
|
|
18
|
+
question: '',
|
|
19
|
+
expectedAnswers: [''],
|
|
20
|
+
isExample: false,
|
|
21
|
+
points: 1,
|
|
22
|
+
questionNumber,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function normalizeExpectedAnswers(value) {
|
|
26
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
27
|
+
return [''];
|
|
28
|
+
}
|
|
29
|
+
return value.map((item) => (typeof item === 'string' ? item : ''));
|
|
30
|
+
}
|
|
31
|
+
function normalizeItems(rawItems) {
|
|
32
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
|
33
|
+
return [createEmptyItem(1)];
|
|
34
|
+
}
|
|
35
|
+
return rawItems.map((item, index) => ({
|
|
36
|
+
question: item.question || '',
|
|
37
|
+
expectedAnswers: normalizeExpectedAnswers(item.expectedAnswers),
|
|
38
|
+
isExample: item.isExample === true,
|
|
39
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
40
|
+
questionNumber: item.questionNumber || index + 1,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
function sumPoints(items) {
|
|
44
|
+
return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
|
|
45
|
+
}
|
|
46
|
+
function isContentEmpty(html) {
|
|
47
|
+
return !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
|
|
48
|
+
}
|
|
49
|
+
export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
50
|
+
const [instruction, setInstruction] = useState(initialData?.instruction || DEFAULT_INSTRUCTION);
|
|
51
|
+
const [passage, setPassage] = useState(initialData?.passage || '');
|
|
52
|
+
const [items, setItems] = useState(() => normalizeItems(initialData?.items));
|
|
53
|
+
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
54
|
+
const [isClientMode, setIsClientMode] = useState(false);
|
|
55
|
+
const [errors, setErrors] = useState([]);
|
|
56
|
+
const onChangeRef = useRef(onChange);
|
|
57
|
+
const previousPayloadRef = useRef('');
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
onChangeRef.current = onChange;
|
|
60
|
+
}, [onChange]);
|
|
61
|
+
const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation) => ({
|
|
62
|
+
instruction: nextInstruction,
|
|
63
|
+
...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
|
|
64
|
+
items: nextItems,
|
|
65
|
+
explanation: nextExplanation,
|
|
66
|
+
points: sumPoints(nextItems),
|
|
67
|
+
});
|
|
68
|
+
const debouncedOnChange = useDebouncedCallback((payload) => {
|
|
69
|
+
onChangeRef.current?.(payload);
|
|
70
|
+
}, 300);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const payload = buildPayload();
|
|
73
|
+
const serialized = JSON.stringify(payload);
|
|
74
|
+
if (serialized === previousPayloadRef.current) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
previousPayloadRef.current = serialized;
|
|
78
|
+
onUnsavedChangesChange?.(true);
|
|
79
|
+
debouncedOnChange(payload);
|
|
80
|
+
}, [instruction, passage, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
81
|
+
const validate = () => {
|
|
82
|
+
const nextErrors = [];
|
|
83
|
+
if (!instruction.trim()) {
|
|
84
|
+
nextErrors.push('Vui lòng nhập hướng dẫn.');
|
|
85
|
+
}
|
|
86
|
+
if (items.length < 1) {
|
|
87
|
+
nextErrors.push('Cần ít nhất một câu hỏi.');
|
|
88
|
+
}
|
|
89
|
+
items.forEach((item, index) => {
|
|
90
|
+
if (!item.question.trim()) {
|
|
91
|
+
nextErrors.push(`Câu ${index + 1}: nội dung câu hỏi là bắt buộc.`);
|
|
92
|
+
}
|
|
93
|
+
const filledAnswers = item.expectedAnswers
|
|
94
|
+
.map((answer) => answer.trim())
|
|
95
|
+
.filter((answer) => answer !== '');
|
|
96
|
+
if (!item.isExample && filledAnswers.length === 0) {
|
|
97
|
+
nextErrors.push(`Câu ${index + 1}: cần ít nhất một đáp án mẫu.`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return nextErrors;
|
|
101
|
+
};
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!validationRef) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
validationRef.current = {
|
|
107
|
+
triggerValidation: async () => {
|
|
108
|
+
const nextErrors = validate();
|
|
109
|
+
setErrors(nextErrors);
|
|
110
|
+
return nextErrors.length === 0;
|
|
111
|
+
},
|
|
112
|
+
getFormData: () => buildPayload(),
|
|
113
|
+
};
|
|
114
|
+
}, [validationRef, instruction, passage, items, explanation]);
|
|
115
|
+
const updateItem = (index, patch) => {
|
|
116
|
+
setItems((prev) => {
|
|
117
|
+
const next = [...prev];
|
|
118
|
+
const current = next[index];
|
|
119
|
+
if (!current)
|
|
120
|
+
return prev;
|
|
121
|
+
next[index] = { ...current, ...patch };
|
|
122
|
+
return next;
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
const updateExpectedAnswer = (itemIndex, answerIndex, value) => {
|
|
126
|
+
setItems((prev) => {
|
|
127
|
+
const next = [...prev];
|
|
128
|
+
const current = next[itemIndex];
|
|
129
|
+
if (!current)
|
|
130
|
+
return prev;
|
|
131
|
+
const expectedAnswers = [...current.expectedAnswers];
|
|
132
|
+
expectedAnswers[answerIndex] = value;
|
|
133
|
+
next[itemIndex] = { ...current, expectedAnswers };
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
const addExpectedAnswer = (itemIndex) => {
|
|
138
|
+
setItems((prev) => {
|
|
139
|
+
const next = [...prev];
|
|
140
|
+
const current = next[itemIndex];
|
|
141
|
+
if (!current)
|
|
142
|
+
return prev;
|
|
143
|
+
next[itemIndex] = {
|
|
144
|
+
...current,
|
|
145
|
+
expectedAnswers: [...current.expectedAnswers, ''],
|
|
146
|
+
};
|
|
147
|
+
return next;
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
const removeExpectedAnswer = (itemIndex, answerIndex) => {
|
|
151
|
+
setItems((prev) => {
|
|
152
|
+
const next = [...prev];
|
|
153
|
+
const current = next[itemIndex];
|
|
154
|
+
if (!current || current.expectedAnswers.length <= 1)
|
|
155
|
+
return prev;
|
|
156
|
+
next[itemIndex] = {
|
|
157
|
+
...current,
|
|
158
|
+
expectedAnswers: current.expectedAnswers.filter((_, index) => index !== answerIndex),
|
|
159
|
+
};
|
|
160
|
+
return next;
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
const addItem = () => {
|
|
164
|
+
setItems((prev) => [...prev, createEmptyItem(prev.length + 1)]);
|
|
165
|
+
};
|
|
166
|
+
const removeItem = (index) => {
|
|
167
|
+
setItems((prev) => {
|
|
168
|
+
if (prev.length <= 1)
|
|
169
|
+
return prev;
|
|
170
|
+
return prev
|
|
171
|
+
.filter((_, itemIndex) => itemIndex !== index)
|
|
172
|
+
.map((item, itemIndex) => ({ ...item, questionNumber: itemIndex + 1 }));
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
|
|
176
|
+
if (isClientMode) {
|
|
177
|
+
return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Ch\u1EBF \u0111\u1ED9 ch\u1EC9nh s\u1EEDa"] }) }), _jsx(AnswerTheQuestionGroupClient, { questionData: buildPayload(), isReviewMode: true })] }));
|
|
178
|
+
}
|
|
179
|
+
return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "\u0110\u1ECDc v\u00E0 tr\u1EA3 l\u1EDDi c\u00E2u h\u1ECFi (AI)" }), _jsx("p", { className: "text-sm text-gray-500", children: "Nh\u00F3m c\u00E2u \u0111\u1ECDc hi\u1EC3u l\u01B0u 1 row. AI ch\u1EA5m theo question + expectedAnswers." })] }), _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m \u0111\u1ECDc hi\u1EC3u" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: DEFAULT_INSTRUCTION })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-violet-100 bg-violet-50 p-4 text-sm text-violet-800", children: [_jsxs("div", { className: "flex items-center gap-2 font-medium", children: [_jsx(Sparkles, { className: "h-4 w-4" }), "Ch\u1EA5m \u0111i\u1EC3m b\u1EB1ng AI"] }), _jsx("p", { className: "mt-1 text-xs text-violet-700", children: "H\u1ECDc sinh vi\u1EBFt c\u00E2u tr\u1EA3 l\u1EDDi. AI so v\u1EDBi b\u1EA5t k\u1EF3 \u0111\u00E1p \u00E1n m\u1EABu n\u00E0o c\u1EE7a t\u1EEBng c\u00E2u (binary 0/1)." })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
|
|
180
|
+
isExample: checked,
|
|
181
|
+
points: checked ? 0 : (item.points || 1),
|
|
182
|
+
}) })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "C\u00E2u h\u1ECFi" }), _jsx(Textarea, { value: item.question, onChange: (event) => updateItem(itemIndex, { question: event.target.value }), rows: 2, placeholder: "What is autism?" })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "\u0110\u00E1p \u00E1n m\u1EABu cho AI" }), item.expectedAnswers.map((answer, answerIndex) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { value: answer, onChange: (event) => updateExpectedAnswer(itemIndex, answerIndex, event.target.value), placeholder: `Đáp án mẫu ${answerIndex + 1}` }), item.expectedAnswers.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeExpectedAnswer(itemIndex, answerIndex), children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }, `expected-${itemIndex}-${answerIndex}`))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => addExpectedAnswer(itemIndex), children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Th\u00EAm \u0111\u00E1p \u00E1n m\u1EABu"] })] }), !item.isExample && (_jsx(PointsInput, { value: item.points, allowZero: true, onChange: (e) => updateItem(itemIndex, { points: Number(e.target.value) || 0 }) }))] })] }, `item-${itemIndex}`))), _jsxs(Button, { type: "button", variant: "outline", onClick: addItem, children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Th\u00EAm c\u00E2u h\u1ECFi"] }), displayErrors.length > 0 && (_jsx("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: displayErrors.map((error) => (_jsx("p", { children: error }, error))) }))] }));
|
|
183
|
+
}
|