@tinyweb_dev/oe-exam-sdk 0.2.14 → 1.0.1

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 (72) hide show
  1. package/README.md +12 -0
  2. package/dist/api/exam-questions.d.ts +1 -1
  3. package/dist/api/exam-questions.js +39 -1
  4. package/dist/api/exam-questions.types.d.ts +28 -0
  5. package/dist/api/exam-taking.types.d.ts +1 -0
  6. package/dist/components/exams/ExamCreator.d.ts +7 -1
  7. package/dist/components/exams/ExamCreator.js +57 -21
  8. package/dist/components/exams/ExamPreviewDialog.js +4 -2
  9. package/dist/components/exams/ExamQuestionsPageContainer.d.ts +5 -0
  10. package/dist/components/exams/ExamQuestionsPageContainer.js +2 -2
  11. package/dist/components/exams/api.d.ts +1 -1
  12. package/dist/components/exams/index.d.ts +1 -1
  13. package/dist/components/exams/take/components/QuestionRenderer.d.ts +6 -1
  14. package/dist/components/exams/take/components/QuestionRenderer.js +4 -4
  15. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +3 -1
  16. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +5 -2
  17. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.d.ts +7 -1
  18. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +18 -9
  19. package/dist/components/exams/take/exam-taking.utils.d.ts +1 -0
  20. package/dist/components/exams/take/exam-taking.utils.js +1 -0
  21. package/dist/components/exams/take/index.d.ts +6 -0
  22. package/dist/components/exams/take/index.js +5 -0
  23. package/dist/components/exams/take/utils/question-transformers.d.ts +9 -1
  24. package/dist/components/exams/take/utils/question-transformers.js +68 -4
  25. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.d.ts +6 -2
  26. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.js +26 -5
  27. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +9 -0
  28. package/dist/components/questions/_shared/types/question-group.type.d.ts +11 -0
  29. package/dist/components/questions/_shared/types/question-group.type.js +4 -1
  30. package/dist/components/questions/_shared/types/spontaneous-qa.type.d.ts +7 -0
  31. package/dist/components/questions/groups/ImageLabelGroupCard.d.ts +15 -0
  32. package/dist/components/questions/groups/ImageLabelGroupCard.js +42 -0
  33. package/dist/components/questions/groups/ImageLabelWordBank.d.ts +10 -0
  34. package/dist/components/questions/groups/ImageLabelWordBank.js +17 -0
  35. package/dist/components/questions/question-bank/QuestionBankPickerDialog.d.ts +45 -0
  36. package/dist/components/questions/question-bank/QuestionBankPickerDialog.js +209 -0
  37. package/dist/components/questions/question-bank/QuestionBankPickerRow.d.ts +9 -0
  38. package/dist/components/questions/question-bank/QuestionBankPickerRow.js +20 -0
  39. package/dist/components/questions/question-bank/index.d.ts +1 -0
  40. package/dist/components/questions/question-bank/index.js +1 -0
  41. package/dist/components/questions/question-bank/question-bank-picker.utils.d.ts +55 -0
  42. package/dist/components/questions/question-bank/question-bank-picker.utils.js +408 -0
  43. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerClient.js +1 -2
  44. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +33 -9
  45. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +6 -2
  46. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +28 -5
  47. package/dist/components/questions/types/fill-in-blank/register.js +5 -0
  48. package/dist/components/questions/types/fill-in-blank/transform.js +4 -1
  49. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +11 -0
  50. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +27 -0
  51. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.d.ts +2 -0
  52. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.js +15 -0
  53. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.d.ts +2 -0
  54. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.js +33 -0
  55. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +8 -1
  56. package/dist/components/questions/types/word-fill-structured-form/transform.js +4 -0
  57. package/dist/components/questions/types/word-ordering/WordOrderingClient.js +51 -33
  58. package/dist/components/questions/viewer/QuestionViewer.js +12 -1
  59. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +3 -1
  60. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +4 -2
  61. package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
  62. package/dist/components/themes/english-certification/EnglishCertificationQuestionRenderer.js +8 -0
  63. package/dist/index.d.ts +4 -3
  64. package/dist/index.js +2 -1
  65. package/dist/shared/constants/ApiConstant.d.ts +1 -0
  66. package/dist/shared/constants/ApiConstant.js +1 -0
  67. package/dist/shared/lib/rich-text.d.ts +4 -0
  68. package/dist/shared/lib/rich-text.js +25 -0
  69. package/dist/shared/lib/stores/examQuestionStore.d.ts +4 -0
  70. package/dist/shared/lib/utils/question-transform-types.d.ts +4 -0
  71. package/dist/shared/lib/utils/question-transform.js +1 -1
  72. package/package.json +1 -1
@@ -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';
@@ -63,6 +63,7 @@ export interface QuestionPart {
63
63
  /** Zero-based exam-order start index of this part/section (for question numbering). */
64
64
  startIndex?: number;
65
65
  endIndex?: number;
66
+ suggestedAnswers?: string[];
66
67
  }
67
68
  /** Nested template group payload ({ items, config, type }) from the API template. */
68
69
  export interface TemplateGroupConfig {
@@ -108,6 +109,7 @@ export interface TemplatePart {
108
109
  category?: string;
109
110
  /** Max points for this part from exams.template.parts */
110
111
  points?: number;
112
+ suggestedAnswers?: string[];
111
113
  }
112
114
  /**
113
115
  * Group questions by part_id AND questionGroup.id
@@ -122,7 +124,9 @@ export interface TemplatePart {
122
124
  * @param templateParts - Optional template parts with name/instructions metadata
123
125
  */
124
126
  export declare function groupQuestionsByPart(questions: ApiQuestion[], templateParts?: TemplatePart[]): QuestionPart[];
125
- export declare function transformFillInBlank(questions: ApiQuestion[]): {
127
+ export declare function transformFillInBlank(questions: ApiQuestion[], options?: {
128
+ suggestedAnswers?: string[];
129
+ }): {
126
130
  questions: FillBlankQuestion[];
127
131
  title: string;
128
132
  imageUrl?: string;
@@ -134,6 +138,10 @@ export declare function transformFillInBlank(questions: ApiQuestion[]): {
134
138
  groupContent?: string;
135
139
  groupTitle?: string;
136
140
  groupImageUrl?: string;
141
+ imageLabels?: Array<{
142
+ imageUrl: string;
143
+ label: string;
144
+ }>;
137
145
  };
138
146
  export declare function transformWriteCorrectVerbForm(questions: ApiQuestion[]): {
139
147
  questions: FillBlankQuestion[];
@@ -41,7 +41,9 @@ export function groupQuestionsByPart(questions, templateParts) {
41
41
  // a single screen with the passage on the left and ALL questions on the
42
42
  // right — so keep it whole and pass the sections through for the renderer.
43
43
  const partLevelGroup = templatePart?.group;
44
- const hasDocumentGroup = Boolean(templatePart?.isGroup) && partLevelGroup?.type === 'DOCUMENT';
44
+ const sharedGroupType = String(partLevelGroup?.type || '').toUpperCase();
45
+ const hasDocumentGroup = Boolean(templatePart?.isGroup) &&
46
+ (sharedGroupType === 'DOCUMENT' || sharedGroupType === 'IMAGE_LABEL' || sharedGroupType === 'ARTICLES');
45
47
  // Mixed multi-section part (e.g. Listening SECTION 2: CHOOSE + MATCH +
46
48
  // WORD_FILL): keep it whole and pass the sections through so the grouped
47
49
  // renderer can stack each sub-section on one screen. Without this the part
@@ -68,6 +70,7 @@ export function groupQuestionsByPart(questions, templateParts) {
68
70
  sections: templatePart?.sections,
69
71
  startIndex: templatePart?.startIndex,
70
72
  endIndex: templatePart?.endIndex,
73
+ suggestedAnswers: templatePart?.suggestedAnswers,
71
74
  });
72
75
  continue;
73
76
  }
@@ -96,6 +99,7 @@ export function groupQuestionsByPart(questions, templateParts) {
96
99
  group: templatePart?.group,
97
100
  startIndex: templatePart?.startIndex,
98
101
  endIndex: templatePart?.endIndex,
102
+ suggestedAnswers: templatePart?.suggestedAnswers,
99
103
  });
100
104
  continue;
101
105
  }
@@ -138,6 +142,7 @@ export function groupQuestionsByPart(questions, templateParts) {
138
142
  group: section.group,
139
143
  startIndex: section.startIndex,
140
144
  endIndex: section.endIndex,
145
+ suggestedAnswers: section.suggestedAnswers ?? templatePart?.suggestedAnswers,
141
146
  });
142
147
  }
143
148
  }
@@ -150,7 +155,42 @@ export function groupQuestionsByPart(questions, templateParts) {
150
155
  return (a.questions[0]?.question_number || 0) - (b.questions[0]?.question_number || 0);
151
156
  });
152
157
  }
153
- export function transformFillInBlank(questions) {
158
+ // ============================================
159
+ // FILL_IN_BLANK Transformer
160
+ // ============================================
161
+ function asImageLabelItems(value) {
162
+ if (!Array.isArray(value))
163
+ return undefined;
164
+ const items = value
165
+ .map((item) => {
166
+ if (!item || typeof item !== 'object')
167
+ return null;
168
+ const imageUrl = String(item.imageUrl ?? '').trim();
169
+ const label = String(item.label ?? '').trim();
170
+ if (!imageUrl && !label)
171
+ return null;
172
+ return { imageUrl, label };
173
+ })
174
+ .filter((item) => item !== null);
175
+ return items.length > 0 ? items : undefined;
176
+ }
177
+ function collectFillInBlankImageLabels(questions, suggestedAnswers) {
178
+ for (const question of questions) {
179
+ const payload = question.questionGroup?.payload ||
180
+ question.question_group?.payload;
181
+ const fromGroup = asImageLabelItems(payload?.imageLabels);
182
+ if (fromGroup)
183
+ return fromGroup;
184
+ const fromContent = asImageLabelItems(question.content?.imageLabels);
185
+ if (fromContent)
186
+ return fromContent;
187
+ }
188
+ if (suggestedAnswers?.length) {
189
+ return suggestedAnswers.map((label) => ({ imageUrl: '', label: String(label) }));
190
+ }
191
+ return undefined;
192
+ }
193
+ export function transformFillInBlank(questions, options) {
154
194
  const firstQuestion = questions[0];
155
195
  const content = firstQuestion?.content;
156
196
  const groupPayload = firstQuestion?.questionGroup?.payload;
@@ -187,6 +227,7 @@ export function transformFillInBlank(questions) {
187
227
  groupContent: groupPayload?.content || groupPayload?.groupContent,
188
228
  groupTitle: groupPayload?.title,
189
229
  groupImageUrl: groupPayload?.mainImageUrl || groupPayload?.imageUrl,
230
+ imageLabels: collectFillInBlankImageLabels(questions, options?.suggestedAnswers),
190
231
  };
191
232
  }
192
233
  export function transformWriteCorrectVerbForm(questions) {
@@ -1199,6 +1240,20 @@ function normalizeWfsfWordBank(raw) {
1199
1240
  export function transformWordFillStructuredForm(questions) {
1200
1241
  const firstQuestion = questions[0];
1201
1242
  const groupPayload = firstQuestion?.questionGroup?.payload;
1243
+ const firstContent = firstQuestion?.content;
1244
+ const firstMetaRaw = firstQuestion?.meta;
1245
+ const firstMeta = firstMetaRaw &&
1246
+ typeof firstMetaRaw === 'object' &&
1247
+ !Array.isArray(firstMetaRaw)
1248
+ ? firstMetaRaw
1249
+ : undefined;
1250
+ // Bank: meta.instruction; school: content.instruction; exam group: payload.instructions.
1251
+ const topLevelInstruction = typeof firstQuestion?.instruction === 'string'
1252
+ ? String(firstQuestion.instruction).trim()
1253
+ : '';
1254
+ const metaInstruction = String(firstMeta?.instruction || '').trim();
1255
+ const contentInstruction = String(firstContent?.instruction || firstContent?.instructions || '').trim();
1256
+ const groupInstruction = String(groupPayload?.instructions || groupPayload?.instruction || '').trim();
1202
1257
  const transformed = questions.map((q) => {
1203
1258
  const content = q.content;
1204
1259
  const correctAnswer = q.correct_answer || {};
@@ -1218,11 +1273,16 @@ export function transformWordFillStructuredForm(questions) {
1218
1273
  const hasWords = Boolean(wordBank?.some((e) => e != null && 'word' in e && e.word));
1219
1274
  const viewMode = content?.viewMode ||
1220
1275
  (hasOptions ? 'WITH_OPTIONS' : hasWords ? 'WITH_WORD_BANK' : undefined);
1276
+ // Empty string imageUrl breaks ResolvedImage in some hosts — treat as absent.
1277
+ const imageUrlRaw = content?.imageUrl;
1278
+ const imageUrl = typeof imageUrlRaw === 'string' && imageUrlRaw.trim().length > 0
1279
+ ? imageUrlRaw.trim()
1280
+ : undefined;
1221
1281
  return {
1222
1282
  id: q.id,
1223
1283
  questionNumber: q.question_number,
1224
1284
  title: content?.title || '',
1225
- imageUrl: content?.imageUrl,
1285
+ imageUrl,
1226
1286
  content: resolvedContent,
1227
1287
  answers: correctAnswer?.answers || {},
1228
1288
  caseSensitive: correctAnswer?.caseSensitive ?? content?.caseSensitive ?? false,
@@ -1233,7 +1293,11 @@ export function transformWordFillStructuredForm(questions) {
1233
1293
  });
1234
1294
  return {
1235
1295
  questions: transformed,
1236
- instruction: groupPayload?.instructions || 'COMPLETE THE FORM BELOW',
1296
+ instruction: groupInstruction ||
1297
+ contentInstruction ||
1298
+ topLevelInstruction ||
1299
+ metaInstruction ||
1300
+ 'COMPLETE THE FORM BELOW',
1237
1301
  groupTitle: groupPayload?.title,
1238
1302
  groupContent: groupPayload?.content,
1239
1303
  groupImageUrl: groupPayload?.imageUrl,
@@ -1,4 +1,4 @@
1
- import type { QuestionGroupConfig, BasicGroupData, ArticleData } from '../types/question-group.type';
1
+ import type { QuestionGroupConfig, BasicGroupData, ArticleData, ImageLabelData } from '../types/question-group.type';
2
2
  /**
3
3
  * Derives which group number a question at `questionIndexInPart` belongs to,
4
4
  * given the part's group configuration array.
@@ -23,6 +23,8 @@ export interface UseBasicQuestionGroupOptions {
23
23
  initialData?: BasicGroupData;
24
24
  /** Number of articles for ARTICLES group type (creates empty articles array when > 0) */
25
25
  articleCount?: number;
26
+ /** Number of image+label items for IMAGE_LABEL group type */
27
+ imageLabelCount?: number;
26
28
  }
27
29
  export interface UseBasicQuestionGroupReturn {
28
30
  /** Is this question part of a group (i.e. groups array is non-empty)? */
@@ -36,11 +38,13 @@ export interface UseBasicQuestionGroupReturn {
36
38
  groupContent: string;
37
39
  imageUrl: string;
38
40
  articles: ArticleData[];
41
+ imageLabels: ImageLabelData[];
39
42
  setGroupTitle: (value: string) => void;
40
43
  setGroupSubtitle: (value: string) => void;
41
44
  setGroupContent: (value: string) => void;
42
45
  setImageUrl: (value: string) => void;
43
46
  setArticles: (articles: ArticleData[]) => void;
47
+ setImageLabels: (imageLabels: ImageLabelData[]) => void;
44
48
  }
45
49
  /**
46
50
  * `useBasicQuestionGroup`
@@ -53,4 +57,4 @@ export interface UseBasicQuestionGroupReturn {
53
57
  * the same group), falls back to `initialData`, and writes every change back to
54
58
  * the store so sibling questions instantly see the updated values.
55
59
  */
56
- export declare function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount, }: UseBasicQuestionGroupOptions): UseBasicQuestionGroupReturn;
60
+ export declare function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount, imageLabelCount, }: UseBasicQuestionGroupOptions): UseBasicQuestionGroupReturn;
@@ -54,7 +54,7 @@ export function getIsFirstInGroup(questionIndexInPart, groups) {
54
54
  * the same group), falls back to `initialData`, and writes every change back to
55
55
  * the store so sibling questions instantly see the updated values.
56
56
  */
57
- export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount = 0, }) {
57
+ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount = 0, imageLabelCount = 0, }) {
58
58
  const setSharedPassageData = useExamQuestionStore((s) => s.setSharedPassageData);
59
59
  const getSharedPassageData = useExamQuestionStore((s) => s.getSharedPassageData);
60
60
  const isGrouped = Boolean(groups && groups.length > 0);
@@ -67,6 +67,9 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
67
67
  const defaultArticles = articleCount > 0
68
68
  ? Array.from({ length: articleCount }, () => ({ name: '', content: '', imageUrl: '' }))
69
69
  : [];
70
+ const defaultImageLabels = imageLabelCount > 0
71
+ ? Array.from({ length: imageLabelCount }, () => ({ imageUrl: '', label: '' }))
72
+ : [];
70
73
  const resolveInitial = () => {
71
74
  if (isGrouped && partId) {
72
75
  const stored = getSharedPassageData(partId, currentGroupNumber);
@@ -77,6 +80,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
77
80
  content: stored.groupContent ?? stored.passage ?? initialData?.content ?? '',
78
81
  imageUrl: stored.imageUrl ?? initialData?.imageUrl ?? '',
79
82
  articles: stored.articles ?? initialData?.articles ?? defaultArticles,
83
+ imageLabels: stored.imageLabels ?? initialData?.imageLabels ?? defaultImageLabels,
80
84
  };
81
85
  }
82
86
  }
@@ -86,6 +90,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
86
90
  content: initialData?.content ?? '',
87
91
  imageUrl: initialData?.imageUrl ?? '',
88
92
  articles: initialData?.articles ?? defaultArticles,
93
+ imageLabels: initialData?.imageLabels ?? defaultImageLabels,
89
94
  };
90
95
  };
91
96
  const initial = resolveInitial();
@@ -94,6 +99,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
94
99
  const [groupContent, setGroupContentState] = useState(initial.content ?? '');
95
100
  const [imageUrl, setImageUrlState] = useState(initial.imageUrl ?? '');
96
101
  const [articles, setArticlesState] = useState(initial.articles ?? defaultArticles);
102
+ const [imageLabels, setImageLabelsState] = useState(initial.imageLabels ?? defaultImageLabels);
97
103
  // Track the last rendered group number so we can re-sync when navigating
98
104
  const prevGroupNumberRef = useRef(currentGroupNumber);
99
105
  // Track whether we have already seeded from initialData for the current group
@@ -121,7 +127,8 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
121
127
  const newContent = initialData.content || null;
122
128
  const newImage = initialData.imageUrl || null;
123
129
  const newArticles = initialData.articles || null;
124
- if (newTitle === null && newSubtitle === null && newContent === null && newImage === null && newArticles === null)
130
+ const newImageLabels = initialData.imageLabels || null;
131
+ if (newTitle === null && newSubtitle === null && newContent === null && newImage === null && newArticles === null && newImageLabels === null)
125
132
  return;
126
133
  if (newTitle !== null)
127
134
  setGroupTitleState(newTitle);
@@ -133,6 +140,8 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
133
140
  setImageUrlState(newImage);
134
141
  if (newArticles !== null)
135
142
  setArticlesState(newArticles);
143
+ if (newImageLabels !== null)
144
+ setImageLabelsState(newImageLabels);
136
145
  setSharedPassageData(partId, currentGroupNumber, {
137
146
  passageTitle: newTitle ?? '',
138
147
  passageSubtitle: newSubtitle ?? '',
@@ -140,6 +149,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
140
149
  passage: newContent ?? '',
141
150
  imageUrl: newImage ?? '',
142
151
  articles: newArticles ?? defaultArticles,
152
+ imageLabels: newImageLabels ?? defaultImageLabels,
143
153
  });
144
154
  // eslint-disable-next-line react-hooks/exhaustive-deps
145
155
  }, [initialData, partId, currentGroupNumber, isGrouped]);
@@ -153,12 +163,13 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
153
163
  if (!isGrouped || !partId)
154
164
  return;
155
165
  const stored = getSharedPassageData(partId, currentGroupNumber);
156
- if (stored && (stored.passageTitle || stored.passageSubtitle || stored.groupContent || stored.passage || stored.imageUrl || stored.articles)) {
166
+ if (stored && (stored.passageTitle || stored.passageSubtitle || stored.groupContent || stored.passage || stored.imageUrl || stored.articles || stored.imageLabels)) {
157
167
  setGroupTitleState(stored.passageTitle ?? '');
158
168
  setGroupSubtitleState(stored.passageSubtitle ?? '');
159
169
  setGroupContentState(stored.groupContent ?? stored.passage ?? '');
160
170
  setImageUrlState(stored.imageUrl ?? '');
161
171
  setArticlesState(stored.articles ?? defaultArticles);
172
+ setImageLabelsState(stored.imageLabels ?? defaultImageLabels);
162
173
  }
163
174
  else {
164
175
  setGroupTitleState(initialData?.title ?? '');
@@ -166,22 +177,26 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
166
177
  setGroupContentState(initialData?.content ?? '');
167
178
  setImageUrlState(initialData?.imageUrl ?? '');
168
179
  setArticlesState(initialData?.articles ?? defaultArticles);
180
+ setImageLabelsState(initialData?.imageLabels ?? defaultImageLabels);
169
181
  }
170
182
  // eslint-disable-next-line react-hooks/exhaustive-deps
171
183
  }, [currentGroupNumber, partId, isGrouped]);
172
184
  // ------------------------------------------------------------------
173
185
  // Helpers: write state + persist to store
174
186
  // ------------------------------------------------------------------
175
- const persistToStore = (title, subtitle, content, image, arts) => {
187
+ const persistToStore = (title, subtitle, content, image, arts, labels) => {
176
188
  if (!isGrouped || !partId)
177
189
  return;
190
+ const existing = getSharedPassageData(partId, currentGroupNumber);
178
191
  setSharedPassageData(partId, currentGroupNumber, {
192
+ ...existing,
179
193
  passageTitle: title,
180
194
  passageSubtitle: subtitle,
181
195
  groupContent: content,
182
196
  passage: content,
183
197
  imageUrl: image,
184
198
  articles: arts ?? articles,
199
+ imageLabels: labels ?? imageLabels,
185
200
  });
186
201
  };
187
202
  const setGroupTitle = (value) => {
@@ -202,7 +217,11 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
202
217
  };
203
218
  const setArticles = (value) => {
204
219
  setArticlesState(value);
205
- persistToStore(groupTitle, groupSubtitle, groupContent, imageUrl, value);
220
+ persistToStore(groupTitle, groupSubtitle, groupContent, imageUrl, value, imageLabels);
221
+ };
222
+ const setImageLabels = (value) => {
223
+ setImageLabelsState(value);
224
+ persistToStore(groupTitle, groupSubtitle, groupContent, imageUrl, articles, value);
206
225
  };
207
226
  return {
208
227
  isGrouped,
@@ -213,10 +232,12 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
213
232
  groupContent,
214
233
  imageUrl,
215
234
  articles,
235
+ imageLabels,
216
236
  setGroupTitle,
217
237
  setGroupSubtitle,
218
238
  setGroupContent,
219
239
  setImageUrl,
220
240
  setArticles,
241
+ setImageLabels,
221
242
  };
222
243
  }
@@ -40,6 +40,11 @@ export interface FillInBlankData {
40
40
  groupImageUrl?: string;
41
41
  groupNumber?: number;
42
42
  groups?: FillInBlankGroup[];
43
+ /** Picture + label bank when groupType === "IMAGE_LABEL" */
44
+ imageLabels?: Array<{
45
+ imageUrl: string;
46
+ label: string;
47
+ }>;
43
48
  blanks?: BlankItem[];
44
49
  }
45
50
  export interface FillInBlankCreatorProps {
@@ -55,6 +60,10 @@ export interface FillInBlankCreatorProps {
55
60
  partId?: string;
56
61
  /** Template questionConfig — controls which fields are shown (e.g. "audio") */
57
62
  questionConfig?: string[];
63
+ /** Template group.type (e.g. IMAGE_LABEL) */
64
+ groupType?: string;
65
+ /** Template suggestedAnswers — prefill IMAGE_LABEL labels when empty */
66
+ suggestedAnswers?: string[];
58
67
  }
59
68
  export interface FillInBlankClientProps {
60
69
  questionData: FillInBlankData;
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * Different question types may use different group shapes:
8
8
  * - BasicGroupData → ANSWER_THE_QUESTION, CHOOSE_THE_CORRECT_ANSWER, FILL_IN_BLANK
9
+ * - IMAGE_LABEL → FILL_IN_BLANK word bank (image + label items)
9
10
  * - (future) → MATCH_BY_WRITING_ANSWER, LISTENING_*, etc.
10
11
  */
11
12
  /** One entry in a part's group configuration array */
@@ -24,6 +25,14 @@ export interface ArticleData {
24
25
  /** Hình ảnh – S3 key hoặc URL */
25
26
  imageUrl: string;
26
27
  }
28
+ /** One item in an IMAGE_LABEL word bank (e.g. GET Primary vocabulary box) */
29
+ export interface ImageLabelData {
30
+ /** Hình ảnh – S3 key hoặc URL */
31
+ imageUrl: string;
32
+ /** Nhãn / từ hiển thị dưới ảnh */
33
+ label: string;
34
+ }
35
+ export declare function hasImageLabels(items?: ImageLabelData[] | null): boolean;
27
36
  /**
28
37
  * Shared context fields for a group of ANSWER_THE_QUESTION,
29
38
  * CHOOSE_THE_CORRECT_ANSWER, or FILL_IN_BLANK questions.
@@ -41,4 +50,6 @@ export interface BasicGroupData {
41
50
  imageUrl?: string;
42
51
  /** Mảng bài viết – dùng cho groupType "ARTICLES" (optional) */
43
52
  articles?: ArticleData[];
53
+ /** Mảng ảnh + nhãn – dùng cho groupType "IMAGE_LABEL" (optional) */
54
+ imageLabels?: ImageLabelData[];
44
55
  }
@@ -6,6 +6,9 @@
6
6
  *
7
7
  * Different question types may use different group shapes:
8
8
  * - BasicGroupData → ANSWER_THE_QUESTION, CHOOSE_THE_CORRECT_ANSWER, FILL_IN_BLANK
9
+ * - IMAGE_LABEL → FILL_IN_BLANK word bank (image + label items)
9
10
  * - (future) → MATCH_BY_WRITING_ANSWER, LISTENING_*, etc.
10
11
  */
11
- export {};
12
+ export function hasImageLabels(items) {
13
+ return Array.isArray(items) && items.some((item) => Boolean(item?.imageUrl?.trim() || item?.label?.trim()));
14
+ }
@@ -13,6 +13,13 @@ export interface SpontaneousQAData {
13
13
  /** Points per question */
14
14
  points?: number;
15
15
  }
16
+ export interface SpontaneousQAClientProps {
17
+ questionData: SpontaneousQAData & {
18
+ isBackup?: boolean;
19
+ expectedAnswers?: string[];
20
+ };
21
+ isReviewMode?: boolean;
22
+ }
16
23
  export interface SpontaneousQACreatorProps {
17
24
  initialData?: SpontaneousQAData;
18
25
  onSave?: (data: SpontaneousQAData) => void;
@@ -0,0 +1,15 @@
1
+ import type { ImageLabelData } from '../_shared/types/question-group.type';
2
+ export interface ImageLabelGroupCardProps {
3
+ groupNumber: number;
4
+ imageLabels: ImageLabelData[];
5
+ onImageLabelsChange: (items: ImageLabelData[]) => void;
6
+ title?: string;
7
+ onTitleChange?: (value: string) => void;
8
+ isReadOnly?: boolean;
9
+ uploadIdPrefix?: string;
10
+ }
11
+ /**
12
+ * Editor for groupType IMAGE_LABEL: repeatable image + label items
13
+ * that render as a horizontal word bank for students.
14
+ */
15
+ export declare function ImageLabelGroupCard({ groupNumber, imageLabels, onImageLabelsChange, title, onTitleChange, isReadOnly, uploadIdPrefix, }: ImageLabelGroupCardProps): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ImagePreview } from '../../../components/ui/image-preview';
4
+ import { Input } from '../../../components/ui/input';
5
+ import { Label } from '../../../components/ui/label';
6
+ import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card';
7
+ import { FileUpload } from '../../../components/ui/file-upload';
8
+ import { Button } from '../../../components/ui/button';
9
+ import { ImageIcon, Layers, PlusCircle, Trash2, Type } from 'lucide-react';
10
+ import { usePresignedFileUrl } from '../../../shared/lib/hooks';
11
+ import { ImageLabelWordBank } from './ImageLabelWordBank';
12
+ function ItemImagePreview({ src, alt }) {
13
+ const { previewUrl } = usePresignedFileUrl(src);
14
+ if (!previewUrl)
15
+ return null;
16
+ return (_jsx(ImagePreview, { src: previewUrl, alt: alt, className: "mt-2 h-28 w-full rounded-lg border border-gray-200 object-contain" }));
17
+ }
18
+ /**
19
+ * Editor for groupType IMAGE_LABEL: repeatable image + label items
20
+ * that render as a horizontal word bank for students.
21
+ */
22
+ export function ImageLabelGroupCard({ groupNumber, imageLabels, onImageLabelsChange, title = '', onTitleChange, isReadOnly = false, uploadIdPrefix = 'image-label-group', }) {
23
+ const displayItems = imageLabels.length > 0
24
+ ? imageLabels
25
+ : [{ imageUrl: '', label: '' }];
26
+ const handleFieldChange = (index, field, value) => {
27
+ if (isReadOnly)
28
+ return;
29
+ onImageLabelsChange(displayItems.map((item, i) => (i === index ? { ...item, [field]: value } : item)));
30
+ };
31
+ const handleAdd = () => {
32
+ if (isReadOnly)
33
+ return;
34
+ onImageLabelsChange([...displayItems, { imageUrl: '', label: '' }]);
35
+ };
36
+ const handleDelete = (index) => {
37
+ if (isReadOnly)
38
+ return;
39
+ onImageLabelsChange(displayItems.filter((_, i) => i !== index));
40
+ };
41
+ return (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-indigo-100/50", children: [_jsx("div", { className: "h-1.5 bg-gradient-to-r from-indigo-500 via-blue-500 to-sky-500" }), _jsx(CardHeader, { className: "pb-4", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-blue-600 shadow-md", children: _jsx(Layers, { className: "h-5 w-5 text-white" }) }), _jsxs("div", { className: "flex-1", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(CardTitle, { className: "text-lg font-bold text-gray-900", children: "Ng\u00E2n h\u00E0ng \u1EA3nh + t\u1EEB" }), _jsxs("span", { className: "inline-flex items-center rounded-full border border-indigo-300 bg-indigo-100 px-2.5 py-0.5 text-xs font-semibold text-indigo-700", children: ["Nh\u00F3m ", groupNumber] }), _jsxs("span", { className: "inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600", children: [displayItems.length, " m\u1EE5c"] })] }), _jsx("p", { className: "text-sm text-gray-500", children: "M\u1ED7i m\u1EE5c g\u1ED3m 1 h\u00ECnh v\u00E0 1 nh\u00E3n \u2014 h\u1ECDc sinh th\u1EA5y h\u00E0ng \u1EA3nh + t\u1EEB ph\u00EDa tr\u00EAn c\u00E2u h\u1ECFi." })] })] }) }), _jsxs(CardContent, { className: "space-y-5", children: [_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: `${uploadIdPrefix}-title`, className: "flex items-center gap-2 text-sm font-semibold text-gray-800", children: [_jsx(Type, { className: "h-4 w-4 text-indigo-500" }), "Ti\u00EAu \u0111\u1EC1 nh\u00F3m", ' ', _jsx("span", { className: "text-xs font-normal text-gray-500", children: "(T\u00F9y ch\u1ECDn)" })] }), _jsx(Input, { id: `${uploadIdPrefix}-title`, value: title, onChange: (e) => onTitleChange?.(e.target.value), placeholder: "VD: Words from the box", readOnly: isReadOnly, className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${isReadOnly ? 'cursor-not-allowed border-transparent bg-gray-50 text-gray-500 focus:border-transparent focus:ring-0' : ''}` })] }), _jsx("div", { className: "grid gap-4 sm:grid-cols-2 xl:grid-cols-3", children: displayItems.map((item, index) => (_jsxs("div", { className: "rounded-xl border-2 border-gray-200 bg-gradient-to-b from-slate-50 to-white p-3 transition-all hover:border-indigo-200 hover:shadow-sm", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between", children: [_jsxs("span", { className: "text-xs font-semibold text-gray-600", children: ["M\u1EE5c ", index + 1] }), displayItems.length > 1 && !isReadOnly && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => handleDelete(index), className: "h-7 w-7 p-0 text-red-500 hover:bg-red-50 hover:text-red-600", title: "X\u00F3a m\u1EE5c", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] }), _jsxs("div", { className: "space-y-2.5", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: `${uploadIdPrefix}-label-${index}`, className: "flex items-center gap-1.5 text-xs font-medium text-gray-700", children: [_jsx(Type, { className: "h-3.5 w-3.5 text-indigo-500" }), "Nh\u00E3n"] }), _jsx(Input, { id: `${uploadIdPrefix}-label-${index}`, value: item.label, onChange: (e) => handleFieldChange(index, 'label', e.target.value), placeholder: "VD: swimming", readOnly: isReadOnly, className: `border-gray-200 bg-white text-sm focus:border-indigo-300 focus:ring-indigo-200 ${isReadOnly ? 'cursor-not-allowed bg-gray-50 text-gray-500' : ''}` })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { className: "flex items-center gap-1.5 text-xs font-medium text-gray-700", children: [_jsx(ImageIcon, { className: "h-3.5 w-3.5 text-indigo-500" }), "H\u00ECnh \u1EA3nh"] }), !isReadOnly && (_jsx(FileUpload, { id: `${uploadIdPrefix}-image-${index}`, label: "", accept: "image/*", value: item.imageUrl, onChange: (url) => handleFieldChange(index, 'imageUrl', url), maxSize: 5, placeholder: "Upload \u1EA3nh", autoUpload: true, prefix: "questions" })), item.imageUrl && (_jsx(ItemImagePreview, { src: item.imageUrl, alt: item.label || `Item ${index + 1}` }))] })] })] }, index))) }), !isReadOnly && (_jsxs(Button, { type: "button", variant: "outline", className: "w-full border-dashed border-indigo-300 bg-indigo-50/50 text-indigo-700 hover:border-indigo-400 hover:bg-indigo-50 hover:text-indigo-800", onClick: handleAdd, children: [_jsx(PlusCircle, { className: "mr-2 h-4 w-4" }), "Th\u00EAm m\u1EE5c \u1EA3nh + t\u1EEB"] })), _jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50/40 p-4", children: [_jsx("p", { className: "mb-2 text-xs font-semibold uppercase tracking-wide text-indigo-600", children: "Xem tr\u01B0\u1EDBc h\u00E0ng t\u1EEB" }), _jsx(ImageLabelWordBank, { items: displayItems })] })] })] }));
42
+ }
@@ -0,0 +1,10 @@
1
+ import { type ImageLabelData } from '../_shared/types/question-group.type';
2
+ export interface ImageLabelWordBankProps {
3
+ items?: ImageLabelData[];
4
+ title?: string;
5
+ className?: string;
6
+ }
7
+ /**
8
+ * Horizontal picture + label bank shown to students (and creator preview).
9
+ */
10
+ export declare function ImageLabelWordBank({ items, title, className }: ImageLabelWordBankProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ResolvedImage } from '../../../components/common/ResolvedImage';
4
+ import { hasImageLabels } from '../_shared/types/question-group.type';
5
+ /**
6
+ * Horizontal picture + label bank shown to students (and creator preview).
7
+ */
8
+ export function ImageLabelWordBank({ items, title, className }) {
9
+ if (!hasImageLabels(items))
10
+ return null;
11
+ const visible = items.filter((item) => item.imageUrl?.trim() || item.label?.trim());
12
+ const hasAnyImage = visible.some((item) => Boolean(item.imageUrl?.trim()));
13
+ if (!hasAnyImage) {
14
+ return (_jsxs("div", { className: className, children: [title ? (_jsx("p", { className: "mb-3 text-center text-sm font-semibold text-slate-700", children: title })) : null, _jsx("div", { className: "flex flex-wrap items-center justify-center gap-x-4 gap-y-2 rounded-xl border-2 border-slate-300 bg-white px-4 py-3 sm:gap-x-8", children: visible.map((item, index) => (_jsx("span", { className: "text-sm font-semibold text-slate-800 sm:text-base", children: item.label }, `${item.label || 'item'}-${index}`))) })] }));
15
+ }
16
+ return (_jsxs("div", { className: className, children: [title ? (_jsx("p", { className: "mb-3 text-center text-sm font-semibold text-slate-700", children: title })) : null, _jsx("div", { className: "flex flex-wrap justify-center gap-3 sm:gap-4", children: visible.map((item, index) => (_jsxs("div", { className: "flex w-[5.5rem] flex-col items-center gap-1.5 sm:w-24", children: [_jsx("div", { className: "flex h-20 w-full items-center justify-center overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm sm:h-24", children: item.imageUrl ? (_jsx(ResolvedImage, { src: item.imageUrl, alt: item.label || `Item ${index + 1}`, className: "h-full w-full object-contain p-1" })) : (_jsx("span", { className: "px-1 text-center text-[10px] text-slate-400", children: "Ch\u01B0a c\u00F3 \u1EA3nh" })) }), item.label?.trim() ? (_jsx("span", { className: "text-center text-xs font-semibold leading-tight text-slate-800 sm:text-sm", children: item.label })) : null] }, `${item.label || 'item'}-${index}`))) })] }));
17
+ }
@@ -0,0 +1,45 @@
1
+ import type { QuestionBankFiltersInput, QuestionBankFiltersResult, QuestionSearchItem, SearchQuestionBankInput, SearchQuestionBankResult } from '../../../api/exam-questions.types';
2
+ /**
3
+ * Prefill cascade filters from host context.
4
+ * Values are Pearson meta ids: bookId / unitId / lessonId.
5
+ * `lessonIds` = session textbook lessons (default search scope when lesson select = Tất cả).
6
+ */
7
+ export type QuestionBankPickerDefaultFilters = {
8
+ bookId?: string;
9
+ unitId?: string;
10
+ lessonId?: string;
11
+ /** Session lesson ids — used when lesson dropdown is "Tất cả". */
12
+ lessonIds?: string[];
13
+ questionType?: string;
14
+ /** Optional labels so prefill renders before cascade options load. */
15
+ bookLabel?: string;
16
+ unitLabel?: string;
17
+ };
18
+ /** Session textbook lesson → merge into Lesson select + default search scope. */
19
+ export type QuestionBankLessonSuggestion = {
20
+ lessonId?: string | null;
21
+ label?: string | null;
22
+ /** @deprecated use lessonId */
23
+ lesson?: string | null;
24
+ book?: string | null;
25
+ unit?: string | null;
26
+ };
27
+ export interface QuestionBankPickerDialogProps {
28
+ open: boolean;
29
+ onOpenChange: (open: boolean) => void;
30
+ searchQuestions: (input: SearchQuestionBankInput) => Promise<SearchQuestionBankResult>;
31
+ getQuestionFilters?: (input?: QuestionBankFiltersInput) => Promise<QuestionBankFiltersResult>;
32
+ defaultFilters?: QuestionBankPickerDefaultFilters;
33
+ /** Session lessons: options + default multi-lesson search scope. */
34
+ lessonSuggestions?: QuestionBankLessonSuggestion[];
35
+ onConfirm: (items: QuestionSearchItem[]) => void;
36
+ title?: string;
37
+ description?: string;
38
+ confirmLabel?: string;
39
+ }
40
+ /**
41
+ * Multi-select bank picker. Cascade filters use stable meta ids:
42
+ * bookId → unitId → lessonId (labels from meta.*Name).
43
+ * Default lesson scope = session lessonIds when dropdown is "Tất cả".
44
+ */
45
+ export declare function QuestionBankPickerDialog({ open, onOpenChange, searchQuestions, getQuestionFilters, defaultFilters, lessonSuggestions, onConfirm, title, description, confirmLabel, }: QuestionBankPickerDialogProps): import("react").JSX.Element;