@tinyweb_dev/oe-exam-sdk 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
- package/dist/components/exams/take/components/QuestionRenderer.js +10 -2
- package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +22 -0
- package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +60 -0
- package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.d.ts +13 -0
- package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.js +31 -0
- package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
- package/dist/components/exams/take/components/question-renderers/index.js +2 -0
- package/dist/components/exams/take/utils/answer-transformers.js +16 -0
- package/dist/components/exams/take/utils/question-transformers.d.ts +45 -1
- package/dist/components/exams/take/utils/question-transformers.js +90 -0
- package/dist/components/questions/_shared/config/question-types.config.js +12 -0
- package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +50 -0
- package/dist/components/questions/_shared/types/fill-in-blank-group.type.js +2 -0
- package/dist/components/questions/_shared/types/index.d.ts +2 -0
- package/dist/components/questions/_shared/types/index.js +2 -0
- package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +40 -0
- package/dist/components/questions/_shared/types/matching-with-lines-group.type.js +1 -0
- package/dist/components/questions/components/QuestionSearchDropdown.js +4 -0
- package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
- package/dist/components/questions/creator/question-type-registry.js +4 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.d.ts +2 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +64 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.d.ts +2 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +158 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.d.ts +7 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.js +30 -0
- package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +20 -0
- package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +99 -0
- package/dist/components/questions/types/fill-in-blank-group/index.d.ts +5 -0
- package/dist/components/questions/types/fill-in-blank-group/index.js +5 -0
- package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.d.ts +4 -0
- package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +102 -0
- package/dist/components/questions/types/fill-in-blank-group/register.d.ts +3 -0
- package/dist/components/questions/types/fill-in-blank-group/register.js +37 -0
- package/dist/components/questions/types/fill-in-blank-group/transform.d.ts +2 -0
- package/dist/components/questions/types/fill-in-blank-group/transform.js +68 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +20 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +109 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.d.ts +2 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +30 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.d.ts +2 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +180 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.d.ts +6 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.js +26 -0
- package/dist/components/questions/types/matching-with-lines-group/index.d.ts +4 -0
- package/dist/components/questions/types/matching-with-lines-group/index.js +4 -0
- package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.d.ts +8 -0
- package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +104 -0
- package/dist/components/questions/types/matching-with-lines-group/register.d.ts +3 -0
- package/dist/components/questions/types/matching-with-lines-group/register.js +37 -0
- package/dist/components/questions/types/matching-with-lines-group/transform.d.ts +2 -0
- package/dist/components/questions/types/matching-with-lines-group/transform.js +44 -0
- package/dist/components/questions/viewer/FillInBlankGroupViewer.d.ts +10 -0
- package/dist/components/questions/viewer/FillInBlankGroupViewer.js +14 -0
- package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.d.ts +7 -0
- package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.js +48 -0
- package/dist/components/questions/viewer/QuestionViewer.js +6 -0
- package/dist/components/questions/viewer/index.d.ts +2 -0
- package/dist/components/questions/viewer/index.js +2 -0
- package/dist/components/results/renderers/review-question-body-movers.js +37 -2
- package/dist/components/results/review-data.js +37 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/constants/question-skills.js +4 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +86 -0
- package/dist/shared/lib/utils/question-transform.js +4 -0
- package/dist/shared/types/common.types.d.ts +1 -1
- package/dist/shared/types/questions/fill-in-blank-group.d.ts +45 -0
- package/dist/shared/types/questions/fill-in-blank-group.js +1 -0
- package/dist/shared/types/questions/index.d.ts +2 -0
- package/dist/shared/types/questions/index.js +4 -0
- package/dist/shared/types/questions/matching-with-lines-group.d.ts +39 -0
- package/dist/shared/types/questions/matching-with-lines-group.js +1 -0
- package/package.json +1 -1
|
@@ -14,6 +14,7 @@ import { chooseTheCorrectAnswerRegistration } from '../types/choose-the-correct-
|
|
|
14
14
|
import { chooseTheCorrectAnswerGroupRegistration } from '../types/choose-the-correct-answer-group/register';
|
|
15
15
|
import { trueFalseGroupRegistration } from '../types/true-false-group/register';
|
|
16
16
|
import { trueFalseCorrectGroupRegistration } from '../types/true-false-correct-group/register';
|
|
17
|
+
import { fillInBlankGroupRegistration } from '../types/fill-in-blank-group/register';
|
|
17
18
|
import { crossOutWordGroupRegistration } from '../types/cross-out-word-group/register';
|
|
18
19
|
import { writeAShortLetterRegistration } from '../types/write-a-short-letter/register';
|
|
19
20
|
import { writeCorrectVerbFormRegistration } from '../types/write-correct-verb-form/register';
|
|
@@ -25,6 +26,7 @@ import { wordOrderingRegistration } from '../types/word-ordering/register';
|
|
|
25
26
|
import { wordFillParagraphRegistration } from '../types/word-fill-paragraph/register';
|
|
26
27
|
import { matchByWritingAnswerRegistration } from '../types/match-by-writing-answer/register';
|
|
27
28
|
import { matchWordToPictureRegistration } from '../types/match-word-to-picture/register';
|
|
29
|
+
import { matchingWithLinesGroupRegistration } from '../types/matching-with-lines-group/register';
|
|
28
30
|
import { writeSentencesRegistration } from '../types/write-sentences/register';
|
|
29
31
|
import { listenAndChooseFromAnswerGroupRegistration } from '../types/listen-and-choose-from-answer-group/register';
|
|
30
32
|
import { listenAndChooseObjectsInSceneRegistration } from '../types/listen-and-choose-objects-in-scene/register';
|
|
@@ -53,6 +55,7 @@ export const questionTypeRegistry = {
|
|
|
53
55
|
CHOOSE_THE_CORRECT_ANSWER_GROUP: chooseTheCorrectAnswerGroupRegistration,
|
|
54
56
|
TRUE_FALSE_GROUP: trueFalseGroupRegistration,
|
|
55
57
|
TRUE_FALSE_CORRECT_GROUP: trueFalseCorrectGroupRegistration,
|
|
58
|
+
FILL_IN_BLANK_GROUP: fillInBlankGroupRegistration,
|
|
56
59
|
CROSS_OUT_WORD_GROUP: crossOutWordGroupRegistration,
|
|
57
60
|
WORD_ORDER_AND_MATCH_GROUP: wordOrderAndMatchGroupRegistration,
|
|
58
61
|
FILL_IN_BLANK: fillInBlankRegistration,
|
|
@@ -76,6 +79,7 @@ export const questionTypeRegistry = {
|
|
|
76
79
|
WORD_FILL_PARAGRAPH: wordFillParagraphRegistration, // Will need to handle viewMode mapping either here or in wrapper
|
|
77
80
|
MATCH_BY_WRITING_ANSWER: matchByWritingAnswerRegistration,
|
|
78
81
|
MATCH_WORD_TO_PICTURE: matchWordToPictureRegistration,
|
|
82
|
+
MATCHING_WITH_LINES_GROUP: matchingWithLinesGroupRegistration,
|
|
79
83
|
WRITE_SENTENCES: writeSentencesRegistration,
|
|
80
84
|
LISTEN_AND_CHOOSE_FROM_ANSWER_GROUP: listenAndChooseFromAnswerGroupRegistration,
|
|
81
85
|
LISTEN_AND_CHOOSE_OBJECTS_IN_SCENE: listenAndChooseObjectsInSceneRegistration,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Lightbulb, Star, Volume2 } from 'lucide-react';
|
|
4
|
+
import { cn } from '../../../../shared/lib/utils';
|
|
5
|
+
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
6
|
+
import { blankSlotCount, extractPlaceholderIndices, isBlankMatch, joinSlashAnswers, padStudentAnswers, splitStem, } from './blank-utils';
|
|
7
|
+
function MediaPreview({ src, kind, alt }) {
|
|
8
|
+
const { previewUrl, isLoading } = usePresignedFileUrl(src);
|
|
9
|
+
if (isLoading) {
|
|
10
|
+
return _jsx("div", { className: "h-16 w-16 animate-pulse rounded-md bg-slate-100" });
|
|
11
|
+
}
|
|
12
|
+
const url = previewUrl || src;
|
|
13
|
+
if (kind === 'audio') {
|
|
14
|
+
return _jsx("audio", { controls: true, src: url, className: "h-8 max-w-full" });
|
|
15
|
+
}
|
|
16
|
+
return (_jsx("img", { src: url, alt: alt, className: "max-h-28 w-auto rounded-md object-contain", onError: (event) => {
|
|
17
|
+
const target = event.target;
|
|
18
|
+
target.onerror = null;
|
|
19
|
+
target.src = '/images/placeholder-image.svg';
|
|
20
|
+
} }));
|
|
21
|
+
}
|
|
22
|
+
export function FillInBlankGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
|
|
23
|
+
const items = questionData.items || [];
|
|
24
|
+
const groupHints = questionData.showHints ? (questionData.hints || []) : [];
|
|
25
|
+
const handleBlankChange = (itemIndex, blankIndex, value) => {
|
|
26
|
+
const item = items[itemIndex];
|
|
27
|
+
if (isReviewMode || item?.isExample || !onAnswerChange) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const next = items.map((row, index) => ({
|
|
31
|
+
answers: padStudentAnswers(userAnswers[index], blankSlotCount(row.question || '{0}')),
|
|
32
|
+
}));
|
|
33
|
+
const current = [...next[itemIndex].answers];
|
|
34
|
+
current[blankIndex] = value;
|
|
35
|
+
next[itemIndex] = { answers: current };
|
|
36
|
+
onAnswerChange(next);
|
|
37
|
+
};
|
|
38
|
+
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 })), questionData.audioUrl && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx(MediaPreview, { src: questionData.audioUrl, kind: "audio", alt: "Group audio" })] })), questionData.imageUrl && (_jsx("div", { className: "flex justify-center rounded-lg border border-slate-100 bg-slate-50 p-3", children: _jsx(MediaPreview, { src: questionData.imageUrl, kind: "image", alt: "Group image" }) })), groupHints.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: groupHints.map((hint) => (_jsx("span", { className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700", children: hint }, hint))) })), _jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3", children: items.map((item, itemIndex) => {
|
|
39
|
+
const blankCount = blankSlotCount(item.question || '{0}');
|
|
40
|
+
const placeholderIndices = extractPlaceholderIndices(item.question || '{0}');
|
|
41
|
+
const userValue = padStudentAnswers(userAnswers[itemIndex], blankCount);
|
|
42
|
+
const stem = splitStem(item.question || '{0}');
|
|
43
|
+
const showItemAudio = Boolean(item.audioUrl) && !questionData.audioUrl;
|
|
44
|
+
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-gray-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white", children: 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 items-center gap-3 p-3", children: [item.imageUrl && (_jsx(MediaPreview, { src: item.imageUrl, kind: "image", alt: `Item ${item.questionNumber}` })), _jsxs("div", { className: "min-w-0 flex-1 space-y-2 text-sm leading-relaxed text-slate-800", children: [showItemAudio && item.audioUrl && (_jsx(MediaPreview, { src: item.audioUrl, kind: "audio", alt: `Item ${item.questionNumber} audio` })), stem.map((part, partIndex) => {
|
|
45
|
+
if (part.type === 'text') {
|
|
46
|
+
return _jsx("span", { children: part.value }, partIndex);
|
|
47
|
+
}
|
|
48
|
+
const alts = item.answers[part.index] || [];
|
|
49
|
+
const student = userValue[part.index] || '';
|
|
50
|
+
const canEdit = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
|
|
51
|
+
const correct = isReviewMode && isBlankMatch(student, alts);
|
|
52
|
+
const wrong = isReviewMode && student.trim().length > 0 && !correct;
|
|
53
|
+
return (_jsx("input", { type: "text", disabled: !canEdit, value: item.isExample ? (alts[0] || '') : student, onChange: (event) => handleBlankChange(itemIndex, part.index, event.target.value), placeholder: item.isExample ? '' : '...', className: cn('mx-1 inline-block w-16 rounded-md border-2 px-1 py-0.5 text-center text-sm font-semibold outline-none', item.isExample
|
|
54
|
+
? 'border-amber-300 bg-amber-50 text-amber-800'
|
|
55
|
+
: correct
|
|
56
|
+
? 'border-emerald-400 bg-emerald-50 text-emerald-800'
|
|
57
|
+
: wrong
|
|
58
|
+
? 'border-rose-400 bg-rose-50 text-rose-800'
|
|
59
|
+
: canEdit
|
|
60
|
+
? 'border-blue-300 bg-white text-slate-800 focus:border-blue-500'
|
|
61
|
+
: 'border-blue-200 bg-blue-50 text-blue-900') }, partIndex));
|
|
62
|
+
})] })] }), isReviewMode && !item.isExample && (_jsxs("div", { className: "border-t border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-800", children: ["\u0110\u00E1p \u00E1n: ", placeholderIndices.map((index) => joinSlashAnswers(item.answers[index]) || '(trống)').join(' | ')] }))] }, `${item.questionNumber}-${itemIndex}`));
|
|
63
|
+
}) }), 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] }))] }));
|
|
64
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { FillInBlankGroupCreatorProps } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
export declare function FillInBlankGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: FillInBlankGroupCreatorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,158 @@
|
|
|
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 { Textarea } from '../../../../components/ui/textarea';
|
|
8
|
+
import { PointsInput } from '../../../../components/ui/points-input';
|
|
9
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
|
|
10
|
+
import { Switch } from '../../../../components/ui/switch';
|
|
11
|
+
import { useDebouncedCallback } from '../../../../shared/lib/hooks';
|
|
12
|
+
import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
|
|
13
|
+
import { FillInBlankGroupClient } from './FillInBlankGroupClient';
|
|
14
|
+
import { FillInBlankGroupMediaField } from './FillInBlankGroupMediaField';
|
|
15
|
+
import { createEmptyFillInBlankGroupItem } from './map-fill-in-blank-group-data';
|
|
16
|
+
import { blankSlotCount, extractPlaceholderIndices, joinSlashAnswers, normalizeAnswersMatrix, parseSlashAnswers, } from './blank-utils';
|
|
17
|
+
import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
|
|
18
|
+
function parsePointsValue(raw, allowZero = false) {
|
|
19
|
+
if (raw === '')
|
|
20
|
+
return allowZero ? 0 : 1;
|
|
21
|
+
const parsed = parseInt(raw.replace(/[^0-9]/g, ''), 10);
|
|
22
|
+
if (Number.isNaN(parsed))
|
|
23
|
+
return allowZero ? 0 : 1;
|
|
24
|
+
const min = allowZero ? 0 : 1;
|
|
25
|
+
return Math.max(min, parsed);
|
|
26
|
+
}
|
|
27
|
+
function syncItemAnswers(item) {
|
|
28
|
+
const indices = extractPlaceholderIndices(item.question || '');
|
|
29
|
+
const requiredLength = blankSlotCount(item.question || '');
|
|
30
|
+
return {
|
|
31
|
+
...item,
|
|
32
|
+
answers: normalizeAnswersMatrix(item.answers, requiredLength).map((alts, index) => {
|
|
33
|
+
if (!indices.includes(index))
|
|
34
|
+
return [];
|
|
35
|
+
return alts.length > 0 ? alts : [''];
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function normalizeItems(rawItems) {
|
|
40
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
|
41
|
+
return [createEmptyFillInBlankGroupItem(1)];
|
|
42
|
+
}
|
|
43
|
+
return rawItems.map((item, index) => syncItemAnswers({
|
|
44
|
+
...createEmptyFillInBlankGroupItem(index + 1),
|
|
45
|
+
...item,
|
|
46
|
+
question: item.question || '{0}',
|
|
47
|
+
isExample: item.isExample === true,
|
|
48
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
49
|
+
questionNumber: item.questionNumber || index + 1,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
function sumPoints(items) {
|
|
53
|
+
return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
|
|
54
|
+
}
|
|
55
|
+
export function FillInBlankGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
56
|
+
const [instruction, setInstruction] = useState(initialData?.instruction || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION);
|
|
57
|
+
const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
|
|
58
|
+
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
|
59
|
+
const [showHints, setShowHints] = useState(initialData?.showHints === true);
|
|
60
|
+
const [hintsText, setHintsText] = useState((initialData?.hints || []).join(' / '));
|
|
61
|
+
const [items, setItems] = useState(() => normalizeItems(initialData?.items));
|
|
62
|
+
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
63
|
+
const [isClientMode, setIsClientMode] = useState(false);
|
|
64
|
+
const [errors, setErrors] = useState([]);
|
|
65
|
+
const onChangeRef = useRef(onChange);
|
|
66
|
+
const previousPayloadRef = useRef('');
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
onChangeRef.current = onChange;
|
|
69
|
+
}, [onChange]);
|
|
70
|
+
const parseHints = (value) => parseSlashAnswers(value);
|
|
71
|
+
const buildPayload = (nextInstruction = instruction, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl, nextImageUrl = imageUrl, nextShowHints = showHints, nextHintsText = hintsText) => ({
|
|
72
|
+
instruction: nextInstruction,
|
|
73
|
+
audioUrl: nextAudioUrl,
|
|
74
|
+
imageUrl: nextImageUrl,
|
|
75
|
+
showHints: nextShowHints,
|
|
76
|
+
hints: parseHints(nextHintsText),
|
|
77
|
+
items: nextItems,
|
|
78
|
+
explanation: nextExplanation,
|
|
79
|
+
points: sumPoints(nextItems),
|
|
80
|
+
});
|
|
81
|
+
const debouncedOnChange = useDebouncedCallback((payload) => {
|
|
82
|
+
onChangeRef.current?.(payload);
|
|
83
|
+
}, 300);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const payload = buildPayload();
|
|
86
|
+
const serialized = JSON.stringify(payload);
|
|
87
|
+
if (serialized !== previousPayloadRef.current) {
|
|
88
|
+
previousPayloadRef.current = serialized;
|
|
89
|
+
debouncedOnChange(payload);
|
|
90
|
+
onUnsavedChangesChange?.(true);
|
|
91
|
+
}
|
|
92
|
+
}, [instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
93
|
+
const validate = () => {
|
|
94
|
+
const nextErrors = [];
|
|
95
|
+
if (!instruction.trim()) {
|
|
96
|
+
nextErrors.push('Hướng dẫn (Instruction) không được để trống.');
|
|
97
|
+
}
|
|
98
|
+
if (items.length === 0) {
|
|
99
|
+
nextErrors.push('Cần ít nhất một câu hỏi trong nhóm.');
|
|
100
|
+
}
|
|
101
|
+
items.forEach((item, index) => {
|
|
102
|
+
if (!item.question.trim()) {
|
|
103
|
+
nextErrors.push(`Câu ${index + 1}: Stem không được để trống.`);
|
|
104
|
+
}
|
|
105
|
+
else if (extractPlaceholderIndices(item.question).length === 0) {
|
|
106
|
+
nextErrors.push(`Câu ${index + 1}: Stem cần có chỗ trống {0}, {1}...`);
|
|
107
|
+
}
|
|
108
|
+
item.answers.forEach((alts, blankIndex) => {
|
|
109
|
+
if (!extractPlaceholderIndices(item.question).includes(blankIndex))
|
|
110
|
+
return;
|
|
111
|
+
if (!alts.some((alt) => alt.trim())) {
|
|
112
|
+
nextErrors.push(`Câu ${index + 1}: Blank {${blankIndex}} cần ít nhất một đáp án.`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
setErrors(nextErrors);
|
|
117
|
+
return nextErrors.length === 0;
|
|
118
|
+
};
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (validationRef) {
|
|
121
|
+
validationRef.current = {
|
|
122
|
+
triggerValidation: async () => validate(),
|
|
123
|
+
getFormData: () => buildPayload(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}, [validationRef, instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation]);
|
|
127
|
+
const updateItem = (index, patch) => {
|
|
128
|
+
setItems((prev) => {
|
|
129
|
+
const next = [...prev];
|
|
130
|
+
const current = next[index];
|
|
131
|
+
if (!current)
|
|
132
|
+
return prev;
|
|
133
|
+
next[index] = syncItemAnswers({ ...current, ...patch });
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
const addItem = () => {
|
|
138
|
+
setItems((prev) => [...prev, createEmptyFillInBlankGroupItem(prev.length + 1)]);
|
|
139
|
+
};
|
|
140
|
+
const removeItem = (index) => {
|
|
141
|
+
setItems((prev) => {
|
|
142
|
+
if (prev.length <= 1)
|
|
143
|
+
return prev;
|
|
144
|
+
return prev
|
|
145
|
+
.filter((_, i) => i !== index)
|
|
146
|
+
.map((item, i) => ({ ...item, questionNumber: i + 1 }));
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const currentPayload = buildPayload();
|
|
150
|
+
const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
|
|
151
|
+
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 \u0110i\u1EC1n ch\u1ED7 tr\u1ED1ng (Nh\u00F3m)" }), _jsxs("p", { className: "text-sm text-gray-500", children: ["M\u1ED7i d\u00F2ng l\u00E0 1 stem c\u00F3 ", '{0}', ". C\u00F3 th\u1EC3 th\u00EAm audio/\u1EA3nh chung ho\u1EB7c t\u1EEBng item."] })] }), _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 && (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4", children: [_jsx("h3", { className: "text-sm font-medium text-red-800", children: "C\u1EA7n s\u1EEDa c\u00E1c l\u1ED7i sau:" }), _jsx("ul", { className: "mt-2 list-disc space-y-1 pl-5 text-sm text-red-700", 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(FillInBlankGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "H\u01B0\u1EDBng d\u1EABn v\u00E0 media chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: "fib-group-audio", label: "Audio chung (optional)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl }), _jsx(FillInBlankGroupMediaField, { id: "fib-group-image", label: "\u1EA2nh chung (optional)", accept: "image/*", value: imageUrl, onChange: setImageUrl })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-hints", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-hints", children: "Hi\u1EC7n h\u1ED9p t\u1EEB g\u1EE3i \u00FD chung" })] }), showHints && (_jsx(Input, { value: hintsText, onChange: (e) => setHintsText(e.target.value), placeholder: "am / is / are" }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Danh s\u00E1ch c\u00E2u (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u00E2u"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 bg-gray-50/50 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("span", { className: "flex items-center gap-2 text-sm font-medium text-gray-700", children: [_jsx("span", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700", children: item.questionNumber || index + 1 }), "C\u00E2u ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
|
|
152
|
+
points: parsePointsValue(event.target.value, true),
|
|
153
|
+
}) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(index), className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { children: [_jsxs(Label, { htmlFor: `question-${index}`, children: ["Stem (d\u00F9ng ", '{0}', ", ", '{1}', "...)"] }), _jsx(Textarea, { id: `question-${index}`, value: item.question, onChange: (e) => updateItem(index, { question: e.target.value }), placeholder: "{0}", className: "mt-1" })] }), extractPlaceholderIndices(item.question).map((blankIndex) => (_jsxs("div", { children: [_jsxs(Label, { htmlFor: `answer-${index}-${blankIndex}`, children: ["\u0110\u00E1p \u00E1n blank ", '{' + blankIndex + '}', " (c\u00E1ch nhau b\u1EDFi /)"] }), _jsx(Input, { id: `answer-${index}-${blankIndex}`, value: joinSlashAnswers(item.answers[blankIndex]), onChange: (e) => {
|
|
154
|
+
const next = [...item.answers];
|
|
155
|
+
next[blankIndex] = parseSlashAnswers(e.target.value);
|
|
156
|
+
updateItem(index, { answers: next });
|
|
157
|
+
}, placeholder: "is / 's", className: "mt-1" })] }, blankIndex))), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: `fib-item-image-${index}`, label: "\u1EA2nh item (optional)", accept: "image/*", value: item.imageUrl || '', onChange: (url) => updateItem(index, { imageUrl: url }) }), !audioUrl && (_jsx(FillInBlankGroupMediaField, { id: `fib-item-audio-${index}`, label: "Audio item (khi kh\u00F4ng c\u00F3 audio nh\u00F3m)", accept: "audio/*", value: item.audioUrl || '', onChange: (url) => updateItem(index, { audioUrl: url }) }))] })] }, index))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { id: "explanation", value: explanation, onChange: (event) => setExplanation(event.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3 }) })] })] }))] }));
|
|
158
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
function resolveDisplayUrl(fileUrl, previewOverride, fetchedPreviewUrl) {
|
|
10
|
+
if (previewOverride)
|
|
11
|
+
return previewOverride;
|
|
12
|
+
if (fetchedPreviewUrl)
|
|
13
|
+
return fetchedPreviewUrl;
|
|
14
|
+
if (fileUrl.startsWith('http') || fileUrl.startsWith('blob:'))
|
|
15
|
+
return fileUrl;
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
export function FillInBlankGroupMediaField({ id, label, accept, value, onChange, }) {
|
|
19
|
+
const [previewOverride, setPreviewOverride] = useState('');
|
|
20
|
+
const { previewUrl: fetchedPreviewUrl } = usePresignedFileUrl(value);
|
|
21
|
+
const displayUrl = resolveDisplayUrl(value, previewOverride, fetchedPreviewUrl);
|
|
22
|
+
const isImage = accept === 'image/*';
|
|
23
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { id: id, label: label, accept: accept, value: value, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: accept === 'image/*' ? 5 : 20, placeholder: isImage ? 'Upload ảnh hoặc paste URL' : 'Upload audio hoặc paste URL', autoUpload: true, prefix: "questions" }), value && isImage && (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(ImagePreview, { src: displayUrl || value, alt: label, className: "h-20 w-20 rounded-md object-contain" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
|
|
24
|
+
setPreviewOverride('');
|
|
25
|
+
onChange('');
|
|
26
|
+
}, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] })), value && !isImage && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("audio", { controls: true, src: displayUrl || value, className: "h-8 max-w-full" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
|
|
27
|
+
setPreviewOverride('');
|
|
28
|
+
onChange('');
|
|
29
|
+
}, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] }))] }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
export { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION };
|
|
3
|
+
export type FillBlankStemPart = {
|
|
4
|
+
type: 'text';
|
|
5
|
+
value: string;
|
|
6
|
+
} | {
|
|
7
|
+
type: 'blank';
|
|
8
|
+
index: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function extractPlaceholderIndices(question: string): number[];
|
|
11
|
+
/** answers[j] aligns with placeholder {j}; length = max(N)+1 */
|
|
12
|
+
export declare function blankSlotCount(question: string): number;
|
|
13
|
+
export declare function normalizeBlankAlts(value: unknown): string[];
|
|
14
|
+
export declare function normalizeAnswersMatrix(raw: unknown, requiredLength?: number): string[][];
|
|
15
|
+
export declare function joinSlashAnswers(alts: string[] | undefined): string;
|
|
16
|
+
export declare function parseSlashAnswers(text: string): string[];
|
|
17
|
+
export declare function splitStem(question: string): FillBlankStemPart[];
|
|
18
|
+
export declare function normalizeStudentText(value: string): string;
|
|
19
|
+
export declare function isBlankMatch(studentValue: string | undefined, alts: string[] | undefined): boolean;
|
|
20
|
+
export declare function padStudentAnswers(raw: unknown, blankCount: number): string[];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
export { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION };
|
|
3
|
+
export function extractPlaceholderIndices(question) {
|
|
4
|
+
if (!question)
|
|
5
|
+
return [];
|
|
6
|
+
const regex = /\{(\d+)\}/g;
|
|
7
|
+
const indices = [];
|
|
8
|
+
let match;
|
|
9
|
+
while ((match = regex.exec(question)) !== null) {
|
|
10
|
+
const index = Number.parseInt(match[1], 10);
|
|
11
|
+
if (!indices.includes(index)) {
|
|
12
|
+
indices.push(index);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return indices.sort((a, b) => a - b);
|
|
16
|
+
}
|
|
17
|
+
/** answers[j] aligns with placeholder {j}; length = max(N)+1 */
|
|
18
|
+
export function blankSlotCount(question) {
|
|
19
|
+
const indices = extractPlaceholderIndices(question);
|
|
20
|
+
if (indices.length === 0)
|
|
21
|
+
return 1;
|
|
22
|
+
return Math.max(...indices, 0) + 1;
|
|
23
|
+
}
|
|
24
|
+
export function normalizeBlankAlts(value) {
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return value
|
|
27
|
+
.map((item) => (item == null ? '' : String(item).trim()))
|
|
28
|
+
.filter((item) => item.length > 0);
|
|
29
|
+
}
|
|
30
|
+
if (value == null)
|
|
31
|
+
return [];
|
|
32
|
+
const text = String(value).trim();
|
|
33
|
+
return text ? [text] : [];
|
|
34
|
+
}
|
|
35
|
+
export function normalizeAnswersMatrix(raw, requiredLength) {
|
|
36
|
+
let result = [];
|
|
37
|
+
if (Array.isArray(raw)) {
|
|
38
|
+
result = raw.map((item) => normalizeBlankAlts(item));
|
|
39
|
+
}
|
|
40
|
+
if (typeof requiredLength === 'number' && requiredLength > 0) {
|
|
41
|
+
if (result.length > requiredLength) {
|
|
42
|
+
result = result.slice(0, requiredLength);
|
|
43
|
+
}
|
|
44
|
+
while (result.length < requiredLength) {
|
|
45
|
+
result.push([]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
export function joinSlashAnswers(alts) {
|
|
51
|
+
return (alts || []).map((item) => item.trim()).filter(Boolean).join(' / ');
|
|
52
|
+
}
|
|
53
|
+
export function parseSlashAnswers(text) {
|
|
54
|
+
return text
|
|
55
|
+
.split('/')
|
|
56
|
+
.map((item) => item.trim())
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
export function splitStem(question) {
|
|
60
|
+
if (!question)
|
|
61
|
+
return [];
|
|
62
|
+
const parts = [];
|
|
63
|
+
const regex = /\{(\d+)\}/g;
|
|
64
|
+
let lastIndex = 0;
|
|
65
|
+
let match;
|
|
66
|
+
while ((match = regex.exec(question)) !== null) {
|
|
67
|
+
if (match.index > lastIndex) {
|
|
68
|
+
parts.push({ type: 'text', value: question.slice(lastIndex, match.index) });
|
|
69
|
+
}
|
|
70
|
+
parts.push({ type: 'blank', index: Number.parseInt(match[1], 10) });
|
|
71
|
+
lastIndex = match.index + match[0].length;
|
|
72
|
+
}
|
|
73
|
+
if (lastIndex < question.length) {
|
|
74
|
+
parts.push({ type: 'text', value: question.slice(lastIndex) });
|
|
75
|
+
}
|
|
76
|
+
return parts;
|
|
77
|
+
}
|
|
78
|
+
export function normalizeStudentText(value) {
|
|
79
|
+
return value.toString().trim().toLowerCase().replace(/\s+/g, ' ');
|
|
80
|
+
}
|
|
81
|
+
export function isBlankMatch(studentValue, alts) {
|
|
82
|
+
const user = normalizeStudentText(studentValue ?? '');
|
|
83
|
+
if (!user || !alts?.length)
|
|
84
|
+
return false;
|
|
85
|
+
return alts.some((alt) => normalizeStudentText(alt) === user);
|
|
86
|
+
}
|
|
87
|
+
export function padStudentAnswers(raw, blankCount) {
|
|
88
|
+
const values = Array.isArray(raw?.answers)
|
|
89
|
+
? raw.answers.map((item) => String(item ?? ''))
|
|
90
|
+
: Array.isArray(raw)
|
|
91
|
+
? raw.map((item) => String(item ?? ''))
|
|
92
|
+
: typeof raw === 'string'
|
|
93
|
+
? [raw]
|
|
94
|
+
: [];
|
|
95
|
+
const next = [...values];
|
|
96
|
+
while (next.length < blankCount)
|
|
97
|
+
next.push('');
|
|
98
|
+
return next.slice(0, Math.max(blankCount, 0));
|
|
99
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FillInBlankGroupData, FillInBlankGroupItemData } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
export declare function createEmptyFillInBlankGroupItem(questionNumber: number): FillInBlankGroupItemData;
|
|
3
|
+
export declare function mapFillInBlankGroupItem(item: unknown, index: number, fallbackAnswer?: unknown): FillInBlankGroupItemData;
|
|
4
|
+
export declare function mapQuestionToFillInBlankGroupData(question: Record<string, unknown> | null | undefined): FillInBlankGroupData;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
import { blankSlotCount, extractPlaceholderIndices, normalizeAnswersMatrix, } from './blank-utils';
|
|
3
|
+
function isRecord(value) {
|
|
4
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
function asRecord(value) {
|
|
7
|
+
return isRecord(value) ? value : {};
|
|
8
|
+
}
|
|
9
|
+
function asString(value, fallback = '') {
|
|
10
|
+
return typeof value === 'string' ? value : fallback;
|
|
11
|
+
}
|
|
12
|
+
function asStringArray(value) {
|
|
13
|
+
return Array.isArray(value)
|
|
14
|
+
? value.map((item) => String(item ?? '').trim()).filter(Boolean)
|
|
15
|
+
: [];
|
|
16
|
+
}
|
|
17
|
+
export function createEmptyFillInBlankGroupItem(questionNumber) {
|
|
18
|
+
return {
|
|
19
|
+
question: '{0}',
|
|
20
|
+
answers: [['']],
|
|
21
|
+
caseSensitive: false,
|
|
22
|
+
imageUrl: '',
|
|
23
|
+
audioUrl: '',
|
|
24
|
+
isExample: false,
|
|
25
|
+
points: 1,
|
|
26
|
+
questionNumber,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function mapFillInBlankGroupItem(item, index, fallbackAnswer) {
|
|
30
|
+
const record = asRecord(item);
|
|
31
|
+
const content = asRecord(record.content);
|
|
32
|
+
const correctAnswer = asRecord(record.correctAnswer);
|
|
33
|
+
const fallback = asRecord(fallbackAnswer);
|
|
34
|
+
const isExample = record.isExample === true;
|
|
35
|
+
const question = asString(content.question) || asString(record.question) || '{0}';
|
|
36
|
+
const indices = extractPlaceholderIndices(question);
|
|
37
|
+
const answers = normalizeAnswersMatrix(correctAnswer.answers ?? record.answers ?? fallback.answers, blankSlotCount(question)).map((alts, index) => {
|
|
38
|
+
if (!indices.includes(index))
|
|
39
|
+
return [];
|
|
40
|
+
return alts.length > 0 ? alts : [''];
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
question,
|
|
44
|
+
answers,
|
|
45
|
+
caseSensitive: correctAnswer.caseSensitive === true
|
|
46
|
+
|| record.caseSensitive === true
|
|
47
|
+
|| fallback.caseSensitive === true,
|
|
48
|
+
imageUrl: asString(content.imageUrl) || asString(record.imageUrl),
|
|
49
|
+
audioUrl: asString(content.audioUrl) || asString(record.audioUrl),
|
|
50
|
+
showHints: content.showHints === true,
|
|
51
|
+
hints: asStringArray(content.hints),
|
|
52
|
+
isExample,
|
|
53
|
+
points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
|
|
54
|
+
questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function mapQuestionToFillInBlankGroupData(question) {
|
|
58
|
+
const source = asRecord(question);
|
|
59
|
+
const content = asRecord(source.content);
|
|
60
|
+
const answer = asRecord(source.answer);
|
|
61
|
+
const correctAnswer = asRecord(source.correctAnswer ?? source.correct_answer ?? answer.correctAnswer);
|
|
62
|
+
const explanation = String(source.explanation || answer.explanation || '');
|
|
63
|
+
const fallbackAnswers = Array.isArray(correctAnswer.answer)
|
|
64
|
+
? correctAnswer.answer
|
|
65
|
+
: [];
|
|
66
|
+
const meta = asRecord(content.meta);
|
|
67
|
+
if (Array.isArray(content.items)) {
|
|
68
|
+
const items = content.items.map((item, index) => mapFillInBlankGroupItem(item, index, fallbackAnswers[index]));
|
|
69
|
+
return {
|
|
70
|
+
instruction: asString(meta.instruction) || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION,
|
|
71
|
+
audioUrl: asString(meta.audioUrl),
|
|
72
|
+
imageUrl: asString(meta.imageUrl),
|
|
73
|
+
showHints: meta.showHints === true,
|
|
74
|
+
hints: asStringArray(meta.hints),
|
|
75
|
+
items,
|
|
76
|
+
explanation,
|
|
77
|
+
points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (Array.isArray(answer.items)) {
|
|
81
|
+
return {
|
|
82
|
+
instruction: asString(answer.instruction, FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION),
|
|
83
|
+
audioUrl: asString(answer.audioUrl),
|
|
84
|
+
imageUrl: asString(answer.imageUrl),
|
|
85
|
+
showHints: answer.showHints === true,
|
|
86
|
+
hints: asStringArray(answer.hints),
|
|
87
|
+
items: answer.items.map((item, index) => mapFillInBlankGroupItem(item, index, fallbackAnswers[index])),
|
|
88
|
+
explanation,
|
|
89
|
+
points: typeof answer.points === 'number' ? answer.points : undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
instruction: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION,
|
|
94
|
+
audioUrl: '',
|
|
95
|
+
imageUrl: '',
|
|
96
|
+
showHints: false,
|
|
97
|
+
hints: [],
|
|
98
|
+
items: [createEmptyFillInBlankGroupItem(1)],
|
|
99
|
+
explanation,
|
|
100
|
+
points: 1,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { QuestionTypeRegistration } from '../../creator/question-type-registry';
|
|
2
|
+
export { createEmptyFillInBlankGroupItem, mapFillInBlankGroupItem, mapQuestionToFillInBlankGroupData, } from './map-fill-in-blank-group-data';
|
|
3
|
+
export declare const fillInBlankGroupRegistration: QuestionTypeRegistration;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { FillInBlankGroupCreator } from './FillInBlankGroupCreator';
|
|
2
|
+
import { mapQuestionToFillInBlankGroupData } from './map-fill-in-blank-group-data';
|
|
3
|
+
export { createEmptyFillInBlankGroupItem, mapFillInBlankGroupItem, mapQuestionToFillInBlankGroupData, } from './map-fill-in-blank-group-data';
|
|
4
|
+
export const fillInBlankGroupRegistration = {
|
|
5
|
+
component: FillInBlankGroupCreator,
|
|
6
|
+
transformInitialData: (initialData) => mapQuestionToFillInBlankGroupData(initialData),
|
|
7
|
+
getExtraProps: (ctx) => ({
|
|
8
|
+
onUnsavedChangesChange: ctx.onUnsavedChangesChange,
|
|
9
|
+
validationRef: ctx.validationRef,
|
|
10
|
+
}),
|
|
11
|
+
wrapOnSave: (data, ctx) => {
|
|
12
|
+
const payload = data;
|
|
13
|
+
return {
|
|
14
|
+
type: ctx.questionType,
|
|
15
|
+
content: ctx.state.content,
|
|
16
|
+
points: payload.points ?? ctx.state.points ?? 1,
|
|
17
|
+
level: ctx.state.level,
|
|
18
|
+
difficulty: ctx.state.difficulty,
|
|
19
|
+
skill: ctx.state.skill,
|
|
20
|
+
answer: payload,
|
|
21
|
+
explanation: payload.explanation || '',
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
wrapOnChange: (data, ctx) => {
|
|
25
|
+
const payload = data;
|
|
26
|
+
return {
|
|
27
|
+
type: ctx.questionType,
|
|
28
|
+
content: ctx.state.content,
|
|
29
|
+
points: payload.points ?? ctx.state.points ?? 1,
|
|
30
|
+
level: ctx.state.level,
|
|
31
|
+
difficulty: ctx.state.difficulty,
|
|
32
|
+
skill: ctx.state.skill,
|
|
33
|
+
answer: payload,
|
|
34
|
+
explanation: payload.explanation || '',
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|