@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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, FILL_IN_BLANK_ITEM_TYPE, } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
import { blankSlotCount, extractPlaceholderIndices, normalizeAnswersMatrix, } from './blank-utils';
|
|
3
|
+
function sumGradablePoints(items) {
|
|
4
|
+
return items.reduce((total, item) => {
|
|
5
|
+
if (item.isExample)
|
|
6
|
+
return total;
|
|
7
|
+
const points = typeof item.points === 'number' ? item.points : 1;
|
|
8
|
+
return total + points;
|
|
9
|
+
}, 0);
|
|
10
|
+
}
|
|
11
|
+
function toItemAnswer(item) {
|
|
12
|
+
const indices = extractPlaceholderIndices(item.question || '');
|
|
13
|
+
const requiredLength = blankSlotCount(item.question || '');
|
|
14
|
+
return {
|
|
15
|
+
answers: normalizeAnswersMatrix(item.answers, requiredLength).map((alts, index) => {
|
|
16
|
+
if (!indices.includes(index))
|
|
17
|
+
return [];
|
|
18
|
+
return alts.length > 0 ? alts : [''];
|
|
19
|
+
}),
|
|
20
|
+
caseSensitive: item.caseSensitive === true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export const transformFillInBlankGroup = (question) => {
|
|
24
|
+
const answerData = question.answer;
|
|
25
|
+
const items = Array.isArray(answerData?.items) ? answerData.items : [];
|
|
26
|
+
if (items.length === 0) {
|
|
27
|
+
return { apiContent: { items: [] }, apiCorrectAnswer: { answer: [] } };
|
|
28
|
+
}
|
|
29
|
+
const apiItems = items.map((item, index) => {
|
|
30
|
+
const correctAnswer = toItemAnswer(item);
|
|
31
|
+
const hints = Array.isArray(item.hints)
|
|
32
|
+
? item.hints.map((hint) => String(hint ?? '').trim()).filter(Boolean)
|
|
33
|
+
: [];
|
|
34
|
+
return {
|
|
35
|
+
questionType: FILL_IN_BLANK_ITEM_TYPE,
|
|
36
|
+
...(item.isExample ? { isExample: true } : {}),
|
|
37
|
+
content: {
|
|
38
|
+
question: item.question || '',
|
|
39
|
+
...(item.imageUrl ? { imageUrl: item.imageUrl } : {}),
|
|
40
|
+
...(item.audioUrl ? { audioUrl: item.audioUrl } : {}),
|
|
41
|
+
...(item.showHints ? { showHints: true } : {}),
|
|
42
|
+
...(hints.length > 0 ? { hints } : {}),
|
|
43
|
+
},
|
|
44
|
+
correctAnswer,
|
|
45
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
46
|
+
questionNumber: item.questionNumber || index + 1,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
const groupHints = Array.isArray(answerData?.hints)
|
|
50
|
+
? answerData.hints.map((hint) => String(hint ?? '').trim()).filter(Boolean)
|
|
51
|
+
: [];
|
|
52
|
+
return {
|
|
53
|
+
apiContent: {
|
|
54
|
+
meta: {
|
|
55
|
+
instruction: answerData?.instruction || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION,
|
|
56
|
+
...(answerData?.audioUrl ? { audioUrl: answerData.audioUrl } : {}),
|
|
57
|
+
...(answerData?.imageUrl ? { imageUrl: answerData.imageUrl } : {}),
|
|
58
|
+
...(answerData?.showHints ? { showHints: true } : {}),
|
|
59
|
+
...(groupHints.length > 0 ? { hints: groupHints } : {}),
|
|
60
|
+
},
|
|
61
|
+
items: apiItems,
|
|
62
|
+
},
|
|
63
|
+
apiCorrectAnswer: {
|
|
64
|
+
answer: apiItems.map((item) => item.correctAnswer),
|
|
65
|
+
},
|
|
66
|
+
totalPoints: sumGradablePoints(items),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface MatchingWithLinesBoardLeftItem {
|
|
2
|
+
imageUrl: string;
|
|
3
|
+
isExample?: boolean;
|
|
4
|
+
questionNumber?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface MatchingWithLinesBoardRightItem {
|
|
7
|
+
id: string;
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
interface MatchingWithLinesBoardProps {
|
|
11
|
+
leftItems: MatchingWithLinesBoardLeftItem[];
|
|
12
|
+
rightItems: MatchingWithLinesBoardRightItem[];
|
|
13
|
+
values: string[];
|
|
14
|
+
correctValues?: string[];
|
|
15
|
+
onChange?: (next: string[]) => void;
|
|
16
|
+
isReviewMode?: boolean;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function MatchingWithLinesBoard({ leftItems, rightItems, values, correctValues, onChange, isReviewMode, disabled, }: MatchingWithLinesBoardProps): import("react").JSX.Element;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Star } from 'lucide-react';
|
|
5
|
+
import { cn } from '../../../../shared/lib/utils';
|
|
6
|
+
import { ResolvedImage } from '../../../../components/common/ResolvedImage';
|
|
7
|
+
export function MatchingWithLinesBoard({ leftItems, rightItems, values, correctValues, onChange, isReviewMode = false, disabled = false, }) {
|
|
8
|
+
const containerRef = useRef(null);
|
|
9
|
+
const leftDotRefs = useRef([]);
|
|
10
|
+
const rightDotRefs = useRef([]);
|
|
11
|
+
const [lines, setLines] = useState([]);
|
|
12
|
+
const [selectedLeft, setSelectedLeft] = useState(null);
|
|
13
|
+
const measure = useCallback(() => {
|
|
14
|
+
const container = containerRef.current;
|
|
15
|
+
if (!container)
|
|
16
|
+
return;
|
|
17
|
+
const box = container.getBoundingClientRect();
|
|
18
|
+
const next = [];
|
|
19
|
+
leftItems.forEach((left, index) => {
|
|
20
|
+
const rightId = values[index] || '';
|
|
21
|
+
if (!rightId)
|
|
22
|
+
return;
|
|
23
|
+
const rightIndex = rightItems.findIndex((item) => item.id === rightId);
|
|
24
|
+
if (rightIndex < 0)
|
|
25
|
+
return;
|
|
26
|
+
const leftDot = leftDotRefs.current[index];
|
|
27
|
+
const rightDot = rightDotRefs.current[rightIndex];
|
|
28
|
+
if (!leftDot || !rightDot)
|
|
29
|
+
return;
|
|
30
|
+
const leftBox = leftDot.getBoundingClientRect();
|
|
31
|
+
const rightBox = rightDot.getBoundingClientRect();
|
|
32
|
+
const expected = correctValues?.[index] || '';
|
|
33
|
+
let color = '#0f766e';
|
|
34
|
+
if (isReviewMode && !left.isExample) {
|
|
35
|
+
color = rightId && expected && rightId === expected ? '#16a34a' : '#dc2626';
|
|
36
|
+
}
|
|
37
|
+
else if (left.isExample) {
|
|
38
|
+
color = '#d97706';
|
|
39
|
+
}
|
|
40
|
+
next.push({
|
|
41
|
+
x1: leftBox.left + leftBox.width / 2 - box.left,
|
|
42
|
+
y1: leftBox.top + leftBox.height / 2 - box.top,
|
|
43
|
+
x2: rightBox.left + rightBox.width / 2 - box.left,
|
|
44
|
+
y2: rightBox.top + rightBox.height / 2 - box.top,
|
|
45
|
+
color,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
setLines(next);
|
|
49
|
+
}, [correctValues, isReviewMode, leftItems, rightItems, values]);
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
measure();
|
|
52
|
+
const container = containerRef.current;
|
|
53
|
+
if (!container || typeof ResizeObserver === 'undefined')
|
|
54
|
+
return undefined;
|
|
55
|
+
const observer = new ResizeObserver(() => measure());
|
|
56
|
+
observer.observe(container);
|
|
57
|
+
window.addEventListener('resize', measure);
|
|
58
|
+
return () => {
|
|
59
|
+
observer.disconnect();
|
|
60
|
+
window.removeEventListener('resize', measure);
|
|
61
|
+
};
|
|
62
|
+
}, [measure]);
|
|
63
|
+
const locked = disabled || isReviewMode;
|
|
64
|
+
const assignPair = (leftIndex, rightId) => {
|
|
65
|
+
if (locked || leftItems[leftIndex]?.isExample)
|
|
66
|
+
return;
|
|
67
|
+
const next = leftItems.map((_, index) => values[index] || '');
|
|
68
|
+
next.forEach((id, index) => {
|
|
69
|
+
if (index !== leftIndex && id === rightId)
|
|
70
|
+
next[index] = '';
|
|
71
|
+
});
|
|
72
|
+
next[leftIndex] = next[leftIndex] === rightId ? '' : rightId;
|
|
73
|
+
onChange?.(next);
|
|
74
|
+
setSelectedLeft(null);
|
|
75
|
+
};
|
|
76
|
+
const handleLeftClick = (index) => {
|
|
77
|
+
if (locked || leftItems[index]?.isExample)
|
|
78
|
+
return;
|
|
79
|
+
if (selectedLeft === index) {
|
|
80
|
+
setSelectedLeft(null);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (values[index]) {
|
|
84
|
+
const next = [...values];
|
|
85
|
+
next[index] = '';
|
|
86
|
+
onChange?.(next);
|
|
87
|
+
setSelectedLeft(null);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setSelectedLeft(index);
|
|
91
|
+
};
|
|
92
|
+
const handleRightClick = (rightId) => {
|
|
93
|
+
if (locked || selectedLeft === null)
|
|
94
|
+
return;
|
|
95
|
+
assignPair(selectedLeft, rightId);
|
|
96
|
+
};
|
|
97
|
+
return (_jsxs("div", { ref: containerRef, className: "relative", children: [_jsx("svg", { className: "pointer-events-none absolute inset-0 h-full w-full", "aria-hidden": true, children: lines.map((line, index) => (_jsx("line", { x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2, stroke: line.color, strokeWidth: 3, strokeLinecap: "round" }, `${line.x1}-${line.y1}-${index}`))) }), _jsxs("div", { className: "flex justify-between gap-6 sm:gap-10", children: [_jsx("div", { className: "flex w-max flex-col gap-2", children: leftItems.map((item, index) => (_jsxs("button", { type: "button", onClick: () => handleLeftClick(index), disabled: locked || item.isExample, "aria-label": `Connect left item ${index + 1}`, "aria-pressed": selectedLeft === index, className: cn('box-border flex h-14 w-max items-center gap-2 rounded-lg border bg-white px-1.5 text-left shadow-sm', item.isExample ? 'border-amber-200' : 'border-gray-200', selectedLeft === index && 'ring-2 ring-teal-500', !locked && !item.isExample && 'cursor-pointer hover:border-teal-400'), children: [_jsxs("div", { className: "relative size-11 shrink-0 overflow-hidden rounded-md bg-gray-50", children: [_jsx(ResolvedImage, { src: item.imageUrl, alt: `Match item ${item.questionNumber || index + 1}`, className: "h-full w-full object-contain" }), item.isExample && (_jsxs("span", { className: "absolute left-0.5 top-0.5 inline-flex items-center gap-0.5 rounded-full bg-amber-100 px-1 py-0.5 text-[10px] font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Ex"] }))] }), _jsx("span", { ref: (node) => {
|
|
98
|
+
leftDotRefs.current[index] = node;
|
|
99
|
+
}, className: cn('h-4 w-4 shrink-0 rounded-full border-2 bg-white', selectedLeft === index
|
|
100
|
+
? 'border-teal-600 bg-teal-500'
|
|
101
|
+
: values[index]
|
|
102
|
+
? 'border-teal-600'
|
|
103
|
+
: 'border-gray-400'), "aria-hidden": true })] }, `left-${index}`))) }), _jsx("div", { className: "flex w-max flex-col gap-2", children: rightItems.map((item, index) => {
|
|
104
|
+
const used = values.includes(item.id);
|
|
105
|
+
return (_jsxs("button", { type: "button", onClick: () => handleRightClick(item.id), disabled: locked, "aria-label": `Connect right item ${item.text || index + 1}`, className: cn('box-border flex h-14 w-full items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 text-left shadow-sm', selectedLeft !== null && !locked && 'cursor-pointer hover:border-teal-400', used && 'border-teal-200'), children: [_jsx("span", { ref: (node) => {
|
|
106
|
+
rightDotRefs.current[index] = node;
|
|
107
|
+
}, className: cn('h-4 w-4 shrink-0 rounded-full border-2 bg-white', used || selectedLeft !== null ? 'border-teal-600' : 'border-gray-400', selectedLeft !== null && 'bg-teal-100'), "aria-hidden": true }), _jsx("span", { className: "whitespace-nowrap text-sm font-medium text-gray-800", children: item.text || '—' })] }, item.id || `right-${index}`));
|
|
108
|
+
}) })] })] }));
|
|
109
|
+
}
|
package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { MatchingWithLinesGroupClientProps } from '../../_shared/types/matching-with-lines-group.type';
|
|
2
|
+
export declare function MatchingWithLinesGroupClient({ questionData, isReviewMode, userAnswers, onAnswerChange, onAnswersChange, }: MatchingWithLinesGroupClientProps): import("react").JSX.Element;
|
package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Lightbulb } from 'lucide-react';
|
|
4
|
+
import { MatchingWithLinesBoard } from './MatchingWithLinesBoard';
|
|
5
|
+
export function MatchingWithLinesGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, onAnswersChange, }) {
|
|
6
|
+
const items = questionData.items || [];
|
|
7
|
+
const rightItems = questionData.rightItems || [];
|
|
8
|
+
const canEdit = Boolean(onAnswerChange || onAnswersChange);
|
|
9
|
+
const values = items.map((item, index) => {
|
|
10
|
+
if (item.isExample)
|
|
11
|
+
return item.correctAnswer || '';
|
|
12
|
+
return typeof userAnswers[index] === 'string' ? userAnswers[index] : '';
|
|
13
|
+
});
|
|
14
|
+
const correctValues = items.map((item) => item.correctAnswer || '');
|
|
15
|
+
const handleChange = (next) => {
|
|
16
|
+
onAnswersChange?.(next);
|
|
17
|
+
next.forEach((rightId, index) => {
|
|
18
|
+
if (items[index]?.isExample)
|
|
19
|
+
return;
|
|
20
|
+
if ((userAnswers[index] || '') !== rightId) {
|
|
21
|
+
onAnswerChange?.(index, rightId);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-indigo-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-indigo-800", children: questionData.instruction }))] })), _jsx(MatchingWithLinesBoard, { leftItems: items.map((item) => ({
|
|
26
|
+
imageUrl: item.imageUrl,
|
|
27
|
+
isExample: item.isExample,
|
|
28
|
+
questionNumber: item.questionNumber,
|
|
29
|
+
})), rightItems: rightItems, values: values, correctValues: isReviewMode ? correctValues : undefined, onChange: handleChange, isReviewMode: isReviewMode, disabled: isReviewMode || !canEdit }), 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] }))] }));
|
|
30
|
+
}
|
package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { MatchingWithLinesGroupCreatorProps } from '../../_shared/types/matching-with-lines-group.type';
|
|
2
|
+
export declare function MatchingWithLinesGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: MatchingWithLinesGroupCreatorProps): import("react").JSX.Element;
|
package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
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 { MatchingWithLinesGroupClient } from './MatchingWithLinesGroupClient';
|
|
14
|
+
import { MatchingWithLinesImageField } from './MatchingWithLinesImageField';
|
|
15
|
+
import { createEmptyMatchingPair, nextRightId } from './map-matching-with-lines-group-data';
|
|
16
|
+
import { MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/matching-with-lines-group.type';
|
|
17
|
+
function parsePointsValue(raw, allowZero = false) {
|
|
18
|
+
if (raw === '')
|
|
19
|
+
return allowZero ? 0 : 1;
|
|
20
|
+
const parsed = parseInt(raw.replace(/[^0-9]/g, ''), 10);
|
|
21
|
+
if (Number.isNaN(parsed))
|
|
22
|
+
return allowZero ? 0 : 1;
|
|
23
|
+
const min = allowZero ? 0 : 1;
|
|
24
|
+
return Math.max(min, parsed);
|
|
25
|
+
}
|
|
26
|
+
function normalizeRightItems(raw) {
|
|
27
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
28
|
+
return [
|
|
29
|
+
{ id: 'r1', text: '' },
|
|
30
|
+
{ id: 'r2', text: '' },
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
return raw.map((item, index) => ({
|
|
34
|
+
id: item.id || `r${index + 1}`,
|
|
35
|
+
text: item.text || '',
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
function normalizeItems(rawItems, rightItems) {
|
|
39
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
|
40
|
+
return rightItems.map((right, index) => ({
|
|
41
|
+
imageUrl: '',
|
|
42
|
+
correctAnswer: right.id,
|
|
43
|
+
isExample: false,
|
|
44
|
+
points: 1,
|
|
45
|
+
questionNumber: index + 1,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
return rawItems.map((item, index) => ({
|
|
49
|
+
imageUrl: item.imageUrl || '',
|
|
50
|
+
correctAnswer: item.correctAnswer || rightItems[index]?.id || '',
|
|
51
|
+
isExample: item.isExample === true,
|
|
52
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
53
|
+
questionNumber: item.questionNumber || index + 1,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
function sumPoints(items) {
|
|
57
|
+
return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
|
|
58
|
+
}
|
|
59
|
+
export function MatchingWithLinesGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
60
|
+
const [title, setTitle] = useState(initialData?.title || '');
|
|
61
|
+
const [instruction, setInstruction] = useState(initialData?.instruction || MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION);
|
|
62
|
+
const [rightItems, setRightItems] = useState(() => normalizeRightItems(initialData?.rightItems));
|
|
63
|
+
const [items, setItems] = useState(() => normalizeItems(initialData?.items, normalizeRightItems(initialData?.rightItems)));
|
|
64
|
+
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
65
|
+
const [isClientMode, setIsClientMode] = useState(false);
|
|
66
|
+
const [errors, setErrors] = useState([]);
|
|
67
|
+
const onChangeRef = useRef(onChange);
|
|
68
|
+
const previousPayloadRef = useRef('');
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
onChangeRef.current = onChange;
|
|
71
|
+
}, [onChange]);
|
|
72
|
+
const buildPayload = (nextTitle = title, nextInstruction = instruction, nextRightItems = rightItems, nextItems = items, nextExplanation = explanation) => ({
|
|
73
|
+
title: nextTitle,
|
|
74
|
+
instruction: nextInstruction,
|
|
75
|
+
rightItems: nextRightItems,
|
|
76
|
+
items: nextItems,
|
|
77
|
+
explanation: nextExplanation,
|
|
78
|
+
points: sumPoints(nextItems),
|
|
79
|
+
});
|
|
80
|
+
const debouncedOnChange = useDebouncedCallback((payload) => {
|
|
81
|
+
onChangeRef.current?.(payload);
|
|
82
|
+
}, 300);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const payload = buildPayload();
|
|
85
|
+
const serialized = JSON.stringify(payload);
|
|
86
|
+
if (serialized !== previousPayloadRef.current) {
|
|
87
|
+
previousPayloadRef.current = serialized;
|
|
88
|
+
debouncedOnChange(payload);
|
|
89
|
+
onUnsavedChangesChange?.(true);
|
|
90
|
+
}
|
|
91
|
+
}, [title, instruction, rightItems, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
92
|
+
const validate = () => {
|
|
93
|
+
const nextErrors = [];
|
|
94
|
+
if (!instruction.trim()) {
|
|
95
|
+
nextErrors.push('Hướng dẫn (Instruction) không được để trống.');
|
|
96
|
+
}
|
|
97
|
+
if (items.length < 2 || rightItems.length < 2) {
|
|
98
|
+
nextErrors.push('Cần ít nhất 2 cặp để nối.');
|
|
99
|
+
}
|
|
100
|
+
if (items.length !== rightItems.length) {
|
|
101
|
+
nextErrors.push('Số ảnh (cột trái) phải bằng số từ (cột phải).');
|
|
102
|
+
}
|
|
103
|
+
const ids = new Set();
|
|
104
|
+
rightItems.forEach((item, index) => {
|
|
105
|
+
if (!item.id.trim())
|
|
106
|
+
nextErrors.push(`Từ ${index + 1}: id không được để trống.`);
|
|
107
|
+
if (!item.text.trim())
|
|
108
|
+
nextErrors.push(`Từ ${index + 1}: nội dung không được để trống.`);
|
|
109
|
+
if (item.id.trim() && ids.has(item.id.trim())) {
|
|
110
|
+
nextErrors.push(`Từ ${index + 1}: id "${item.id}" bị trùng.`);
|
|
111
|
+
}
|
|
112
|
+
ids.add(item.id.trim());
|
|
113
|
+
});
|
|
114
|
+
const usedAnswers = new Set();
|
|
115
|
+
items.forEach((item, index) => {
|
|
116
|
+
if (!item.imageUrl.trim())
|
|
117
|
+
nextErrors.push(`Ảnh ${index + 1}: cần upload hình.`);
|
|
118
|
+
if (!item.correctAnswer.trim())
|
|
119
|
+
nextErrors.push(`Ảnh ${index + 1}: chưa chọn đáp án.`);
|
|
120
|
+
if (item.correctAnswer && usedAnswers.has(item.correctAnswer)) {
|
|
121
|
+
nextErrors.push(`Ảnh ${index + 1}: đáp án bị trùng (mỗi từ chỉ dùng 1 lần).`);
|
|
122
|
+
}
|
|
123
|
+
usedAnswers.add(item.correctAnswer);
|
|
124
|
+
});
|
|
125
|
+
setErrors(nextErrors);
|
|
126
|
+
return nextErrors.length === 0;
|
|
127
|
+
};
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (validationRef) {
|
|
130
|
+
validationRef.current = {
|
|
131
|
+
triggerValidation: async () => validate(),
|
|
132
|
+
getFormData: () => buildPayload(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}, [validationRef, title, instruction, rightItems, items, explanation]);
|
|
136
|
+
const updateItem = (index, patch) => {
|
|
137
|
+
setItems((prev) => {
|
|
138
|
+
const next = [...prev];
|
|
139
|
+
const current = next[index];
|
|
140
|
+
if (!current)
|
|
141
|
+
return prev;
|
|
142
|
+
next[index] = { ...current, ...patch };
|
|
143
|
+
return next;
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
const updateRightItem = (index, patch) => {
|
|
147
|
+
setRightItems((prev) => {
|
|
148
|
+
const current = prev[index];
|
|
149
|
+
if (!current)
|
|
150
|
+
return prev;
|
|
151
|
+
const next = [...prev];
|
|
152
|
+
const updated = { ...current, ...patch };
|
|
153
|
+
next[index] = updated;
|
|
154
|
+
if (patch.id && patch.id !== current.id) {
|
|
155
|
+
setItems((itemsPrev) => itemsPrev.map((item) => (item.correctAnswer === current.id ? { ...item, correctAnswer: patch.id || '' } : item)));
|
|
156
|
+
}
|
|
157
|
+
return next;
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
const addPair = () => {
|
|
161
|
+
const rightId = nextRightId(rightItems.map((item) => item.id));
|
|
162
|
+
const pair = createEmptyMatchingPair(items.length + 1, rightId);
|
|
163
|
+
setRightItems((prev) => [...prev, pair.rightItem]);
|
|
164
|
+
setItems((prev) => [...prev, pair.item]);
|
|
165
|
+
};
|
|
166
|
+
const removePair = (index) => {
|
|
167
|
+
if (items.length <= 2)
|
|
168
|
+
return;
|
|
169
|
+
setRightItems((prev) => prev.filter((_, i) => i !== index));
|
|
170
|
+
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({
|
|
171
|
+
...item,
|
|
172
|
+
questionNumber: i + 1,
|
|
173
|
+
})));
|
|
174
|
+
};
|
|
175
|
+
const currentPayload = buildPayload();
|
|
176
|
+
const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
|
|
177
|
+
return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi N\u1ED1i b\u1EB1ng \u0111\u01B0\u1EDDng k\u1EBB (Nh\u00F3m)" }), _jsx("p", { className: "text-sm text-gray-500", children: "N\u1ED1i \u1EA3nh c\u1ED9t tr\u00E1i v\u1EDBi t\u1EEB c\u1ED9t ph\u1EA3i. M\u1ED7i t\u1EEB d\u00F9ng \u0111\u00FAng 1 l\u1EA7n." })] }), _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(MatchingWithLinesGroupClient, { questionData: currentPayload, userAnswers: currentPayload.items.map((item) => item.correctAnswer) }) })] })) : (_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 chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "mwl-title", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwl-title", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY", className: "mt-1" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "mwl-instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "mwl-instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["C\u1EB7p n\u1ED1i (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addPair, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u1EB7p"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-4 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: "text-sm font-medium text-gray-700", children: ["C\u1EB7p ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `mwl-example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `mwl-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, {
|
|
178
|
+
points: parsePointsValue(event.target.value, true),
|
|
179
|
+
}) }) })), items.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removePair(index), className: "text-red-500 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(MatchingWithLinesImageField, { id: `mwl-image-${index}`, label: "\u1EA2nh c\u1ED9t tr\u00E1i", value: item.imageUrl, onChange: (url) => updateItem(index, { imageUrl: url }) }), _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-right-text-${index}`, children: "T\u1EEB c\u1ED9t ph\u1EA3i" }), _jsx(Input, { id: `mwl-right-text-${index}`, value: rightItems[index]?.text || '', onChange: (e) => updateRightItem(index, { text: e.target.value }), placeholder: "paintbrush", className: "mt-1 bg-white" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-answer-${index}`, children: "\u0110\u00E1p \u00E1n \u0111\u00FAng (id t\u1EEB)" }), _jsxs("select", { id: `mwl-answer-${index}`, value: item.correctAnswer, onChange: (e) => updateItem(index, { correctAnswer: e.target.value }), className: "mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm", children: [_jsx("option", { value: "", children: "Ch\u1ECDn t\u1EEB \u0111\u00FAng" }), rightItems.map((right) => (_jsxs("option", { value: right.id, children: [right.text || right.id, " (", right.id, ")"] }, right.id)))] })] })] })] })] }, `${rightItems[index]?.id || 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 }) })] })] }))] }));
|
|
180
|
+
}
|
package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
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 MatchingWithLinesImageField({ id, label, value, onChange, }) {
|
|
19
|
+
const [previewOverride, setPreviewOverride] = useState('');
|
|
20
|
+
const { previewUrl: fetchedPreviewUrl } = usePresignedFileUrl(value);
|
|
21
|
+
const displayUrl = resolveDisplayUrl(value, previewOverride, fetchedPreviewUrl);
|
|
22
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { id: id, label: label, accept: "image/*", value: value, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), value && (_jsxs("div", { className: "flex items-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: () => {
|
|
23
|
+
setPreviewOverride('');
|
|
24
|
+
onChange('');
|
|
25
|
+
}, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] }))] }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MatchingWithLinesGroupData, MatchingWithLinesGroupItemData, MatchingWithLinesRightItem } from '../../_shared/types/matching-with-lines-group.type';
|
|
2
|
+
export declare function nextRightId(existing: string[]): string;
|
|
3
|
+
export declare function createEmptyMatchingPair(questionNumber: number, rightId: string): {
|
|
4
|
+
rightItem: MatchingWithLinesRightItem;
|
|
5
|
+
item: MatchingWithLinesGroupItemData;
|
|
6
|
+
};
|
|
7
|
+
export declare function mapMatchingWithLinesGroupItem(item: unknown, index: number, fallbackAnswer?: unknown): MatchingWithLinesGroupItemData;
|
|
8
|
+
export declare function mapQuestionToMatchingWithLinesGroupData(question: Record<string, unknown> | null | undefined): MatchingWithLinesGroupData;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/matching-with-lines-group.type';
|
|
2
|
+
function isRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
function asRecord(value) {
|
|
6
|
+
return isRecord(value) ? value : {};
|
|
7
|
+
}
|
|
8
|
+
function asString(value, fallback = '') {
|
|
9
|
+
return typeof value === 'string' ? value : fallback;
|
|
10
|
+
}
|
|
11
|
+
export function nextRightId(existing) {
|
|
12
|
+
let n = existing.length + 1;
|
|
13
|
+
const used = new Set(existing);
|
|
14
|
+
while (used.has(`r${n}`))
|
|
15
|
+
n += 1;
|
|
16
|
+
return `r${n}`;
|
|
17
|
+
}
|
|
18
|
+
export function createEmptyMatchingPair(questionNumber, rightId) {
|
|
19
|
+
return {
|
|
20
|
+
rightItem: { id: rightId, text: '' },
|
|
21
|
+
item: {
|
|
22
|
+
imageUrl: '',
|
|
23
|
+
correctAnswer: rightId,
|
|
24
|
+
isExample: false,
|
|
25
|
+
points: 1,
|
|
26
|
+
questionNumber,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function mapMatchingWithLinesGroupItem(item, index, fallbackAnswer) {
|
|
31
|
+
const record = asRecord(item);
|
|
32
|
+
const content = asRecord(record.content);
|
|
33
|
+
const correctAnswer = asRecord(record.correctAnswer);
|
|
34
|
+
const isExample = record.isExample === true;
|
|
35
|
+
const answer = asString(correctAnswer.answer) ||
|
|
36
|
+
(typeof fallbackAnswer === 'string' ? fallbackAnswer : '');
|
|
37
|
+
return {
|
|
38
|
+
imageUrl: asString(content.imageUrl) || asString(record.imageUrl),
|
|
39
|
+
correctAnswer: answer,
|
|
40
|
+
isExample,
|
|
41
|
+
points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
|
|
42
|
+
questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function mapRightItems(raw) {
|
|
46
|
+
if (!Array.isArray(raw))
|
|
47
|
+
return [];
|
|
48
|
+
return raw.map((item, index) => {
|
|
49
|
+
const record = asRecord(item);
|
|
50
|
+
return {
|
|
51
|
+
id: asString(record.id, `r${index + 1}`),
|
|
52
|
+
text: asString(record.text),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function mapQuestionToMatchingWithLinesGroupData(question) {
|
|
57
|
+
const source = asRecord(question);
|
|
58
|
+
const content = asRecord(source.content);
|
|
59
|
+
const answer = asRecord(source.answer);
|
|
60
|
+
const correctAnswer = asRecord(source.correctAnswer ?? source.correct_answer ?? answer.correctAnswer);
|
|
61
|
+
const fallbackAnswers = Array.isArray(correctAnswer.answer)
|
|
62
|
+
? correctAnswer.answer
|
|
63
|
+
: Array.isArray(answer.answer)
|
|
64
|
+
? answer.answer
|
|
65
|
+
: [];
|
|
66
|
+
const explanation = asString(source.explanation) || asString(answer.explanation);
|
|
67
|
+
const meta = asRecord(content.meta ?? answer.meta);
|
|
68
|
+
if (Array.isArray(content.items)) {
|
|
69
|
+
const items = content.items.map((item, index) => mapMatchingWithLinesGroupItem(item, index, fallbackAnswers[index]));
|
|
70
|
+
return {
|
|
71
|
+
title: asString(meta.title) || asString(answer.title),
|
|
72
|
+
instruction: asString(meta.instruction) ||
|
|
73
|
+
asString(answer.instruction) ||
|
|
74
|
+
MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
|
|
75
|
+
rightItems: mapRightItems(content.rightItems),
|
|
76
|
+
items,
|
|
77
|
+
explanation,
|
|
78
|
+
points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(answer.items)) {
|
|
82
|
+
const items = answer.items.map((item, index) => mapMatchingWithLinesGroupItem(item, index, fallbackAnswers[index]));
|
|
83
|
+
return {
|
|
84
|
+
title: asString(answer.title),
|
|
85
|
+
instruction: asString(answer.instruction) || MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
|
|
86
|
+
rightItems: mapRightItems(answer.rightItems),
|
|
87
|
+
items,
|
|
88
|
+
explanation,
|
|
89
|
+
points: typeof answer.points === 'number'
|
|
90
|
+
? answer.points
|
|
91
|
+
: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const first = createEmptyMatchingPair(1, 'r1');
|
|
95
|
+
const second = createEmptyMatchingPair(2, 'r2');
|
|
96
|
+
return {
|
|
97
|
+
title: '',
|
|
98
|
+
instruction: MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
|
|
99
|
+
rightItems: [first.rightItem, second.rightItem],
|
|
100
|
+
items: [first.item, second.item],
|
|
101
|
+
explanation,
|
|
102
|
+
points: 2,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { QuestionTypeRegistration } from '../../creator/question-type-registry';
|
|
2
|
+
export { createEmptyMatchingPair, mapMatchingWithLinesGroupItem, mapQuestionToMatchingWithLinesGroupData, nextRightId, } from './map-matching-with-lines-group-data';
|
|
3
|
+
export declare const matchingWithLinesGroupRegistration: QuestionTypeRegistration;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MatchingWithLinesGroupCreator } from './MatchingWithLinesGroupCreator';
|
|
2
|
+
import { mapQuestionToMatchingWithLinesGroupData } from './map-matching-with-lines-group-data';
|
|
3
|
+
export { createEmptyMatchingPair, mapMatchingWithLinesGroupItem, mapQuestionToMatchingWithLinesGroupData, nextRightId, } from './map-matching-with-lines-group-data';
|
|
4
|
+
export const matchingWithLinesGroupRegistration = {
|
|
5
|
+
component: MatchingWithLinesGroupCreator,
|
|
6
|
+
transformInitialData: (initialData) => mapQuestionToMatchingWithLinesGroupData(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
|
+
};
|