@tinyweb_dev/oe-exam-sdk 1.0.0 → 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.
- package/dist/api/exam-questions.d.ts +1 -1
- package/dist/api/exam-questions.js +39 -1
- package/dist/api/exam-questions.types.d.ts +28 -0
- package/dist/api/exam-taking.types.d.ts +1 -0
- package/dist/components/exams/ExamCreator.d.ts +7 -1
- package/dist/components/exams/ExamCreator.js +57 -21
- package/dist/components/exams/ExamPreviewDialog.js +4 -2
- package/dist/components/exams/ExamQuestionsPageContainer.d.ts +5 -0
- package/dist/components/exams/ExamQuestionsPageContainer.js +2 -2
- package/dist/components/exams/api.d.ts +1 -1
- package/dist/components/exams/index.d.ts +1 -1
- package/dist/components/exams/take/components/QuestionRenderer.d.ts +6 -1
- package/dist/components/exams/take/components/QuestionRenderer.js +4 -4
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +3 -1
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +5 -2
- package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.d.ts +7 -1
- package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +18 -9
- package/dist/components/exams/take/exam-taking.utils.d.ts +1 -0
- package/dist/components/exams/take/exam-taking.utils.js +1 -0
- package/dist/components/exams/take/index.d.ts +6 -0
- package/dist/components/exams/take/index.js +5 -0
- package/dist/components/exams/take/utils/question-transformers.d.ts +9 -1
- package/dist/components/exams/take/utils/question-transformers.js +68 -4
- package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.d.ts +6 -2
- package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.js +26 -5
- package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +9 -0
- package/dist/components/questions/_shared/types/question-group.type.d.ts +11 -0
- package/dist/components/questions/_shared/types/question-group.type.js +4 -1
- package/dist/components/questions/_shared/types/spontaneous-qa.type.d.ts +7 -0
- package/dist/components/questions/groups/ImageLabelGroupCard.d.ts +15 -0
- package/dist/components/questions/groups/ImageLabelGroupCard.js +42 -0
- package/dist/components/questions/groups/ImageLabelWordBank.d.ts +10 -0
- package/dist/components/questions/groups/ImageLabelWordBank.js +17 -0
- package/dist/components/questions/question-bank/QuestionBankPickerDialog.d.ts +45 -0
- package/dist/components/questions/question-bank/QuestionBankPickerDialog.js +209 -0
- package/dist/components/questions/question-bank/QuestionBankPickerRow.d.ts +9 -0
- package/dist/components/questions/question-bank/QuestionBankPickerRow.js +20 -0
- package/dist/components/questions/question-bank/index.d.ts +1 -0
- package/dist/components/questions/question-bank/index.js +1 -0
- package/dist/components/questions/question-bank/question-bank-picker.utils.d.ts +55 -0
- package/dist/components/questions/question-bank/question-bank-picker.utils.js +408 -0
- package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +33 -9
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +6 -2
- package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +28 -5
- package/dist/components/questions/types/fill-in-blank/register.js +5 -0
- package/dist/components/questions/types/fill-in-blank/transform.js +4 -1
- package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +11 -0
- package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +27 -0
- package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.d.ts +2 -0
- package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.js +15 -0
- package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.d.ts +2 -0
- package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.js +33 -0
- package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +8 -1
- package/dist/components/questions/types/word-fill-structured-form/transform.js +4 -0
- package/dist/components/questions/types/word-ordering/WordOrderingClient.js +51 -33
- package/dist/components/questions/viewer/QuestionViewer.js +12 -1
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +3 -1
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +4 -2
- package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
- package/dist/components/themes/english-certification/EnglishCertificationQuestionRenderer.js +8 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/shared/constants/ApiConstant.d.ts +1 -0
- package/dist/shared/constants/ApiConstant.js +1 -0
- package/dist/shared/lib/rich-text.d.ts +4 -0
- package/dist/shared/lib/rich-text.js +25 -0
- package/dist/shared/lib/stores/examQuestionStore.d.ts +4 -0
- package/dist/shared/lib/utils/question-transform-types.d.ts +4 -0
- package/dist/shared/lib/utils/question-transform.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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;
|