@tinyweb_dev/oe-exam-sdk 1.0.1 → 1.0.2
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/api/exam-questions.d.ts +1 -1
- package/dist/api/exam-questions.js +1 -39
- package/dist/api/exam-questions.types.d.ts +0 -28
- package/dist/components/exams/ExamCreator.d.ts +1 -7
- package/dist/components/exams/ExamCreator.js +21 -57
- package/dist/components/exams/ExamQuestionsPageContainer.d.ts +0 -5
- package/dist/components/exams/ExamQuestionsPageContainer.js +2 -2
- package/dist/components/exams/api.d.ts +1 -1
- package/dist/components/exams/index.d.ts +1 -1
- package/dist/components/exams/take/components/QuestionRenderer.d.ts +1 -6
- package/dist/components/exams/take/components/QuestionRenderer.js +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.d.ts +1 -7
- package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +22 -38
- package/dist/components/exams/take/exam-taking.utils.d.ts +2 -2
- package/dist/components/exams/take/exam-taking.utils.js +2 -0
- package/dist/components/exams/take/index.d.ts +0 -6
- package/dist/components/exams/take/index.js +0 -5
- package/dist/components/exams/take/utils/question-transformers.d.ts +2 -2
- package/dist/components/exams/take/utils/question-transformers.js +4 -25
- package/dist/components/questions/_shared/types/word-fill-structured-form.type.d.ts +1 -1
- package/dist/components/questions/question-bank/index.d.ts +0 -1
- package/dist/components/questions/question-bank/index.js +0 -1
- package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.d.ts +2 -2
- package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.js +15 -5
- package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +39 -15
- package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +35 -12
- package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +3 -18
- package/dist/components/questions/types/word-fill-structured-form/transform.js +2 -5
- package/dist/components/questions/viewer/QuestionViewer.js +0 -67
- package/dist/components/themes/cambridge-yle/CambridgeYleInstructionBanner.js +12 -3
- package/dist/components/themes/english-certification/EcWordFillStructuredForm.js +13 -20
- package/dist/index.d.ts +3 -4
- package/dist/index.js +1 -2
- package/dist/shared/constants/ApiConstant.d.ts +0 -1
- package/dist/shared/constants/ApiConstant.js +0 -1
- package/dist/shared/constants/routes.d.ts +2 -2
- package/dist/shared/constants/routes.js +3 -3
- package/dist/shared/lib/utils/wfsf-blank-match.d.ts +9 -0
- package/dist/shared/lib/utils/wfsf-blank-match.js +60 -0
- package/package.json +1 -1
- package/dist/components/questions/question-bank/QuestionBankPickerDialog.d.ts +0 -45
- package/dist/components/questions/question-bank/QuestionBankPickerDialog.js +0 -209
- package/dist/components/questions/question-bank/QuestionBankPickerRow.d.ts +0 -9
- package/dist/components/questions/question-bank/QuestionBankPickerRow.js +0 -20
- package/dist/components/questions/question-bank/question-bank-picker.utils.d.ts +0 -55
- package/dist/components/questions/question-bank/question-bank-picker.utils.js +0 -408
- package/dist/components/themes/cambridge-yle/assets/images/bg-title-old.png +0 -0
- package/dist/components/themes/cambridge-yle/assets/images/border-old.png +0 -0
- package/dist/components/themes/cambridge-yle/assets/images/close_old.png +0 -0
- package/dist/components/themes/cambridge-yle/assets/images/frame-old.png +0 -0
|
@@ -6,4 +6,4 @@ export declare function createMockExamQuestionsApi(seed: {
|
|
|
6
6
|
exam: ExamWithTemplate;
|
|
7
7
|
questions?: Partial<ExamQuestion>[];
|
|
8
8
|
}): ExamQuestionsSdkApi;
|
|
9
|
-
export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions,
|
|
9
|
+
export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput, } from './exam-questions.types';
|
|
@@ -41,40 +41,17 @@ export function createExamQuestionsApi(options = {}) {
|
|
|
41
41
|
},
|
|
42
42
|
saveExam: async () => undefined,
|
|
43
43
|
updatePartAudio: (input) => updatePartAudio(request, input),
|
|
44
|
-
searchQuestions: async ({ questionType, keyword,
|
|
44
|
+
searchQuestions: async ({ questionType, keyword, page, size }) => {
|
|
45
45
|
const query = new URLSearchParams();
|
|
46
46
|
if (questionType)
|
|
47
47
|
query.set('questionType', questionType);
|
|
48
48
|
if (keyword)
|
|
49
49
|
query.set('keyword', keyword);
|
|
50
|
-
if (bookId)
|
|
51
|
-
query.set('bookId', bookId);
|
|
52
|
-
if (unitId)
|
|
53
|
-
query.set('unitId', unitId);
|
|
54
|
-
if (lessonId)
|
|
55
|
-
query.set('lessonId', lessonId);
|
|
56
|
-
appendLessonIds(query, lessonIds);
|
|
57
50
|
query.set('page', String(page));
|
|
58
51
|
query.set('size', String(size));
|
|
59
52
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
|
60
53
|
return request(`${API_ENDPOINTS.MANAGE.QUESTION_BANKS}${suffix}`);
|
|
61
54
|
},
|
|
62
|
-
getQuestionFilters: async (input = {}) => {
|
|
63
|
-
const query = new URLSearchParams();
|
|
64
|
-
if (input.bookId)
|
|
65
|
-
query.set('bookId', input.bookId);
|
|
66
|
-
if (input.unitId)
|
|
67
|
-
query.set('unitId', input.unitId);
|
|
68
|
-
if (input.lessonId)
|
|
69
|
-
query.set('lessonId', input.lessonId);
|
|
70
|
-
appendLessonIds(query, input.lessonIds);
|
|
71
|
-
const suffix = query.toString() ? `?${query.toString()}` : '';
|
|
72
|
-
const result = await request(`${API_ENDPOINTS.MANAGE.QUESTION_BANKS}/filters${suffix}`);
|
|
73
|
-
if (result && typeof result === 'object' && 'data' in result && result.data) {
|
|
74
|
-
return result.data;
|
|
75
|
-
}
|
|
76
|
-
return result;
|
|
77
|
-
},
|
|
78
55
|
};
|
|
79
56
|
}
|
|
80
57
|
export const createExamQuestionsFetchApi = createExamQuestionsApi;
|
|
@@ -116,15 +93,6 @@ export function createMockExamQuestionsApi(seed) {
|
|
|
116
93
|
})),
|
|
117
94
|
meta: { totalItems: questions.size },
|
|
118
95
|
}),
|
|
119
|
-
getQuestionFilters: async () => ({
|
|
120
|
-
questionTypes: Array.from(new Set(Array.from(questions.values())
|
|
121
|
-
.map((q) => q.type)
|
|
122
|
-
.filter((t) => Boolean(t))
|
|
123
|
-
.map(String))),
|
|
124
|
-
books: [],
|
|
125
|
-
units: [],
|
|
126
|
-
lessons: [],
|
|
127
|
-
}),
|
|
128
96
|
};
|
|
129
97
|
}
|
|
130
98
|
function resolveTransformOptions(input) {
|
|
@@ -159,12 +127,6 @@ function createRequest({ baseUrl = API_BASE_URL ?? '', fetcher = fetch, headers,
|
|
|
159
127
|
return response.json();
|
|
160
128
|
};
|
|
161
129
|
}
|
|
162
|
-
function appendLessonIds(query, lessonIds) {
|
|
163
|
-
const ids = (lessonIds ?? []).map((id) => id.trim()).filter(Boolean);
|
|
164
|
-
if (ids.length === 0)
|
|
165
|
-
return;
|
|
166
|
-
query.set('lessonIds', ids.join(','));
|
|
167
|
-
}
|
|
168
130
|
function toRecord(value) {
|
|
169
131
|
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
170
132
|
}
|
|
@@ -7,8 +7,6 @@ export interface ExamQuestionsSdkApi {
|
|
|
7
7
|
saveExam?: (input: SaveExamInput) => Promise<void>;
|
|
8
8
|
updatePartAudio?: (input: UpdatePartAudioInput) => Promise<void>;
|
|
9
9
|
searchQuestions?: (input: SearchQuestionBankInput) => Promise<SearchQuestionBankResult>;
|
|
10
|
-
/** Distinct question types + cascade book/unit/lesson names from meta. */
|
|
11
|
-
getQuestionFilters?: (input?: QuestionBankFiltersInput) => Promise<QuestionBankFiltersResult>;
|
|
12
10
|
}
|
|
13
11
|
export interface ExamQuestionsApiResponse<T> {
|
|
14
12
|
data: T;
|
|
@@ -71,34 +69,9 @@ export interface UpdatePartAudioInput {
|
|
|
71
69
|
export interface SearchQuestionBankInput {
|
|
72
70
|
questionType?: QuestionType;
|
|
73
71
|
keyword?: string;
|
|
74
|
-
/** meta.bookId */
|
|
75
|
-
bookId?: string;
|
|
76
|
-
/** meta.unitId */
|
|
77
|
-
unitId?: string;
|
|
78
|
-
/** meta.lessonId */
|
|
79
|
-
lessonId?: string;
|
|
80
|
-
/** Session scope: multiple meta.lessonId (IN). */
|
|
81
|
-
lessonIds?: string[];
|
|
82
72
|
page: number;
|
|
83
73
|
size: number;
|
|
84
74
|
}
|
|
85
|
-
export interface QuestionBankFiltersInput {
|
|
86
|
-
bookId?: string;
|
|
87
|
-
unitId?: string;
|
|
88
|
-
lessonId?: string;
|
|
89
|
-
lessonIds?: string[];
|
|
90
|
-
}
|
|
91
|
-
/** Cascade option: stable meta id + display label. */
|
|
92
|
-
export interface QuestionBankFilterOption {
|
|
93
|
-
id: string;
|
|
94
|
-
name: string;
|
|
95
|
-
}
|
|
96
|
-
export interface QuestionBankFiltersResult {
|
|
97
|
-
questionTypes: string[];
|
|
98
|
-
books: QuestionBankFilterOption[];
|
|
99
|
-
units: QuestionBankFilterOption[];
|
|
100
|
-
lessons: QuestionBankFilterOption[];
|
|
101
|
-
}
|
|
102
75
|
export interface QuestionSearchItem {
|
|
103
76
|
id: string;
|
|
104
77
|
questionType: QuestionType | string;
|
|
@@ -113,7 +86,6 @@ export interface QuestionSearchItem {
|
|
|
113
86
|
code?: string;
|
|
114
87
|
title?: string | null;
|
|
115
88
|
usageCount?: number;
|
|
116
|
-
meta?: Record<string, unknown> | null;
|
|
117
89
|
}
|
|
118
90
|
export interface SearchQuestionBankResult {
|
|
119
91
|
data: QuestionSearchItem[];
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { type ExistingQuestionBank } from '../questions/components/QuestionBankOption';
|
|
2
|
-
import { type QuestionBankLessonSuggestion, type QuestionBankPickerDefaultFilters } from '../questions/question-bank/QuestionBankPickerDialog';
|
|
3
2
|
import type { ExamData, ExamQuestion, ExamTemplate } from '../../shared/types/entities/exam.types';
|
|
4
|
-
import type {
|
|
3
|
+
import type { SaveQuestionInput, SearchQuestionBankInput, SearchQuestionBankResult } from '../../api/exam-questions.types';
|
|
5
4
|
export interface ExamCreatorProps {
|
|
6
5
|
template: ExamTemplate;
|
|
7
6
|
examId?: string;
|
|
@@ -18,11 +17,6 @@ export interface ExamCreatorProps {
|
|
|
18
17
|
onPartAudioChange?: (partId: string, audioUrl: string) => void | Promise<void>;
|
|
19
18
|
partAudioOverrides?: Record<string, string>;
|
|
20
19
|
searchQuestions?: (input: SearchQuestionBankInput) => Promise<SearchQuestionBankResult>;
|
|
21
|
-
getQuestionFilters?: (input?: QuestionBankFiltersInput) => Promise<QuestionBankFiltersResult>;
|
|
22
|
-
/** Prefill book/unit/lesson filters in the bank picker. */
|
|
23
|
-
questionBankDefaultFilters?: QuestionBankPickerDefaultFilters;
|
|
24
|
-
/** Quick-pick chips for lesson provenance. */
|
|
25
|
-
questionBankLessonSuggestions?: QuestionBankLessonSuggestion[];
|
|
26
20
|
saveExamRef?: React.MutableRefObject<(() => void) | null>;
|
|
27
21
|
}
|
|
28
22
|
export declare function ExamCreator(props: ExamCreatorProps): import("react").JSX.Element;
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
-
import { CheckCircle, ChevronLeft, ChevronRight,
|
|
4
|
+
import { CheckCircle, ChevronLeft, ChevronRight, Loader2, Save } from 'lucide-react';
|
|
5
5
|
import { Button } from '../../components/ui/button';
|
|
6
6
|
import { QuestionBankOption } from '../questions/components/QuestionBankOption';
|
|
7
7
|
import { QuestionSearchDropdown } from '../questions/components/QuestionSearchDropdown';
|
|
8
|
-
import { QuestionBankPickerDialog, } from '../questions/question-bank/QuestionBankPickerDialog';
|
|
9
8
|
import { QuestionCreator } from '../questions/creator/QuestionCreator';
|
|
10
9
|
import { useToast } from '../../shared/lib/hooks/useToast';
|
|
11
10
|
import { useT } from '../../shared/lib/i18n';
|
|
@@ -51,7 +50,6 @@ export function ExamCreator(props) {
|
|
|
51
50
|
const [selectedQuestionInfo, setSelectedQuestionInfo] = useState({});
|
|
52
51
|
const [questionBankCodeError, setQuestionBankCodeError] = useState();
|
|
53
52
|
const [questionBankTitleError, setQuestionBankTitleError] = useState();
|
|
54
|
-
const [pickerOpen, setPickerOpen] = useState(false);
|
|
55
53
|
const currentPart = getPartByQuestionIndex(template, currentQuestionIndex);
|
|
56
54
|
const currentPartId = currentPart?.id ?? '';
|
|
57
55
|
const currentConfig = currentPart ? getEffectiveQuestionConfig(currentPart, currentQuestionIndex) : undefined;
|
|
@@ -200,64 +198,30 @@ export function ExamCreator(props) {
|
|
|
200
198
|
setIsSavingQuestion(false);
|
|
201
199
|
}
|
|
202
200
|
};
|
|
203
|
-
const
|
|
204
|
-
const questionType = 'questionType' in item ? item.questionType : item.type;
|
|
205
|
-
const correctAnswer = 'correctAnswer' in item ? item.correctAnswer : item.answer;
|
|
201
|
+
const handleExistingQuestionSelect = (question) => {
|
|
206
202
|
const transformed = transformApiQuestionToFrontend({
|
|
207
|
-
question_type:
|
|
208
|
-
content:
|
|
209
|
-
correct_answer:
|
|
210
|
-
points:
|
|
211
|
-
difficult_level:
|
|
212
|
-
question_skill_type:
|
|
213
|
-
explanation:
|
|
203
|
+
question_type: question.type,
|
|
204
|
+
content: question.content,
|
|
205
|
+
correct_answer: question.answer,
|
|
206
|
+
points: question.points,
|
|
207
|
+
difficult_level: question.difficulty,
|
|
208
|
+
question_skill_type: question.skill,
|
|
209
|
+
explanation: question.explanation,
|
|
214
210
|
});
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
questionNumber: targetIndex + 1,
|
|
221
|
-
isCompleted: false,
|
|
222
|
-
};
|
|
223
|
-
upsertQuestionData(targetIndex, selectedQuestion);
|
|
224
|
-
const bankId = item.id;
|
|
225
|
-
const code = 'code' in item ? item.code : undefined;
|
|
226
|
-
const title = 'title' in item ? (item.title ?? undefined) : undefined;
|
|
211
|
+
const selectedQuestion = { ...transformed, id: undefined, partId: currentPartId, questionNumber: currentQuestionIndex + 1, isCompleted: false };
|
|
212
|
+
upsertQuestionData(currentQuestionIndex, selectedQuestion);
|
|
213
|
+
const bankId = question.id;
|
|
214
|
+
const code = question.code;
|
|
215
|
+
const title = question.title;
|
|
227
216
|
if (code || title)
|
|
228
|
-
setSelectedQuestionInfo((prev) => ({ ...prev, [
|
|
229
|
-
if (bankId && (code || title))
|
|
230
|
-
setExistingQuestionBanks((prev) => ({
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
setQuestionStatus(examId, targetIndex + 1, QuestionStatus.UNSAVED);
|
|
236
|
-
if (targetIndex === currentQuestionIndex) {
|
|
237
|
-
setCurrentQuestionHasUnsavedChanges(true);
|
|
238
|
-
markQuestionCompleted(targetIndex, false);
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
241
|
-
markQuestionCompleted(targetIndex, false);
|
|
242
|
-
}
|
|
243
|
-
}, [currentPartId, currentQuestionIndex, examId, markQuestionCompleted, setCurrentQuestionHasUnsavedChanges, template, upsertQuestionData]);
|
|
244
|
-
const handleExistingQuestionSelect = (question) => {
|
|
245
|
-
applyBankItemToIndex(question, currentQuestionIndex);
|
|
217
|
+
setSelectedQuestionInfo((prev) => ({ ...prev, [currentQuestionIndex]: { code, title } }));
|
|
218
|
+
if (bankId && (code || title))
|
|
219
|
+
setExistingQuestionBanks((prev) => ({ ...prev, [currentQuestionIndex]: { id: bankId, code: code ?? '', title: title ?? '' } }));
|
|
220
|
+
setQuestionStatus(examId, currentQuestionIndex + 1, QuestionStatus.UNSAVED);
|
|
221
|
+
setCurrentQuestionHasUnsavedChanges(true);
|
|
222
|
+
markQuestionCompleted(currentQuestionIndex, false);
|
|
246
223
|
success({ message: t('common.examCreator.bankSelected') });
|
|
247
224
|
};
|
|
248
|
-
const handlePickerConfirm = (items) => {
|
|
249
|
-
if (items.length === 0)
|
|
250
|
-
return;
|
|
251
|
-
items.forEach((item, offset) => {
|
|
252
|
-
const targetIndex = Math.min(currentQuestionIndex + offset, template.totalQuestions - 1);
|
|
253
|
-
applyBankItemToIndex(item, targetIndex);
|
|
254
|
-
});
|
|
255
|
-
success({
|
|
256
|
-
message: items.length === 1
|
|
257
|
-
? t('common.examCreator.bankSelected')
|
|
258
|
-
: `Đã chèn ${items.length} câu từ ngân hàng`,
|
|
259
|
-
});
|
|
260
|
-
};
|
|
261
225
|
const handleConfirmSaveExam = () => {
|
|
262
226
|
setShowCompletionDialog(false);
|
|
263
227
|
clearQuestionStatuses(examId);
|
|
@@ -270,7 +234,7 @@ export function ExamCreator(props) {
|
|
|
270
234
|
else
|
|
271
235
|
setShowCompletionDialog(true);
|
|
272
236
|
});
|
|
273
|
-
return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50/30", children: [_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "w-80 overflow-y-auto border-r border-gray-200/60 bg-white/80 p-4 backdrop-blur-sm", children: _jsx(ExamPartTabs, { template: template, currentPartId: currentPartId, completedQuestions: completedQuestions, onPartChange: (partId) => tryNavigate(template.parts.find((part) => part.id === partId)?.startIndex ?? 0) }) }), _jsxs("div", { className: "flex flex-1 flex-col overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-4 pb-4", children: _jsxs("div", { className: "mx-auto max-w-4xl", children: [
|
|
237
|
+
return (_jsxs("div", { className: "flex min-h-0 flex-1 flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50/30", children: [_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "w-80 overflow-y-auto border-r border-gray-200/60 bg-white/80 p-4 backdrop-blur-sm", children: _jsx(ExamPartTabs, { template: template, currentPartId: currentPartId, completedQuestions: completedQuestions, onPartChange: (partId) => tryNavigate(template.parts.find((part) => part.id === partId)?.startIndex ?? 0) }) }), _jsxs("div", { className: "flex flex-1 flex-col overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-4 pb-4", children: _jsxs("div", { className: "mx-auto max-w-4xl", children: [currentPart?.skill === 'LISTENING' && _jsx(PartAudioUploader, { partId: currentPart.id, partName: currentPart.name, audioUrl: props.partAudioOverrides?.[currentPart.id] ?? currentPart.audioUrl ?? '', examId: examId, onAudioChange: (url) => void props.onPartAudioChange?.(currentPart.id, url), isReadOnly: !props.onPartAudioChange }), _jsx(QuestionCreator, { initialData: currentQuestionDraft, prefilledType: currentConfig?.questionType, prefilledSkill: currentPart?.skill, optionCount: currentConfig?.optionCount, optionMode: currentConfig?.optionMode, defaultValue: currentConfig?.defaultValue, defaultQuestion: currentConfig?.defaultQuestion, viewMode: currentConfig?.viewMode, groups: currentConfig?.groups || normalizedPart?.groups, groupConfig: currentConfig?.groupConfig || normalizedPart?.groupConfig, groupType: currentConfig?.groupType || normalizedPart?.groupType, articleCount: currentConfig?.articleCount ?? currentPart?.articleCount, questionConfig: currentConfig?.questionConfig || currentPart?.questionConfig, questionIndexInPart: isPartLevelGroup ? currentQuestionIndex - (currentPart?.startIndex ?? 0) : currentQuestionIndex - (currentConfig?.startIndex ?? currentPart?.startIndex ?? 0), partId: sharedDataPartId, onChange: handleQuestionDraftChange, onUnsavedChangesChange: setCurrentQuestionHasUnsavedChanges, onSave: handleQuestionSave, onCancel: props.onCancel, isEmbedded: true, externalErrors: validationErrors[currentQuestionIndex], validationRef: validationRef, suggestedAnswers: currentConfig?.suggestedAnswers || currentPart?.suggestedAnswers, hasSuggestedAnswers: currentConfig?.hasSuggestedAnswers ?? currentPart?.hasSuggestedAnswers, headerExtra: _jsx(QuestionSearchDropdown, { questionType: currentConfig?.questionType || currentPart?.questionType, onSelect: handleExistingQuestionSelect, selectedQuestionLabel: selectedQuestionInfo[currentQuestionIndex] ? `${selectedQuestionInfo[currentQuestionIndex].code ? `#${selectedQuestionInfo[currentQuestionIndex].code} - ` : ''}${selectedQuestionInfo[currentQuestionIndex].title || ''}` : undefined, disabled: !(currentConfig?.questionType || currentPart?.questionType), compact: true, searchQuestions: props.searchQuestions }) }, currentQuestionIndex), _jsx("div", { className: "mt-8", children: _jsx(QuestionBankOption, { checked: bankSetting.checked, onCheckedChange: (checked) => updateBankSetting(currentQuestionIndex, { checked }, setQuestionBankSettings, setCurrentQuestionHasUnsavedChanges, examId), code: bankSetting.code, onCodeChange: (code) => updateBankSetting(currentQuestionIndex, { code }, setQuestionBankSettings, setCurrentQuestionHasUnsavedChanges, examId), title: bankSetting.title, onTitleChange: (title) => updateBankSetting(currentQuestionIndex, { title }, setQuestionBankSettings, setCurrentQuestionHasUnsavedChanges, examId), codeError: questionBankCodeError, error: questionBankTitleError, existingBank: existingQuestionBanks[currentQuestionIndex] || null }) })] }) }), _jsx("div", { className: "flex-shrink-0 border-t border-gray-200/80 bg-white/95 shadow-[0_-4px_20px_rgba(0,0,0,0.08)] backdrop-blur-sm", children: _jsxs("div", { className: "mx-auto flex max-w-4xl items-center justify-between px-6 py-4", children: [_jsxs(Button, { variant: "outline", onClick: () => tryNavigate(currentQuestionIndex - 1), disabled: currentQuestionIndex === 0, className: "gap-2 border-gray-300 hover:bg-gray-50", children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), "C\u00E2u tr\u01B0\u1EDBc"] }), _jsxs("div", { className: "flex items-center gap-4", children: [currentQuestionHasUnsavedChanges ? _jsxs("div", { className: "flex items-center gap-2 rounded-full bg-amber-50 px-3 py-1.5 ring-1 ring-amber-200", children: [_jsx("span", { className: "h-2 w-2 animate-pulse rounded-full bg-amber-500" }), _jsx("span", { className: "text-xs font-medium text-amber-700", children: "Ch\u01B0a l\u01B0u" })] }) : questionsState[currentQuestionIndex]?.isCompleted ? _jsxs("div", { className: "flex items-center gap-2 rounded-full bg-green-50 px-3 py-1.5 ring-1 ring-green-200", children: [_jsx(CheckCircle, { className: "h-3.5 w-3.5 text-green-500" }), _jsx("span", { className: "text-xs font-medium text-green-700", children: "\u0110\u00E3 l\u01B0u" })] }) : null, _jsxs("div", { className: "flex items-center gap-2 rounded-full bg-gray-100 px-4 py-2", children: [_jsxs("span", { className: "text-sm font-bold text-gray-900", children: ["C\u00E2u ", currentQuestionIndex + 1] }), _jsx("span", { className: "text-sm text-gray-400", children: "/" }), _jsx("span", { className: "text-sm text-gray-500", children: template.totalQuestions })] }), _jsx(Button, { onClick: () => void handleQuestionSave(getQuestionData(currentQuestionIndex)), disabled: isSavingQuestion || !currentQuestionHasUnsavedChanges, className: "gap-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 shadow-md hover:shadow-lg transition-all", children: isSavingQuestion ? _jsxs(_Fragment, { children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin" }), "\u0110ang l\u01B0u..."] }) : _jsxs(_Fragment, { children: [_jsx(Save, { className: "h-4 w-4" }), "L\u01B0u c\u00E2u h\u1ECFi"] }) })] }), _jsxs(Button, { variant: "outline", onClick: () => tryNavigate(currentQuestionIndex + 1), disabled: currentQuestionIndex >= template.totalQuestions - 1 || !questionsState[currentQuestionIndex]?.isCompleted, className: "gap-2 border-gray-300 hover:bg-gray-50", children: ["C\u00E2u ti\u1EBFp theo", _jsx(ChevronRight, { className: "h-4 w-4" })] })] }) })] }), _jsx("div", { className: "w-80 overflow-y-auto border-l border-gray-200/60 bg-white/80 p-4 backdrop-blur-sm", children: _jsx(ExamQuestionGrid, { template: template, currentQuestionIndex: currentQuestionIndex, completedQuestions: completedQuestions, onQuestionChange: tryNavigate }) })] }), _jsx(CompletionDialog, { open: showCompletionDialog, onOpenChange: setShowCompletionDialog, completedCount: completedQuestions.size, totalQuestions: template.totalQuestions, onConfirm: handleConfirmSaveExam }), _jsx(UnsavedChangesDialog, { open: showUnsavedDialog, onOpenChange: setShowUnsavedDialog, onConfirm: confirmNavigation, onCancel: () => setShowUnsavedDialog(false), questionNumber: currentQuestionIndex + 1 })] }));
|
|
274
238
|
}
|
|
275
239
|
function buildQuestionForSave(questionData, freshData, existing, questionType, currentPart, currentQuestionIndex, examDifficulty) {
|
|
276
240
|
return { ...existing, ...questionData, ...(freshData ? { answer: freshData } : {}), type: questionData?.type ?? existing?.type ?? questionType, id: existing?.id ?? questionData?.id, partId: currentPart.id, questionNumber: currentQuestionIndex + 1, difficulty: (questionData?.difficulty ?? examDifficulty ?? existing?.difficulty), skill: currentPart.skill, isCompleted: true };
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { UserRole } from '../../shared/types/common.types';
|
|
2
2
|
import type { ExamQuestionsSdkApi } from '../../api/exam-questions.types';
|
|
3
|
-
import type { QuestionBankLessonSuggestion, QuestionBankPickerDefaultFilters } from '../questions/question-bank/QuestionBankPickerDialog';
|
|
4
3
|
import type { CreateExamTexts } from './create-exam.texts';
|
|
5
4
|
export interface ExamQuestionsPageContainerProps {
|
|
6
5
|
examId: string;
|
|
@@ -10,9 +9,5 @@ export interface ExamQuestionsPageContainerProps {
|
|
|
10
9
|
onNavigate?: (href: string) => void;
|
|
11
10
|
/** Optional label overrides for embedded contexts. */
|
|
12
11
|
texts?: CreateExamTexts;
|
|
13
|
-
/** Prefill book/unit/lesson in bank picker (e.g. homework-context). */
|
|
14
|
-
questionBankDefaultFilters?: QuestionBankPickerDefaultFilters;
|
|
15
|
-
/** Lesson chips for bank picker cascade. */
|
|
16
|
-
questionBankLessonSuggestions?: QuestionBankLessonSuggestion[];
|
|
17
12
|
}
|
|
18
13
|
export declare function ExamQuestionsPageContainer(props: ExamQuestionsPageContainerProps): import("react").JSX.Element;
|
|
@@ -9,7 +9,7 @@ import { useT } from '../../shared/lib/i18n';
|
|
|
9
9
|
import { transformApiQuestionsToFrontend } from '../../shared/lib/utils/question-reverse-transform';
|
|
10
10
|
import { ExamCreator } from './ExamCreator';
|
|
11
11
|
import { initializeQuestionStatusesFromAPI } from './exam-question-status';
|
|
12
|
-
function ExamQuestionsContent({ api, examId, listPath = '/manage/exams', onNavigate, texts
|
|
12
|
+
function ExamQuestionsContent({ api, examId, listPath = '/manage/exams', onNavigate, texts }) {
|
|
13
13
|
const t = useT();
|
|
14
14
|
const { error: errorToast, success } = useToast();
|
|
15
15
|
const saveExamRef = useRef(null);
|
|
@@ -78,7 +78,7 @@ function ExamQuestionsContent({ api, examId, listPath = '/manage/exams', onNavig
|
|
|
78
78
|
return _jsx(Loading, { label: texts?.questionsLoadingLabel ?? t('admin.exams.questions.loading') });
|
|
79
79
|
if (!exam || !template)
|
|
80
80
|
return _jsx(Empty, { onBack: () => navigate(listPath), label: texts?.questionsNoTemplateLabel ?? t('admin.exams.questions.noTemplate') });
|
|
81
|
-
return (_jsxs("div", { className: "-m-4 flex h-[calc(100vh-4rem)] flex-col overflow-hidden md:-m-6 md:-mb-10", children: [_jsxs("div", { className: "flex flex-shrink-0 items-center gap-4 border-b border-gray-200/60 bg-white px-4 py-3", children: [_jsx(Button, { variant: "ghost", size: "icon", onClick: () => navigate(listPath), className: "h-8 w-8", children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsxs("h1", { className: "truncate text-lg font-semibold tracking-tight text-gray-900", children: [exam.name, exam.code && _jsxs(Badge, { variant: "outline", className: "ml-2 align-middle border-blue-200 bg-blue-50 text-blue-700 text-xs font-normal", children: [_jsx(Hash, { className: "mr-0.5 h-3 w-3" }), exam.code] })] }) }), _jsxs(Button, { onClick: () => saveExamRef.current?.(), className: "gap-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 shadow-sm", size: "sm", children: [_jsx(Save, { className: "h-4 w-4" }), texts?.saveQuestionsLabel ?? 'Lưu đề thi'] })] }), _jsx(ExamCreator, { template: template, examId: examId, examDifficulty: exam.difficulty, examLevel: exam.examLevel ?? undefined, subjectId: exam.subject?.id, initialQuestions: questions, onSaveQuestion: api.saveQuestion, onSave: handleSaveExam, onCancel: () => navigate(listPath), onPartAudioChange: api.updatePartAudio ? handlePartAudioChange : undefined, partAudioOverrides: partAudioOverrides, searchQuestions: api.searchQuestions,
|
|
81
|
+
return (_jsxs("div", { className: "-m-4 flex h-[calc(100vh-4rem)] flex-col overflow-hidden md:-m-6 md:-mb-10", children: [_jsxs("div", { className: "flex flex-shrink-0 items-center gap-4 border-b border-gray-200/60 bg-white px-4 py-3", children: [_jsx(Button, { variant: "ghost", size: "icon", onClick: () => navigate(listPath), className: "h-8 w-8", children: _jsx(ArrowLeft, { className: "h-4 w-4" }) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsxs("h1", { className: "truncate text-lg font-semibold tracking-tight text-gray-900", children: [exam.name, exam.code && _jsxs(Badge, { variant: "outline", className: "ml-2 align-middle border-blue-200 bg-blue-50 text-blue-700 text-xs font-normal", children: [_jsx(Hash, { className: "mr-0.5 h-3 w-3" }), exam.code] })] }) }), _jsxs(Button, { onClick: () => saveExamRef.current?.(), className: "gap-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 shadow-sm", size: "sm", children: [_jsx(Save, { className: "h-4 w-4" }), texts?.saveQuestionsLabel ?? 'Lưu đề thi'] })] }), _jsx(ExamCreator, { template: template, examId: examId, examDifficulty: exam.difficulty, examLevel: exam.examLevel ?? undefined, subjectId: exam.subject?.id, initialQuestions: questions, onSaveQuestion: api.saveQuestion, onSave: handleSaveExam, onCancel: () => navigate(listPath), onPartAudioChange: api.updatePartAudio ? handlePartAudioChange : undefined, partAudioOverrides: partAudioOverrides, searchQuestions: api.searchQuestions, saveExamRef: saveExamRef })] }));
|
|
82
82
|
}
|
|
83
83
|
export function ExamQuestionsPageContainer(props) {
|
|
84
84
|
return _jsx(Suspense, { fallback: _jsx(Loading, { label: "Loading..." }), children: _jsx(ExamQuestionsContent, { ...props }) });
|
|
@@ -2,4 +2,4 @@ export { buildExamTemplatePayload, createExamCreateApi, createMockCreateExamApi
|
|
|
2
2
|
export type { CreateExamFetchApiOptions, CreateExamPayload, CreateExamSdkApi, ExamFormData, ExamLevelOption, ExamResponse, ExamTemplatePayload, UpdateExamTemplateInput } from '../../api/exam-create';
|
|
3
3
|
export { createExamQuestionsApi, createExamQuestionsFetchApi, createMockExamQuestionsApi } from '../../api/exam-questions';
|
|
4
4
|
export { createExamTakingApi, createMockExamTakingApi } from '../../api/exam-taking';
|
|
5
|
-
export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions,
|
|
5
|
+
export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput } from '../../api/exam-questions';
|
|
@@ -21,7 +21,7 @@ export { ExamQuestionGrid } from './ExamQuestionGrid';
|
|
|
21
21
|
export { PartAudioUploader } from './PartAudioUploader';
|
|
22
22
|
export type { PartAudioUploaderProps } from './PartAudioUploader';
|
|
23
23
|
export { createExamQuestionsApi, createExamQuestionsFetchApi, createMockExamQuestionsApi } from './api';
|
|
24
|
-
export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions,
|
|
24
|
+
export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput } from './api';
|
|
25
25
|
export { ExamQuestionsPageContainer } from './ExamQuestionsPageContainer';
|
|
26
26
|
export type { ExamQuestionsPageContainerProps } from './ExamQuestionsPageContainer';
|
|
27
27
|
export { UnsavedChangesDialog } from './UnsavedChangesDialog';
|
|
@@ -20,11 +20,6 @@ interface QuestionRendererProps {
|
|
|
20
20
|
onTeacherIntroPlayed?: () => void;
|
|
21
21
|
/** Index of the question to render within the part (for Cambridge YLE 1-by-1 navigation) */
|
|
22
22
|
currentQuestionIndex?: number;
|
|
23
|
-
/**
|
|
24
|
-
* Compact chrome for host embeds (school homework). Currently applied to
|
|
25
|
-
* WORD_FILL_STRUCTURED_FORM only — exam take UI stays full banners by default.
|
|
26
|
-
*/
|
|
27
|
-
compactLayout?: boolean;
|
|
28
23
|
}
|
|
29
|
-
export declare function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex,
|
|
24
|
+
export declare function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, }: QuestionRendererProps): import("react").JSX.Element;
|
|
30
25
|
export {};
|
|
@@ -13,7 +13,7 @@ import { SpeakingQuestionRenderer } from './speaking/renderers';
|
|
|
13
13
|
import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformLabelPicture, transformFillWordHint, transformGridFill, transformAnswerTheQuestion, transformAnswerTheQuestionGroup, transformTrueFalse, transformTrueFalseGroup, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformWordOrdering, transformWordOrderingGroupTake, transformWordFillParagraph, transformMatchByWritingAnswer, transformMatchWordToPicture, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, transformMatchingWithLinesGroup, transformCrosswordPuzzle, transformFindWordsInMatrix, transformSortWordsIntoCategories, transformChooseThenAnswerGroup, } from '../utils/question-transformers';
|
|
14
14
|
import { transformCrossOutWordGroup } from '../utils/cross-out-word-group.transformer';
|
|
15
15
|
import { normalizeColoringAssignments } from '../utils/coloring-assignments';
|
|
16
|
-
export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode = false, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex,
|
|
16
|
+
export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode = false, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, }) {
|
|
17
17
|
const { partNo, questionType, questions } = part;
|
|
18
18
|
const questionCount = questions.filter(q => !q.is_example).length;
|
|
19
19
|
const getRenderQuestions = (arr) => {
|
|
@@ -222,7 +222,7 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
|
|
|
222
222
|
}
|
|
223
223
|
case 'WORD_FILL_STRUCTURED_FORM': {
|
|
224
224
|
const data = transformWordFillStructuredForm(questions);
|
|
225
|
-
return (_jsx(MoversWordFillStructuredFormRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl || part.audioUrl, groupTitle: data.groupTitle, groupContent: data.groupContent, groupImageUrl: data.groupImageUrl, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode
|
|
225
|
+
return (_jsx(MoversWordFillStructuredFormRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl || part.audioUrl, groupTitle: data.groupTitle, groupContent: data.groupContent, groupImageUrl: data.groupImageUrl, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
226
226
|
}
|
|
227
227
|
case 'WRITE_SENTENCES': {
|
|
228
228
|
const data = transformWriteSentences(questions);
|
|
@@ -13,12 +13,6 @@ interface MoversWordFillStructuredFormRendererProps {
|
|
|
13
13
|
answers: Record<string, any>;
|
|
14
14
|
onAnswerChange: (questionId: string, value: any) => void;
|
|
15
15
|
isReviewMode?: boolean;
|
|
16
|
-
/**
|
|
17
|
-
* Host embeds (e.g. school homework): hide Cambridge part/instruction banners
|
|
18
|
-
* and put the rubric inline next to the question number. Default false keeps
|
|
19
|
-
* full exam chrome unchanged.
|
|
20
|
-
*/
|
|
21
|
-
compactLayout?: boolean;
|
|
22
16
|
}
|
|
23
|
-
export declare function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode,
|
|
17
|
+
export declare function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode, }: MoversWordFillStructuredFormRendererProps): React.JSX.Element;
|
|
24
18
|
export {};
|
|
@@ -6,6 +6,7 @@ import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-y
|
|
|
6
6
|
import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
|
|
7
7
|
import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
|
|
8
8
|
import { cn } from '../../../../../shared/lib/utils';
|
|
9
|
+
import { expandWfsfCorrectAlts, formatWfsfCorrectLabel, isWfsfBlankCorrect, } from '../../../../../shared/lib/utils/wfsf-blank-match';
|
|
9
10
|
/**
|
|
10
11
|
* Replace `{key}` placeholders with inline blank controls.
|
|
11
12
|
* WITH_OPTIONS blanks become <select>; free-type stay <input>.
|
|
@@ -51,14 +52,6 @@ function buildHtmlWithInputPlaceholders(html, wordBank) {
|
|
|
51
52
|
`;
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
|
-
function normalizeStructuredAnswer(value, caseSensitive) {
|
|
55
|
-
const normalized = value
|
|
56
|
-
.trim()
|
|
57
|
-
.replace(/[\p{P}]/gu, '')
|
|
58
|
-
.replace(/\s+/g, ' ')
|
|
59
|
-
.trim();
|
|
60
|
-
return caseSensitive ? normalized : normalized.toLowerCase();
|
|
61
|
-
}
|
|
62
55
|
// Stable empty object to avoid new {} on every render (breaks React.memo)
|
|
63
56
|
const emptyObj = {};
|
|
64
57
|
/**
|
|
@@ -73,23 +66,21 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
|
|
|
73
66
|
const containerRef = useRef(null);
|
|
74
67
|
const onBlankChangeRef = useRef(onBlankChange);
|
|
75
68
|
onBlankChangeRef.current = onBlankChange;
|
|
76
|
-
|
|
77
|
-
// survive React Strict Mode double-invoke (cleanup may leave empty DOM).
|
|
78
|
-
const appliedHtmlRef = useRef(null);
|
|
69
|
+
const mountedRef = useRef(false);
|
|
79
70
|
// Store initial answers in a ref so they're available on mount but don't trigger re-renders
|
|
80
71
|
const initialAnswersRef = useRef(initialAnswers);
|
|
81
72
|
const processedHtml = useMemo(() => buildHtmlWithInputPlaceholders(question.content, question.wordBank), [question.content, question.wordBank]);
|
|
82
|
-
// Set innerHTML via ref and attach event listeners.
|
|
83
|
-
//
|
|
84
|
-
//
|
|
73
|
+
// Set innerHTML ONCE via ref and attach event listeners.
|
|
74
|
+
// This completely bypasses React's reconciliation so inputs are never
|
|
75
|
+
// destroyed by re-renders.
|
|
85
76
|
useEffect(() => {
|
|
86
77
|
const container = containerRef.current;
|
|
87
78
|
if (!container)
|
|
88
79
|
return;
|
|
89
|
-
|
|
90
|
-
if (
|
|
80
|
+
// Only set innerHTML on first mount or if HTML content actually changed
|
|
81
|
+
if (!mountedRef.current) {
|
|
91
82
|
container.innerHTML = processedHtml;
|
|
92
|
-
|
|
83
|
+
mountedRef.current = true;
|
|
93
84
|
}
|
|
94
85
|
const inputs = container.querySelectorAll('input.structured-blank-input, select.structured-blank-input');
|
|
95
86
|
const handlers = [];
|
|
@@ -98,33 +89,33 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
|
|
|
98
89
|
const wrapper = container.querySelector(`[data-blank-wrapper="${key}"]`);
|
|
99
90
|
const hint = container.querySelector(`[data-blank-hint="${key}"]`);
|
|
100
91
|
const userAnswer = initialAnswersRef.current[key] || '';
|
|
101
|
-
const correctAnswer = question.answers[key]
|
|
92
|
+
const correctAnswer = question.answers[key];
|
|
93
|
+
const correctLabel = formatWfsfCorrectLabel(correctAnswer);
|
|
102
94
|
const hasUserAnswer = userAnswer.trim().length > 0;
|
|
103
95
|
const isCorrect = hasUserAnswer
|
|
104
|
-
&&
|
|
105
|
-
=== normalizeStructuredAnswer(correctAnswer, question.caseSensitive);
|
|
96
|
+
&& isWfsfBlankCorrect(userAnswer, correctAnswer, question.caseSensitive);
|
|
106
97
|
// Restore saved answer value on mount
|
|
107
|
-
const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect &&
|
|
98
|
+
const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect && correctLabel.length > 0;
|
|
108
99
|
input.value = shouldReplaceInputWithReviewText
|
|
109
|
-
? `${hasUserAnswer ? userAnswer : '(trống)'} → ${
|
|
100
|
+
? `${hasUserAnswer ? userAnswer : '(trống)'} → ${correctLabel}`
|
|
110
101
|
: userAnswer;
|
|
111
102
|
input.disabled = isReviewMode;
|
|
112
103
|
const isSelect = input.tagName === 'SELECT';
|
|
113
104
|
// Set classes from blankStyle (letter = square box)
|
|
114
105
|
const blankStyle = question.blankStyle || 'word';
|
|
115
106
|
const isLetter = blankStyle === 'letter';
|
|
116
|
-
const isPhrase = blankStyle === 'phrase';
|
|
107
|
+
const isPhrase = blankStyle === 'phrase' || blankStyle === 'translate';
|
|
117
108
|
if (isLetter && !isSelect && 'maxLength' in input) {
|
|
118
|
-
|
|
109
|
+
const maxAltLen = expandWfsfCorrectAlts(correctAnswer).reduce((max, alt) => Math.max(max, alt.trim().length), 0);
|
|
110
|
+
input.maxLength = Math.max(1, maxAltLen || 1);
|
|
119
111
|
}
|
|
120
112
|
input.className = cn('structured-blank-input inline-block bg-slate-50/40 text-sm font-semibold outline-none transition-all align-baseline hover:bg-slate-100/60 focus:bg-teal-50/60 focus:ring-0', isSelect
|
|
121
113
|
? 'min-w-[5.5rem] max-w-[10rem] rounded border px-2 py-1 text-center'
|
|
122
114
|
: isLetter
|
|
123
115
|
? 'h-7 w-7 rounded border-2 px-0 text-center uppercase'
|
|
124
116
|
: isPhrase
|
|
125
|
-
? 'min-w-[
|
|
126
|
-
:
|
|
127
|
-
'min-w-[4.5rem] max-w-[7.5rem] w-[8ch] rounded-t border-b-2 px-1 py-0.5 text-center', isReviewMode
|
|
117
|
+
? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-t border-b-2 px-2 py-0.5 text-center'
|
|
118
|
+
: 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-t border-b-2 px-1 py-0.5 text-center', isReviewMode
|
|
128
119
|
? isCorrect
|
|
129
120
|
? 'border-emerald-500 bg-emerald-50 text-emerald-800 font-bold'
|
|
130
121
|
: hasUserAnswer
|
|
@@ -132,7 +123,7 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
|
|
|
132
123
|
: 'border-amber-500 bg-amber-50 text-amber-800 font-bold'
|
|
133
124
|
: 'border-slate-400 text-slate-800 focus:border-teal-600');
|
|
134
125
|
if (!isSelect && !isLetter && 'size' in input) {
|
|
135
|
-
input.size = isPhrase ?
|
|
126
|
+
input.size = isPhrase ? 28 : 10;
|
|
136
127
|
}
|
|
137
128
|
// letter inline box — host FE may not scan Tailwind h-7/w-7
|
|
138
129
|
if (isLetter) {
|
|
@@ -176,9 +167,9 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
|
|
|
176
167
|
}, [processedHtml, isReviewMode]);
|
|
177
168
|
return (_jsxs("div", { className: "space-y-3", children: [question.title && (_jsx("h3", { className: "text-base font-semibold text-gray-800", children: question.title })), question.imageUrl && (_jsx("div", { className: "flex justify-center", children: _jsx("div", { className: "overflow-hidden rounded-lg border border-gray-200", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: question.title || 'Question image', className: "max-h-48 w-auto object-contain" }) }) })), _jsx("div", { ref: containerRef, className: cn('structured-form-content rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4 text-sm leading-relaxed text-gray-800', '[&_.structured-blank-wrapper]:inline-flex [&_.structured-blank-wrapper]:align-baseline [&_.structured-blank-wrapper]:items-baseline [&_.structured-blank-hint]:max-w-[180px]',
|
|
178
169
|
// Conversation: speaker column hug name (not equal 50/50 split)
|
|
179
|
-
'[&_table.dialogue]:w-full [&_table.dialogue]:border-collapse [&_table.dialogue]:table-fixed', '[&_table.dialogue_td.speaker]:w-[1%] [&_table.dialogue_td.speaker]:whitespace-nowrap', '[&_table.dialogue_td.speaker]:align-top [&_table.dialogue_td.speaker]:pr-3', '[&_table.dialogue_td.speaker]:font-semibold [&_table.dialogue_td.speaker]:text-slate-900', '[&_table.dialogue_td]:align-top [&_table.dialogue_td]:py-1.5') }), isReviewMode && (_jsxs("div", { className: "space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3", children: [_jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] font-medium", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-green-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-green-500" }), "\u0110\u00FAng"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-red-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-red-500" }), "Sai"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-1 text-amber-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-amber-500" }), "Ch\u01B0a tr\u1EA3 l\u1EDDi"] })] }), _jsxs("div", { children: [_jsx("span", { className: "text-[10px] font-medium uppercase tracking-wider text-slate-500", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-1 flex flex-wrap gap-1.5", children: Object.entries(question.answers).map(([key, val]) => (_jsxs("span", { className: "rounded border border-green-300 bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-800", children: [key, ": ", val] }, key))) })] })] }))] }));
|
|
170
|
+
'[&_table.dialogue]:w-full [&_table.dialogue]:border-collapse [&_table.dialogue]:table-fixed', '[&_table.dialogue_td.speaker]:w-[1%] [&_table.dialogue_td.speaker]:whitespace-nowrap', '[&_table.dialogue_td.speaker]:align-top [&_table.dialogue_td.speaker]:pr-3', '[&_table.dialogue_td.speaker]:font-semibold [&_table.dialogue_td.speaker]:text-slate-900', '[&_table.dialogue_td]:align-top [&_table.dialogue_td]:py-1.5') }), isReviewMode && (_jsxs("div", { className: "space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3", children: [_jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] font-medium", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-green-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-green-500" }), "\u0110\u00FAng"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-red-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-red-500" }), "Sai"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-1 text-amber-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-amber-500" }), "Ch\u01B0a tr\u1EA3 l\u1EDDi"] })] }), _jsxs("div", { children: [_jsx("span", { className: "text-[10px] font-medium uppercase tracking-wider text-slate-500", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-1 flex flex-wrap gap-1.5", children: Object.entries(question.answers).map(([key, val]) => (_jsxs("span", { className: "rounded border border-green-300 bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-800", children: [key, ": ", formatWfsfCorrectLabel(val)] }, key))) })] })] }))] }));
|
|
180
171
|
});
|
|
181
|
-
export function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode = false,
|
|
172
|
+
export function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode = false, }) {
|
|
182
173
|
const answersRef = useRef(answers);
|
|
183
174
|
answersRef.current = answers;
|
|
184
175
|
const onAnswerChangeRef = useRef(onAnswerChange);
|
|
@@ -196,12 +187,5 @@ export function MoversWordFillStructuredFormRenderer({ partNumber, partName, que
|
|
|
196
187
|
});
|
|
197
188
|
return map;
|
|
198
189
|
}, [questions, handleBlankChange]);
|
|
199
|
-
|
|
200
|
-
const hasGroupChrome = Boolean(groupTitle || audioUrl || groupImageUrl || groupContent);
|
|
201
|
-
return (
|
|
202
|
-
// No h-full/overflow-y-auto: homework host embeds this in a card without a
|
|
203
|
-
// fixed-height parent; clipping made stems look blank. Exam shell scrolls page.
|
|
204
|
-
_jsxs("div", { className: cn('flex flex-col', compactLayout ? 'gap-3' : 'gap-4 sm:gap-6'), children: [!compactLayout && (_jsxs("div", { className: "flex flex-col gap-4 p-3 sm:px-6 sm:pt-2", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, isReviewMode: isReviewMode }), groupTitle && (_jsx("h2", { className: "text-center text-xl font-bold uppercase text-coral-500", children: groupTitle })), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), groupImageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: groupImageUrl, alt: groupTitle || 'Group image', className: "max-h-[300px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsx("div", { className: "article-content rounded-lg bg-amber-50 p-4 text-sm leading-relaxed text-gray-700 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar", dangerouslySetInnerHTML: { __html: groupContent } }))] })), compactLayout && hasGroupChrome && (_jsxs("div", { className: "flex flex-col gap-3 px-1 sm:px-2", children: [groupTitle && (_jsx("h2", { className: "text-center text-lg font-bold uppercase text-coral-500", children: groupTitle })), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), groupImageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: groupImageUrl, alt: groupTitle || 'Group image', className: "max-h-[300px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsx("div", { className: "article-content rounded-lg bg-amber-50 p-3 text-sm leading-relaxed text-gray-700 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar", dangerouslySetInnerHTML: { __html: groupContent } }))] })), !compactLayout && questions.length > 0 && (_jsx("div", { className: "px-3 sm:px-6", children: _jsx("hr", { className: "border-t border-slate-200/60 my-1" }) })), _jsx("div", { className: cn('grid grid-cols-1 gap-4 sm:gap-5', compactLayout ? 'p-0' : 'p-3 sm:px-6 sm:pb-6'), children: questions.map((q) => (_jsxs("div", { id: `question-${q.id}`, className: cn('rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-200 hover:shadow-md', compactLayout && 'border-0 p-0 shadow-none hover:border-0 hover:shadow-none'), children: [_jsxs("div", { className: cn('mb-2 flex gap-2.5',
|
|
205
|
-
// Single-line rubric: center with badge. Multi-line: align to top of badge.
|
|
206
|
-
compactLayout && rubric ? 'items-center' : 'items-start'), children: [_jsx("span", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-xs font-bold leading-none text-white shadow-sm", children: q.questionNumber }), compactLayout && rubric ? (_jsx("p", { className: "min-w-0 flex-1 text-sm font-semibold leading-7 text-slate-800", children: rubric })) : null] }), _jsx(StructuredFormQuestion, { question: q, initialAnswers: answers[q.id] || emptyObj, onBlankChange: blankChangeCallbacks[q.id], isReviewMode: isReviewMode })] }, q.id))) })] }));
|
|
190
|
+
return (_jsxs("div", { className: "flex flex-col h-full gap-4 sm:gap-6 overflow-y-auto custom-scrollbar", children: [_jsxs("div", { className: "flex flex-col gap-4 p-3 sm:px-6 sm:pt-2", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, isReviewMode: isReviewMode }), groupTitle && (_jsx("h2", { className: "text-center text-xl font-bold uppercase text-coral-500", children: groupTitle })), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), groupImageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: groupImageUrl, alt: groupTitle || 'Group image', className: "max-h-[300px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsx("div", { className: "article-content rounded-lg bg-amber-50 p-4 text-sm leading-relaxed text-gray-700 max-h-[500px] overflow-y-auto pr-2 custom-scrollbar", dangerouslySetInnerHTML: { __html: groupContent } }))] }), questions.length > 0 && (_jsx("div", { className: "px-3 sm:px-6", children: _jsx("hr", { className: "border-t border-slate-200/60 my-1" }) })), _jsx("div", { className: "grid grid-cols-1 gap-4 sm:gap-5 p-3 sm:px-6 sm:pb-6", children: questions.map((q) => (_jsxs("div", { id: `question-${q.id}`, className: "rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-200 hover:shadow-md", children: [_jsx("div", { className: "mb-2 flex items-center gap-2", children: _jsx("span", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-600 text-xs font-bold text-white shadow-sm", children: q.questionNumber }) }), _jsx(StructuredFormQuestion, { question: q, initialAnswers: answers[q.id] || emptyObj, onBlankChange: blankChangeCallbacks[q.id], isReviewMode: isReviewMode })] }, q.id))) })] }));
|
|
207
191
|
}
|
|
@@ -11,7 +11,7 @@ export interface TemplateSection {
|
|
|
11
11
|
viewMode?: string;
|
|
12
12
|
wordBankConfig?: string[];
|
|
13
13
|
}
|
|
14
|
-
export type WfsfBlankStyle = 'letter' | 'word' | 'phrase';
|
|
14
|
+
export type WfsfBlankStyle = 'letter' | 'word' | 'phrase' | 'translate';
|
|
15
15
|
export type WfsfWordBankEntry = {
|
|
16
16
|
options: string[];
|
|
17
17
|
} | {
|
|
@@ -21,7 +21,7 @@ export type WfsfWordBankEntry = {
|
|
|
21
21
|
export interface WordFillStructuredFormQuestion {
|
|
22
22
|
id: string;
|
|
23
23
|
content: string;
|
|
24
|
-
answers: Record<string, string>;
|
|
24
|
+
answers: Record<string, string | string[]>;
|
|
25
25
|
title?: string;
|
|
26
26
|
instruction?: string;
|
|
27
27
|
caseSensitive?: boolean;
|
|
@@ -118,6 +118,8 @@ export function transformWordFillStructuredForm(questions) {
|
|
|
118
118
|
return 'letter';
|
|
119
119
|
if (v === 'phrase' || v === 'sentence')
|
|
120
120
|
return 'phrase';
|
|
121
|
+
if (v === 'translate')
|
|
122
|
+
return 'translate';
|
|
121
123
|
return 'word';
|
|
122
124
|
};
|
|
123
125
|
const normalizeWordBank = (raw) => {
|
|
@@ -2,13 +2,7 @@ export { ExamTakingPageContainer } from './ExamTakingPageContainer';
|
|
|
2
2
|
export type { ExamTakingPageContainerProps } from './ExamTakingPageContainer';
|
|
3
3
|
export { GenericQuestionRenderer } from './GenericQuestionRenderer';
|
|
4
4
|
export type { GenericQuestionRendererProps } from './GenericQuestionRenderer';
|
|
5
|
-
/** Per-type dispatcher used by ExamTakingPageContainer and host lite flows (BTVN). */
|
|
6
|
-
export { QuestionRenderer } from './components/QuestionRenderer';
|
|
7
5
|
export type { ExamTakingPart } from './exam-taking.utils';
|
|
8
|
-
export type { QuestionPart, TemplatePart, TemplateSection, } from './utils/question-transformers';
|
|
9
|
-
export { groupQuestionsByPart } from './utils/question-transformers';
|
|
10
|
-
/** Normalize UI answer map → API answer payloads (option index wrap, group composites). */
|
|
11
|
-
export { transformAnswersForSubmission } from './utils/answer-transformers';
|
|
12
6
|
export type { ExamTakingAnswerValue, ExamTakingAttempt, ExamTakingQuestion, ExamTakingQuestionsResult, ExamTakingSdkApi, ExamTakingTemplatePart, SaveExamTakingAnswersInput } from '../../../api/exam-taking';
|
|
13
7
|
export { useExamCountdown } from './hooks/useExamCountdown';
|
|
14
8
|
export type { UseExamCountdownOptions, UseExamCountdownResult } from './hooks/useExamCountdown';
|