@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.
Files changed (43) hide show
  1. package/dist/api/exam-questions.d.ts +1 -1
  2. package/dist/api/exam-questions.js +39 -1
  3. package/dist/api/exam-questions.types.d.ts +28 -0
  4. package/dist/components/exams/ExamCreator.d.ts +7 -1
  5. package/dist/components/exams/ExamCreator.js +57 -21
  6. package/dist/components/exams/ExamQuestionsPageContainer.d.ts +5 -0
  7. package/dist/components/exams/ExamQuestionsPageContainer.js +2 -2
  8. package/dist/components/exams/api.d.ts +1 -1
  9. package/dist/components/exams/index.d.ts +1 -1
  10. package/dist/components/exams/take/components/QuestionRenderer.d.ts +6 -1
  11. package/dist/components/exams/take/components/QuestionRenderer.js +2 -2
  12. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +1 -1
  13. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +1 -1
  14. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.d.ts +7 -1
  15. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +18 -9
  16. package/dist/components/exams/take/index.d.ts +6 -0
  17. package/dist/components/exams/take/index.js +5 -0
  18. package/dist/components/exams/take/utils/question-transformers.js +25 -2
  19. package/dist/components/questions/_shared/types/question-group.type.d.ts +0 -1
  20. package/dist/components/questions/_shared/types/question-group.type.js +1 -3
  21. package/dist/components/questions/_shared/types/question-group.utils.d.ts +2 -0
  22. package/dist/components/questions/_shared/types/question-group.utils.js +3 -0
  23. package/dist/components/questions/groups/ImageLabelWordBank.d.ts +1 -1
  24. package/dist/components/questions/groups/ImageLabelWordBank.js +1 -1
  25. package/dist/components/questions/question-bank/QuestionBankPickerDialog.d.ts +45 -0
  26. package/dist/components/questions/question-bank/QuestionBankPickerDialog.js +209 -0
  27. package/dist/components/questions/question-bank/QuestionBankPickerRow.d.ts +9 -0
  28. package/dist/components/questions/question-bank/QuestionBankPickerRow.js +20 -0
  29. package/dist/components/questions/question-bank/index.d.ts +1 -0
  30. package/dist/components/questions/question-bank/index.js +1 -0
  31. package/dist/components/questions/question-bank/question-bank-picker.utils.d.ts +55 -0
  32. package/dist/components/questions/question-bank/question-bank-picker.utils.js +408 -0
  33. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +1 -1
  34. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +1 -1
  35. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +8 -1
  36. package/dist/components/questions/types/word-fill-structured-form/transform.js +4 -0
  37. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +1 -1
  38. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +1 -1
  39. package/dist/index.d.ts +4 -3
  40. package/dist/index.js +2 -1
  41. package/dist/shared/constants/ApiConstant.d.ts +1 -0
  42. package/dist/shared/constants/ApiConstant.js +1 -0
  43. 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 handleExistingQuestionSelect = (question) => {
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: 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,
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 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;
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, [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);
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);
@@ -1,5 +1,5 @@
1
1
  import type { FillBlankQuestion } from '../../types';
2
- import { type ImageLabelData } from '../../../../questions/_shared/types/question-group.type';
2
+ import type { ImageLabelData } from '../../../../questions/_shared/types/question-group.type';
3
3
  interface MoversListenAndWriteRendererProps {
4
4
  partNumber: number;
5
5
  partName?: string;
@@ -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.type';
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
- const mountedRef = useRef(false);
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 ONCE via ref and attach event listeners.
74
- // This completely bypasses React's reconciliation so inputs are never
75
- // destroyed by re-renders.
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
- // Only set innerHTML on first mount or if HTML content actually changed
81
- if (!mountedRef.current) {
82
+ const needsApply = appliedHtmlRef.current !== processedHtml || container.childNodes.length === 0;
83
+ if (needsApply) {
82
84
  container.innerHTML = processedHtml;
83
- mountedRef.current = true;
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
- 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))) })] }));
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: content?.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: groupPayload?.instructions || 'COMPLETE THE FORM BELOW',
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 function hasImageLabels(items) {
13
- return Array.isArray(items) && items.some((item) => Boolean(item?.imageUrl?.trim() || item?.label?.trim()));
14
- }
12
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { ImageLabelData } from './question-group.type';
2
+ export declare function hasImageLabels(items?: ImageLabelData[] | null): boolean;
@@ -0,0 +1,3 @@
1
+ export function hasImageLabels(items) {
2
+ return Array.isArray(items) && items.some((item) => Boolean(item?.imageUrl?.trim() || item?.label?.trim()));
3
+ }
@@ -1,4 +1,4 @@
1
- import { type ImageLabelData } from '../_shared/types/question-group.type';
1
+ import type { ImageLabelData } from '../_shared/types/question-group.type';
2
2
  export interface ImageLabelWordBankProps {
3
3
  items?: ImageLabelData[];
4
4
  title?: string;