@tinyweb_dev/oe-exam-sdk 0.2.9 → 0.2.11

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.
Files changed (93) hide show
  1. package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
  2. package/dist/components/exams/take/components/QuestionRenderer.js +7 -3
  3. package/dist/components/exams/take/components/question-renderers/MoversWordOrderingGroupRenderer.d.ts +13 -0
  4. package/dist/components/exams/take/components/question-renderers/MoversWordOrderingGroupRenderer.js +30 -0
  5. package/dist/components/exams/take/components/question-renderers/index.d.ts +1 -0
  6. package/dist/components/exams/take/components/question-renderers/index.js +1 -0
  7. package/dist/components/exams/take/utils/answer-transformers.js +8 -1
  8. package/dist/components/exams/take/utils/question-transformers.d.ts +16 -1
  9. package/dist/components/exams/take/utils/question-transformers.js +47 -2
  10. package/dist/components/questions/_shared/config/question-types.config.js +18 -0
  11. package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +1 -0
  12. package/dist/components/questions/_shared/types/index.d.ts +1 -0
  13. package/dist/components/questions/_shared/types/index.js +1 -0
  14. package/dist/components/questions/_shared/types/match-columns-to-make-sentences.type.d.ts +56 -0
  15. package/dist/components/questions/_shared/types/match-columns-to-make-sentences.type.js +2 -0
  16. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +35 -0
  17. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.js +2 -0
  18. package/dist/components/questions/_shared/types/word-ordering-group.type.d.ts +34 -0
  19. package/dist/components/questions/_shared/types/word-ordering-group.type.js +2 -0
  20. package/dist/components/questions/components/QuestionSearchDropdown.js +3 -0
  21. package/dist/components/questions/components/QuestionTypeSelector.js +3 -0
  22. package/dist/components/questions/creator/question-type-registry.js +6 -0
  23. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +1 -1
  24. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +5 -3
  25. package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +16 -2
  26. package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +4 -0
  27. package/dist/components/questions/types/fill-in-blank-group/transform.js +21 -2
  28. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesClient.d.ts +2 -0
  29. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesClient.js +74 -0
  30. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesCreator.d.ts +2 -0
  31. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesCreator.js +222 -0
  32. package/dist/components/questions/types/match-columns-to-make-sentences/index.d.ts +5 -0
  33. package/dist/components/questions/types/match-columns-to-make-sentences/index.js +5 -0
  34. package/dist/components/questions/types/match-columns-to-make-sentences/map-match-columns-to-make-sentences-data.d.ts +8 -0
  35. package/dist/components/questions/types/match-columns-to-make-sentences/map-match-columns-to-make-sentences-data.js +199 -0
  36. package/dist/components/questions/types/match-columns-to-make-sentences/register.d.ts +2 -0
  37. package/dist/components/questions/types/match-columns-to-make-sentences/register.js +36 -0
  38. package/dist/components/questions/types/match-columns-to-make-sentences/transform.d.ts +2 -0
  39. package/dist/components/questions/types/match-columns-to-make-sentences/transform.js +66 -0
  40. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.d.ts +2 -0
  41. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +65 -0
  42. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.d.ts +2 -0
  43. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +95 -0
  44. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupImageField.d.ts +6 -0
  45. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupImageField.js +16 -0
  46. package/dist/components/questions/types/match-word-to-picture-group/index.d.ts +5 -0
  47. package/dist/components/questions/types/match-word-to-picture-group/index.js +5 -0
  48. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.d.ts +4 -0
  49. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +77 -0
  50. package/dist/components/questions/types/match-word-to-picture-group/register.d.ts +2 -0
  51. package/dist/components/questions/types/match-word-to-picture-group/register.js +36 -0
  52. package/dist/components/questions/types/match-word-to-picture-group/transform.d.ts +2 -0
  53. package/dist/components/questions/types/match-word-to-picture-group/transform.js +36 -0
  54. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupClient.d.ts +2 -0
  55. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupClient.js +25 -0
  56. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupCreator.d.ts +2 -0
  57. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupCreator.js +121 -0
  58. package/dist/components/questions/types/word-ordering-group/index.d.ts +5 -0
  59. package/dist/components/questions/types/word-ordering-group/index.js +5 -0
  60. package/dist/components/questions/types/word-ordering-group/map-word-ordering-group-data.d.ts +4 -0
  61. package/dist/components/questions/types/word-ordering-group/map-word-ordering-group-data.js +74 -0
  62. package/dist/components/questions/types/word-ordering-group/register.d.ts +2 -0
  63. package/dist/components/questions/types/word-ordering-group/register.js +36 -0
  64. package/dist/components/questions/types/word-ordering-group/transform.d.ts +2 -0
  65. package/dist/components/questions/types/word-ordering-group/transform.js +33 -0
  66. package/dist/components/questions/types/write-a-short-letter/WriteAShortLetterCreator.js +5 -5
  67. package/dist/components/questions/viewer/MatchColumnsToMakeSentencesViewer.d.ts +8 -0
  68. package/dist/components/questions/viewer/MatchColumnsToMakeSentencesViewer.js +49 -0
  69. package/dist/components/questions/viewer/MatchWordToPictureGroupViewer.js +2 -2
  70. package/dist/components/questions/viewer/QuestionViewer.js +6 -0
  71. package/dist/components/questions/viewer/WordOrderingGroupViewer.d.ts +7 -0
  72. package/dist/components/questions/viewer/WordOrderingGroupViewer.js +29 -0
  73. package/dist/components/questions/viewer/index.d.ts +2 -0
  74. package/dist/components/questions/viewer/index.js +2 -0
  75. package/dist/components/results/renderers/review-question-body-dedicated.js +1 -1
  76. package/dist/components/results/renderers/review-question-body-movers.js +6 -2
  77. package/dist/components/results/review-data.js +13 -0
  78. package/dist/index.d.ts +1 -1
  79. package/dist/index.js +1 -1
  80. package/dist/shared/constants/question-skills.js +6 -0
  81. package/dist/shared/lib/utils/question-reverse-transform.js +111 -2
  82. package/dist/shared/lib/utils/question-transform.js +6 -0
  83. package/dist/shared/types/common.types.d.ts +1 -1
  84. package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +1 -0
  85. package/dist/shared/types/questions/index.d.ts +3 -0
  86. package/dist/shared/types/questions/index.js +6 -0
  87. package/dist/shared/types/questions/match-columns-to-make-sentences.d.ts +50 -0
  88. package/dist/shared/types/questions/match-columns-to-make-sentences.js +1 -0
  89. package/dist/shared/types/questions/match-word-to-picture-group.d.ts +31 -0
  90. package/dist/shared/types/questions/match-word-to-picture-group.js +1 -0
  91. package/dist/shared/types/questions/word-ordering-group.d.ts +28 -0
  92. package/dist/shared/types/questions/word-ordering-group.js +1 -0
  93. package/package.json +1 -1
@@ -0,0 +1,121 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Button } from '../../../../components/ui/button';
5
+ import { Label } from '../../../../components/ui/label';
6
+ import { Input } from '../../../../components/ui/input';
7
+ import { 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
+ function parsePointsValue(raw, allowZero = false) {
13
+ if (raw === '')
14
+ return allowZero ? 0 : 1;
15
+ const parsed = parseInt(raw.replace(/[^0-9]/g, ''), 10);
16
+ if (Number.isNaN(parsed))
17
+ return allowZero ? 0 : 1;
18
+ const min = allowZero ? 0 : 1;
19
+ return Math.max(min, parsed);
20
+ }
21
+ import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
22
+ import { WordOrderingGroupClient } from './WordOrderingGroupClient';
23
+ import { WORD_ORDERING_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/word-ordering-group.type';
24
+ import { createEmptyWordOrderingGroupItem, mapQuestionToWordOrderingGroupData, } from './map-word-ordering-group-data';
25
+ function withMinTokens(tokens, min = 2) {
26
+ if (tokens.length >= min) {
27
+ return tokens;
28
+ }
29
+ return [...tokens, ...Array.from({ length: min - tokens.length }, () => '')];
30
+ }
31
+ function setTokenAt(tokens, index, value) {
32
+ const next = tokens.length > index
33
+ ? [...tokens]
34
+ : [...tokens, ...Array.from({ length: index - tokens.length + 1 }, () => '')];
35
+ next[index] = value;
36
+ return next;
37
+ }
38
+ function TokenBoxRow({ tokens, onChange, onAdd, onRemove, }) {
39
+ return (_jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-2", children: [tokens.map((token, index) => (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("span", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-gray-100 text-xs font-semibold text-gray-600", children: index + 1 }), _jsx(Input, { className: "h-9 w-28", value: token, onChange: (event) => onChange(index, event.target.value), placeholder: `Token ${index + 1}` }), tokens.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-700", onClick: () => onRemove(index), children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] }, index))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: onAdd, children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), "Th\u00EAm"] })] }));
40
+ }
41
+ function sumPoints(items) {
42
+ return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
43
+ }
44
+ export function WordOrderingGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
45
+ const seeded = mapQuestionToWordOrderingGroupData(initialData ? { answer: initialData } : null);
46
+ const [instruction, setInstruction] = useState(initialData?.instruction || seeded.instruction || WORD_ORDERING_GROUP_DEFAULT_INSTRUCTION);
47
+ const [items, setItems] = useState(() => (initialData?.items?.length ? initialData.items : seeded.items));
48
+ const [explanation, setExplanation] = useState(initialData?.explanation || '');
49
+ const [isClientMode, setIsClientMode] = useState(false);
50
+ const [errors, setErrors] = useState([]);
51
+ const onChangeRef = useRef(onChange);
52
+ const previousPayloadRef = useRef('');
53
+ useEffect(() => {
54
+ onChangeRef.current = onChange;
55
+ }, [onChange]);
56
+ const buildPayload = (nextInstruction = instruction, nextItems = items, nextExplanation = explanation) => ({
57
+ instruction: nextInstruction,
58
+ items: nextItems,
59
+ explanation: nextExplanation,
60
+ points: sumPoints(nextItems),
61
+ });
62
+ const debouncedOnChange = useDebouncedCallback((payload) => {
63
+ onChangeRef.current?.(payload);
64
+ }, 300);
65
+ useEffect(() => {
66
+ const payload = buildPayload();
67
+ const serialized = JSON.stringify(payload);
68
+ if (serialized !== previousPayloadRef.current) {
69
+ previousPayloadRef.current = serialized;
70
+ debouncedOnChange(payload);
71
+ onUnsavedChangesChange?.(true);
72
+ }
73
+ }, [instruction, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
74
+ const validate = () => {
75
+ const nextErrors = [];
76
+ if (!instruction.trim())
77
+ nextErrors.push('Hướng dẫn không được để trống.');
78
+ if (items.length < 1)
79
+ nextErrors.push('Cần ít nhất một câu.');
80
+ items.forEach((item, index) => {
81
+ if (item.words.filter(Boolean).length < 2) {
82
+ nextErrors.push(`Câu ${index + 1}: cần ít nhất 2 token (đã xáo).`);
83
+ }
84
+ if (item.answer.filter(Boolean).length < 2) {
85
+ nextErrors.push(`Câu ${index + 1}: cần thứ tự đúng.`);
86
+ }
87
+ const normalizeSet = (tokens) => tokens.map((token) => token.trim().toLowerCase()).filter(Boolean).sort().join('\0');
88
+ if (item.words.filter(Boolean).length >= 2 &&
89
+ item.answer.filter(Boolean).length >= 2 &&
90
+ normalizeSet(item.words) !== normalizeSet(item.answer)) {
91
+ nextErrors.push(`Câu ${index + 1}: thứ tự đúng phải dùng cùng bộ token đã xáo.`);
92
+ }
93
+ });
94
+ setErrors(nextErrors);
95
+ return nextErrors.length === 0;
96
+ };
97
+ useEffect(() => {
98
+ if (validationRef) {
99
+ validationRef.current = {
100
+ triggerValidation: async () => validate(),
101
+ getFormData: () => buildPayload(),
102
+ };
103
+ }
104
+ }, [validationRef, instruction, items, explanation]);
105
+ const updateItem = (index, patch) => {
106
+ setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
107
+ };
108
+ const currentPayload = buildPayload();
109
+ const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
110
+ 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 S\u1EAFp x\u1EBFp t\u1EEB (nh\u00F3m)" }), _jsx("p", { className: "text-sm text-gray-500", children: "M\u1ED7i d\u00F2ng m\u1ED9t c\u00E2u x\u00E1o tr\u1ED9n. M\u1ED7i token \u0111i\u1EC1n m\u1ED9t \u00F4." })] }), _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(WordOrderingGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "H\u01B0\u1EDBng d\u1EABn" }) }), _jsx(CardContent, { children: _jsx(Input, { value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: WORD_ORDERING_GROUP_DEFAULT_INSTRUCTION }) })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["C\u00E2u h\u1ECFi (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setItems((prev) => [...prev, createEmptyWordOrderingGroupItem(prev.length + 1)]), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Th\u00EAm c\u00E2u"] })] }), _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, {
111
+ points: parsePointsValue(event.target.value, true),
112
+ }) }) })), 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" }) }))] })] }), _jsxs("div", { children: [_jsx(Label, { children: "Token x\u00E1o tr\u1ED9n" }), _jsx(TokenBoxRow, { tokens: withMinTokens(item.words), onChange: (tokenIndex, value) => updateItem(index, {
113
+ words: setTokenAt(item.words, tokenIndex, value),
114
+ }), onAdd: () => updateItem(index, { words: [...item.words, ''] }), onRemove: (tokenIndex) => updateItem(index, {
115
+ words: item.words.filter((_, current) => current !== tokenIndex),
116
+ }) })] }), _jsxs("div", { children: [_jsx(Label, { children: "Th\u1EE9 t\u1EF1 \u0111\u00FAng" }), _jsx(TokenBoxRow, { tokens: withMinTokens(item.answer), onChange: (tokenIndex, value) => updateItem(index, {
117
+ answer: setTokenAt(item.answer, tokenIndex, value),
118
+ }), onAdd: () => updateItem(index, { answer: [...item.answer, ''] }), onRemove: (tokenIndex) => updateItem(index, {
119
+ answer: item.answer.filter((_, current) => current !== tokenIndex),
120
+ }) })] })] }, 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 }) })] })] }))] }));
121
+ }
@@ -0,0 +1,5 @@
1
+ export * from './WordOrderingGroupCreator';
2
+ export * from './WordOrderingGroupClient';
3
+ export * from './transform';
4
+ export * from './register';
5
+ export * from './map-word-ordering-group-data';
@@ -0,0 +1,5 @@
1
+ export * from './WordOrderingGroupCreator';
2
+ export * from './WordOrderingGroupClient';
3
+ export * from './transform';
4
+ export * from './register';
5
+ export * from './map-word-ordering-group-data';
@@ -0,0 +1,4 @@
1
+ import { type WordOrderingGroupData, type WordOrderingGroupItemData } from '../../_shared/types/word-ordering-group.type';
2
+ export declare function createEmptyWordOrderingGroupItem(questionNumber: number): WordOrderingGroupItemData;
3
+ export declare function mapWordOrderingGroupItem(item: unknown, index: number, fallbackAnswer?: unknown): WordOrderingGroupItemData;
4
+ export declare function mapQuestionToWordOrderingGroupData(question: Record<string, unknown> | null | undefined): WordOrderingGroupData;
@@ -0,0 +1,74 @@
1
+ import { WORD_ORDERING_GROUP_DEFAULT_INSTRUCTION, } from '../../_shared/types/word-ordering-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
+ function asStringList(value) {
12
+ if (!Array.isArray(value))
13
+ return [];
14
+ return value
15
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
16
+ .filter(Boolean);
17
+ }
18
+ export function createEmptyWordOrderingGroupItem(questionNumber) {
19
+ return {
20
+ words: ['', ''],
21
+ answer: ['', ''],
22
+ isExample: false,
23
+ points: 1,
24
+ questionNumber,
25
+ };
26
+ }
27
+ export function mapWordOrderingGroupItem(item, index, fallbackAnswer) {
28
+ const record = asRecord(item);
29
+ const content = asRecord(record.content);
30
+ const correctAnswer = asRecord(record.correctAnswer);
31
+ const isExample = record.isExample === true;
32
+ const words = asStringList(content.words).length
33
+ ? asStringList(content.words)
34
+ : asStringList(record.words);
35
+ const answer = asStringList(correctAnswer.answer).length
36
+ ? asStringList(correctAnswer.answer)
37
+ : asStringList(record.answer).length
38
+ ? asStringList(record.answer)
39
+ : asStringList(fallbackAnswer);
40
+ return {
41
+ words,
42
+ answer,
43
+ isExample,
44
+ points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
45
+ questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
46
+ };
47
+ }
48
+ export function mapQuestionToWordOrderingGroupData(question) {
49
+ const source = asRecord(question);
50
+ const content = asRecord(source.content);
51
+ const meta = asRecord(content.meta);
52
+ const answer = asRecord(source.answer);
53
+ const correctAnswer = asRecord(source.correctAnswer ?? source.correct_answer ?? answer.correctAnswer);
54
+ const explanation = asString(source.explanation) || asString(answer.explanation);
55
+ const fallbackAnswers = Array.isArray(correctAnswer.answer) ? correctAnswer.answer : [];
56
+ if (Array.isArray(content.items) || Array.isArray(answer.items)) {
57
+ const rawItems = Array.isArray(content.items) ? content.items : answer.items;
58
+ const items = rawItems.map((item, index) => mapWordOrderingGroupItem(item, index, fallbackAnswers[index]));
59
+ return {
60
+ instruction: asString(meta.instruction) ||
61
+ asString(answer.instruction) ||
62
+ WORD_ORDERING_GROUP_DEFAULT_INSTRUCTION,
63
+ items,
64
+ explanation,
65
+ points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
66
+ };
67
+ }
68
+ return {
69
+ instruction: WORD_ORDERING_GROUP_DEFAULT_INSTRUCTION,
70
+ items: [createEmptyWordOrderingGroupItem(1)],
71
+ explanation,
72
+ points: 1,
73
+ };
74
+ }
@@ -0,0 +1,2 @@
1
+ import type { QuestionTypeRegistration } from '../../creator/question-type-registry';
2
+ export declare const wordOrderingGroupRegistration: QuestionTypeRegistration;
@@ -0,0 +1,36 @@
1
+ import { WordOrderingGroupCreator } from './WordOrderingGroupCreator';
2
+ import { mapQuestionToWordOrderingGroupData } from './map-word-ordering-group-data';
3
+ export const wordOrderingGroupRegistration = {
4
+ component: WordOrderingGroupCreator,
5
+ transformInitialData: (initialData) => mapQuestionToWordOrderingGroupData(initialData),
6
+ getExtraProps: (ctx) => ({
7
+ onUnsavedChangesChange: ctx.onUnsavedChangesChange,
8
+ validationRef: ctx.validationRef,
9
+ }),
10
+ wrapOnSave: (data, ctx) => {
11
+ const payload = data;
12
+ return {
13
+ type: ctx.questionType,
14
+ content: ctx.state.content,
15
+ points: payload.points ?? ctx.state.points ?? 1,
16
+ level: ctx.state.level,
17
+ difficulty: ctx.state.difficulty,
18
+ skill: ctx.state.skill,
19
+ answer: payload,
20
+ explanation: payload.explanation || '',
21
+ };
22
+ },
23
+ wrapOnChange: (data, ctx) => {
24
+ const payload = data;
25
+ return {
26
+ type: ctx.questionType,
27
+ content: ctx.state.content,
28
+ points: payload.points ?? ctx.state.points ?? 1,
29
+ level: ctx.state.level,
30
+ difficulty: ctx.state.difficulty,
31
+ skill: ctx.state.skill,
32
+ answer: payload,
33
+ explanation: payload.explanation || '',
34
+ };
35
+ },
36
+ };
@@ -0,0 +1,2 @@
1
+ import type { TransformHandler } from '../../../../shared/lib/utils/question-transform-types';
2
+ export declare const transformWordOrderingGroup: TransformHandler;
@@ -0,0 +1,33 @@
1
+ import { WORD_ORDERING_GROUP_DEFAULT_INSTRUCTION, WORD_ORDERING_ITEM_TYPE, } from '../../_shared/types/word-ordering-group.type';
2
+ function tokens(value) {
3
+ if (!Array.isArray(value))
4
+ return [];
5
+ return value
6
+ .filter((item) => typeof item === 'string')
7
+ .map((item) => item.trim())
8
+ .filter(Boolean);
9
+ }
10
+ export const transformWordOrderingGroup = (question) => {
11
+ const answerData = question.answer;
12
+ const items = Array.isArray(answerData?.items) ? answerData.items : [];
13
+ const apiItems = items.map((item, index) => ({
14
+ questionType: WORD_ORDERING_ITEM_TYPE,
15
+ ...(item.isExample ? { isExample: true } : {}),
16
+ content: { words: tokens(item.words) },
17
+ correctAnswer: { answer: tokens(item.answer) },
18
+ points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
19
+ questionNumber: item.questionNumber || index + 1,
20
+ }));
21
+ return {
22
+ apiContent: {
23
+ meta: {
24
+ instruction: answerData?.instruction || WORD_ORDERING_GROUP_DEFAULT_INSTRUCTION,
25
+ },
26
+ items: apiItems,
27
+ },
28
+ apiCorrectAnswer: {
29
+ answer: apiItems.map((item) => item.correctAnswer.answer),
30
+ },
31
+ totalPoints: items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0),
32
+ };
33
+ };
@@ -4,10 +4,10 @@ import { useState, useRef, useEffect } from 'react';
4
4
  import { Button } from '../../../../components/ui/button';
5
5
  import { Label } from '../../../../components/ui/label';
6
6
  import { Input } from '../../../../components/ui/input';
7
- import { Textarea } from '../../../../components/ui/textarea';
8
7
  import { PointsInput } from '../../../../components/ui/points-input';
9
8
  import { FileUpload } from '../../../../components/ui/file-upload';
10
9
  import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
10
+ import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
11
11
  import { Pencil, Eye, Plus, Trash2, Lightbulb, Mail, ImageIcon, X } from 'lucide-react';
12
12
  import { WriteAShortLetterClient } from './WriteAShortLetterClient';
13
13
  import { useReactHookForm } from '../../../../shared/lib/hooks/useReactHookForm';
@@ -316,10 +316,10 @@ function WriteAShortLetterCreatorContent({ initialData, onChange, externalErrors
316
316
  setMaxWords(numValue < 1 ? 1 : numValue);
317
317
  }
318
318
  form.clearErrors('maxWords');
319
- }, placeholder: "\u221E", className: `mt-1 ${errors.maxWords ? 'border-red-500 focus:ring-red-500' : ''}` }), errors.maxWords && (_jsx("p", { className: "mt-1 text-xs text-red-600", children: errors.maxWords }))] })] })] })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "instruction", className: "text-sm font-medium", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Textarea, { id: "instruction", value: instruction, onChange: (e) => {
320
- setInstruction(e.target.value);
321
- form.clearErrors('instruction');
322
- }, placeholder: "V\u00ED d\u1EE5: You are having a birthday party. Write a short letter to your friend, Minh, to invite him. Tell him about the party.", className: `mt-1.5 resize-none ${errors.instruction ? 'border-red-500' : ''}`, rows: 3 }), errors.instruction && (_jsx("p", { className: "mt-1 text-sm text-red-600", children: errors.instruction }))] }), _jsxs("div", { className: "space-y-3 rounded-lg border border-blue-100 bg-blue-50/30 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-100", children: _jsx("span", { className: "text-xs font-semibold text-blue-600", children: "i" }) }), _jsx(Label, { className: "text-sm font-semibold text-gray-800", children: "Th\u00F4ng tin c\u1EA7n bao g\u1ED3m" })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addInformation, className: "h-8 gap-1.5 border-blue-200 bg-white text-blue-600 hover:bg-blue-50 hover:text-blue-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm"] })] }), errors.informations && (_jsx("div", { className: "rounded-md border border-red-200 bg-red-50 p-3", children: _jsx("p", { className: "text-sm text-red-600", children: errors.informations }) })), _jsx("div", { className: "space-y-2", children: informations.map((info, index) => (_jsx("div", { className: "space-y-1", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600", children: index + 1 }), _jsx(Input, { value: info, onChange: (e) => updateInformation(index, e.target.value), placeholder: `Thông tin ${index + 1}...`, className: "flex-1 border-gray-200" }), informations.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeInformation(index), className: "h-8 w-8 flex-shrink-0 p-0 text-gray-400 hover:bg-red-50 hover:text-red-500", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }) }, index))) }), _jsx("p", { className: "text-xs text-gray-500 italic", children: "\uD83D\uDCA1 Li\u1EC7t k\u00EA c\u00E1c th\u00F4ng tin h\u1ECDc sinh c\u1EA7n \u0111\u1EC1 c\u1EADp trong b\u00E0i vi\u1EBFt" })] }), _jsxs("div", { className: "space-y-3 rounded-lg border border-violet-100 bg-violet-50/30 p-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-violet-100", children: _jsx(ImageIcon, { className: "h-3.5 w-3.5 text-violet-600" }) }), _jsx(Label, { className: "text-sm font-semibold text-gray-800", children: "\u1EA2nh h\u01B0\u1EDBng d\u1EABn" }), _jsx("span", { className: "text-xs text-gray-400" })] }), guideImages.length > 0 && (_jsx("div", { className: "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4", children: guideImages.map((imageKey, index) => (_jsx(GuideImageItem, { imageKey: imageKey, index: index, localPreviewUrl: guideImagePreviewUrls[imageKey], onRemove: removeGuideImage }, `${imageKey}-${index}`))) })), _jsx(FileUpload, { label: "Th\u00EAm \u1EA3nh h\u01B0\u1EDBng d\u1EABn", accept: "image/*", value: "", onChange: handleGuideImageUpload, onPresignedUrlChange: handleGuideImagePresignedUrl, autoUpload: true, prefix: "write-short-letter/guide", placeholder: "Ch\u1ECDn ho\u1EB7c k\u00E9o th\u1EA3 \u1EA3nh v\u00E0o \u0111\u00E2y", icon: _jsx(ImageIcon, { className: "h-4 w-4" }) }), _jsx("p", { className: "text-xs text-gray-500 italic", children: "\uD83D\uDCF7 Upload nhi\u1EC1u \u1EA3nh \u0111\u1EC3 h\u01B0\u1EDBng d\u1EABn h\u1ECDc sinh vi\u1EBFt b\u00E0i (v\u00ED d\u1EE5: m\u1EABu th\u01B0, h\u00ECnh \u1EA3nh minh h\u1ECDa)" })] })] })] })] }));
319
+ }, placeholder: "\u221E", className: `mt-1 ${errors.maxWords ? 'border-red-500 focus:ring-red-500' : ''}` }), errors.maxWords && (_jsx("p", { className: "mt-1 text-xs text-red-600", children: errors.maxWords }))] })] })] })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "instruction", className: "text-sm font-medium", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx("div", { className: `mt-1.5 ${errors.instruction ? 'rounded-md ring-1 ring-red-500' : ''}`, children: _jsx(RichTextEditor, { value: instruction, onChange: (html) => {
320
+ setInstruction(html);
321
+ form.clearErrors('instruction');
322
+ }, minHeightClassName: "min-h-[120px]" }) }), errors.instruction && (_jsx("p", { className: "mt-1 text-sm text-red-600", children: errors.instruction }))] }), _jsxs("div", { className: "space-y-3 rounded-lg border border-blue-100 bg-blue-50/30 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-100", children: _jsx("span", { className: "text-xs font-semibold text-blue-600", children: "i" }) }), _jsx(Label, { className: "text-sm font-semibold text-gray-800", children: "Th\u00F4ng tin c\u1EA7n bao g\u1ED3m" })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addInformation, className: "h-8 gap-1.5 border-blue-200 bg-white text-blue-600 hover:bg-blue-50 hover:text-blue-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm"] })] }), errors.informations && (_jsx("div", { className: "rounded-md border border-red-200 bg-red-50 p-3", children: _jsx("p", { className: "text-sm text-red-600", children: errors.informations }) })), _jsx("div", { className: "space-y-2", children: informations.map((info, index) => (_jsx("div", { className: "space-y-1", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600", children: index + 1 }), _jsx(Input, { value: info, onChange: (e) => updateInformation(index, e.target.value), placeholder: `Thông tin ${index + 1}...`, className: "flex-1 border-gray-200" }), informations.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeInformation(index), className: "h-8 w-8 flex-shrink-0 p-0 text-gray-400 hover:bg-red-50 hover:text-red-500", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }) }, index))) }), _jsx("p", { className: "text-xs text-gray-500 italic", children: "\uD83D\uDCA1 Li\u1EC7t k\u00EA c\u00E1c th\u00F4ng tin h\u1ECDc sinh c\u1EA7n \u0111\u1EC1 c\u1EADp trong b\u00E0i vi\u1EBFt" })] }), _jsxs("div", { className: "space-y-3 rounded-lg border border-violet-100 bg-violet-50/30 p-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-violet-100", children: _jsx(ImageIcon, { className: "h-3.5 w-3.5 text-violet-600" }) }), _jsx(Label, { className: "text-sm font-semibold text-gray-800", children: "\u1EA2nh h\u01B0\u1EDBng d\u1EABn" }), _jsx("span", { className: "text-xs text-gray-400" })] }), guideImages.length > 0 && (_jsx("div", { className: "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4", children: guideImages.map((imageKey, index) => (_jsx(GuideImageItem, { imageKey: imageKey, index: index, localPreviewUrl: guideImagePreviewUrls[imageKey], onRemove: removeGuideImage }, `${imageKey}-${index}`))) })), _jsx(FileUpload, { label: "Th\u00EAm \u1EA3nh h\u01B0\u1EDBng d\u1EABn", accept: "image/*", value: "", onChange: handleGuideImageUpload, onPresignedUrlChange: handleGuideImagePresignedUrl, autoUpload: true, prefix: "write-short-letter/guide", placeholder: "Ch\u1ECDn ho\u1EB7c k\u00E9o th\u1EA3 \u1EA3nh v\u00E0o \u0111\u00E2y", icon: _jsx(ImageIcon, { className: "h-4 w-4" }) }), _jsx("p", { className: "text-xs text-gray-500 italic", children: "\uD83D\uDCF7 Upload nhi\u1EC1u \u1EA3nh \u0111\u1EC3 h\u01B0\u1EDBng d\u1EABn h\u1ECDc sinh vi\u1EBFt b\u00E0i (v\u00ED d\u1EE5: m\u1EABu th\u01B0, h\u00ECnh \u1EA3nh minh h\u1ECDa)" })] })] })] })] }));
323
323
  }
324
324
  export function WriteAShortLetterCreator(props) {
325
325
  return _jsx(WriteAShortLetterCreatorContent, { ...props });
@@ -0,0 +1,8 @@
1
+ import type { Question } from '../../../shared/types/entities/question.types';
2
+ import type { MatchColumnsStudentItemAnswer } from '../_shared/types/match-columns-to-make-sentences.type';
3
+ export declare function MatchColumnsToMakeSentencesViewer({ question, isReviewMode, userAnswer, onSubmitAnswer, }: {
4
+ question: Question;
5
+ isReviewMode?: boolean;
6
+ userAnswer?: unknown;
7
+ onSubmitAnswer?: (answer: MatchColumnsStudentItemAnswer[]) => void;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { MatchColumnsToMakeSentencesClient } from '../types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesClient';
5
+ import { mapQuestionToMatchColumnsGroupData } from '../types/match-columns-to-make-sentences/map-match-columns-to-make-sentences-data';
6
+ function isRecord(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ function parseAnswers(value, length) {
10
+ const source = Array.isArray(value)
11
+ ? value
12
+ : isRecord(value) && Array.isArray(value.answer)
13
+ ? value.answer
14
+ : null;
15
+ if (!source)
16
+ return undefined;
17
+ return Array.from({ length }, (_, index) => {
18
+ const raw = source[index];
19
+ if (!isRecord(raw))
20
+ return { fragmentIds: [], sentence: '' };
21
+ return {
22
+ fragmentIds: Array.isArray(raw.fragmentIds)
23
+ ? raw.fragmentIds.filter((id) => typeof id === 'string')
24
+ : [],
25
+ sentence: typeof raw.sentence === 'string' ? raw.sentence : '',
26
+ };
27
+ });
28
+ }
29
+ export function MatchColumnsToMakeSentencesViewer({ question, isReviewMode = false, userAnswer, onSubmitAnswer, }) {
30
+ const questionData = mapQuestionToMatchColumnsGroupData(question);
31
+ const itemCount = questionData.items.length;
32
+ const parsedUser = parseAnswers(userAnswer, itemCount);
33
+ const questionKey = `${question.id}:${itemCount}`;
34
+ const [cachedKey, setCachedKey] = useState(questionKey);
35
+ const [localAnswers, setLocalAnswers] = useState(() => parsedUser ?? questionData.items.map((item) => (item.isExample
36
+ ? { fragmentIds: item.fragmentIds, sentence: item.sentence }
37
+ : { fragmentIds: [item.stemFragmentId], sentence: '' })));
38
+ if (cachedKey !== questionKey) {
39
+ setCachedKey(questionKey);
40
+ setLocalAnswers(parsedUser ?? questionData.items.map((item) => (item.isExample
41
+ ? { fragmentIds: item.fragmentIds, sentence: item.sentence }
42
+ : { fragmentIds: [item.stemFragmentId], sentence: '' })));
43
+ }
44
+ const userAnswers = parsedUser ?? localAnswers;
45
+ return (_jsx(MatchColumnsToMakeSentencesClient, { questionData: questionData, isReviewMode: isReviewMode, userAnswers: userAnswers, onAnswerChange: isReviewMode ? undefined : (next) => {
46
+ setLocalAnswers(next);
47
+ onSubmitAnswer?.(next);
48
+ } }));
49
+ }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { MatchWordToPictureGroupClient } from '../types/match-word-to-picture/MatchWordToPictureGroupClient';
4
- import { mapQuestionToMatchWordToPictureGroupData } from '../types/match-word-to-picture/map-match-word-to-picture-group-data';
3
+ import { MatchWordToPictureGroupClient } from '../types/match-word-to-picture-group/MatchWordToPictureGroupClient';
4
+ import { mapQuestionToMatchWordToPictureGroupData } from '../types/match-word-to-picture-group/map-match-word-to-picture-group-data';
5
5
  export function MatchWordToPictureGroupViewer({ question, isReviewMode = false, userAnswer, onSubmitAnswer, }) {
6
6
  const questionData = mapQuestionToMatchWordToPictureGroupData(question);
7
7
  const reviewAnswers = isReviewMode && !userAnswer
@@ -34,6 +34,8 @@ import { WordOrderAndMatchGroupViewer } from './WordOrderAndMatchGroupViewer';
34
34
  import { AnswerTheQuestionGroupViewer } from './AnswerTheQuestionGroupViewer';
35
35
  import { MatchWordToPictureViewer } from './MatchWordToPictureViewer';
36
36
  import { MatchWordToPictureGroupViewer } from './MatchWordToPictureGroupViewer';
37
+ import { MatchColumnsToMakeSentencesViewer } from './MatchColumnsToMakeSentencesViewer';
38
+ import { WordOrderingGroupViewer } from './WordOrderingGroupViewer';
37
39
  export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false, userAnswer, }) {
38
40
  const [selectedAnswer, setSelectedAnswer] = useState(userAnswer || null);
39
41
  const [playCount, setPlayCount] = useState(0);
@@ -641,6 +643,8 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
641
643
  return (_jsx(FillInBlankGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
642
644
  case 'MATCHING_WITH_LINES_GROUP':
643
645
  return (_jsx(MatchingWithLinesGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
646
+ case 'MATCH_COLUMNS_TO_MAKE_SENTENCES':
647
+ return (_jsx(MatchColumnsToMakeSentencesViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
644
648
  case 'CROSS_OUT_WORD_GROUP':
645
649
  return (_jsx(CrossOutWordGroupViewer, { question: question, isReviewMode: isReviewMode }));
646
650
  case 'ANSWER_THE_QUESTION_GROUP':
@@ -650,6 +654,8 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
650
654
  case 'WORD_ORDER_AND_MATCH_GROUP':
651
655
  case 'WORD_ORDER_AND_MATCH':
652
656
  return (_jsx(WordOrderAndMatchGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer }));
657
+ case 'WORD_ORDERING_GROUP':
658
+ return (_jsx(WordOrderingGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
653
659
  case 'MATCH_WORD_TO_PICTURE_GROUP':
654
660
  return (_jsx(MatchWordToPictureGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
655
661
  case 'MATCH_WORD_TO_PICTURE':
@@ -0,0 +1,7 @@
1
+ import type { Question } from '../../../shared/types/entities/question.types';
2
+ export declare function WordOrderingGroupViewer({ question, isReviewMode, userAnswer, onSubmitAnswer, }: {
3
+ question: Question;
4
+ isReviewMode?: boolean;
5
+ userAnswer?: unknown;
6
+ onSubmitAnswer?: (answer: string[][]) => void;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { WordOrderingGroupClient } from '../types/word-ordering-group/WordOrderingGroupClient';
5
+ import { mapQuestionToWordOrderingGroupData } from '../types/word-ordering-group/map-word-ordering-group-data';
6
+ function isRecord(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ function parseAnswers(value, length) {
10
+ const source = Array.isArray(value)
11
+ ? value
12
+ : isRecord(value) && Array.isArray(value.answer)
13
+ ? value.answer
14
+ : null;
15
+ if (!source)
16
+ return undefined;
17
+ return Array.from({ length }, (_, index) => Array.isArray(source[index])
18
+ ? source[index].filter((token) => typeof token === 'string')
19
+ : []);
20
+ }
21
+ export function WordOrderingGroupViewer({ question, isReviewMode = false, userAnswer, onSubmitAnswer, }) {
22
+ const questionData = mapQuestionToWordOrderingGroupData(question);
23
+ const parsedUser = parseAnswers(userAnswer, questionData.items.length);
24
+ const [localAnswers, setLocalAnswers] = useState(() => parsedUser ?? questionData.items.map((item) => (item.isExample ? item.answer : [])));
25
+ return (_jsx(WordOrderingGroupClient, { questionData: questionData, isReviewMode: isReviewMode, userAnswers: parsedUser ?? localAnswers, onAnswerChange: isReviewMode ? undefined : (next) => {
26
+ setLocalAnswers(next);
27
+ onSubmitAnswer?.(next);
28
+ } }));
29
+ }
@@ -12,3 +12,5 @@ export { WordOrderAndMatchGroupViewer } from './WordOrderAndMatchGroupViewer';
12
12
  export { AnswerTheQuestionGroupViewer } from './AnswerTheQuestionGroupViewer';
13
13
  export { MatchWordToPictureViewer } from './MatchWordToPictureViewer';
14
14
  export { MatchWordToPictureGroupViewer } from './MatchWordToPictureGroupViewer';
15
+ export { MatchColumnsToMakeSentencesViewer } from './MatchColumnsToMakeSentencesViewer';
16
+ export { WordOrderingGroupViewer } from './WordOrderingGroupViewer';
@@ -12,3 +12,5 @@ export { WordOrderAndMatchGroupViewer } from './WordOrderAndMatchGroupViewer';
12
12
  export { AnswerTheQuestionGroupViewer } from './AnswerTheQuestionGroupViewer';
13
13
  export { MatchWordToPictureViewer } from './MatchWordToPictureViewer';
14
14
  export { MatchWordToPictureGroupViewer } from './MatchWordToPictureGroupViewer';
15
+ export { MatchColumnsToMakeSentencesViewer } from './MatchColumnsToMakeSentencesViewer';
16
+ export { WordOrderingGroupViewer } from './WordOrderingGroupViewer';
@@ -40,7 +40,7 @@ export function renderDedicatedReviewBody(ctx) {
40
40
  }
41
41
  case 'CHOOSE_THE_CORRECT_ANSWER_GROUP': {
42
42
  const data = transformChooseTheCorrectAnswerGroup(apiQuestions);
43
- return (_jsx(ReviewChooseBestAnswerRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, correctOptionIdMap: correctOptionIdMap, example: data.example }));
43
+ return (_jsx(ReviewChooseBestAnswerRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl, imageUrl: data.imageUrl, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, correctOptionIdMap: correctOptionIdMap, example: data.example }));
44
44
  }
45
45
  case 'CHOOSE_CORRECT_ADJECTIVE': {
46
46
  const data = transformChooseCorrectAdjective(apiQuestions);
@@ -4,8 +4,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
4
4
  * Movers fallback review cases (isReviewMode=true).
5
5
  * Source: oe-exam-fe ReviewQuestionRenderer.tsx
6
6
  */
7
- import { MoversAnswerTheQuestionRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversLabelThePictureRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversFillInBlankGroupRenderer, MoversMatchingWithLinesGroupRenderer, MoversCrossOutWordGroupRenderer, MoversWordFillParagraphRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderingRenderer, MoversWriteSentencesRenderer, MoversWritingRenderer, MoversCrosswordPuzzleRenderer, } from '../../../components/exams/take/components/question-renderers';
8
- import { transformAnswerTheQuestion, transformFillWordHint, transformGridFill, transformLabelPicture, transformMatchByWritingAnswer, transformMatchWordToPicture, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformTrueFalse, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformMatchingWithLinesGroup, transformWordFillParagraph, transformWordFillStructuredForm, transformWordOrdering, transformWriteSentences, transformWriteShortLetter, transformCrosswordPuzzle, } from '../../../components/exams/take/utils/question-transformers';
7
+ import { MoversAnswerTheQuestionRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversLabelThePictureRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversFillInBlankGroupRenderer, MoversMatchingWithLinesGroupRenderer, MoversCrossOutWordGroupRenderer, MoversWordFillParagraphRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderingRenderer, MoversWordOrderingGroupRenderer, MoversWriteSentencesRenderer, MoversWritingRenderer, MoversCrosswordPuzzleRenderer, } from '../../../components/exams/take/components/question-renderers';
8
+ import { transformAnswerTheQuestion, transformFillWordHint, transformGridFill, transformLabelPicture, transformMatchByWritingAnswer, transformMatchWordToPicture, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformTrueFalse, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformMatchingWithLinesGroup, transformWordFillParagraph, transformWordFillStructuredForm, transformWordOrdering, transformWordOrderingGroupTake, transformWriteSentences, transformWriteShortLetter, transformCrosswordPuzzle, } from '../../../components/exams/take/utils/question-transformers';
9
9
  import { normalizeCrossOutWordGroupAnswer, transformCrossOutWordGroup, } from '../../../components/exams/take/utils/cross-out-word-group.transformer';
10
10
  function asApiQuestions(questions) {
11
11
  return questions;
@@ -189,6 +189,10 @@ export function renderMoversReviewBody(ctx) {
189
189
  const data = transformMatchWordToPicture(apiQuestions);
190
190
  return (_jsx(MoversMatchWordToPictureRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: questionCount, wordBank: data.wordBank, questions: data.questions, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true, correctAnswerTextMap: correctAnswerTextMap, isCorrectMap: isCorrectMap }));
191
191
  }
192
+ case 'WORD_ORDERING_GROUP': {
193
+ const data = transformWordOrderingGroupTake(apiQuestions);
194
+ return (_jsx(MoversWordOrderingGroupRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: data.questions.filter((q) => !q.isExample).length, data: data, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true }));
195
+ }
192
196
  case 'WORD_ORDERING': {
193
197
  const data = transformWordOrdering(apiQuestions);
194
198
  return (_jsx(MoversWordOrderingRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: questionCount, questions: data.questions, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true, rawCorrectAnswerMap: rawCorrectAnswerMap, isCorrectMap: isCorrectMap }));
@@ -231,6 +231,19 @@ function transformStudentAnswer(questionId, questionType, value) {
231
231
  return [`${questionId}-item-${index}`, { answers: [] }];
232
232
  }));
233
233
  }
234
+ if (questionType === 'WORD_ORDERING_GROUP') {
235
+ const answer = Array.isArray(record.answer)
236
+ ? record.answer
237
+ : Array.isArray(value)
238
+ ? value
239
+ : [];
240
+ return Object.fromEntries(answer.map((item, index) => [
241
+ `${questionId}-item-${index}`,
242
+ Array.isArray(item)
243
+ ? item.filter((token) => typeof token === 'string')
244
+ : [],
245
+ ]));
246
+ }
234
247
  if (questionType === 'MATCHING_WITH_LINES_GROUP') {
235
248
  const answer = Array.isArray(record.answer)
236
249
  ? record.answer
package/dist/index.d.ts CHANGED
@@ -26,4 +26,4 @@ export { validateQuestion } from './components/questions/_shared/utils/question-
26
26
  export { buildAnswerPayload } from './components/questions/_shared/utils/answer-builder';
27
27
  export type { QuestionFormState } from './components/questions/_shared/hooks/useQuestionFormState';
28
28
  export * from './components/questions/_shared/types';
29
- export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, FillInBlankGroupViewer, MatchingWithLinesGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, } from './components/questions/viewer';
29
+ export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, FillInBlankGroupViewer, MatchingWithLinesGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, MatchColumnsToMakeSentencesViewer, WordOrderingGroupViewer, } from './components/questions/viewer';
package/dist/index.js CHANGED
@@ -21,4 +21,4 @@ export { QUESTION_TYPE_CONFIG, QUESTION_TYPES_WITH_DEDICATED_COMPONENTS } from '
21
21
  export { validateQuestion } from './components/questions/_shared/utils/question-validation';
22
22
  export { buildAnswerPayload } from './components/questions/_shared/utils/answer-builder';
23
23
  export * from './components/questions/_shared/types';
24
- export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, FillInBlankGroupViewer, MatchingWithLinesGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, } from './components/questions/viewer';
24
+ export { QuestionViewer, QuestionGroupViewer, WordFillStructuredFormViewer, ChooseTheCorrectAnswerGroupViewer, TrueFalseGroupViewer, TrueFalseCorrectGroupViewer, FillInBlankGroupViewer, MatchingWithLinesGroupViewer, CrossOutWordGroupViewer, SpontaneousQaGroupViewer, WordOrderAndMatchGroupViewer, AnswerTheQuestionGroupViewer, MatchWordToPictureViewer, MatchWordToPictureGroupViewer, MatchColumnsToMakeSentencesViewer, WordOrderingGroupViewer, } from './components/questions/viewer';
@@ -84,6 +84,9 @@ export const QUESTION_TYPE_SKILLS = {
84
84
  'WORD_FILL_STRUCTURED_FORM': SkillEnum.READING,
85
85
  'CROSSWORD_PUZZLE': SkillEnum.READING,
86
86
  'FIND_WORDS_IN_MATRIX': SkillEnum.READING,
87
+ 'MATCH_COLUMNS_TO_MAKE_SENTENCES': SkillEnum.READING,
88
+ 'MATCH_WORD_TO_PICTURE_GROUP': SkillEnum.READING,
89
+ 'WORD_ORDERING_GROUP': SkillEnum.READING,
87
90
  // ========================================
88
91
  // WRITING SKILLS (Viết)
89
92
  // ========================================
@@ -216,6 +219,9 @@ export const QUESTION_TYPE_LABELS = {
216
219
  'WORD_FILL_STRUCTURED_FORM': 'Điền từ vào biểu mẫu có cấu trúc',
217
220
  'CROSSWORD_PUZZLE': 'Giải ô chữ (Crossword)',
218
221
  'FIND_WORDS_IN_MATRIX': 'Tìm từ trong ma trận (Word Search)',
222
+ 'MATCH_COLUMNS_TO_MAKE_SENTENCES': 'Ghép cột thành câu',
223
+ 'MATCH_WORD_TO_PICTURE_GROUP': 'Nối từ với tranh (nhóm)',
224
+ 'WORD_ORDERING_GROUP': 'Sắp xếp từ (nhóm)',
219
225
  // ========================================
220
226
  // WRITING SKILLS (Viết)
221
227
  // ========================================