@tinyweb_dev/oe-exam-sdk 0.2.13 → 1.0.0
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/README.md +12 -0
- 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-the-correct-answer/ChooseTheCorrectAnswerClient.js +1 -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
|
@@ -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 }) })] })] }))] }));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION, } from '../../_shared/types/match-word-to-picture-group.type';
|
|
1
|
+
import { MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION, fromMatchWordToPictureImageUrls, } from '../../_shared/types/match-word-to-picture-group.type';
|
|
2
2
|
function isRecord(value) {
|
|
3
3
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
4
|
}
|
|
@@ -30,7 +30,7 @@ export function mapMatchWordToPictureGroupItem(item, index, fallbackAnswer) {
|
|
|
30
30
|
const correctAnswer = asRecord(record.correctAnswer);
|
|
31
31
|
const isExample = record.isExample === true;
|
|
32
32
|
return {
|
|
33
|
-
imageUrl:
|
|
33
|
+
imageUrl: fromMatchWordToPictureImageUrls(content.imageUrl ?? record.imageUrl),
|
|
34
34
|
correctAnswer: asString(correctAnswer.answer) ||
|
|
35
35
|
asString(record.correctAnswer) ||
|
|
36
36
|
asString(fallbackAnswer),
|
|
@@ -58,12 +58,17 @@ export function mapQuestionToMatchWordToPictureGroupData(question) {
|
|
|
58
58
|
? asStringList(answer.wordBank)
|
|
59
59
|
: Array.from(new Set(items.map((item) => item.correctAnswer).filter(Boolean)));
|
|
60
60
|
const title = asString(meta.title) || asString(answer.title);
|
|
61
|
-
const
|
|
61
|
+
const passage = asString(meta.passage).trim() || asString(answer.passage).trim();
|
|
62
|
+
const audioUrl = asString(meta.audioUrl).trim() || asString(answer.audioUrl).trim();
|
|
63
|
+
const imageUrl = fromMatchWordToPictureImageUrls(meta.imageUrl)
|
|
64
|
+
?? fromMatchWordToPictureImageUrls(answer.imageUrl);
|
|
62
65
|
return {
|
|
63
66
|
...(title ? { title } : {}),
|
|
64
67
|
instruction: asString(meta.instruction) ||
|
|
65
68
|
asString(answer.instruction) ||
|
|
66
69
|
MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION,
|
|
70
|
+
...(passage ? { passage } : {}),
|
|
71
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
67
72
|
...(imageUrl ? { imageUrl } : {}),
|
|
68
73
|
wordBank,
|
|
69
74
|
items,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION, MATCH_WORD_TO_PICTURE_ITEM_TYPE, } from '../../_shared/types/match-word-to-picture-group.type';
|
|
1
|
+
import { MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION, MATCH_WORD_TO_PICTURE_ITEM_TYPE, fromMatchWordToPictureImageUrls, } from '../../_shared/types/match-word-to-picture-group.type';
|
|
2
2
|
function sumGradablePoints(items) {
|
|
3
3
|
return items.reduce((total, item) => {
|
|
4
4
|
if (item.isExample)
|
|
@@ -12,16 +12,20 @@ export const transformMatchWordToPictureGroup = (question) => {
|
|
|
12
12
|
const wordBank = Array.isArray(answerData?.wordBank)
|
|
13
13
|
? answerData.wordBank.filter((word) => typeof word === 'string').map((word) => word.trim()).filter(Boolean)
|
|
14
14
|
: [];
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
15
|
+
const groupImageUrl = fromMatchWordToPictureImageUrls(answerData?.imageUrl);
|
|
16
|
+
const apiItems = items.map((item, index) => {
|
|
17
|
+
const itemImageUrl = fromMatchWordToPictureImageUrls(item.imageUrl);
|
|
18
|
+
return {
|
|
19
|
+
questionType: MATCH_WORD_TO_PICTURE_ITEM_TYPE,
|
|
20
|
+
...(item.isExample ? { isExample: true } : {}),
|
|
21
|
+
content: {
|
|
22
|
+
...(itemImageUrl ? { imageUrl: itemImageUrl } : {}),
|
|
23
|
+
},
|
|
24
|
+
correctAnswer: { answer: item.correctAnswer || '' },
|
|
25
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
26
|
+
questionNumber: item.questionNumber || index + 1,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
25
29
|
return {
|
|
26
30
|
apiContent: {
|
|
27
31
|
meta: {
|
|
@@ -29,9 +33,13 @@ export const transformMatchWordToPictureGroup = (question) => {
|
|
|
29
33
|
...(typeof answerData?.title === 'string' && answerData.title.trim()
|
|
30
34
|
? { title: answerData.title.trim() }
|
|
31
35
|
: {}),
|
|
32
|
-
...(typeof answerData?.
|
|
33
|
-
? {
|
|
36
|
+
...(typeof answerData?.passage === 'string' && answerData.passage.trim()
|
|
37
|
+
? { passage: answerData.passage }
|
|
38
|
+
: {}),
|
|
39
|
+
...(typeof answerData?.audioUrl === 'string' && answerData.audioUrl.trim()
|
|
40
|
+
? { audioUrl: answerData.audioUrl.trim() }
|
|
34
41
|
: {}),
|
|
42
|
+
...(groupImageUrl ? { imageUrl: groupImageUrl } : {}),
|
|
35
43
|
wordBank,
|
|
36
44
|
},
|
|
37
45
|
items: apiItems,
|
package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SortWordsCategory, SortWordsPlacement, SortWordsWordItem } from '../../_shared/types/sort-words-into-categories.type';
|
|
2
|
+
interface SortWordsIntoCategoriesBoardProps {
|
|
3
|
+
categories: SortWordsCategory[];
|
|
4
|
+
words: SortWordsWordItem[];
|
|
5
|
+
placements: SortWordsPlacement[];
|
|
6
|
+
correctPlacements?: SortWordsPlacement[];
|
|
7
|
+
onChange?: (next: SortWordsPlacement[]) => void;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
isReviewMode?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function SortWordsIntoCategoriesBoard({ categories, words, placements, correctPlacements, onChange, disabled, isReviewMode, }: SortWordsIntoCategoriesBoardProps): import("react").JSX.Element;
|
|
12
|
+
export {};
|
package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo, useState } from 'react';
|
|
4
|
+
import { cn } from '../../../../shared/lib/utils';
|
|
5
|
+
const POOL_ZONE = '__pool__';
|
|
6
|
+
function placementMap(items) {
|
|
7
|
+
return new Map(items.map((item) => [item.wordId, item.categoryId]));
|
|
8
|
+
}
|
|
9
|
+
export function SortWordsIntoCategoriesBoard({ categories, words, placements, correctPlacements, onChange, disabled = false, isReviewMode = false, }) {
|
|
10
|
+
const [selectedWordId, setSelectedWordId] = useState(null);
|
|
11
|
+
const current = useMemo(() => placementMap(placements), [placements]);
|
|
12
|
+
const correct = useMemo(() => (correctPlacements ? placementMap(correctPlacements) : new Map()), [correctPlacements]);
|
|
13
|
+
const wordById = useMemo(() => new Map(words.map((word) => [word.id, word])), [words]);
|
|
14
|
+
const emit = (wordId, categoryId) => {
|
|
15
|
+
const word = wordById.get(wordId);
|
|
16
|
+
if (!word || word.isExample || disabled)
|
|
17
|
+
return;
|
|
18
|
+
const next = words.map((item) => ({
|
|
19
|
+
wordId: item.id,
|
|
20
|
+
categoryId: item.id === wordId ? categoryId : (current.get(item.id) ?? item.categoryId ?? ''),
|
|
21
|
+
}));
|
|
22
|
+
onChange?.(next);
|
|
23
|
+
};
|
|
24
|
+
const handleSelect = (wordId) => {
|
|
25
|
+
const word = wordById.get(wordId);
|
|
26
|
+
if (!word || word.isExample || disabled)
|
|
27
|
+
return;
|
|
28
|
+
setSelectedWordId((prev) => (prev === wordId ? null : wordId));
|
|
29
|
+
};
|
|
30
|
+
const handlePlace = (zoneId) => {
|
|
31
|
+
if (!selectedWordId)
|
|
32
|
+
return;
|
|
33
|
+
emit(selectedWordId, zoneId === POOL_ZONE ? '' : zoneId);
|
|
34
|
+
setSelectedWordId(null);
|
|
35
|
+
};
|
|
36
|
+
const handleDrop = (event, zoneId) => {
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
const wordId = event.dataTransfer.getData('text/word-id');
|
|
39
|
+
if (!wordId)
|
|
40
|
+
return;
|
|
41
|
+
emit(wordId, zoneId === POOL_ZONE ? '' : zoneId);
|
|
42
|
+
setSelectedWordId(null);
|
|
43
|
+
};
|
|
44
|
+
const poolWords = words.filter((word) => {
|
|
45
|
+
if (word.isExample)
|
|
46
|
+
return false;
|
|
47
|
+
return !(current.get(word.id) || '');
|
|
48
|
+
});
|
|
49
|
+
const renderChip = (word) => {
|
|
50
|
+
const expected = correct.get(word.id) || word.categoryId || '';
|
|
51
|
+
const actual = current.get(word.id) ?? '';
|
|
52
|
+
const isSelected = selectedWordId === word.id;
|
|
53
|
+
const showReview = isReviewMode && !word.isExample && Boolean(expected);
|
|
54
|
+
const isCorrect = showReview && actual === expected;
|
|
55
|
+
const isWrong = showReview && actual !== expected;
|
|
56
|
+
return (_jsxs("button", { type: "button", draggable: !disabled && !word.isExample, onDragStart: (event) => {
|
|
57
|
+
event.dataTransfer.setData('text/word-id', word.id);
|
|
58
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
59
|
+
}, onClick: () => handleSelect(word.id), onDoubleClick: () => {
|
|
60
|
+
if (!word.isExample)
|
|
61
|
+
emit(word.id, '');
|
|
62
|
+
}, disabled: disabled || word.isExample, className: cn('rounded-md border px-3 py-1.5 text-sm shadow-sm transition-colors', word.isExample && 'cursor-default opacity-80', isSelected && 'border-black bg-black text-white', !isSelected && !isCorrect && !isWrong && 'border-gray-300 bg-white text-gray-900', isCorrect && 'border-emerald-500 bg-emerald-50 text-emerald-900', isWrong && 'border-red-500 bg-red-50 text-red-900'), children: [word.text || word.id, word.isExample && (_jsx("span", { className: "ml-1 text-[10px] uppercase tracking-wide opacity-70", children: "ex" }))] }, word.id));
|
|
63
|
+
};
|
|
64
|
+
return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "min-h-[56px] rounded-lg border border-gray-200 bg-white p-3", onDragOver: (event) => event.preventDefault(), onDrop: (event) => handleDrop(event, POOL_ZONE), onClick: (event) => {
|
|
65
|
+
if (event.target === event.currentTarget)
|
|
66
|
+
handlePlace(POOL_ZONE);
|
|
67
|
+
}, children: _jsx("div", { className: "flex flex-wrap gap-2", children: poolWords.length === 0 ? (_jsx("p", { className: "text-sm text-gray-400", children: disabled ? 'Không còn từ trong pool.' : 'Chọn từ rồi bấm nhóm, hoặc kéo thả. Bấm nền này để trả từ về pool.' })) : (poolWords.map(renderChip)) }) }), _jsx("div", { className: cn('grid gap-4', categories.length <= 2 ? 'md:grid-cols-2' : 'md:grid-cols-3'), children: categories.map((category) => {
|
|
68
|
+
const boxed = words.filter((word) => (current.get(word.id) || '') === category.id);
|
|
69
|
+
return (_jsxs("section", { className: "overflow-hidden rounded-lg border border-gray-300 bg-white", onDragOver: (event) => event.preventDefault(), onDrop: (event) => handleDrop(event, category.id), children: [_jsx("button", { type: "button", className: "w-full bg-gray-200 px-3 py-2 text-center text-sm font-semibold text-gray-800", onClick: () => handlePlace(category.id), disabled: disabled, children: category.name || category.id }), _jsx("div", { className: "flex min-h-[160px] flex-wrap content-start gap-2 p-3", onClick: (event) => {
|
|
70
|
+
if (event.target === event.currentTarget)
|
|
71
|
+
handlePlace(category.id);
|
|
72
|
+
}, children: boxed.map(renderChip) })] }, category.id));
|
|
73
|
+
}) })] }));
|
|
74
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { SortWordsIntoCategoriesClientProps } from '../../_shared/types/sort-words-into-categories.type';
|
|
2
|
+
export declare function SortWordsIntoCategoriesClient({ questionData, isReviewMode, userPlacements, onPlacementsChange, }: SortWordsIntoCategoriesClientProps): import("react").JSX.Element;
|
package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Lightbulb } from 'lucide-react';
|
|
4
|
+
import { SortWordsIntoCategoriesBoard } from './SortWordsIntoCategoriesBoard';
|
|
5
|
+
import { buildPlacements, padPlacements } from './map-sort-words-into-categories-data';
|
|
6
|
+
export function SortWordsIntoCategoriesClient({ questionData, isReviewMode = false, userPlacements, onPlacementsChange, }) {
|
|
7
|
+
const correctPlacements = buildPlacements(questionData.words);
|
|
8
|
+
const exampleLocked = questionData.words.map((word) => ({
|
|
9
|
+
wordId: word.id,
|
|
10
|
+
categoryId: word.isExample ? word.categoryId : '',
|
|
11
|
+
}));
|
|
12
|
+
const rawPlacements = isReviewMode && !userPlacements
|
|
13
|
+
? correctPlacements
|
|
14
|
+
: (userPlacements ?? exampleLocked);
|
|
15
|
+
const placements = padPlacements(questionData.words, rawPlacements).map((item) => {
|
|
16
|
+
const word = questionData.words.find((entry) => entry.id === item.wordId);
|
|
17
|
+
if (word?.isExample) {
|
|
18
|
+
return { wordId: item.wordId, categoryId: word.categoryId };
|
|
19
|
+
}
|
|
20
|
+
return item;
|
|
21
|
+
});
|
|
22
|
+
const canEdit = Boolean(onPlacementsChange) && !isReviewMode;
|
|
23
|
+
return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-slate-200 bg-slate-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-slate-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-slate-800", children: questionData.instruction }))] })), _jsx(SortWordsIntoCategoriesBoard, { categories: questionData.categories, words: questionData.words, placements: placements, correctPlacements: isReviewMode ? correctPlacements : undefined, onChange: canEdit ? onPlacementsChange : undefined, disabled: !canEdit, isReviewMode: isReviewMode }), 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] }))] }));
|
|
24
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { SortWordsIntoCategoriesCreatorProps } from '../../_shared/types/sort-words-into-categories.type';
|
|
2
|
+
export declare function SortWordsIntoCategoriesCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: SortWordsIntoCategoriesCreatorProps): import("react").JSX.Element;
|
package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
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 { SortWordsIntoCategoriesClient } from './SortWordsIntoCategoriesClient';
|
|
14
|
+
import { createEmptyCategory, createEmptyWord, sumGradablePoints, } from './map-sort-words-into-categories-data';
|
|
15
|
+
import { SORT_WORDS_INTO_CATEGORIES_DEFAULT_INSTRUCTION } from '../../_shared/types/sort-words-into-categories.type';
|
|
16
|
+
function normalizeCategories(raw) {
|
|
17
|
+
if (!Array.isArray(raw) || raw.length < 2) {
|
|
18
|
+
const first = createEmptyCategory([]);
|
|
19
|
+
const second = createEmptyCategory([first.id]);
|
|
20
|
+
return [
|
|
21
|
+
{ ...first, name: raw?.[0]?.name || 'Technology item' },
|
|
22
|
+
{ ...second, name: raw?.[1]?.name || 'Activity using technology' },
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
return raw.map((item, index) => ({
|
|
26
|
+
id: item.id || `cat_${index + 1}`,
|
|
27
|
+
name: item.name || '',
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
function normalizeWords(raw, categories) {
|
|
31
|
+
const fallbackCategory = categories[0]?.id || '';
|
|
32
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
33
|
+
const first = createEmptyWord([], fallbackCategory);
|
|
34
|
+
const second = createEmptyWord([first.id], categories[1]?.id || fallbackCategory);
|
|
35
|
+
return [first, second];
|
|
36
|
+
}
|
|
37
|
+
return raw.map((item, index) => ({
|
|
38
|
+
id: item.id || `w${index + 1}`,
|
|
39
|
+
text: item.text || '',
|
|
40
|
+
categoryId: item.categoryId || fallbackCategory,
|
|
41
|
+
isExample: item.isExample === true,
|
|
42
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
export function SortWordsIntoCategoriesCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
46
|
+
const [title, setTitle] = useState(initialData?.title || '');
|
|
47
|
+
const [instruction, setInstruction] = useState(initialData?.instruction || SORT_WORDS_INTO_CATEGORIES_DEFAULT_INSTRUCTION);
|
|
48
|
+
const [categories, setCategories] = useState(() => normalizeCategories(initialData?.categories));
|
|
49
|
+
const [words, setWords] = useState(() => normalizeWords(initialData?.words, normalizeCategories(initialData?.categories)));
|
|
50
|
+
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
51
|
+
const [isClientMode, setIsClientMode] = useState(false);
|
|
52
|
+
const [errors, setErrors] = useState([]);
|
|
53
|
+
const onChangeRef = useRef(onChange);
|
|
54
|
+
const previousPayloadRef = useRef('');
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
onChangeRef.current = onChange;
|
|
57
|
+
}, [onChange]);
|
|
58
|
+
const buildPayload = (nextTitle = title, nextInstruction = instruction, nextCategories = categories, nextWords = words, nextExplanation = explanation) => ({
|
|
59
|
+
title: nextTitle,
|
|
60
|
+
instruction: nextInstruction,
|
|
61
|
+
categories: nextCategories,
|
|
62
|
+
words: nextWords,
|
|
63
|
+
explanation: nextExplanation,
|
|
64
|
+
points: sumGradablePoints(nextWords),
|
|
65
|
+
});
|
|
66
|
+
const debouncedOnChange = useDebouncedCallback((payload) => {
|
|
67
|
+
onChangeRef.current?.(payload);
|
|
68
|
+
}, 300);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const payload = buildPayload();
|
|
71
|
+
const serialized = JSON.stringify(payload);
|
|
72
|
+
if (serialized !== previousPayloadRef.current) {
|
|
73
|
+
previousPayloadRef.current = serialized;
|
|
74
|
+
debouncedOnChange(payload);
|
|
75
|
+
onUnsavedChangesChange?.(true);
|
|
76
|
+
}
|
|
77
|
+
}, [title, instruction, categories, words, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
78
|
+
const validate = () => {
|
|
79
|
+
const nextErrors = [];
|
|
80
|
+
if (!instruction.trim()) {
|
|
81
|
+
nextErrors.push('Hướng dẫn (Instruction) không được để trống.');
|
|
82
|
+
}
|
|
83
|
+
if (categories.length < 2) {
|
|
84
|
+
nextErrors.push('Cần ít nhất 2 nhóm.');
|
|
85
|
+
}
|
|
86
|
+
const categoryIds = new Set();
|
|
87
|
+
categories.forEach((category, index) => {
|
|
88
|
+
if (!category.id.trim())
|
|
89
|
+
nextErrors.push(`Nhóm ${index + 1}: id không được để trống.`);
|
|
90
|
+
if (!category.name.trim())
|
|
91
|
+
nextErrors.push(`Nhóm ${index + 1}: tên không được để trống.`);
|
|
92
|
+
if (category.id.trim() && categoryIds.has(category.id.trim())) {
|
|
93
|
+
nextErrors.push(`Nhóm ${index + 1}: id "${category.id}" bị trùng.`);
|
|
94
|
+
}
|
|
95
|
+
categoryIds.add(category.id.trim());
|
|
96
|
+
});
|
|
97
|
+
if (words.length < 1) {
|
|
98
|
+
nextErrors.push('Cần ít nhất 1 từ.');
|
|
99
|
+
}
|
|
100
|
+
const wordIds = new Set();
|
|
101
|
+
words.forEach((word, index) => {
|
|
102
|
+
if (!word.id.trim())
|
|
103
|
+
nextErrors.push(`Từ ${index + 1}: id không được để trống.`);
|
|
104
|
+
if (!word.text.trim())
|
|
105
|
+
nextErrors.push(`Từ ${index + 1}: nội dung không được để trống.`);
|
|
106
|
+
if (word.id.trim() && wordIds.has(word.id.trim())) {
|
|
107
|
+
nextErrors.push(`Từ ${index + 1}: id "${word.id}" bị trùng.`);
|
|
108
|
+
}
|
|
109
|
+
wordIds.add(word.id.trim());
|
|
110
|
+
if (!word.categoryId.trim()) {
|
|
111
|
+
nextErrors.push(`Từ ${index + 1}: chưa chọn nhóm đúng.`);
|
|
112
|
+
}
|
|
113
|
+
else if (!categoryIds.has(word.categoryId)) {
|
|
114
|
+
nextErrors.push(`Từ ${index + 1}: nhóm không hợp lệ.`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
setErrors(nextErrors);
|
|
118
|
+
return nextErrors.length === 0;
|
|
119
|
+
};
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!validationRef)
|
|
122
|
+
return;
|
|
123
|
+
validationRef.current = {
|
|
124
|
+
triggerValidation: async () => validate(),
|
|
125
|
+
getFormData: () => buildPayload(),
|
|
126
|
+
};
|
|
127
|
+
return () => {
|
|
128
|
+
validationRef.current = null;
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
const updateCategory = (index, patch) => {
|
|
132
|
+
setCategories((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
|
133
|
+
};
|
|
134
|
+
const updateWord = (index, patch) => {
|
|
135
|
+
setWords((prev) => prev.map((item, i) => {
|
|
136
|
+
if (i !== index)
|
|
137
|
+
return item;
|
|
138
|
+
const next = { ...item, ...patch };
|
|
139
|
+
if (next.isExample)
|
|
140
|
+
next.points = 0;
|
|
141
|
+
return next;
|
|
142
|
+
}));
|
|
143
|
+
};
|
|
144
|
+
const addCategory = () => {
|
|
145
|
+
setCategories((prev) => [...prev, createEmptyCategory(prev.map((item) => item.id))]);
|
|
146
|
+
};
|
|
147
|
+
const removeCategory = (index) => {
|
|
148
|
+
if (categories.length <= 2)
|
|
149
|
+
return;
|
|
150
|
+
const removedId = categories[index]?.id;
|
|
151
|
+
const nextCategories = categories.filter((_, i) => i !== index);
|
|
152
|
+
const fallback = nextCategories[0]?.id || '';
|
|
153
|
+
setCategories(nextCategories);
|
|
154
|
+
setWords((prev) => prev.map((word) => (word.categoryId === removedId ? { ...word, categoryId: fallback } : word)));
|
|
155
|
+
};
|
|
156
|
+
const addWord = () => {
|
|
157
|
+
setWords((prev) => [
|
|
158
|
+
...prev,
|
|
159
|
+
createEmptyWord(prev.map((item) => item.id), categories[0]?.id || ''),
|
|
160
|
+
]);
|
|
161
|
+
};
|
|
162
|
+
const removeWord = (index) => {
|
|
163
|
+
if (words.length <= 1)
|
|
164
|
+
return;
|
|
165
|
+
setWords((prev) => prev.filter((_, i) => i !== index));
|
|
166
|
+
};
|
|
167
|
+
const displayErrors = [...errors, ...(externalErrors || [])];
|
|
168
|
+
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(SortWordsIntoCategoriesClient, { questionData: buildPayload(), isReviewMode: true })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "swc-title", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "swc-title", value: title, onChange: (event) => setTitle(event.target.value), placeholder: "VOCABULARY" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "swc-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "swc-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: SORT_WORDS_INTO_CATEGORIES_DEFAULT_INSTRUCTION })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsx(CardTitle, { className: "text-base", children: "Nh\u00F3m (categories)" }), _jsxs(Button, { type: "button", size: "sm", variant: "outline", onClick: addCategory, children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), " Th\u00EAm nh\u00F3m"] })] }), _jsx(CardContent, { className: "space-y-3", children: categories.map((category, index) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { value: category.name, onChange: (event) => updateCategory(index, { name: event.target.value }), placeholder: `Nhóm ${index + 1}` }), categories.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeCategory(index), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] }, category.id))) })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsx(CardTitle, { className: "text-base", children: "T\u1EEB / c\u1EE5m t\u1EEB" }), _jsxs(Button, { type: "button", size: "sm", variant: "outline", onClick: addWord, children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), " Th\u00EAm t\u1EEB"] })] }), _jsx(CardContent, { className: "space-y-3", children: words.map((word, index) => (_jsxs("div", { className: "grid gap-2 rounded-lg border p-3 md:grid-cols-[1fr_180px_auto]", children: [_jsx(Input, { value: word.text, onChange: (event) => updateWord(index, { text: event.target.value }), placeholder: `Từ #${index + 1} (VD: press a button)` }), _jsx("select", { className: "h-10 rounded-md border border-input bg-background px-3 text-sm", value: word.categoryId, onChange: (event) => updateWord(index, { categoryId: event.target.value }), children: categories.map((category) => (_jsx("option", { value: category.id, children: category.name || category.id }, category.id))) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Switch, { id: `swc-example-${index}`, checked: word.isExample === true, onCheckedChange: (checked) => updateWord(index, { isExample: checked }) }), _jsxs(Label, { htmlFor: `swc-example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !word.isExample && (_jsx(PointsInput, { value: word.points, allowZero: true, onChange: (event) => updateWord(index, {
|
|
169
|
+
points: Number.parseInt(event.target.value, 10) || 1,
|
|
170
|
+
}) })), words.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeWord(index), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] })] }, word.id))) })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "swc-explanation", children: "Gi\u1EA3i th\u00EDch" }), _jsx(Textarea, { id: "swc-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))) }))] }));
|
|
171
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SortWordsCategory, SortWordsIntoCategoriesData, SortWordsPlacement, SortWordsWordItem } from '../../_shared/types/sort-words-into-categories.type';
|
|
2
|
+
export declare function nextPrefixedId(prefix: string, existing: string[]): string;
|
|
3
|
+
export declare function sumGradablePoints(words: SortWordsWordItem[]): number;
|
|
4
|
+
export declare function buildPlacements(words: SortWordsWordItem[]): SortWordsPlacement[];
|
|
5
|
+
export declare function readPlacements(value: unknown): SortWordsPlacement[] | undefined;
|
|
6
|
+
export declare function padPlacements(words: Array<{
|
|
7
|
+
id: string;
|
|
8
|
+
}>, raw: unknown): SortWordsPlacement[];
|
|
9
|
+
export declare function createEmptyCategory(existingIds: string[]): SortWordsCategory;
|
|
10
|
+
export declare function createEmptyWord(existingIds: string[], categoryId: string): SortWordsWordItem;
|
|
11
|
+
export declare function mapQuestionToSortWordsIntoCategoriesData(question: Record<string, unknown> | null | undefined): SortWordsIntoCategoriesData;
|