@tinyweb_dev/oe-exam-sdk 1.0.2 → 1.0.3
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 +39 -1
- package/dist/api/exam-questions.types.d.ts +28 -0
- package/dist/components/exams/ExamCreator.d.ts +7 -1
- package/dist/components/exams/ExamCreator.js +57 -21
- package/dist/components/exams/ExamQuestionsPageContainer.d.ts +5 -0
- 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 +6 -1
- package/dist/components/exams/take/components/QuestionRenderer.js +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +1 -1
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +1 -1
- package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.d.ts +7 -1
- package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +18 -9
- package/dist/components/exams/take/index.d.ts +6 -0
- package/dist/components/exams/take/index.js +5 -0
- package/dist/components/exams/take/utils/question-transformers.js +25 -2
- package/dist/components/questions/_shared/types/question-group.type.d.ts +0 -1
- package/dist/components/questions/_shared/types/question-group.type.js +1 -3
- package/dist/components/questions/_shared/types/question-group.utils.d.ts +2 -0
- package/dist/components/questions/_shared/types/question-group.utils.js +3 -0
- package/dist/components/questions/groups/ImageLabelWordBank.d.ts +1 -1
- package/dist/components/questions/groups/ImageLabelWordBank.js +1 -1
- package/dist/components/questions/question-bank/QuestionBankPickerDialog.d.ts +45 -0
- package/dist/components/questions/question-bank/QuestionBankPickerDialog.js +209 -0
- package/dist/components/questions/question-bank/QuestionBankPickerRow.d.ts +9 -0
- package/dist/components/questions/question-bank/QuestionBankPickerRow.js +20 -0
- package/dist/components/questions/question-bank/index.d.ts +1 -0
- package/dist/components/questions/question-bank/index.js +1 -0
- package/dist/components/questions/question-bank/question-bank-picker.utils.d.ts +55 -0
- package/dist/components/questions/question-bank/question-bank-picker.utils.js +408 -0
- package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +1 -1
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +1 -1
- package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +8 -1
- package/dist/components/questions/types/word-fill-structured-form/transform.js +4 -0
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +1 -1
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/shared/constants/ApiConstant.d.ts +1 -0
- package/dist/shared/constants/ApiConstant.js +1 -0
- package/package.json +1 -1
|
@@ -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, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput, } from './exam-questions.types';
|
|
9
|
+
export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionBankFiltersInput, QuestionBankFiltersResult, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput, } from './exam-questions.types';
|
|
@@ -41,17 +41,40 @@ export function createExamQuestionsApi(options = {}) {
|
|
|
41
41
|
},
|
|
42
42
|
saveExam: async () => undefined,
|
|
43
43
|
updatePartAudio: (input) => updatePartAudio(request, input),
|
|
44
|
-
searchQuestions: async ({ questionType, keyword, page, size }) => {
|
|
44
|
+
searchQuestions: async ({ questionType, keyword, bookId, unitId, lessonId, lessonIds, 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);
|
|
50
57
|
query.set('page', String(page));
|
|
51
58
|
query.set('size', String(size));
|
|
52
59
|
const suffix = query.toString() ? `?${query.toString()}` : '';
|
|
53
60
|
return request(`${API_ENDPOINTS.MANAGE.QUESTION_BANKS}${suffix}`);
|
|
54
61
|
},
|
|
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
|
+
},
|
|
55
78
|
};
|
|
56
79
|
}
|
|
57
80
|
export const createExamQuestionsFetchApi = createExamQuestionsApi;
|
|
@@ -93,6 +116,15 @@ export function createMockExamQuestionsApi(seed) {
|
|
|
93
116
|
})),
|
|
94
117
|
meta: { totalItems: questions.size },
|
|
95
118
|
}),
|
|
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
|
+
}),
|
|
96
128
|
};
|
|
97
129
|
}
|
|
98
130
|
function resolveTransformOptions(input) {
|
|
@@ -127,6 +159,12 @@ function createRequest({ baseUrl = API_BASE_URL ?? '', fetcher = fetch, headers,
|
|
|
127
159
|
return response.json();
|
|
128
160
|
};
|
|
129
161
|
}
|
|
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
|
+
}
|
|
130
168
|
function toRecord(value) {
|
|
131
169
|
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : undefined;
|
|
132
170
|
}
|
|
@@ -7,6 +7,8 @@ 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>;
|
|
10
12
|
}
|
|
11
13
|
export interface ExamQuestionsApiResponse<T> {
|
|
12
14
|
data: T;
|
|
@@ -69,9 +71,34 @@ export interface UpdatePartAudioInput {
|
|
|
69
71
|
export interface SearchQuestionBankInput {
|
|
70
72
|
questionType?: QuestionType;
|
|
71
73
|
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[];
|
|
72
82
|
page: number;
|
|
73
83
|
size: number;
|
|
74
84
|
}
|
|
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
|
+
}
|
|
75
102
|
export interface QuestionSearchItem {
|
|
76
103
|
id: string;
|
|
77
104
|
questionType: QuestionType | string;
|
|
@@ -86,6 +113,7 @@ export interface QuestionSearchItem {
|
|
|
86
113
|
code?: string;
|
|
87
114
|
title?: string | null;
|
|
88
115
|
usageCount?: number;
|
|
116
|
+
meta?: Record<string, unknown> | null;
|
|
89
117
|
}
|
|
90
118
|
export interface SearchQuestionBankResult {
|
|
91
119
|
data: QuestionSearchItem[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ExistingQuestionBank } from '../questions/components/QuestionBankOption';
|
|
2
|
+
import { type QuestionBankLessonSuggestion, type QuestionBankPickerDefaultFilters } from '../questions/question-bank/QuestionBankPickerDialog';
|
|
2
3
|
import type { ExamData, ExamQuestion, ExamTemplate } from '../../shared/types/entities/exam.types';
|
|
3
|
-
import type { SaveQuestionInput, SearchQuestionBankInput, SearchQuestionBankResult } from '../../api/exam-questions.types';
|
|
4
|
+
import type { QuestionBankFiltersInput, QuestionBankFiltersResult, SaveQuestionInput, SearchQuestionBankInput, SearchQuestionBankResult } from '../../api/exam-questions.types';
|
|
4
5
|
export interface ExamCreatorProps {
|
|
5
6
|
template: ExamTemplate;
|
|
6
7
|
examId?: string;
|
|
@@ -17,6 +18,11 @@ export interface ExamCreatorProps {
|
|
|
17
18
|
onPartAudioChange?: (partId: string, audioUrl: string) => void | Promise<void>;
|
|
18
19
|
partAudioOverrides?: Record<string, string>;
|
|
19
20
|
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[];
|
|
20
26
|
saveExamRef?: React.MutableRefObject<(() => void) | null>;
|
|
21
27
|
}
|
|
22
28
|
export declare function ExamCreator(props: ExamCreatorProps): import("react").JSX.Element;
|
|
@@ -1,10 +1,11 @@
|
|
|
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, Loader2, Save } from 'lucide-react';
|
|
4
|
+
import { CheckCircle, ChevronLeft, ChevronRight, Library, 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';
|
|
8
9
|
import { QuestionCreator } from '../questions/creator/QuestionCreator';
|
|
9
10
|
import { useToast } from '../../shared/lib/hooks/useToast';
|
|
10
11
|
import { useT } from '../../shared/lib/i18n';
|
|
@@ -50,6 +51,7 @@ export function ExamCreator(props) {
|
|
|
50
51
|
const [selectedQuestionInfo, setSelectedQuestionInfo] = useState({});
|
|
51
52
|
const [questionBankCodeError, setQuestionBankCodeError] = useState();
|
|
52
53
|
const [questionBankTitleError, setQuestionBankTitleError] = useState();
|
|
54
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
53
55
|
const currentPart = getPartByQuestionIndex(template, currentQuestionIndex);
|
|
54
56
|
const currentPartId = currentPart?.id ?? '';
|
|
55
57
|
const currentConfig = currentPart ? getEffectiveQuestionConfig(currentPart, currentQuestionIndex) : undefined;
|
|
@@ -198,30 +200,64 @@ export function ExamCreator(props) {
|
|
|
198
200
|
setIsSavingQuestion(false);
|
|
199
201
|
}
|
|
200
202
|
};
|
|
201
|
-
const
|
|
203
|
+
const applyBankItemToIndex = useCallback((item, targetIndex) => {
|
|
204
|
+
const questionType = 'questionType' in item ? item.questionType : item.type;
|
|
205
|
+
const correctAnswer = 'correctAnswer' in item ? item.correctAnswer : item.answer;
|
|
202
206
|
const transformed = transformApiQuestionToFrontend({
|
|
203
|
-
question_type:
|
|
204
|
-
content:
|
|
205
|
-
correct_answer:
|
|
206
|
-
points:
|
|
207
|
-
difficult_level:
|
|
208
|
-
question_skill_type:
|
|
209
|
-
explanation:
|
|
207
|
+
question_type: questionType,
|
|
208
|
+
content: item.content,
|
|
209
|
+
correct_answer: correctAnswer,
|
|
210
|
+
points: item.points,
|
|
211
|
+
difficult_level: item.difficulty,
|
|
212
|
+
question_skill_type: item.skill,
|
|
213
|
+
explanation: item.explanation,
|
|
210
214
|
});
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
215
|
+
const part = getPartByQuestionIndex(template, targetIndex);
|
|
216
|
+
const selectedQuestion = {
|
|
217
|
+
...transformed,
|
|
218
|
+
id: undefined,
|
|
219
|
+
partId: part?.id ?? currentPartId,
|
|
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;
|
|
216
227
|
if (code || title)
|
|
217
|
-
setSelectedQuestionInfo((prev) => ({ ...prev, [
|
|
218
|
-
if (bankId && (code || title))
|
|
219
|
-
setExistingQuestionBanks((prev) => ({
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
228
|
+
setSelectedQuestionInfo((prev) => ({ ...prev, [targetIndex]: { code, title: title ?? undefined } }));
|
|
229
|
+
if (bankId && (code || title)) {
|
|
230
|
+
setExistingQuestionBanks((prev) => ({
|
|
231
|
+
...prev,
|
|
232
|
+
[targetIndex]: { id: bankId, code: code ?? '', title: title ?? '' },
|
|
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);
|
|
223
246
|
success({ message: t('common.examCreator.bankSelected') });
|
|
224
247
|
};
|
|
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
|
+
};
|
|
225
261
|
const handleConfirmSaveExam = () => {
|
|
226
262
|
setShowCompletionDialog(false);
|
|
227
263
|
clearQuestionStatuses(examId);
|
|
@@ -234,7 +270,7 @@ export function ExamCreator(props) {
|
|
|
234
270
|
else
|
|
235
271
|
setShowCompletionDialog(true);
|
|
236
272
|
});
|
|
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 })] }));
|
|
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: [props.searchQuestions ? (_jsx("div", { className: "mb-3 flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "gap-1.5", onClick: () => setPickerOpen(true), children: [_jsx(Library, { className: "h-4 w-4" }), "Ng\u00E2n h\u00E0ng c\u00E2u h\u1ECFi"] }) })) : null, 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 }), props.searchQuestions ? (_jsx(QuestionBankPickerDialog, { open: pickerOpen, onOpenChange: setPickerOpen, searchQuestions: props.searchQuestions, getQuestionFilters: props.getQuestionFilters, defaultFilters: props.questionBankDefaultFilters, lessonSuggestions: props.questionBankLessonSuggestions, onConfirm: handlePickerConfirm })) : null] }));
|
|
238
274
|
}
|
|
239
275
|
function buildQuestionForSave(questionData, freshData, existing, questionType, currentPart, currentQuestionIndex, examDifficulty) {
|
|
240
276
|
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,5 +1,6 @@
|
|
|
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';
|
|
3
4
|
import type { CreateExamTexts } from './create-exam.texts';
|
|
4
5
|
export interface ExamQuestionsPageContainerProps {
|
|
5
6
|
examId: string;
|
|
@@ -9,5 +10,9 @@ export interface ExamQuestionsPageContainerProps {
|
|
|
9
10
|
onNavigate?: (href: string) => void;
|
|
10
11
|
/** Optional label overrides for embedded contexts. */
|
|
11
12
|
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[];
|
|
12
17
|
}
|
|
13
18
|
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, questionBankDefaultFilters, questionBankLessonSuggestions, }) {
|
|
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, saveExamRef: saveExamRef })] }));
|
|
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, getQuestionFilters: api.getQuestionFilters, questionBankDefaultFilters: questionBankDefaultFilters, questionBankLessonSuggestions: questionBankLessonSuggestions, 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, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput } from '../../api/exam-questions';
|
|
5
|
+
export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionBankFiltersInput, QuestionBankFiltersResult, 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, QuestionSearchItem, SaveExamInput, SaveQuestionInput, SaveQuestionResult, SearchQuestionBankInput, SearchQuestionBankResult, UpdatePartAudioInput } from './api';
|
|
24
|
+
export type { ExamQuestionsSdkApi, ExamWithTemplate, ExistingQuestionBank, FetchApiOptions, QuestionBankFiltersInput, QuestionBankFiltersResult, 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,6 +20,11 @@ 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;
|
|
23
28
|
}
|
|
24
|
-
export declare function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, }: QuestionRendererProps): import("react").JSX.Element;
|
|
29
|
+
export declare function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, compactLayout, }: QuestionRendererProps): import("react").JSX.Element;
|
|
25
30
|
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, compactLayout = false, }) {
|
|
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, compactLayout: compactLayout }));
|
|
226
226
|
}
|
|
227
227
|
case 'WRITE_SENTENCES': {
|
|
228
228
|
const data = transformWriteSentences(questions);
|
package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FillBlankQuestion } from '../../types';
|
|
2
|
-
import {
|
|
2
|
+
import type { ImageLabelData } from '../../../../questions/_shared/types/question-group.type';
|
|
3
3
|
interface MoversListenAndWriteRendererProps {
|
|
4
4
|
partNumber: number;
|
|
5
5
|
partName?: string;
|
package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js
CHANGED
|
@@ -7,7 +7,7 @@ import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-
|
|
|
7
7
|
import CambridgeYleExampleText from '../../../../../components/themes/cambridge-yle/CambridgeYleExampleText';
|
|
8
8
|
import { countBlanks, formatAcceptedAnswers, getPrimaryAnswer, isBlankCorrect, toBlankValues, } from '../../../../../shared/lib/utils/fill-in-blank';
|
|
9
9
|
import { ImageLabelWordBank } from '../../../../questions/groups/ImageLabelWordBank';
|
|
10
|
-
import { hasImageLabels } from '../../../../questions/_shared/types/question-group.
|
|
10
|
+
import { hasImageLabels } from '../../../../questions/_shared/types/question-group.utils';
|
|
11
11
|
/** Preserve author newlines in fill-blank stems by mapping `\n` → `<br />`. */
|
|
12
12
|
function renderTextWithLineBreaks(text) {
|
|
13
13
|
const lines = text.split('\n');
|
|
@@ -13,6 +13,12 @@ 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;
|
|
16
22
|
}
|
|
17
|
-
export declare function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode, }: MoversWordFillStructuredFormRendererProps): React.JSX.Element;
|
|
23
|
+
export declare function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode, compactLayout, }: MoversWordFillStructuredFormRendererProps): React.JSX.Element;
|
|
18
24
|
export {};
|
|
@@ -66,21 +66,23 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
|
|
|
66
66
|
const containerRef = useRef(null);
|
|
67
67
|
const onBlankChangeRef = useRef(onBlankChange);
|
|
68
68
|
onBlankChangeRef.current = onBlankChange;
|
|
69
|
-
|
|
69
|
+
// Track the HTML last applied so we re-apply when content changes and
|
|
70
|
+
// survive React Strict Mode double-invoke (cleanup may leave empty DOM).
|
|
71
|
+
const appliedHtmlRef = useRef(null);
|
|
70
72
|
// Store initial answers in a ref so they're available on mount but don't trigger re-renders
|
|
71
73
|
const initialAnswersRef = useRef(initialAnswers);
|
|
72
74
|
const processedHtml = useMemo(() => buildHtmlWithInputPlaceholders(question.content, question.wordBank), [question.content, question.wordBank]);
|
|
73
|
-
// Set innerHTML
|
|
74
|
-
//
|
|
75
|
-
//
|
|
75
|
+
// Set innerHTML via ref and attach event listeners.
|
|
76
|
+
// Bypasses React reconciliation so inputs keep focus on answer re-renders.
|
|
77
|
+
// Re-apply when processedHtml changes OR container is empty (Strict Mode remount).
|
|
76
78
|
useEffect(() => {
|
|
77
79
|
const container = containerRef.current;
|
|
78
80
|
if (!container)
|
|
79
81
|
return;
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
+
const needsApply = appliedHtmlRef.current !== processedHtml || container.childNodes.length === 0;
|
|
83
|
+
if (needsApply) {
|
|
82
84
|
container.innerHTML = processedHtml;
|
|
83
|
-
|
|
85
|
+
appliedHtmlRef.current = processedHtml;
|
|
84
86
|
}
|
|
85
87
|
const inputs = container.querySelectorAll('input.structured-blank-input, select.structured-blank-input');
|
|
86
88
|
const handlers = [];
|
|
@@ -169,7 +171,7 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
|
|
|
169
171
|
// Conversation: speaker column hug name (not equal 50/50 split)
|
|
170
172
|
'[&_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))) })] })] }))] }));
|
|
171
173
|
});
|
|
172
|
-
export function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode = false, }) {
|
|
174
|
+
export function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode = false, compactLayout = false, }) {
|
|
173
175
|
const answersRef = useRef(answers);
|
|
174
176
|
answersRef.current = answers;
|
|
175
177
|
const onAnswerChangeRef = useRef(onAnswerChange);
|
|
@@ -187,5 +189,12 @@ export function MoversWordFillStructuredFormRenderer({ partNumber, partName, que
|
|
|
187
189
|
});
|
|
188
190
|
return map;
|
|
189
191
|
}, [questions, handleBlankChange]);
|
|
190
|
-
|
|
192
|
+
const rubric = String(instruction || '').trim();
|
|
193
|
+
const hasGroupChrome = Boolean(groupTitle || audioUrl || groupImageUrl || groupContent);
|
|
194
|
+
return (
|
|
195
|
+
// No h-full/overflow-y-auto: homework host embeds this in a card without a
|
|
196
|
+
// fixed-height parent; clipping made stems look blank. Exam shell scrolls page.
|
|
197
|
+
_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',
|
|
198
|
+
// Single-line rubric: center with badge. Multi-line: align to top of badge.
|
|
199
|
+
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))) })] }));
|
|
191
200
|
}
|
|
@@ -2,7 +2,13 @@ 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';
|
|
5
7
|
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';
|
|
6
12
|
export type { ExamTakingAnswerValue, ExamTakingAttempt, ExamTakingQuestion, ExamTakingQuestionsResult, ExamTakingSdkApi, ExamTakingTemplatePart, SaveExamTakingAnswersInput } from '../../../api/exam-taking';
|
|
7
13
|
export { useExamCountdown } from './hooks/useExamCountdown';
|
|
8
14
|
export type { UseExamCountdownOptions, UseExamCountdownResult } from './hooks/useExamCountdown';
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { ExamTakingPageContainer } from './ExamTakingPageContainer';
|
|
2
2
|
export { GenericQuestionRenderer } from './GenericQuestionRenderer';
|
|
3
|
+
/** Per-type dispatcher used by ExamTakingPageContainer and host lite flows (BTVN). */
|
|
4
|
+
export { QuestionRenderer } from './components/QuestionRenderer';
|
|
5
|
+
export { groupQuestionsByPart } from './utils/question-transformers';
|
|
6
|
+
/** Normalize UI answer map → API answer payloads (option index wrap, group composites). */
|
|
7
|
+
export { transformAnswersForSubmission } from './utils/answer-transformers';
|
|
3
8
|
export { useExamCountdown } from './hooks/useExamCountdown';
|
|
4
9
|
export { useAnswerAutosave } from './hooks/useAnswerAutosave';
|
|
5
10
|
export { useProctoringSnapshot } from './hooks/useProctoringSnapshot';
|
|
@@ -1242,6 +1242,20 @@ function normalizeWfsfWordBank(raw) {
|
|
|
1242
1242
|
export function transformWordFillStructuredForm(questions) {
|
|
1243
1243
|
const firstQuestion = questions[0];
|
|
1244
1244
|
const groupPayload = firstQuestion?.questionGroup?.payload;
|
|
1245
|
+
const firstContent = firstQuestion?.content;
|
|
1246
|
+
const firstMetaRaw = firstQuestion?.meta;
|
|
1247
|
+
const firstMeta = firstMetaRaw &&
|
|
1248
|
+
typeof firstMetaRaw === 'object' &&
|
|
1249
|
+
!Array.isArray(firstMetaRaw)
|
|
1250
|
+
? firstMetaRaw
|
|
1251
|
+
: undefined;
|
|
1252
|
+
// Bank: meta.instruction; school: content.instruction; exam group: payload.instructions.
|
|
1253
|
+
const topLevelInstruction = typeof firstQuestion?.instruction === 'string'
|
|
1254
|
+
? String(firstQuestion.instruction).trim()
|
|
1255
|
+
: '';
|
|
1256
|
+
const metaInstruction = String(firstMeta?.instruction || '').trim();
|
|
1257
|
+
const contentInstruction = String(firstContent?.instruction || firstContent?.instructions || '').trim();
|
|
1258
|
+
const groupInstruction = String(groupPayload?.instructions || groupPayload?.instruction || '').trim();
|
|
1245
1259
|
const transformed = questions.map((q) => {
|
|
1246
1260
|
const content = q.content;
|
|
1247
1261
|
const correctAnswer = q.correct_answer || {};
|
|
@@ -1261,11 +1275,16 @@ export function transformWordFillStructuredForm(questions) {
|
|
|
1261
1275
|
const hasWords = Boolean(wordBank?.some((e) => e != null && 'word' in e && e.word));
|
|
1262
1276
|
const viewMode = content?.viewMode ||
|
|
1263
1277
|
(hasOptions ? 'WITH_OPTIONS' : hasWords ? 'WITH_WORD_BANK' : undefined);
|
|
1278
|
+
// Empty string imageUrl breaks ResolvedImage in some hosts — treat as absent.
|
|
1279
|
+
const imageUrlRaw = content?.imageUrl;
|
|
1280
|
+
const imageUrl = typeof imageUrlRaw === 'string' && imageUrlRaw.trim().length > 0
|
|
1281
|
+
? imageUrlRaw.trim()
|
|
1282
|
+
: undefined;
|
|
1264
1283
|
return {
|
|
1265
1284
|
id: q.id,
|
|
1266
1285
|
questionNumber: q.question_number,
|
|
1267
1286
|
title: content?.title || '',
|
|
1268
|
-
imageUrl
|
|
1287
|
+
imageUrl,
|
|
1269
1288
|
content: resolvedContent,
|
|
1270
1289
|
answers: correctAnswer?.answers || {},
|
|
1271
1290
|
caseSensitive: correctAnswer?.caseSensitive ?? content?.caseSensitive ?? false,
|
|
@@ -1276,7 +1295,11 @@ export function transformWordFillStructuredForm(questions) {
|
|
|
1276
1295
|
});
|
|
1277
1296
|
return {
|
|
1278
1297
|
questions: transformed,
|
|
1279
|
-
instruction:
|
|
1298
|
+
instruction: groupInstruction ||
|
|
1299
|
+
contentInstruction ||
|
|
1300
|
+
topLevelInstruction ||
|
|
1301
|
+
metaInstruction ||
|
|
1302
|
+
'COMPLETE THE FORM BELOW',
|
|
1280
1303
|
groupTitle: groupPayload?.title,
|
|
1281
1304
|
groupContent: groupPayload?.content,
|
|
1282
1305
|
groupImageUrl: groupPayload?.imageUrl,
|
|
@@ -32,7 +32,6 @@ export interface ImageLabelData {
|
|
|
32
32
|
/** Nhãn / từ hiển thị dưới ảnh */
|
|
33
33
|
label: string;
|
|
34
34
|
}
|
|
35
|
-
export declare function hasImageLabels(items?: ImageLabelData[] | null): boolean;
|
|
36
35
|
/**
|
|
37
36
|
* Shared context fields for a group of ANSWER_THE_QUESTION,
|
|
38
37
|
* CHOOSE_THE_CORRECT_ANSWER, or FILL_IN_BLANK questions.
|
|
@@ -9,6 +9,4 @@
|
|
|
9
9
|
* - IMAGE_LABEL → FILL_IN_BLANK word bank (image + label items)
|
|
10
10
|
* - (future) → MATCH_BY_WRITING_ANSWER, LISTENING_*, etc.
|
|
11
11
|
*/
|
|
12
|
-
export
|
|
13
|
-
return Array.isArray(items) && items.some((item) => Boolean(item?.imageUrl?.trim() || item?.label?.trim()));
|
|
14
|
-
}
|
|
12
|
+
export {};
|