@tinyweb_dev/oe-exam-sdk 0.2.13 → 0.2.14
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/take/ExamTakingPageContainer.js +6 -0
- package/dist/components/exams/take/components/QuestionRenderer.js +13 -3
- package/dist/components/exams/take/components/question-renderers/ChooseThenAnswerGroupRenderer.d.ts +14 -0
- package/dist/components/exams/take/components/question-renderers/ChooseThenAnswerGroupRenderer.js +32 -0
- package/dist/components/exams/take/components/question-renderers/MoversAnswerTheQuestionRenderer.d.ts +1 -1
- package/dist/components/exams/take/components/question-renderers/MoversAnswerTheQuestionRenderer.js +10 -2
- package/dist/components/exams/take/components/question-renderers/SortWordsIntoCategoriesRenderer.d.ts +16 -0
- package/dist/components/exams/take/components/question-renderers/SortWordsIntoCategoriesRenderer.js +23 -0
- package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
- package/dist/components/exams/take/components/question-renderers/index.js +2 -0
- package/dist/components/exams/take/utils/answer-transformers.js +34 -1
- package/dist/components/exams/take/utils/question-transformers.d.ts +42 -3
- package/dist/components/exams/take/utils/question-transformers.js +71 -1
- package/dist/components/questions/_shared/config/question-types.config.js +12 -0
- package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +5 -1
- package/dist/components/questions/_shared/types/answer-the-question-group.type.js +7 -0
- package/dist/components/questions/_shared/types/answer-the-question.type.d.ts +3 -1
- package/dist/components/questions/_shared/types/answer-the-question.type.js +19 -1
- package/dist/components/questions/_shared/types/choose-then-answer-group.type.d.ts +47 -0
- package/dist/components/questions/_shared/types/choose-then-answer-group.type.js +2 -0
- package/dist/components/questions/_shared/types/index.d.ts +2 -0
- package/dist/components/questions/_shared/types/index.js +2 -0
- package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +9 -3
- package/dist/components/questions/_shared/types/match-word-to-picture-group.type.js +19 -0
- package/dist/components/questions/_shared/types/sort-words-into-categories.type.d.ts +43 -0
- package/dist/components/questions/_shared/types/sort-words-into-categories.type.js +1 -0
- package/dist/components/questions/components/QuestionSearchDropdown.js +2 -0
- package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
- package/dist/components/questions/creator/question-type-registry.js +4 -0
- package/dist/components/questions/types/answer-the-question/AnswerTheQuestionClient.js +30 -6
- package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.d.ts +2 -0
- package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.js +56 -0
- package/dist/components/questions/types/answer-the-question/transform.js +9 -3
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +42 -9
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +7 -5
- package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +18 -4
- package/dist/components/questions/types/answer-the-question-group/transform.js +8 -2
- package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.d.ts +2 -0
- package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.js +34 -0
- package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.d.ts +2 -0
- package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.js +174 -0
- package/dist/components/questions/types/choose-then-answer-group/index.d.ts +5 -0
- package/dist/components/questions/types/choose-then-answer-group/index.js +5 -0
- package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.d.ts +11 -0
- package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.js +127 -0
- package/dist/components/questions/types/choose-then-answer-group/register.d.ts +3 -0
- package/dist/components/questions/types/choose-then-answer-group/register.js +37 -0
- package/dist/components/questions/types/choose-then-answer-group/transform.d.ts +2 -0
- package/dist/components/questions/types/choose-then-answer-group/transform.js +37 -0
- package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +39 -10
- package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +15 -8
- package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +8 -3
- package/dist/components/questions/types/match-word-to-picture-group/transform.js +21 -13
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.d.ts +12 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.js +74 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.d.ts +2 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.js +24 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.d.ts +2 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.js +171 -0
- package/dist/components/questions/types/sort-words-into-categories/index.d.ts +4 -0
- package/dist/components/questions/types/sort-words-into-categories/index.js +4 -0
- package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.d.ts +11 -0
- package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.js +153 -0
- package/dist/components/questions/types/sort-words-into-categories/register.d.ts +3 -0
- package/dist/components/questions/types/sort-words-into-categories/register.js +37 -0
- package/dist/components/questions/types/sort-words-into-categories/transform.d.ts +2 -0
- package/dist/components/questions/types/sort-words-into-categories/transform.js +33 -0
- package/dist/components/questions/viewer/AnswerTheQuestionViewer.d.ts +9 -0
- package/dist/components/questions/viewer/AnswerTheQuestionViewer.js +8 -0
- package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.d.ts +10 -0
- package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.js +39 -0
- package/dist/components/questions/viewer/QuestionViewer.js +13 -2
- package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.d.ts +10 -0
- package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.js +27 -0
- package/dist/components/questions/viewer/index.d.ts +3 -0
- package/dist/components/questions/viewer/index.js +3 -0
- package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.d.ts +2 -1
- package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.js +12 -3
- package/dist/components/results/renderers/review-question-body-dedicated.js +1 -1
- package/dist/components/results/renderers/review-question-body-movers.js +24 -2
- package/dist/components/results/review-data.js +28 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/constants/question-skills.js +4 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +98 -2
- package/dist/shared/lib/utils/question-transform-types.d.ts +1 -1
- package/dist/shared/lib/utils/question-transform.js +4 -0
- package/dist/shared/types/common.types.d.ts +1 -1
- package/dist/shared/types/questions/answer-the-question-group.d.ts +3 -0
- package/dist/shared/types/questions/choose-then-answer-group.d.ts +46 -0
- package/dist/shared/types/questions/choose-then-answer-group.js +1 -0
- package/dist/shared/types/questions/index.d.ts +2 -0
- package/dist/shared/types/questions/index.js +3 -0
- package/dist/shared/types/questions/match-word-to-picture-group.d.ts +6 -2
- package/dist/shared/types/questions/sort-words-into-categories.d.ts +37 -0
- package/dist/shared/types/questions/sort-words-into-categories.js +1 -0
- package/package.json +1 -1
package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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 { PointsInput } from '../../../../components/ui/points-input';
|
|
8
|
+
import { Textarea } from '../../../../components/ui/textarea';
|
|
9
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
|
|
10
|
+
import { Switch } from '../../../../components/ui/switch';
|
|
11
|
+
import { useDebouncedCallback } from '../../../../shared/lib/hooks';
|
|
12
|
+
import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
|
|
13
|
+
import { ChooseThenAnswerGroupClient } from './ChooseThenAnswerGroupClient';
|
|
14
|
+
import { createEmptyItem, createEmptyOption, sumGradablePoints, } from './map-choose-then-answer-group-data';
|
|
15
|
+
import { CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/choose-then-answer-group.type';
|
|
16
|
+
function normalizeItems(raw) {
|
|
17
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
18
|
+
return [createEmptyItem(1)];
|
|
19
|
+
}
|
|
20
|
+
return raw.map((item, index) => {
|
|
21
|
+
const fallback = createEmptyItem(index + 1);
|
|
22
|
+
const options = Array.isArray(item.options) && item.options.length >= 2
|
|
23
|
+
? item.options.map((option, optionIndex) => ({
|
|
24
|
+
id: option.id || `opt-${optionIndex + 1}`,
|
|
25
|
+
text: option.text || '',
|
|
26
|
+
}))
|
|
27
|
+
: fallback.options;
|
|
28
|
+
return {
|
|
29
|
+
question: item.question || '',
|
|
30
|
+
options,
|
|
31
|
+
optionId: item.optionId || options[0]?.id || '',
|
|
32
|
+
isExample: item.isExample === true,
|
|
33
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
34
|
+
questionNumber: item.questionNumber || index + 1,
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function ChooseThenAnswerGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
39
|
+
const [title, setTitle] = useState(initialData?.title || '');
|
|
40
|
+
const [instruction, setInstruction] = useState(initialData?.instruction || CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION);
|
|
41
|
+
const [items, setItems] = useState(() => normalizeItems(initialData?.items));
|
|
42
|
+
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
43
|
+
const [isClientMode, setIsClientMode] = useState(false);
|
|
44
|
+
const [errors, setErrors] = useState([]);
|
|
45
|
+
const onChangeRef = useRef(onChange);
|
|
46
|
+
const previousPayloadRef = useRef('');
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
onChangeRef.current = onChange;
|
|
49
|
+
}, [onChange]);
|
|
50
|
+
const buildPayload = (nextTitle = title, nextInstruction = instruction, nextItems = items, nextExplanation = explanation) => ({
|
|
51
|
+
title: nextTitle,
|
|
52
|
+
instruction: nextInstruction,
|
|
53
|
+
items: nextItems,
|
|
54
|
+
explanation: nextExplanation,
|
|
55
|
+
points: sumGradablePoints(nextItems),
|
|
56
|
+
});
|
|
57
|
+
const debouncedOnChange = useDebouncedCallback((payload) => {
|
|
58
|
+
onChangeRef.current?.(payload);
|
|
59
|
+
}, 300);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const payload = buildPayload();
|
|
62
|
+
const serialized = JSON.stringify(payload);
|
|
63
|
+
if (serialized !== previousPayloadRef.current) {
|
|
64
|
+
previousPayloadRef.current = serialized;
|
|
65
|
+
debouncedOnChange(payload);
|
|
66
|
+
onUnsavedChangesChange?.(true);
|
|
67
|
+
}
|
|
68
|
+
}, [title, instruction, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
69
|
+
const validate = () => {
|
|
70
|
+
const nextErrors = [];
|
|
71
|
+
if (!instruction.trim()) {
|
|
72
|
+
nextErrors.push('Hướng dẫn (Instruction) không được để trống.');
|
|
73
|
+
}
|
|
74
|
+
if (items.length < 1) {
|
|
75
|
+
nextErrors.push('Cần ít nhất 1 câu.');
|
|
76
|
+
}
|
|
77
|
+
items.forEach((item, index) => {
|
|
78
|
+
if (!item.question.trim()) {
|
|
79
|
+
nextErrors.push(`Câu ${index + 1}: nội dung không được để trống.`);
|
|
80
|
+
}
|
|
81
|
+
else if (!item.question.includes('{0}')) {
|
|
82
|
+
nextErrors.push(`Câu ${index + 1}: phải có chỗ chọn {0}.`);
|
|
83
|
+
}
|
|
84
|
+
if (item.options.length < 2) {
|
|
85
|
+
nextErrors.push(`Câu ${index + 1}: cần ít nhất 2 lựa chọn.`);
|
|
86
|
+
}
|
|
87
|
+
const ids = new Set();
|
|
88
|
+
item.options.forEach((option, optionIndex) => {
|
|
89
|
+
if (!option.id.trim())
|
|
90
|
+
nextErrors.push(`Câu ${index + 1} option ${optionIndex + 1}: thiếu id.`);
|
|
91
|
+
if (!option.text.trim())
|
|
92
|
+
nextErrors.push(`Câu ${index + 1} option ${optionIndex + 1}: thiếu nội dung.`);
|
|
93
|
+
if (option.id.trim() && ids.has(option.id.trim())) {
|
|
94
|
+
nextErrors.push(`Câu ${index + 1}: id "${option.id}" bị trùng.`);
|
|
95
|
+
}
|
|
96
|
+
ids.add(option.id.trim());
|
|
97
|
+
});
|
|
98
|
+
if (!item.optionId.trim() || !ids.has(item.optionId)) {
|
|
99
|
+
nextErrors.push(`Câu ${index + 1}: chưa chọn đáp án đúng.`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
setErrors(nextErrors);
|
|
103
|
+
return nextErrors.length === 0;
|
|
104
|
+
};
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!validationRef)
|
|
107
|
+
return;
|
|
108
|
+
validationRef.current = {
|
|
109
|
+
triggerValidation: async () => validate(),
|
|
110
|
+
getFormData: () => buildPayload(),
|
|
111
|
+
};
|
|
112
|
+
return () => {
|
|
113
|
+
validationRef.current = null;
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
const updateItem = (index, patch) => {
|
|
117
|
+
setItems((prev) => prev.map((item, i) => {
|
|
118
|
+
if (i !== index)
|
|
119
|
+
return item;
|
|
120
|
+
const next = { ...item, ...patch };
|
|
121
|
+
if (next.isExample)
|
|
122
|
+
next.points = 0;
|
|
123
|
+
return next;
|
|
124
|
+
}));
|
|
125
|
+
};
|
|
126
|
+
const updateOption = (itemIndex, optionIndex, text) => {
|
|
127
|
+
setItems((prev) => prev.map((item, i) => {
|
|
128
|
+
if (i !== itemIndex)
|
|
129
|
+
return item;
|
|
130
|
+
return {
|
|
131
|
+
...item,
|
|
132
|
+
options: item.options.map((option, j) => (j === optionIndex ? { ...option, text } : option)),
|
|
133
|
+
};
|
|
134
|
+
}));
|
|
135
|
+
};
|
|
136
|
+
const addOption = (itemIndex) => {
|
|
137
|
+
setItems((prev) => prev.map((item, i) => {
|
|
138
|
+
if (i !== itemIndex)
|
|
139
|
+
return item;
|
|
140
|
+
return {
|
|
141
|
+
...item,
|
|
142
|
+
options: [...item.options, createEmptyOption(item.options.map((option) => option.id))],
|
|
143
|
+
};
|
|
144
|
+
}));
|
|
145
|
+
};
|
|
146
|
+
const removeOption = (itemIndex, optionIndex) => {
|
|
147
|
+
setItems((prev) => prev.map((item, i) => {
|
|
148
|
+
if (i !== itemIndex || item.options.length <= 2)
|
|
149
|
+
return item;
|
|
150
|
+
const options = item.options.filter((_, j) => j !== optionIndex);
|
|
151
|
+
const removedId = item.options[optionIndex]?.id;
|
|
152
|
+
return {
|
|
153
|
+
...item,
|
|
154
|
+
options,
|
|
155
|
+
optionId: item.optionId === removedId ? (options[0]?.id || '') : item.optionId,
|
|
156
|
+
};
|
|
157
|
+
}));
|
|
158
|
+
};
|
|
159
|
+
const addItem = () => {
|
|
160
|
+
setItems((prev) => [...prev, createEmptyItem(prev.length + 1)]);
|
|
161
|
+
};
|
|
162
|
+
const removeItem = (index) => {
|
|
163
|
+
if (items.length <= 1)
|
|
164
|
+
return;
|
|
165
|
+
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({
|
|
166
|
+
...item,
|
|
167
|
+
questionNumber: i + 1,
|
|
168
|
+
})));
|
|
169
|
+
};
|
|
170
|
+
const displayErrors = [...errors, ...(externalErrors || [])];
|
|
171
|
+
return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((v) => !v), children: [isClientMode ? _jsx(Pencil, { className: "mr-2 h-4 w-4" }) : _jsx(Eye, { className: "mr-2 h-4 w-4" }), isClientMode ? 'Sửa' : 'Xem trước'] }) }), isClientMode ? (_jsx(ChooseThenAnswerGroupClient, { questionData: buildPayload(), isReviewMode: true })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "cta-title", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "cta-title", value: title, onChange: (event) => setTitle(event.target.value), placeholder: "GRAMMAR" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "cta-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "cta-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION })] })] }), items.map((item, index) => (_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 ", index + 1] }), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(index), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] }), _jsxs(CardContent, { className: "space-y-3", children: [_jsx(Input, { value: item.question, onChange: (event) => updateItem(index, { question: event.target.value }), placeholder: "Did you eat {0} for lunch today?" }), _jsxs("p", { className: "text-xs text-slate-500", children: ["D\u00F9ng ", '{0}', " cho ch\u1ED7 hi\u1EC7n chip ch\u1ECDn."] }), item.options.map((option, optionIndex) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { value: option.text, onChange: (event) => updateOption(index, optionIndex, event.target.value), placeholder: `Lựa chọn ${optionIndex + 1}` }), item.options.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeOption(index, optionIndex), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] }, option.id))), _jsxs(Button, { type: "button", size: "sm", variant: "outline", onClick: () => addOption(index), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), " Th\u00EAm l\u1EF1a ch\u1ECDn"] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("select", { className: "h-10 rounded-md border border-input bg-background px-3 text-sm", value: item.optionId, onChange: (event) => updateItem(index, { optionId: event.target.value }), children: item.options.map((option) => (_jsx("option", { value: option.id, children: option.text || option.id }, option.id))) }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Switch, { id: `cta-example-${index}`, checked: item.isExample === true, onCheckedChange: (checked) => updateItem(index, { isExample: checked }) }), _jsxs(Label, { htmlFor: `cta-example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
|
|
172
|
+
points: Number.parseInt(event.target.value, 10) || 1,
|
|
173
|
+
}) }))] })] })] }, `${item.questionNumber}-${index}`))), _jsxs(Button, { type: "button", variant: "outline", onClick: addItem, children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), " Th\u00EAm c\u00E2u"] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "cta-explanation", children: "Gi\u1EA3i th\u00EDch" }), _jsx(Textarea, { id: "cta-explanation", value: explanation, onChange: (event) => setExplanation(event.target.value) })] })] })), displayErrors.length > 0 && (_jsx("ul", { className: "list-disc space-y-1 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700", children: displayErrors.map((error) => (_jsx("li", { children: error }, error))) }))] }));
|
|
174
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ChooseThenAnswerGroupCreator } from './ChooseThenAnswerGroupCreator';
|
|
2
|
+
export { ChooseThenAnswerGroupClient } from './ChooseThenAnswerGroupClient';
|
|
3
|
+
export { transformChooseThenAnswerGroup } from './transform';
|
|
4
|
+
export { chooseThenAnswerGroupRegistration } from './register';
|
|
5
|
+
export { mapQuestionToChooseThenAnswerGroupData, parseStudentAnswer, } from './map-choose-then-answer-group-data';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ChooseThenAnswerGroupCreator } from './ChooseThenAnswerGroupCreator';
|
|
2
|
+
export { ChooseThenAnswerGroupClient } from './ChooseThenAnswerGroupClient';
|
|
3
|
+
export { transformChooseThenAnswerGroup } from './transform';
|
|
4
|
+
export { chooseThenAnswerGroupRegistration } from './register';
|
|
5
|
+
export { mapQuestionToChooseThenAnswerGroupData, parseStudentAnswer, } from './map-choose-then-answer-group-data';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ChooseThenAnswerGroupData, ChooseThenAnswerGroupItemData, ChooseThenAnswerOption, ChooseThenAnswerStudentItemAnswer } from '../../_shared/types/choose-then-answer-group.type';
|
|
2
|
+
export declare function nextPrefixedId(prefix: string, existing: string[]): string;
|
|
3
|
+
export declare function sumGradablePoints(items: ChooseThenAnswerGroupItemData[]): number;
|
|
4
|
+
export declare function createEmptyOption(existingIds: string[]): ChooseThenAnswerOption;
|
|
5
|
+
export declare function createEmptyItem(questionNumber: number): ChooseThenAnswerGroupItemData;
|
|
6
|
+
export declare function parseItemAnswer(raw: unknown): {
|
|
7
|
+
optionId: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function parseStudentAnswer(raw: unknown): ChooseThenAnswerStudentItemAnswer;
|
|
10
|
+
export declare function mapApiItem(item: unknown, index: number, fallbackAnswer?: unknown): ChooseThenAnswerGroupItemData;
|
|
11
|
+
export declare function mapQuestionToChooseThenAnswerGroupData(question: Record<string, unknown> | null | undefined): ChooseThenAnswerGroupData;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/choose-then-answer-group.type';
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
function asRecord(value) {
|
|
6
|
+
return isRecord(value) ? value : {};
|
|
7
|
+
}
|
|
8
|
+
function asString(value, fallback = '') {
|
|
9
|
+
return typeof value === 'string' ? value : fallback;
|
|
10
|
+
}
|
|
11
|
+
export function nextPrefixedId(prefix, existing) {
|
|
12
|
+
let n = existing.length + 1;
|
|
13
|
+
const used = new Set(existing);
|
|
14
|
+
while (used.has(`${prefix}${n}`))
|
|
15
|
+
n += 1;
|
|
16
|
+
return `${prefix}${n}`;
|
|
17
|
+
}
|
|
18
|
+
export function sumGradablePoints(items) {
|
|
19
|
+
return items.reduce((total, item) => {
|
|
20
|
+
if (item.isExample)
|
|
21
|
+
return total;
|
|
22
|
+
return total + (typeof item.points === 'number' ? item.points : 1);
|
|
23
|
+
}, 0);
|
|
24
|
+
}
|
|
25
|
+
export function createEmptyOption(existingIds) {
|
|
26
|
+
return {
|
|
27
|
+
id: nextPrefixedId('opt-', existingIds),
|
|
28
|
+
text: '',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function createEmptyItem(questionNumber) {
|
|
32
|
+
const first = createEmptyOption([]);
|
|
33
|
+
const second = createEmptyOption([first.id]);
|
|
34
|
+
return {
|
|
35
|
+
question: '',
|
|
36
|
+
options: [first, second],
|
|
37
|
+
optionId: first.id,
|
|
38
|
+
isExample: false,
|
|
39
|
+
points: 1,
|
|
40
|
+
questionNumber,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function parseItemAnswer(raw) {
|
|
44
|
+
if (!isRecord(raw))
|
|
45
|
+
return { optionId: '' };
|
|
46
|
+
return { optionId: asString(raw.optionId) };
|
|
47
|
+
}
|
|
48
|
+
export function parseStudentAnswer(raw) {
|
|
49
|
+
if (!isRecord(raw))
|
|
50
|
+
return { optionId: '', text: '' };
|
|
51
|
+
return {
|
|
52
|
+
optionId: asString(raw.optionId),
|
|
53
|
+
text: asString(raw.text),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function mapOptions(raw) {
|
|
57
|
+
if (!Array.isArray(raw) || raw.length < 2) {
|
|
58
|
+
const first = createEmptyOption([]);
|
|
59
|
+
const second = createEmptyOption([first.id]);
|
|
60
|
+
return [first, second];
|
|
61
|
+
}
|
|
62
|
+
return raw.map((item, index) => {
|
|
63
|
+
const record = asRecord(item);
|
|
64
|
+
return {
|
|
65
|
+
id: asString(record.id, `opt-${index + 1}`),
|
|
66
|
+
text: asString(record.text),
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export function mapApiItem(item, index, fallbackAnswer) {
|
|
71
|
+
const record = asRecord(item);
|
|
72
|
+
const content = asRecord(record.content);
|
|
73
|
+
const correctAnswer = asRecord(record.correctAnswer);
|
|
74
|
+
const isExample = record.isExample === true;
|
|
75
|
+
const options = mapOptions(content.options);
|
|
76
|
+
const parsed = parseItemAnswer(correctAnswer.answer ?? fallbackAnswer);
|
|
77
|
+
return {
|
|
78
|
+
question: asString(content.question) || asString(record.question),
|
|
79
|
+
options,
|
|
80
|
+
optionId: parsed.optionId || options[0]?.id || '',
|
|
81
|
+
isExample,
|
|
82
|
+
points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
|
|
83
|
+
questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function mapQuestionToChooseThenAnswerGroupData(question) {
|
|
87
|
+
const source = asRecord(question);
|
|
88
|
+
const content = asRecord(source.content);
|
|
89
|
+
const answer = asRecord(source.answer);
|
|
90
|
+
const correctAnswer = asRecord(source.correctAnswer ?? source.correct_answer ?? answer.correctAnswer);
|
|
91
|
+
const explanation = asString(source.explanation) || asString(answer.explanation);
|
|
92
|
+
const meta = asRecord(content.meta ?? answer.meta);
|
|
93
|
+
const fallbackAnswers = Array.isArray(correctAnswer.answer) ? correctAnswer.answer : [];
|
|
94
|
+
if (Array.isArray(content.items)) {
|
|
95
|
+
const items = content.items.map((item, index) => mapApiItem(item, index, fallbackAnswers[index]));
|
|
96
|
+
return {
|
|
97
|
+
title: asString(meta.title) || asString(answer.title),
|
|
98
|
+
instruction: asString(meta.instruction) ||
|
|
99
|
+
asString(answer.instruction) ||
|
|
100
|
+
CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION,
|
|
101
|
+
items,
|
|
102
|
+
explanation,
|
|
103
|
+
points: sumGradablePoints(items),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(answer.items)) {
|
|
107
|
+
const items = answer.items.map((item, index) => ({
|
|
108
|
+
...createEmptyItem(index + 1),
|
|
109
|
+
...item,
|
|
110
|
+
questionNumber: item.questionNumber || index + 1,
|
|
111
|
+
}));
|
|
112
|
+
return {
|
|
113
|
+
title: asString(answer.title),
|
|
114
|
+
instruction: asString(answer.instruction) || CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION,
|
|
115
|
+
items,
|
|
116
|
+
explanation,
|
|
117
|
+
points: typeof answer.points === 'number' ? answer.points : sumGradablePoints(items),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
title: '',
|
|
122
|
+
instruction: CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION,
|
|
123
|
+
items: [createEmptyItem(1)],
|
|
124
|
+
explanation,
|
|
125
|
+
points: 1,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { QuestionTypeRegistration } from '../../creator/question-type-registry';
|
|
2
|
+
export { createEmptyItem, mapQuestionToChooseThenAnswerGroupData, parseStudentAnswer, sumGradablePoints, } from './map-choose-then-answer-group-data';
|
|
3
|
+
export declare const chooseThenAnswerGroupRegistration: QuestionTypeRegistration;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ChooseThenAnswerGroupCreator } from './ChooseThenAnswerGroupCreator';
|
|
2
|
+
import { mapQuestionToChooseThenAnswerGroupData } from './map-choose-then-answer-group-data';
|
|
3
|
+
export { createEmptyItem, mapQuestionToChooseThenAnswerGroupData, parseStudentAnswer, sumGradablePoints, } from './map-choose-then-answer-group-data';
|
|
4
|
+
export const chooseThenAnswerGroupRegistration = {
|
|
5
|
+
component: ChooseThenAnswerGroupCreator,
|
|
6
|
+
transformInitialData: (initialData) => mapQuestionToChooseThenAnswerGroupData(initialData),
|
|
7
|
+
getExtraProps: (ctx) => ({
|
|
8
|
+
onUnsavedChangesChange: ctx.onUnsavedChangesChange,
|
|
9
|
+
validationRef: ctx.validationRef,
|
|
10
|
+
}),
|
|
11
|
+
wrapOnSave: (data, ctx) => {
|
|
12
|
+
const payload = data;
|
|
13
|
+
return {
|
|
14
|
+
type: ctx.questionType,
|
|
15
|
+
content: ctx.state.content,
|
|
16
|
+
points: payload.points ?? ctx.state.points ?? 1,
|
|
17
|
+
level: ctx.state.level,
|
|
18
|
+
difficulty: ctx.state.difficulty,
|
|
19
|
+
skill: ctx.state.skill,
|
|
20
|
+
answer: payload,
|
|
21
|
+
explanation: payload.explanation || '',
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
wrapOnChange: (data, ctx) => {
|
|
25
|
+
const payload = data;
|
|
26
|
+
return {
|
|
27
|
+
type: ctx.questionType,
|
|
28
|
+
content: ctx.state.content,
|
|
29
|
+
points: payload.points ?? ctx.state.points ?? 1,
|
|
30
|
+
level: ctx.state.level,
|
|
31
|
+
difficulty: ctx.state.difficulty,
|
|
32
|
+
skill: ctx.state.skill,
|
|
33
|
+
answer: payload,
|
|
34
|
+
explanation: payload.explanation || '',
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION, CHOOSE_THEN_ANSWER_ITEM_TYPE, } from '../../_shared/types/choose-then-answer-group.type';
|
|
2
|
+
import { sumGradablePoints } from './map-choose-then-answer-group-data';
|
|
3
|
+
export const transformChooseThenAnswerGroup = (question) => {
|
|
4
|
+
const answerData = question.answer;
|
|
5
|
+
const items = Array.isArray(answerData?.items) ? answerData.items : [];
|
|
6
|
+
if (items.length === 0) {
|
|
7
|
+
return { apiContent: { items: [] }, apiCorrectAnswer: { answer: [] } };
|
|
8
|
+
}
|
|
9
|
+
const title = typeof answerData?.title === 'string' ? answerData.title.trim() : '';
|
|
10
|
+
const apiItems = items.map((item, index) => ({
|
|
11
|
+
questionType: CHOOSE_THEN_ANSWER_ITEM_TYPE,
|
|
12
|
+
...(item.isExample ? { isExample: true } : {}),
|
|
13
|
+
content: {
|
|
14
|
+
question: item.question || '',
|
|
15
|
+
options: (item.options || []).map((option) => ({
|
|
16
|
+
id: option.id || '',
|
|
17
|
+
text: option.text || '',
|
|
18
|
+
})),
|
|
19
|
+
},
|
|
20
|
+
correctAnswer: { answer: { optionId: item.optionId || '' } },
|
|
21
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
22
|
+
questionNumber: item.questionNumber || index + 1,
|
|
23
|
+
}));
|
|
24
|
+
return {
|
|
25
|
+
apiContent: {
|
|
26
|
+
meta: {
|
|
27
|
+
instruction: answerData?.instruction || CHOOSE_THEN_ANSWER_GROUP_DEFAULT_INSTRUCTION,
|
|
28
|
+
...(title ? { title } : {}),
|
|
29
|
+
},
|
|
30
|
+
items: apiItems,
|
|
31
|
+
},
|
|
32
|
+
apiCorrectAnswer: {
|
|
33
|
+
answer: apiItems.map((item) => item.correctAnswer.answer),
|
|
34
|
+
},
|
|
35
|
+
totalPoints: sumGradablePoints(items),
|
|
36
|
+
};
|
|
37
|
+
};
|
package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js
CHANGED
|
@@ -1,18 +1,47 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Check, CircleHelp, Lightbulb, Star, X } from 'lucide-react';
|
|
3
|
+
import { BookOpen, Check, CircleHelp, Lightbulb, Star, Volume2, X } from 'lucide-react';
|
|
4
4
|
import { cn } from '../../../../shared/lib/utils';
|
|
5
5
|
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
6
|
+
import { toMatchWordToPictureImageUrls } from '../../_shared/types/match-word-to-picture-group.type';
|
|
6
7
|
function normalizeWord(value) {
|
|
7
8
|
return value.trim().toLowerCase();
|
|
8
9
|
}
|
|
10
|
+
function isPlayableAudioUrl(value) {
|
|
11
|
+
return (value.startsWith('http://')
|
|
12
|
+
|| value.startsWith('https://')
|
|
13
|
+
|| value.startsWith('blob:')
|
|
14
|
+
|| value.startsWith('data:'));
|
|
15
|
+
}
|
|
16
|
+
function toPresignKey(value) {
|
|
17
|
+
const trimmed = value.trim();
|
|
18
|
+
if (isPlayableAudioUrl(trimmed)) {
|
|
19
|
+
return trimmed;
|
|
20
|
+
}
|
|
21
|
+
return trimmed.replace(/^\/+/, '');
|
|
22
|
+
}
|
|
23
|
+
function ClientAudio({ url }) {
|
|
24
|
+
const fileKey = toPresignKey(url);
|
|
25
|
+
const { previewUrl, isLoading } = usePresignedFileUrl(fileKey);
|
|
26
|
+
const src = previewUrl || (isPlayableAudioUrl(fileKey) ? fileKey : '');
|
|
27
|
+
if (isLoading) {
|
|
28
|
+
return (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx("div", { className: "h-8 w-full max-w-xs animate-pulse rounded-md bg-slate-100" })] }));
|
|
29
|
+
}
|
|
30
|
+
if (!src) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx("audio", { controls: true, src: src, className: "h-8 max-w-full", preload: "metadata" })] }));
|
|
34
|
+
}
|
|
35
|
+
function isDisplayableImageUrl(value) {
|
|
36
|
+
return (value.startsWith('http://')
|
|
37
|
+
|| value.startsWith('https://')
|
|
38
|
+
|| value.startsWith('blob:')
|
|
39
|
+
|| value.startsWith('data:'));
|
|
40
|
+
}
|
|
9
41
|
function ClientImageItem({ url, alt, large = false, }) {
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
(url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:'))
|
|
14
|
-
? url
|
|
15
|
-
: '');
|
|
42
|
+
const fileKey = toPresignKey(url);
|
|
43
|
+
const { previewUrl, isLoading } = usePresignedFileUrl(fileKey);
|
|
44
|
+
const src = previewUrl || (isDisplayableImageUrl(fileKey) ? fileKey : '');
|
|
16
45
|
if (!url) {
|
|
17
46
|
return (_jsx("div", { className: "flex h-28 w-28 items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 \u1EA3nh" }));
|
|
18
47
|
}
|
|
@@ -40,19 +69,19 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
|
|
|
40
69
|
usedWords.add(normalizeWord(value));
|
|
41
70
|
}
|
|
42
71
|
});
|
|
43
|
-
return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-indigo-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-indigo-800", children: questionData.instruction }))] })), wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-indigo-50 p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-violet-700", children: "Word bank:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => {
|
|
72
|
+
return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-indigo-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-indigo-800", children: questionData.instruction }))] })), questionData.audioUrl ? _jsx(ClientAudio, { url: questionData.audioUrl }) : null, toMatchWordToPictureImageUrls(questionData.imageUrl).length > 0 ? (_jsx("div", { className: "flex flex-wrap justify-center gap-3 rounded-lg border border-slate-100 bg-slate-50 p-3", children: toMatchWordToPictureImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh bài (${imageIndex + 1})`, large: true }, `mwtp-cover-${imageIndex}`))) })) : null, 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 } })] })) : null, wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-indigo-50 p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-violet-700", children: "Word bank:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => {
|
|
44
73
|
const isUsed = usedWords.has(normalizeWord(word));
|
|
45
74
|
return (_jsx("span", { className: cn('rounded-full border px-3 py-1 text-sm font-semibold', isUsed
|
|
46
75
|
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
47
76
|
: 'border-violet-200 bg-white text-slate-700'), children: word }, `${word}-${index}`));
|
|
48
|
-
}) })] })),
|
|
77
|
+
}) })] })), items.map((item, itemIndex) => {
|
|
49
78
|
const selectedWord = item.isExample
|
|
50
79
|
? item.correctAnswer
|
|
51
80
|
: (userAnswers[itemIndex] ?? '');
|
|
52
81
|
const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
|
|
53
82
|
const isCorrect = normalizeWord(selectedWord) === normalizeWord(item.correctAnswer) &&
|
|
54
83
|
Boolean(item.correctAnswer);
|
|
55
|
-
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-violet-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: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex flex-col gap-4 p-4 sm:flex-row sm:items-center", children: [item.imageUrl
|
|
84
|
+
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-violet-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: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex flex-col gap-4 p-4 sm:flex-row sm:items-center", children: [toMatchWordToPictureImageUrls(item.imageUrl).length > 0 ? (_jsx("div", { className: "flex flex-wrap gap-2", children: toMatchWordToPictureImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1}${imageIndex > 0 ? ` (${imageIndex + 1})` : ''}` }, `${item.questionNumber}-${imageIndex}`))) })) : null, _jsxs("div", { className: "min-w-0 flex-1 space-y-2", children: [_jsxs("p", { className: "text-sm font-semibold text-gray-800", children: [item.questionNumber || itemIndex + 1, ". Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p"] }), _jsxs("div", { className: "flex items-center gap-3", children: [item.isExample ? (_jsx("span", { className: "text-sm italic text-gray-800 underline decoration-gray-400", children: item.correctAnswer })) : (_jsxs("select", { value: selectedWord, disabled: !canSelect, onChange: (event) => handleSelect(itemIndex, event.target.value), className: cn('h-10 max-w-xs rounded-md border-2 bg-white px-3 text-sm font-medium', isReviewMode && isCorrect
|
|
56
85
|
? 'border-green-400 bg-green-50 text-green-700'
|
|
57
86
|
: isReviewMode && selectedWord && !isCorrect
|
|
58
87
|
? 'border-red-400 bg-red-50 text-red-700'
|
|
@@ -19,9 +19,10 @@ function parsePointsValue(raw, allowZero = false) {
|
|
|
19
19
|
return Math.max(min, parsed);
|
|
20
20
|
}
|
|
21
21
|
import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
|
|
22
|
+
import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
|
|
22
23
|
import { MatchWordToPictureGroupClient } from './MatchWordToPictureGroupClient';
|
|
23
24
|
import { MatchWordToPictureGroupImageField } from './MatchWordToPictureGroupImageField';
|
|
24
|
-
import { MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/match-word-to-picture-group.type';
|
|
25
|
+
import { MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION, fromMatchWordToPictureImageUrls, toMatchWordToPictureImageUrls, } from '../../_shared/types/match-word-to-picture-group.type';
|
|
25
26
|
import { createEmptyMatchWordToPictureGroupItem, mapQuestionToMatchWordToPictureGroupData, } from './map-match-word-to-picture-group-data';
|
|
26
27
|
function sumPoints(items) {
|
|
27
28
|
return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
|
|
@@ -30,7 +31,9 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
|
|
|
30
31
|
const seeded = mapQuestionToMatchWordToPictureGroupData(initialData ? { answer: initialData } : null);
|
|
31
32
|
const [title, setTitle] = useState(initialData?.title || '');
|
|
32
33
|
const [instruction, setInstruction] = useState(initialData?.instruction || seeded.instruction || MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION);
|
|
33
|
-
const [
|
|
34
|
+
const [passage, setPassage] = useState(initialData?.passage || seeded.passage || '');
|
|
35
|
+
const [audioUrl] = useState(initialData?.audioUrl || seeded.audioUrl || '');
|
|
36
|
+
const [imageUrl, setImageUrl] = useState(toMatchWordToPictureImageUrls(initialData?.imageUrl)[0] || '');
|
|
34
37
|
const [wordBank, setWordBank] = useState(initialData?.wordBank?.length ? initialData.wordBank : seeded.wordBank);
|
|
35
38
|
const [items, setItems] = useState(() => (initialData?.items?.length ? initialData.items : seeded.items));
|
|
36
39
|
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
@@ -41,9 +44,11 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
|
|
|
41
44
|
useEffect(() => {
|
|
42
45
|
onChangeRef.current = onChange;
|
|
43
46
|
}, [onChange]);
|
|
44
|
-
const buildPayload = (nextTitle = title, nextInstruction = instruction, nextImageUrl = imageUrl, nextWordBank = wordBank, nextItems = items, nextExplanation = explanation) => ({
|
|
47
|
+
const buildPayload = (nextTitle = title, nextInstruction = instruction, nextPassage = passage, nextImageUrl = imageUrl, nextWordBank = wordBank, nextItems = items, nextExplanation = explanation) => ({
|
|
45
48
|
...(nextTitle.trim() ? { title: nextTitle } : {}),
|
|
46
49
|
instruction: nextInstruction,
|
|
50
|
+
...(nextPassage.trim() ? { passage: nextPassage } : {}),
|
|
51
|
+
...(audioUrl.trim() ? { audioUrl: audioUrl.trim() } : {}),
|
|
47
52
|
...(nextImageUrl.trim() ? { imageUrl: nextImageUrl } : {}),
|
|
48
53
|
wordBank: nextWordBank,
|
|
49
54
|
items: nextItems,
|
|
@@ -61,7 +66,7 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
|
|
|
61
66
|
debouncedOnChange(payload);
|
|
62
67
|
onUnsavedChangesChange?.(true);
|
|
63
68
|
}
|
|
64
|
-
}, [title, instruction, imageUrl, wordBank, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
69
|
+
}, [title, instruction, passage, audioUrl, imageUrl, wordBank, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
65
70
|
const validate = () => {
|
|
66
71
|
const nextErrors = [];
|
|
67
72
|
if (!instruction.trim())
|
|
@@ -72,7 +77,7 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
|
|
|
72
77
|
if (items.length < 1)
|
|
73
78
|
nextErrors.push('Cần ít nhất một câu hỏi.');
|
|
74
79
|
items.forEach((item, index) => {
|
|
75
|
-
if (!imageUrl.trim() &&
|
|
80
|
+
if (!imageUrl.trim() && toMatchWordToPictureImageUrls(item.imageUrl).length === 0) {
|
|
76
81
|
nextErrors.push(`Câu ${index + 1}: cần ảnh (hoặc ảnh chung).`);
|
|
77
82
|
}
|
|
78
83
|
if (!item.correctAnswer.trim())
|
|
@@ -88,15 +93,17 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
|
|
|
88
93
|
getFormData: () => buildPayload(),
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
|
-
}, [validationRef, title, instruction, imageUrl, wordBank, items, explanation]);
|
|
96
|
+
}, [validationRef, title, instruction, passage, audioUrl, imageUrl, wordBank, items, explanation]);
|
|
92
97
|
const updateItem = (index, patch) => {
|
|
93
98
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
|
94
99
|
};
|
|
95
100
|
const currentPayload = buildPayload();
|
|
96
101
|
const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
|
|
97
|
-
return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi N\u1ED1i t\u1EEB v\u1EDBi tranh (nh\u00F3m)" }), _jsx("p", { className: "text-sm text-gray-500", children: "Word bank d\u00F9ng chung. C\u00F3 th\u1EC3 1 \u1EA3nh chung + \u00F4 s\u1ED1, ho\u1EB7c m\u1ED7i d\u00F2ng m\u1ED9t tranh." })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsx("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: _jsx("ul", { className: "list-disc space-y-1 pl-5", children: displayErrors.map((err, i) => _jsx("li", { children: err }, i)) }) })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(MatchWordToPictureGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Word bank" }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "mwtp-group-title", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwtp-group-title", className: "mt-1", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "mwtp-group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "mwtp-group-instruction", className: "mt-1", value: instruction, onChange: (e) => setInstruction(e.target.value) })] }), _jsx(MatchWordToPictureGroupImageField, { id: "mwtp-group-shared-image", label: "\u1EA2nh chung (optional \u2014 1 b\u1EE9c tranh nhi\u1EC1u \u00F4 s\u1ED1)", value: imageUrl, onChange: setImageUrl }), wordBank.map((word, index) => (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { value: word, onChange: (e) => setWordBank((prev) => prev.map((item, i) => (i === index ? e.target.value : item))), placeholder: `Từ ${index + 1}` }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setWordBank((prev) => prev.filter((_, i) => i !== index)), children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, index))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setWordBank((prev) => [...prev, '']), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Th\u00EAm t\u1EEB"] })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Tranh (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setItems((prev) => [...prev, createEmptyMatchWordToPictureGroupItem(prev.length + 1)]), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Th\u00EAm tranh"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("span", { className: "text-sm font-medium", children: ["C\u00E2u ", item.questionNumber || index + 1] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Switch, { checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { className: "flex items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
|
|
102
|
+
return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi N\u1ED1i t\u1EEB v\u1EDBi tranh (nh\u00F3m)" }), _jsx("p", { className: "text-sm text-gray-500", children: "Word bank d\u00F9ng chung. C\u00F3 th\u1EC3 1 \u1EA3nh chung + \u00F4 s\u1ED1, ho\u1EB7c m\u1ED7i d\u00F2ng m\u1ED9t tranh." })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsx("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: _jsx("ul", { className: "list-disc space-y-1 pl-5", children: displayErrors.map((err, i) => _jsx("li", { children: err }, i)) }) })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(MatchWordToPictureGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Word bank" }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "mwtp-group-title", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwtp-group-title", className: "mt-1", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "mwtp-group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "mwtp-group-instruction", className: "mt-1", value: instruction, onChange: (e) => setInstruction(e.target.value) })] }), _jsxs("div", { children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage)" }), _jsx("div", { className: "mt-1", children: _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" }) })] }), _jsx(MatchWordToPictureGroupImageField, { id: "mwtp-group-shared-image", label: "\u1EA2nh chung (optional \u2014 1 b\u1EE9c tranh nhi\u1EC1u \u00F4 s\u1ED1)", value: imageUrl, onChange: setImageUrl }), wordBank.map((word, index) => (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { value: word, onChange: (e) => setWordBank((prev) => prev.map((item, i) => (i === index ? e.target.value : item))), placeholder: `Từ ${index + 1}` }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setWordBank((prev) => prev.filter((_, i) => i !== index)), children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, index))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setWordBank((prev) => [...prev, '']), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Th\u00EAm t\u1EEB"] })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Tranh (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setItems((prev) => [...prev, createEmptyMatchWordToPictureGroupItem(prev.length + 1)]), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Th\u00EAm tranh"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("span", { className: "text-sm font-medium", children: ["C\u00E2u ", item.questionNumber || index + 1] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Switch, { checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { className: "flex items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
|
|
98
103
|
points: parsePointsValue(event.target.value, true),
|
|
99
|
-
}) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setItems((prev) => prev.filter((_, i) => i !== index).map((entry, i) => ({ ...entry, questionNumber: i + 1 }))), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] })] }), _jsx(MatchWordToPictureGroupImageField, { id: `mwtp-group-image-${index}`, label: "\u1EA2nh", value: item.imageUrl
|
|
104
|
+
}) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setItems((prev) => prev.filter((_, i) => i !== index).map((entry, i) => ({ ...entry, questionNumber: i + 1 }))), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] })] }), _jsx(MatchWordToPictureGroupImageField, { id: `mwtp-group-image-${index}`, label: "\u1EA2nh", value: toMatchWordToPictureImageUrls(item.imageUrl)[0] || '', onChange: (url) => updateItem(index, {
|
|
105
|
+
imageUrl: fromMatchWordToPictureImageUrls(url),
|
|
106
|
+
}) }), _jsxs("select", { className: "rounded-md border px-3 py-2 text-sm", value: item.correctAnswer, onChange: (e) => updateItem(index, { correctAnswer: e.target.value }), children: [_jsx("option", { value: "", children: "Ch\u1ECDn t\u1EEB \u0111\u00FAng" }), Array.from(new Set([
|
|
100
107
|
...wordBank.map((word) => word.trim()).filter(Boolean),
|
|
101
108
|
item.correctAnswer.trim(),
|
|
102
109
|
].filter(Boolean))).map((word) => (_jsx("option", { value: word, children: word }, word)))] })] }, index))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { value: explanation, onChange: (event) => setExplanation(event.target.value), rows: 3 }) })] })] }))] }));
|