@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,36 @@
1
+ import { MatchColumnsToMakeSentencesCreator } from './MatchColumnsToMakeSentencesCreator';
2
+ import { mapQuestionToMatchColumnsGroupData } from './map-match-columns-to-make-sentences-data';
3
+ export const matchColumnsToMakeSentencesRegistration = {
4
+ component: MatchColumnsToMakeSentencesCreator,
5
+ transformInitialData: (initialData) => mapQuestionToMatchColumnsGroupData(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 transformMatchColumnsToMakeSentences: TransformHandler;
@@ -0,0 +1,66 @@
1
+ import { MATCH_COLUMNS_DEFAULT_INSTRUCTION, MATCH_COLUMNS_TO_MAKE_SENTENCE_ITEM_TYPE, } from '../../_shared/types/match-columns-to-make-sentences.type';
2
+ function sumGradablePoints(items) {
3
+ return items.reduce((total, item) => {
4
+ if (item.isExample) {
5
+ return total;
6
+ }
7
+ return total + (typeof item.points === 'number' ? item.points : 1);
8
+ }, 0);
9
+ }
10
+ function assembleSentence(fragmentIds, columns) {
11
+ const texts = new Map();
12
+ columns.forEach((column) => {
13
+ column.fragments.forEach((fragment) => {
14
+ texts.set(fragment.id, fragment.text);
15
+ });
16
+ });
17
+ return fragmentIds
18
+ .map((id) => texts.get(id) || '')
19
+ .filter(Boolean)
20
+ .join(' ')
21
+ .replace(/\s+/g, ' ')
22
+ .trim();
23
+ }
24
+ export const transformMatchColumnsToMakeSentences = (question) => {
25
+ const answerData = question.answer;
26
+ const columns = Array.isArray(answerData?.columns) ? answerData.columns : [];
27
+ const items = Array.isArray(answerData?.items) ? answerData.items : [];
28
+ if (items.length === 0) {
29
+ return {
30
+ apiContent: { columns, items: [] },
31
+ apiCorrectAnswer: { answer: [] },
32
+ };
33
+ }
34
+ const apiItems = items.map((item, index) => {
35
+ const fragmentIds = Array.isArray(item.fragmentIds) ? item.fragmentIds : [];
36
+ const sentence = item.sentence?.trim() || assembleSentence(fragmentIds, columns);
37
+ return {
38
+ questionType: MATCH_COLUMNS_TO_MAKE_SENTENCE_ITEM_TYPE,
39
+ ...(item.isExample ? { isExample: true } : {}),
40
+ content: {
41
+ stemFragmentId: item.stemFragmentId || fragmentIds[0] || '',
42
+ },
43
+ correctAnswer: {
44
+ answer: { fragmentIds, sentence },
45
+ },
46
+ points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
47
+ questionNumber: item.questionNumber || index + 1,
48
+ };
49
+ });
50
+ const title = answerData?.title?.trim();
51
+ const instruction = answerData?.instruction || MATCH_COLUMNS_DEFAULT_INSTRUCTION;
52
+ return {
53
+ apiContent: {
54
+ meta: {
55
+ ...(title ? { title } : {}),
56
+ instruction,
57
+ },
58
+ columns,
59
+ items: apiItems,
60
+ },
61
+ apiCorrectAnswer: {
62
+ answer: apiItems.map((item) => item.correctAnswer.answer),
63
+ },
64
+ totalPoints: sumGradablePoints(items),
65
+ };
66
+ };
@@ -0,0 +1,2 @@
1
+ import type { MatchWordToPictureGroupClientProps } from '../../_shared/types/match-word-to-picture-group.type';
2
+ export declare function MatchWordToPictureGroupClient({ questionData, isReviewMode, userAnswers, onAnswerChange, }: MatchWordToPictureGroupClientProps): import("react").JSX.Element;
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Check, CircleHelp, Lightbulb, Star, X } from 'lucide-react';
4
+ import { cn } from '../../../../shared/lib/utils';
5
+ import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
+ function normalizeWord(value) {
7
+ return value.trim().toLowerCase();
8
+ }
9
+ function ClientImageItem({ url, alt }) {
10
+ const { previewUrl, isLoading } = usePresignedFileUrl(url);
11
+ const src = previewUrl ||
12
+ (typeof url === 'string' &&
13
+ (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:'))
14
+ ? url
15
+ : '');
16
+ if (!url) {
17
+ return (_jsx("div", { className: "flex h-28 w-28 items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 \u1EA3nh" }));
18
+ }
19
+ if (!src && !isLoading) {
20
+ return (_jsx("div", { className: "flex h-28 w-28 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-xs text-gray-400", children: "Kh\u00F4ng t\u1EA3i \u0111\u01B0\u1EE3c \u1EA3nh" }));
21
+ }
22
+ return (_jsx("div", { className: "relative h-28 w-28 flex-shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-full w-full items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-violet-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Picture', className: "h-full w-full object-contain" })) }));
23
+ }
24
+ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
25
+ const items = questionData.items || [];
26
+ const wordBank = questionData.wordBank || [];
27
+ const handleSelect = (itemIndex, value) => {
28
+ const item = items[itemIndex];
29
+ if (isReviewMode || item?.isExample || !onAnswerChange) {
30
+ return;
31
+ }
32
+ const next = items.map((entry, index) => entry.isExample ? entry.correctAnswer : (userAnswers[index] ?? ''));
33
+ next[itemIndex] = value;
34
+ onAnswerChange(next);
35
+ };
36
+ const usedWords = new Set();
37
+ items.forEach((item, index) => {
38
+ const value = item.isExample ? item.correctAnswer : (userAnswers[index] ?? '');
39
+ if (value.trim()) {
40
+ usedWords.add(normalizeWord(value));
41
+ }
42
+ });
43
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-indigo-50 p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-violet-700", children: "Word bank:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => {
44
+ const isUsed = usedWords.has(normalizeWord(word));
45
+ return (_jsx("span", { className: cn('rounded-full border px-3 py-1 text-sm font-semibold', isUsed
46
+ ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
47
+ : 'border-violet-200 bg-white text-slate-700'), children: word }, `${word}-${index}`));
48
+ }) })] })), items.map((item, itemIndex) => {
49
+ const selectedWord = item.isExample
50
+ ? item.correctAnswer
51
+ : (userAnswers[itemIndex] ?? '');
52
+ const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
53
+ const isCorrect = normalizeWord(selectedWord) === normalizeWord(item.correctAnswer) &&
54
+ Boolean(item.correctAnswer);
55
+ return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex flex-col gap-4 p-4 sm:flex-row sm:items-center", children: [_jsx(ClientImageItem, { url: item.imageUrl, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1}` }), _jsxs("div", { className: "min-w-0 flex-1 space-y-2", children: [_jsx("p", { className: "text-sm font-semibold text-gray-800", children: "Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p" }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("select", { value: selectedWord, disabled: !canSelect, onChange: (event) => handleSelect(itemIndex, event.target.value), className: cn('h-10 max-w-xs rounded-md border-2 bg-white px-3 text-sm font-medium', isReviewMode && isCorrect
56
+ ? 'border-green-400 bg-green-50 text-green-700'
57
+ : isReviewMode && selectedWord && !isCorrect
58
+ ? 'border-red-400 bg-red-50 text-red-700'
59
+ : 'border-violet-300 focus:border-violet-500 focus:ring-violet-200', !canSelect && 'cursor-default opacity-80'), children: [_jsx("option", { value: "", children: "-- Ch\u1ECDn t\u1EEB --" }), wordBank.map((word, wordIndex) => {
60
+ const isUsedByOther = usedWords.has(normalizeWord(word)) &&
61
+ normalizeWord(word) !== normalizeWord(selectedWord);
62
+ return (_jsx("option", { value: word, disabled: isUsedByOther, children: word }, `${word}-${wordIndex}`));
63
+ })] }), isReviewMode && !item.isExample && (_jsx("span", { className: "flex items-center gap-1", children: isCorrect ? (_jsx(Check, { className: "h-5 w-5 text-green-500" })) : (_jsx(X, { className: "h-5 w-5 text-red-500" })) }))] }), isReviewMode && !item.isExample && !isCorrect && (_jsxs("p", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: item.correctAnswer || '—' })] }))] })] })] }, `${item.questionNumber}-${itemIndex}`));
64
+ }), 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] }))] }));
65
+ }
@@ -0,0 +1,2 @@
1
+ import type { MatchWordToPictureGroupCreatorProps } from '../../_shared/types/match-word-to-picture-group.type';
2
+ export declare function MatchWordToPictureGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: MatchWordToPictureGroupCreatorProps): import("react").JSX.Element;
@@ -0,0 +1,95 @@
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 { MatchWordToPictureGroupClient } from './MatchWordToPictureGroupClient';
23
+ import { MatchWordToPictureGroupImageField } from './MatchWordToPictureGroupImageField';
24
+ import { MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/match-word-to-picture-group.type';
25
+ import { createEmptyMatchWordToPictureGroupItem, mapQuestionToMatchWordToPictureGroupData, } from './map-match-word-to-picture-group-data';
26
+ function sumPoints(items) {
27
+ return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
28
+ }
29
+ export function MatchWordToPictureGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
30
+ const seeded = mapQuestionToMatchWordToPictureGroupData(initialData ? { answer: initialData } : null);
31
+ const [instruction, setInstruction] = useState(initialData?.instruction || seeded.instruction || MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION);
32
+ const [wordBank, setWordBank] = useState(initialData?.wordBank?.length ? initialData.wordBank : seeded.wordBank);
33
+ const [items, setItems] = useState(() => (initialData?.items?.length ? initialData.items : seeded.items));
34
+ const [explanation, setExplanation] = useState(initialData?.explanation || '');
35
+ const [isClientMode, setIsClientMode] = useState(false);
36
+ const [errors, setErrors] = useState([]);
37
+ const onChangeRef = useRef(onChange);
38
+ const previousPayloadRef = useRef('');
39
+ useEffect(() => {
40
+ onChangeRef.current = onChange;
41
+ }, [onChange]);
42
+ const buildPayload = (nextInstruction = instruction, nextWordBank = wordBank, nextItems = items, nextExplanation = explanation) => ({
43
+ instruction: nextInstruction,
44
+ wordBank: nextWordBank,
45
+ items: nextItems,
46
+ explanation: nextExplanation,
47
+ points: sumPoints(nextItems),
48
+ });
49
+ const debouncedOnChange = useDebouncedCallback((payload) => {
50
+ onChangeRef.current?.(payload);
51
+ }, 300);
52
+ useEffect(() => {
53
+ const payload = buildPayload();
54
+ const serialized = JSON.stringify(payload);
55
+ if (serialized !== previousPayloadRef.current) {
56
+ previousPayloadRef.current = serialized;
57
+ debouncedOnChange(payload);
58
+ onUnsavedChangesChange?.(true);
59
+ }
60
+ }, [instruction, wordBank, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
61
+ const validate = () => {
62
+ const nextErrors = [];
63
+ if (!instruction.trim())
64
+ nextErrors.push('Hướng dẫn không được để trống.');
65
+ const words = wordBank.map((word) => word.trim()).filter(Boolean);
66
+ if (words.length < 1)
67
+ nextErrors.push('Word bank cần ít nhất một từ.');
68
+ if (items.length < 1)
69
+ nextErrors.push('Cần ít nhất một câu hỏi.');
70
+ items.forEach((item, index) => {
71
+ if (!item.imageUrl.trim())
72
+ nextErrors.push(`Câu ${index + 1}: cần ảnh.`);
73
+ if (!item.correctAnswer.trim())
74
+ nextErrors.push(`Câu ${index + 1}: cần chọn từ đúng.`);
75
+ });
76
+ setErrors(nextErrors);
77
+ return nextErrors.length === 0;
78
+ };
79
+ useEffect(() => {
80
+ if (validationRef) {
81
+ validationRef.current = {
82
+ triggerValidation: async () => validate(),
83
+ getFormData: () => buildPayload(),
84
+ };
85
+ }
86
+ }, [validationRef, instruction, wordBank, items, explanation]);
87
+ const updateItem = (index, patch) => {
88
+ setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
89
+ };
90
+ const currentPayload = buildPayload();
91
+ const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
92
+ 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; 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-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "mwtp-group-instruction", className: "mt-1", value: instruction, onChange: (e) => setInstruction(e.target.value) })] }), 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, {
93
+ points: parsePointsValue(event.target.value, true),
94
+ }) }) })), 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, onChange: (url) => updateItem(index, { imageUrl: url }) }), _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" }), wordBank.filter((word) => word.trim()).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 }) })] })] }))] }));
95
+ }
@@ -0,0 +1,6 @@
1
+ export declare function MatchWordToPictureGroupImageField({ id, label, value, onChange, }: {
2
+ id: string;
3
+ label: string;
4
+ value: string;
5
+ onChange: (url: string) => void;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { Button } from '../../../../components/ui/button';
5
+ import { FileUpload } from '../../../../components/ui/file-upload';
6
+ import { ImagePreview } from '../../../../components/ui/image-preview';
7
+ import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
8
+ import { Trash2 } from 'lucide-react';
9
+ export function MatchWordToPictureGroupImageField({ id, label, value, onChange, }) {
10
+ const [previewOverride, setPreviewOverride] = useState('');
11
+ const { previewUrl: fetchedPreviewUrl } = usePresignedFileUrl(value);
12
+ const displayUrl = previewOverride ||
13
+ fetchedPreviewUrl ||
14
+ (value.startsWith('http') || value.startsWith('blob:') ? value : '');
15
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { id: id, label: label, accept: "image/*", value: value, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), value && (_jsxs("div", { className: "flex items-center gap-2", children: [displayUrl && _jsx(ImagePreview, { src: displayUrl, alt: label, className: "h-20 w-20" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onChange(''), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) })] }))] }));
16
+ }
@@ -0,0 +1,5 @@
1
+ export * from './MatchWordToPictureGroupCreator';
2
+ export * from './MatchWordToPictureGroupClient';
3
+ export * from './transform';
4
+ export * from './register';
5
+ export * from './map-match-word-to-picture-group-data';
@@ -0,0 +1,5 @@
1
+ export * from './MatchWordToPictureGroupCreator';
2
+ export * from './MatchWordToPictureGroupClient';
3
+ export * from './transform';
4
+ export * from './register';
5
+ export * from './map-match-word-to-picture-group-data';
@@ -0,0 +1,4 @@
1
+ import { type MatchWordToPictureGroupData, type MatchWordToPictureGroupItemData } from '../../_shared/types/match-word-to-picture-group.type';
2
+ export declare function createEmptyMatchWordToPictureGroupItem(questionNumber: number): MatchWordToPictureGroupItemData;
3
+ export declare function mapMatchWordToPictureGroupItem(item: unknown, index: number, fallbackAnswer?: string): MatchWordToPictureGroupItemData;
4
+ export declare function mapQuestionToMatchWordToPictureGroupData(question: Record<string, unknown> | null | undefined): MatchWordToPictureGroupData;
@@ -0,0 +1,77 @@
1
+ import { MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION, } from '../../_shared/types/match-word-to-picture-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 createEmptyMatchWordToPictureGroupItem(questionNumber) {
19
+ return {
20
+ imageUrl: '',
21
+ correctAnswer: '',
22
+ isExample: false,
23
+ points: 1,
24
+ questionNumber,
25
+ };
26
+ }
27
+ export function mapMatchWordToPictureGroupItem(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
+ return {
33
+ imageUrl: asString(content.imageUrl) || asString(record.imageUrl),
34
+ correctAnswer: asString(correctAnswer.answer) ||
35
+ asString(record.correctAnswer) ||
36
+ asString(fallbackAnswer),
37
+ isExample,
38
+ points: isExample ? 0 : typeof record.points === 'number' ? record.points : 1,
39
+ questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
40
+ };
41
+ }
42
+ export function mapQuestionToMatchWordToPictureGroupData(question) {
43
+ const source = asRecord(question);
44
+ const content = asRecord(source.content);
45
+ const meta = asRecord(content.meta);
46
+ const answer = asRecord(source.answer);
47
+ const correctAnswer = asRecord(source.correctAnswer ?? source.correct_answer ?? answer.correctAnswer);
48
+ const explanation = asString(source.explanation) || asString(answer.explanation);
49
+ const fallbackAnswers = Array.isArray(correctAnswer.answer)
50
+ ? correctAnswer.answer.map((value) => (typeof value === 'string' ? value : ''))
51
+ : [];
52
+ if (Array.isArray(content.items) || Array.isArray(answer.items)) {
53
+ const rawItems = Array.isArray(content.items) ? content.items : answer.items;
54
+ const items = rawItems.map((item, index) => mapMatchWordToPictureGroupItem(item, index, fallbackAnswers[index]));
55
+ const wordBank = asStringList(meta.wordBank).length
56
+ ? asStringList(meta.wordBank)
57
+ : asStringList(answer.wordBank).length
58
+ ? asStringList(answer.wordBank)
59
+ : Array.from(new Set(items.map((item) => item.correctAnswer).filter(Boolean)));
60
+ return {
61
+ instruction: asString(meta.instruction) ||
62
+ asString(answer.instruction) ||
63
+ MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION,
64
+ wordBank,
65
+ items,
66
+ explanation,
67
+ points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
68
+ };
69
+ }
70
+ return {
71
+ instruction: MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION,
72
+ wordBank: ['', ''],
73
+ items: [createEmptyMatchWordToPictureGroupItem(1)],
74
+ explanation,
75
+ points: 1,
76
+ };
77
+ }
@@ -0,0 +1,2 @@
1
+ import type { QuestionTypeRegistration } from '../../creator/question-type-registry';
2
+ export declare const matchWordToPictureGroupRegistration: QuestionTypeRegistration;
@@ -0,0 +1,36 @@
1
+ import { MatchWordToPictureGroupCreator } from './MatchWordToPictureGroupCreator';
2
+ import { mapQuestionToMatchWordToPictureGroupData } from './map-match-word-to-picture-group-data';
3
+ export const matchWordToPictureGroupRegistration = {
4
+ component: MatchWordToPictureGroupCreator,
5
+ transformInitialData: (initialData) => mapQuestionToMatchWordToPictureGroupData(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 transformMatchWordToPictureGroup: TransformHandler;
@@ -0,0 +1,36 @@
1
+ import { MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION, MATCH_WORD_TO_PICTURE_ITEM_TYPE, } from '../../_shared/types/match-word-to-picture-group.type';
2
+ function sumGradablePoints(items) {
3
+ return items.reduce((total, item) => {
4
+ if (item.isExample)
5
+ return total;
6
+ return total + (typeof item.points === 'number' ? item.points : 1);
7
+ }, 0);
8
+ }
9
+ export const transformMatchWordToPictureGroup = (question) => {
10
+ const answerData = question.answer;
11
+ const items = Array.isArray(answerData?.items) ? answerData.items : [];
12
+ const wordBank = Array.isArray(answerData?.wordBank)
13
+ ? answerData.wordBank.filter((word) => typeof word === 'string').map((word) => word.trim()).filter(Boolean)
14
+ : [];
15
+ const apiItems = items.map((item, index) => ({
16
+ questionType: MATCH_WORD_TO_PICTURE_ITEM_TYPE,
17
+ ...(item.isExample ? { isExample: true } : {}),
18
+ content: { imageUrl: item.imageUrl || '' },
19
+ correctAnswer: { answer: item.correctAnswer || '' },
20
+ points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
21
+ questionNumber: item.questionNumber || index + 1,
22
+ }));
23
+ return {
24
+ apiContent: {
25
+ meta: {
26
+ instruction: answerData?.instruction || MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION,
27
+ wordBank,
28
+ },
29
+ items: apiItems,
30
+ },
31
+ apiCorrectAnswer: {
32
+ answer: apiItems.map((item) => item.correctAnswer.answer),
33
+ },
34
+ totalPoints: sumGradablePoints(items),
35
+ };
36
+ };
@@ -0,0 +1,2 @@
1
+ import type { WordOrderingGroupClientProps } from '../../_shared/types/word-ordering-group.type';
2
+ export declare function WordOrderingGroupClient({ questionData, isReviewMode, userAnswers, onAnswerChange, }: WordOrderingGroupClientProps): import("react").JSX.Element;
@@ -0,0 +1,25 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Lightbulb, Star } from 'lucide-react';
4
+ import { WordOrderingClient } from '../word-ordering/WordOrderingClient';
5
+ export function WordOrderingGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
6
+ const items = questionData.items || [];
7
+ const handleItemSubmit = (itemIndex, ordered) => {
8
+ if (isReviewMode || items[itemIndex]?.isExample || !onAnswerChange)
9
+ return;
10
+ const next = items.map((item, index) => {
11
+ if (item.isExample)
12
+ return item.answer;
13
+ if (index === itemIndex)
14
+ return ordered;
15
+ return userAnswers[index] || [];
16
+ });
17
+ onAnswerChange(next);
18
+ };
19
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), items.map((item, itemIndex) => (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-4", children: [_jsxs("div", { className: "mb-3 flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsx(WordOrderingClient, { questionData: {
20
+ words: item.words,
21
+ answer: item.answer,
22
+ isExample: item.isExample,
23
+ points: item.points,
24
+ }, isReviewMode: isReviewMode || item.isExample, userAnswer: item.isExample ? item.answer : userAnswers[itemIndex], onSubmit: (ordered) => handleItemSubmit(itemIndex, ordered) })] }, `${item.questionNumber}-${itemIndex}`))), 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] }))] }));
25
+ }
@@ -0,0 +1,2 @@
1
+ import type { WordOrderingGroupCreatorProps } from '../../_shared/types/word-ordering-group.type';
2
+ export declare function WordOrderingGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: WordOrderingGroupCreatorProps): import("react").JSX.Element;