@tinyweb_dev/oe-exam-sdk 0.2.8 → 0.2.10

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 (104) 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/MoversChooseBestAnswerRenderer.js +5 -4
  4. package/dist/components/exams/take/components/question-renderers/MoversWordOrderingGroupRenderer.d.ts +13 -0
  5. package/dist/components/exams/take/components/question-renderers/MoversWordOrderingGroupRenderer.js +30 -0
  6. package/dist/components/exams/take/components/question-renderers/index.d.ts +1 -0
  7. package/dist/components/exams/take/components/question-renderers/index.js +1 -0
  8. package/dist/components/exams/take/utils/answer-transformers.js +8 -1
  9. package/dist/components/exams/take/utils/question-transformers.d.ts +16 -1
  10. package/dist/components/exams/take/utils/question-transformers.js +49 -4
  11. package/dist/components/index.d.ts +1 -0
  12. package/dist/components/index.js +1 -0
  13. package/dist/components/questions/_shared/config/question-types.config.js +18 -0
  14. package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +1 -0
  15. package/dist/components/questions/_shared/types/index.d.ts +1 -0
  16. package/dist/components/questions/_shared/types/index.js +1 -0
  17. package/dist/components/questions/_shared/types/match-columns-to-make-sentences.type.d.ts +56 -0
  18. package/dist/components/questions/_shared/types/match-columns-to-make-sentences.type.js +2 -0
  19. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +35 -0
  20. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.js +2 -0
  21. package/dist/components/questions/_shared/types/word-ordering-group.type.d.ts +34 -0
  22. package/dist/components/questions/_shared/types/word-ordering-group.type.js +2 -0
  23. package/dist/components/questions/components/QuestionSearchDropdown.js +3 -0
  24. package/dist/components/questions/components/QuestionTypeSelector.js +3 -0
  25. package/dist/components/questions/creator/question-type-registry.js +6 -0
  26. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerClient.js +6 -4
  27. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +16 -4
  28. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +2 -3
  29. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +6 -4
  30. package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +16 -2
  31. package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +4 -0
  32. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesClient.d.ts +2 -0
  33. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesClient.js +74 -0
  34. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesCreator.d.ts +2 -0
  35. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesCreator.js +222 -0
  36. package/dist/components/questions/types/match-columns-to-make-sentences/index.d.ts +5 -0
  37. package/dist/components/questions/types/match-columns-to-make-sentences/index.js +5 -0
  38. package/dist/components/questions/types/match-columns-to-make-sentences/map-match-columns-to-make-sentences-data.d.ts +8 -0
  39. package/dist/components/questions/types/match-columns-to-make-sentences/map-match-columns-to-make-sentences-data.js +199 -0
  40. package/dist/components/questions/types/match-columns-to-make-sentences/register.d.ts +2 -0
  41. package/dist/components/questions/types/match-columns-to-make-sentences/register.js +36 -0
  42. package/dist/components/questions/types/match-columns-to-make-sentences/transform.d.ts +2 -0
  43. package/dist/components/questions/types/match-columns-to-make-sentences/transform.js +66 -0
  44. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.d.ts +2 -0
  45. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +65 -0
  46. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.d.ts +2 -0
  47. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +95 -0
  48. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupImageField.d.ts +6 -0
  49. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupImageField.js +16 -0
  50. package/dist/components/questions/types/match-word-to-picture-group/index.d.ts +5 -0
  51. package/dist/components/questions/types/match-word-to-picture-group/index.js +5 -0
  52. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.d.ts +4 -0
  53. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +77 -0
  54. package/dist/components/questions/types/match-word-to-picture-group/register.d.ts +2 -0
  55. package/dist/components/questions/types/match-word-to-picture-group/register.js +36 -0
  56. package/dist/components/questions/types/match-word-to-picture-group/transform.d.ts +2 -0
  57. package/dist/components/questions/types/match-word-to-picture-group/transform.js +36 -0
  58. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupClient.d.ts +2 -0
  59. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupClient.js +25 -0
  60. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupCreator.d.ts +2 -0
  61. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupCreator.js +106 -0
  62. package/dist/components/questions/types/word-ordering-group/index.d.ts +5 -0
  63. package/dist/components/questions/types/word-ordering-group/index.js +5 -0
  64. package/dist/components/questions/types/word-ordering-group/map-word-ordering-group-data.d.ts +4 -0
  65. package/dist/components/questions/types/word-ordering-group/map-word-ordering-group-data.js +74 -0
  66. package/dist/components/questions/types/word-ordering-group/register.d.ts +2 -0
  67. package/dist/components/questions/types/word-ordering-group/register.js +36 -0
  68. package/dist/components/questions/types/word-ordering-group/transform.d.ts +2 -0
  69. package/dist/components/questions/types/word-ordering-group/transform.js +33 -0
  70. package/dist/components/questions/viewer/MatchColumnsToMakeSentencesViewer.d.ts +8 -0
  71. package/dist/components/questions/viewer/MatchColumnsToMakeSentencesViewer.js +49 -0
  72. package/dist/components/questions/viewer/MatchWordToPictureGroupViewer.js +2 -2
  73. package/dist/components/questions/viewer/QuestionViewer.js +6 -0
  74. package/dist/components/questions/viewer/WordOrderingGroupViewer.d.ts +7 -0
  75. package/dist/components/questions/viewer/WordOrderingGroupViewer.js +29 -0
  76. package/dist/components/questions/viewer/index.d.ts +2 -0
  77. package/dist/components/questions/viewer/index.js +2 -0
  78. package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.js +3 -2
  79. package/dist/components/results/renderers/review-question-body-dedicated.js +1 -1
  80. package/dist/components/results/renderers/review-question-body-movers.js +6 -2
  81. package/dist/components/results/review-data.js +13 -0
  82. package/dist/components/shared/RichTextEditor.d.ts +4 -2
  83. package/dist/components/shared/RichTextEditor.js +34 -11
  84. package/dist/components/shared/RichTextHtml.d.ts +8 -0
  85. package/dist/components/shared/RichTextHtml.js +9 -0
  86. package/dist/index.d.ts +1 -1
  87. package/dist/index.js +1 -1
  88. package/dist/shared/constants/question-skills.js +6 -0
  89. package/dist/shared/lib/rich-text.d.ts +3 -0
  90. package/dist/shared/lib/rich-text.js +24 -0
  91. package/dist/shared/lib/utils/question-reverse-transform.js +111 -2
  92. package/dist/shared/lib/utils/question-transform.js +6 -0
  93. package/dist/shared/lib/validation/choose-correct-answer.validation.js +4 -2
  94. package/dist/shared/types/common.types.d.ts +1 -1
  95. package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +1 -0
  96. package/dist/shared/types/questions/index.d.ts +3 -0
  97. package/dist/shared/types/questions/index.js +6 -0
  98. package/dist/shared/types/questions/match-columns-to-make-sentences.d.ts +50 -0
  99. package/dist/shared/types/questions/match-columns-to-make-sentences.js +1 -0
  100. package/dist/shared/types/questions/match-word-to-picture-group.d.ts +31 -0
  101. package/dist/shared/types/questions/match-word-to-picture-group.js +1 -0
  102. package/dist/shared/types/questions/word-ordering-group.d.ts +28 -0
  103. package/dist/shared/types/questions/word-ordering-group.js +1 -0
  104. package/package.json +1 -1
@@ -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';
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Check, X } from 'lucide-react';
4
4
  import { ResolvedImage } from '../../../components/common/ResolvedImage';
5
+ import { RichTextHtml } from '../../../components/shared/RichTextHtml';
5
6
  import CambridgeYlePartBanner from '../../../components/themes/cambridge-yle/CambridgeYlePartBanner';
6
7
  import CambridgeYleInstructionBanner from '../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
7
8
  import CambridgeYleAudioPlayer from '../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
@@ -43,9 +44,9 @@ function ReviewQuestionItem({ question, selectedOptionIds, isCorrect, correctOpt
43
44
  }
44
45
  return _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 border-gray-300 bg-white ${shapeClass}` });
45
46
  };
46
- return (_jsxs("div", { className: "mb-4", children: [_jsxs("p", { className: "mb-2 text-lg font-bold text-gray-900", children: [question.questionNumber, ". ", question.questionText] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), question.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), isImageOptions ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-lg border-2 p-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsx("span", { className: "text-xs font-medium text-gray-600", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "h-28 w-28 rounded object-contain" }), renderIndicator(option.id)] }, option.id))) })) : (
47
+ return (_jsxs("div", { className: "mb-4", children: [_jsxs("div", { className: "mb-2 flex items-start gap-1.5 text-lg font-bold text-gray-900", children: [_jsxs("span", { children: [question.questionNumber, "."] }), _jsx(RichTextHtml, { html: question.questionText, className: "flex-1 font-bold text-gray-900" })] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), question.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), isImageOptions ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-lg border-2 p-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsx("span", { className: "text-xs font-medium text-gray-600", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "h-28 w-28 rounded object-contain" }), renderIndicator(option.id)] }, option.id))) })) : (
47
48
  /* Options — text variant */
48
- _jsx("div", { className: "flex flex-col gap-2 pl-2", children: question.options.map((option) => (_jsxs("div", { className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx("span", { className: "flex-1 text-sm text-gray-700", children: option.text }), renderIndicator(option.id)] }, option.id))) }))] }));
49
+ _jsx("div", { className: "flex flex-col gap-2 pl-2", children: question.options.map((option) => (_jsxs("div", { className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx(RichTextHtml, { html: option.text, className: "flex-1 text-sm text-gray-700" }), renderIndicator(option.id)] }, option.id))) }))] }));
49
50
  }
50
51
  // Article Card for ARTICLES groupType (e.g., GN QE RWL Part 2)
51
52
  function ArticleCard({ article, label, }) {
@@ -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
@@ -1,9 +1,11 @@
1
1
  import React from 'react';
2
- export interface RichTextEditorProps {
2
+ interface RichTextEditorProps {
3
3
  value?: string;
4
4
  onChange: (html: string) => void;
5
5
  label?: React.ReactNode;
6
6
  disabled?: boolean;
7
7
  minHeightClassName?: string;
8
+ variant?: 'default' | 'compact';
8
9
  }
9
- export declare function RichTextEditor({ value, onChange, label, disabled, minHeightClassName, }: RichTextEditorProps): React.JSX.Element;
10
+ export declare function RichTextEditor({ value, onChange, label, disabled, minHeightClassName, variant, }: RichTextEditorProps): React.JSX.Element;
11
+ export {};
@@ -3,16 +3,43 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useState } from 'react';
4
4
  import { useEditor, EditorContent } from '@tiptap/react';
5
5
  import StarterKit from '@tiptap/starter-kit';
6
+ import Underline from '@tiptap/extension-underline';
7
+ import TextAlign from '@tiptap/extension-text-align';
8
+ import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
9
+ import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, Code, Columns3, Italic, List, ListOrdered, Minus, Quote, Redo2, RemoveFormatting, Rows3, SquareCode, Strikethrough, TableIcon, Trash2, Underline as UnderlineIcon, Undo2, } from 'lucide-react';
6
10
  const EMPTY_CONTENT = '<p></p>';
7
- const HEADING_LEVELS = [1, 2, 3];
8
- function getToolbarButtonClass(isActive) {
9
- return `rounded px-3 py-1 text-sm ${isActive ? 'bg-blue-500 text-white' : 'border bg-white text-gray-700 hover:bg-gray-100'}`;
11
+ const HEADING_LEVELS = [1, 2, 3, 4];
12
+ const EDITOR_EXTENSIONS = [
13
+ StarterKit.configure({
14
+ heading: { levels: [...HEADING_LEVELS] },
15
+ }),
16
+ Underline,
17
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
18
+ Table.configure({ resizable: false }),
19
+ TableRow,
20
+ TableHeader,
21
+ TableCell,
22
+ ];
23
+ const EDITOR_TABLE_CLASS = '[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-slate-300 [&_td]:border-slate-300 [&_th]:bg-slate-50 [&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1';
24
+ function toolbarButtonClass(isActive) {
25
+ return `inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 transition-colors disabled:opacity-30 ${isActive ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'}`;
10
26
  }
11
- export function RichTextEditor({ value, onChange, label, disabled = false, minHeightClassName = 'min-h-[250px]', }) {
27
+ function ToolbarGroup({ children }) {
28
+ return (_jsx("div", { className: "flex items-center gap-0.5 rounded-md bg-white px-0.5 py-0.5 shadow-sm ring-1 ring-slate-200/80", children: children }));
29
+ }
30
+ function ToolbarButton({ editor, disabled, active, title, onClick, children, }) {
31
+ return (_jsx("button", { type: "button", title: title, disabled: disabled || !editor, onClick: onClick, className: toolbarButtonClass(!!active), children: children }));
32
+ }
33
+ function EditorToolbar({ editor, disabled, }) {
34
+ const iconClass = 'h-4 w-4';
35
+ return (_jsxs("div", { className: "relative flex flex-wrap items-center gap-1 rounded-t-md border-b border-slate-200 bg-slate-100 px-1.5 py-1", children: [_jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "Ho\u00E0n t\u00E1c", onClick: () => editor?.chain().focus().undo().run(), children: _jsx(Undo2, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "L\u00E0m l\u1EA1i", onClick: () => editor?.chain().focus().redo().run(), children: _jsx(Redo2, { className: iconClass, strokeWidth: 2 }) })] }), _jsx(ToolbarGroup, { children: HEADING_LEVELS.map((level) => (_jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('heading', { level }), title: `Heading ${level}`, onClick: () => editor?.chain().focus().toggleHeading({ level }).run(), children: _jsxs("span", { className: "text-[11px] font-bold", children: ["H", level] }) }, level))) }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('bold'), title: "\u0110\u1EADm", onClick: () => editor?.chain().focus().toggleBold().run(), children: _jsx(Bold, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('italic'), title: "Nghi\u00EAng", onClick: () => editor?.chain().focus().toggleItalic().run(), children: _jsx(Italic, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('underline'), title: "G\u1EA1ch ch\u00E2n", onClick: () => editor?.chain().focus().toggleUnderline().run(), children: _jsx(UnderlineIcon, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('strike'), title: "G\u1EA1ch ngang", onClick: () => editor?.chain().focus().toggleStrike().run(), children: _jsx(Strikethrough, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('code'), title: "Code", onClick: () => editor?.chain().focus().toggleCode().run(), children: _jsx(Code, { className: iconClass, strokeWidth: 2 }) })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('bulletList'), title: "Danh s\u00E1ch", onClick: () => editor?.chain().focus().toggleBulletList().run(), children: _jsx(List, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('orderedList'), title: "Danh s\u00E1ch s\u1ED1", onClick: () => editor?.chain().focus().toggleOrderedList().run(), children: _jsx(ListOrdered, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('blockquote'), title: "Tr\u00EDch d\u1EABn", onClick: () => editor?.chain().focus().toggleBlockquote().run(), children: _jsx(Quote, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('codeBlock'), title: "Kh\u1ED1i code", onClick: () => editor?.chain().focus().toggleCodeBlock().run(), children: _jsx(SquareCode, { className: iconClass, strokeWidth: 2 }) })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive({ textAlign: 'left' }), title: "C\u0103n tr\u00E1i", onClick: () => editor?.chain().focus().setTextAlign('left').run(), children: _jsx(AlignLeft, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive({ textAlign: 'center' }), title: "C\u0103n gi\u1EEFa", onClick: () => editor?.chain().focus().setTextAlign('center').run(), children: _jsx(AlignCenter, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive({ textAlign: 'right' }), title: "C\u0103n ph\u1EA3i", onClick: () => editor?.chain().focus().setTextAlign('right').run(), children: _jsx(AlignRight, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive({ textAlign: 'justify' }), title: "C\u0103n \u0111\u1EC1u", onClick: () => editor?.chain().focus().setTextAlign('justify').run(), children: _jsx(AlignJustify, { className: iconClass, strokeWidth: 2 }) })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "\u0110\u01B0\u1EDDng k\u1EBB ngang", onClick: () => editor?.chain().focus().setHorizontalRule().run(), children: _jsx(Minus, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "Ch\u00E8n b\u1EA3ng", onClick: () => editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), children: _jsx(TableIcon, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled || !editor?.can().addRowAfter(), title: "Th\u00EAm h\u00E0ng", onClick: () => editor?.chain().focus().addRowAfter().run(), children: _jsx(Rows3, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled || !editor?.can().addColumnAfter(), title: "Th\u00EAm c\u1ED9t", onClick: () => editor?.chain().focus().addColumnAfter().run(), children: _jsx(Columns3, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled || !editor?.can().deleteTable(), title: "X\u00F3a b\u1EA3ng", onClick: () => editor?.chain().focus().deleteTable().run(), children: _jsx(Trash2, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "X\u00F3a \u0111\u1ECBnh d\u1EA1ng", onClick: () => editor?.chain().focus().unsetAllMarks().clearNodes().run(), children: _jsx(RemoveFormatting, { className: iconClass, strokeWidth: 2 }) })] })] }));
36
+ }
37
+ export function RichTextEditor({ value, onChange, label, disabled = false, minHeightClassName = 'min-h-[250px]', variant = 'default', }) {
12
38
  const [mounted, setMounted] = useState(false);
39
+ const isCompact = variant === 'compact';
13
40
  const editor = useEditor({
14
41
  immediatelyRender: false,
15
- extensions: [StarterKit],
42
+ extensions: EDITOR_EXTENSIONS,
16
43
  editable: !disabled,
17
44
  content: value || EMPTY_CONTENT,
18
45
  onUpdate: ({ editor: currentEditor }) => {
@@ -20,7 +47,7 @@ export function RichTextEditor({ value, onChange, label, disabled = false, minHe
20
47
  },
21
48
  editorProps: {
22
49
  attributes: {
23
- class: `tiptap-editor focus:outline-none ${minHeightClassName} p-4 bg-white border border-gray-200 rounded-b-md prose prose-sm max-w-none`,
50
+ class: `tiptap-editor focus:outline-none ${minHeightClassName} ${isCompact ? 'p-2' : 'p-4'} bg-white rounded-b-md prose prose-sm max-w-none ${EDITOR_TABLE_CLASS}`,
24
51
  },
25
52
  },
26
53
  });
@@ -39,9 +66,5 @@ export function RichTextEditor({ value, onChange, label, disabled = false, minHe
39
66
  if (!mounted) {
40
67
  return null;
41
68
  }
42
- return (_jsxs("div", { className: "flex w-full flex-col gap-4", children: [label ? _jsx("div", { children: label }) : null, _jsxs("div", { className: "flex flex-col rounded-md border border-gray-300 shadow-sm", children: [_jsxs("div", { className: "relative flex flex-wrap gap-2 rounded-t-md border-b border-gray-300 bg-gray-50 p-2", children: [_jsx("button", { type: "button", onClick: () => editor?.chain().focus().toggleBold().run(), disabled: disabled || !editor?.can().chain().focus().toggleBold().run(), className: `${getToolbarButtonClass(!!editor?.isActive('bold'))} font-bold`, children: "B" }), _jsx("button", { type: "button", onClick: () => editor?.chain().focus().toggleItalic().run(), disabled: disabled || !editor?.can().chain().focus().toggleItalic().run(), className: `${getToolbarButtonClass(!!editor?.isActive('italic'))} italic`, children: "I" }), _jsx("div", { className: "mx-1 h-6 w-px self-center bg-gray-300" }), HEADING_LEVELS.map((level) => (_jsxs("button", { type: "button", onClick: () => editor
43
- ?.chain()
44
- .focus()
45
- .toggleHeading({ level })
46
- .run(), disabled: disabled, className: `${getToolbarButtonClass(!!editor?.isActive('heading', { level }))} font-bold`, children: ["H", level] }, level))), _jsx("div", { className: "mx-1 h-6 w-px self-center bg-gray-300" }), _jsx("button", { type: "button", onClick: () => editor?.chain().focus().toggleBulletList().run(), disabled: disabled, className: getToolbarButtonClass(!!editor?.isActive('bulletList')), children: "\u2022 List" }), _jsx("button", { type: "button", onClick: () => editor?.chain().focus().toggleOrderedList().run(), disabled: disabled, className: getToolbarButtonClass(!!editor?.isActive('orderedList')), children: "1. List" })] }), _jsx(EditorContent, { editor: editor })] })] }));
69
+ return (_jsxs("div", { className: `flex w-full flex-col ${isCompact ? 'gap-0' : 'gap-4'}`, children: [label ? _jsx("div", { children: label }) : null, _jsxs("div", { className: "flex flex-col overflow-hidden rounded-md border border-gray-300 shadow-sm", children: [_jsx(EditorToolbar, { editor: editor, disabled: disabled }), _jsx(EditorContent, { editor: editor })] })] }));
47
70
  }
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from 'react';
2
+ interface RichTextHtmlProps {
3
+ html?: string | null;
4
+ className?: string;
5
+ fallback?: ReactNode;
6
+ }
7
+ export declare function RichTextHtml({ html, className, fallback }: RichTextHtmlProps): import("react").JSX.Element;
8
+ export {};
@@ -0,0 +1,9 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { cn } from '../../shared/lib/utils';
3
+ const RICH_TEXT_CLASS = 'rich-text-html max-w-none [&_p]:m-0 [&_p+p]:mt-1 [&_h1]:mb-1 [&_h1]:mt-0 [&_h1]:text-xl [&_h1]:font-bold [&_h2]:mb-1 [&_h2]:mt-0 [&_h2]:text-lg [&_h2]:font-bold [&_h3]:mb-1 [&_h3]:mt-0 [&_h3]:text-base [&_h3]:font-bold [&_h4]:mb-1 [&_h4]:mt-0 [&_h4]:text-sm [&_h4]:font-bold [&_ul]:my-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-1 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:my-1 [&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 [&_blockquote]:pl-3 [&_pre]:my-1 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-slate-100 [&_pre]:p-2 [&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1 [&_u]:underline [&_s]:line-through [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-slate-300 [&_td]:border-slate-300 [&_th]:bg-slate-50 [&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1 [&_hr]:my-2';
4
+ export function RichTextHtml({ html, className, fallback = null }) {
5
+ if (!html) {
6
+ return fallback ? _jsx(_Fragment, { children: fallback }) : null;
7
+ }
8
+ return (_jsx("div", { className: cn(RICH_TEXT_CLASS, className), dangerouslySetInnerHTML: { __html: html } }));
9
+ }
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
  // ========================================
@@ -0,0 +1,3 @@
1
+ /** Strip HTML tags/entities so empty TipTap documents (`<p></p>`) count as blank. */
2
+ export declare function richTextToPlainText(value: string | null | undefined): string;
3
+ export declare function isRichTextEmpty(value: string | null | undefined): boolean;
@@ -0,0 +1,24 @@
1
+ const HTML_TAG_RE = /<[^>]*>/g;
2
+ const HTML_ENTITIES = {
3
+ '&nbsp;': ' ',
4
+ '&amp;': '&',
5
+ '&lt;': '<',
6
+ '&gt;': '>',
7
+ '&quot;': '"',
8
+ '&#39;': "'",
9
+ };
10
+ /** Strip HTML tags/entities so empty TipTap documents (`<p></p>`) count as blank. */
11
+ export function richTextToPlainText(value) {
12
+ if (!value)
13
+ return '';
14
+ return value
15
+ .replace(HTML_TAG_RE, ' ')
16
+ .replace(/&nbsp;|&amp;|&lt;|&gt;|&quot;|&#39;/gi, (entity) => {
17
+ return HTML_ENTITIES[entity.toLowerCase()] ?? entity;
18
+ })
19
+ .replace(/\s+/g, ' ')
20
+ .trim();
21
+ }
22
+ export function isRichTextEmpty(value) {
23
+ return richTextToPlainText(value) === '';
24
+ }
@@ -226,7 +226,9 @@ function normalizeChooseAnswerGroupImageUrl(value) {
226
226
  const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
227
227
  const content = asRecord(apiQuestion.content);
228
228
  const answer = readAnswer(apiQuestion);
229
- const fallbackAnswers = asArray(answer.answer).map((item) => asString(item));
229
+ const fallbackAnswers = asArray(answer.answer).map((item) => (asString(item) || (Array.isArray(item)
230
+ ? asString(item.find((token) => typeof token === 'string' && token.trim() !== ''))
231
+ : '')));
230
232
  const items = asArray(content.items).map((rawItem, index) => {
231
233
  const item = asRecord(rawItem);
232
234
  const itemContent = asRecord(item.content);
@@ -251,7 +253,13 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
251
253
  { id: 'opt-2', text: '' },
252
254
  { id: 'opt-3', text: '' },
253
255
  ],
254
- correctAnswer: asString(itemAnswer.answer) || fallbackAnswers[index] || options[0]?.id || 'opt-1',
256
+ correctAnswer: asString(itemAnswer.answer)
257
+ || (Array.isArray(itemAnswer.answer)
258
+ ? asString(itemAnswer.answer.find((token) => typeof token === 'string' && token.trim() !== ''))
259
+ : '')
260
+ || fallbackAnswers[index]
261
+ || options[0]?.id
262
+ || 'opt-1',
255
263
  isExample,
256
264
  points: isExample ? 0 : asNumber(item.points, 1),
257
265
  questionNumber: asNumber(item.questionNumber, index + 1),
@@ -260,12 +268,14 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
260
268
  };
261
269
  });
262
270
  const audioUrl = asString(asRecord(content.meta).audioUrl).trim();
271
+ const imageUrl = normalizeChooseAnswerGroupImageUrl(asRecord(content.meta).imageUrl);
263
272
  return {
264
273
  content: '',
265
274
  answer: {
266
275
  instruction: asString(asRecord(content.meta).instruction, 'Choose the correct answer.'),
267
276
  passage: asString(asRecord(content.meta).passage),
268
277
  ...(audioUrl ? { audioUrl } : {}),
278
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
269
279
  items,
270
280
  explanation: asString(apiQuestion.explanation),
271
281
  points: items.reduce((total, item) => (item.isExample ? total : total + item.points), 0),
@@ -603,6 +613,102 @@ const reverseSimpleAnswer = (apiQuestion) => {
603
613
  },
604
614
  };
605
615
  };
616
+ const reverseWordOrderingGroup = (apiQuestion) => {
617
+ const content = asRecord(apiQuestion.content);
618
+ const answer = readAnswer(apiQuestion);
619
+ const fallbackAnswers = asArray(answer.answer);
620
+ const items = asArray(content.items);
621
+ const meta = asRecord(content.meta);
622
+ const mappedItems = items.map((rawItem, index) => {
623
+ const item = asRecord(rawItem);
624
+ const itemContent = asRecord(item.content);
625
+ const itemAnswer = asRecord(item.correctAnswer);
626
+ const isExample = item.isExample === true;
627
+ const words = asArray(itemContent.words).map((token) => asString(token)).filter(Boolean);
628
+ const itemTokens = asArray(itemAnswer.answer).map((token) => asString(token)).filter(Boolean);
629
+ const fallbackTokens = asArray(fallbackAnswers[index]).map((token) => asString(token)).filter(Boolean);
630
+ return {
631
+ words,
632
+ answer: itemTokens.length ? itemTokens : fallbackTokens,
633
+ isExample,
634
+ points: isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
635
+ questionNumber: typeof item.questionNumber === 'number' ? item.questionNumber : index + 1,
636
+ };
637
+ });
638
+ return {
639
+ content: '',
640
+ answer: {
641
+ instruction: asString(meta.instruction, 'Order the words.'),
642
+ items: mappedItems,
643
+ explanation: asString(apiQuestion.explanation),
644
+ points: mappedItems.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
645
+ },
646
+ };
647
+ };
648
+ const reverseMatchWordToPictureGroup = (apiQuestion) => {
649
+ const content = asRecord(apiQuestion.content);
650
+ const answer = readAnswer(apiQuestion);
651
+ const fallbackAnswers = asArray(answer.answer);
652
+ const items = asArray(content.items);
653
+ const meta = asRecord(content.meta);
654
+ const mappedItems = items.map((rawItem, index) => {
655
+ const item = asRecord(rawItem);
656
+ const itemContent = asRecord(item.content);
657
+ const itemAnswer = asRecord(item.correctAnswer);
658
+ const isExample = item.isExample === true;
659
+ return {
660
+ imageUrl: asString(itemContent.imageUrl),
661
+ correctAnswer: asString(itemAnswer.answer) || asString(fallbackAnswers[index]),
662
+ isExample,
663
+ points: isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
664
+ questionNumber: typeof item.questionNumber === 'number' ? item.questionNumber : index + 1,
665
+ };
666
+ });
667
+ return {
668
+ content: '',
669
+ answer: {
670
+ instruction: asString(meta.instruction, 'Look and complete.'),
671
+ wordBank: asArray(meta.wordBank).map((word) => asString(word)).filter(Boolean),
672
+ items: mappedItems,
673
+ explanation: asString(apiQuestion.explanation),
674
+ points: mappedItems.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
675
+ },
676
+ };
677
+ };
678
+ const reverseMatchColumnsToMakeSentences = (apiQuestion) => {
679
+ const content = asRecord(apiQuestion.content);
680
+ const answer = readAnswer(apiQuestion);
681
+ const fallbackAnswers = asArray(answer.answer);
682
+ const items = asArray(content.items);
683
+ const columns = asArray(content.columns);
684
+ const meta = asRecord(content.meta);
685
+ const mappedItems = items.map((rawItem, index) => {
686
+ const item = asRecord(rawItem);
687
+ const itemContent = asRecord(item.content);
688
+ const itemAnswer = asRecord(asRecord(item.correctAnswer).answer ?? fallbackAnswers[index]);
689
+ const fragmentIds = asArray(itemAnswer.fragmentIds).map((id) => asString(id)).filter(Boolean);
690
+ const isExample = item.isExample === true;
691
+ return {
692
+ stemFragmentId: asString(itemContent.stemFragmentId) || fragmentIds[0] || '',
693
+ fragmentIds,
694
+ sentence: asString(itemAnswer.sentence),
695
+ isExample,
696
+ points: isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
697
+ questionNumber: typeof item.questionNumber === 'number' ? item.questionNumber : index + 1,
698
+ };
699
+ });
700
+ return {
701
+ content: '',
702
+ answer: {
703
+ title: asString(meta.title),
704
+ instruction: asString(meta.instruction, 'Match the information to make sentences. You can use the same words more than once.'),
705
+ columns,
706
+ items: mappedItems,
707
+ explanation: asString(apiQuestion.explanation),
708
+ points: mappedItems.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
709
+ },
710
+ };
711
+ };
606
712
  const handlers = {
607
713
  CHOOSE_THE_CORRECT_ANSWER: reverseChooseTheCorrectAnswer,
608
714
  CHOOSE_THE_CORRECT_ANSWER_GROUP: reverseChooseTheCorrectAnswerGroup,
@@ -653,6 +759,9 @@ const handlers = {
653
759
  GN_SPEAKING_INTERVIEW: reverseGroupPayload,
654
760
  CROSSWORD_PUZZLE: reverseCrosswordPuzzle,
655
761
  FIND_WORDS_IN_MATRIX: reverseFindWordsInMatrix,
762
+ MATCH_COLUMNS_TO_MAKE_SENTENCES: reverseMatchColumnsToMakeSentences,
763
+ MATCH_WORD_TO_PICTURE_GROUP: reverseMatchWordToPictureGroup,
764
+ WORD_ORDERING_GROUP: reverseWordOrderingGroup,
656
765
  };
657
766
  function reverseWriteAShortLetter(apiQuestion) {
658
767
  const content = asRecord(apiQuestion.content);
@@ -47,6 +47,9 @@ import { transformGNSpeakingInterview } from '../../../components/questions/type
47
47
  import { transformWordFillStructuredForm } from '../../../components/questions/types/word-fill-structured-form/transform';
48
48
  import { transformCrosswordPuzzle } from '../../../components/questions/types/crossword-puzzle/transform';
49
49
  import { transformFindWordsInMatrix } from '../../../components/questions/types/find-words-in-matrix/transform';
50
+ import { transformMatchColumnsToMakeSentences } from '../../../components/questions/types/match-columns-to-make-sentences/transform';
51
+ import { transformMatchWordToPictureGroup } from '../../../components/questions/types/match-word-to-picture-group/transform';
52
+ import { transformWordOrderingGroup } from '../../../components/questions/types/word-ordering-group/transform';
50
53
  // ─── Handler map ────────────────────────────────────────────────────────────
51
54
  const questionTransformHandlers = {
52
55
  // Look picture
@@ -100,6 +103,9 @@ const questionTransformHandlers = {
100
103
  WORD_FILL_STRUCTURED_FORM: transformWordFillStructuredForm,
101
104
  CROSSWORD_PUZZLE: transformCrosswordPuzzle,
102
105
  FIND_WORDS_IN_MATRIX: transformFindWordsInMatrix,
106
+ MATCH_COLUMNS_TO_MAKE_SENTENCES: transformMatchColumnsToMakeSentences,
107
+ MATCH_WORD_TO_PICTURE_GROUP: transformMatchWordToPictureGroup,
108
+ WORD_ORDERING_GROUP: transformWordOrderingGroup,
103
109
  };
104
110
  /**
105
111
  * Transform frontend question format to API format.