question-bank-framework 0.4.0 → 0.5.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/README.md +27 -0
- package/core/config.tsx +10 -0
- package/core/index.ts +10 -0
- package/core/types.ts +254 -52
- package/framework/components/markdown-content.tsx +19 -8
- package/framework/components/question-bank.tsx +32 -3
- package/framework/components/question-response.tsx +211 -0
- package/framework/components/quiz-list.tsx +3 -3
- package/framework/components/quiz-page.tsx +76 -39
- package/framework/convex/quizzes.ts +2 -2
- package/framework/convex/schema.ts +1 -1
- package/framework/index.ts +1 -0
- package/framework/lib/question-filter.ts +3 -1
- package/package.json +1 -1
- package/template/content/questions.json +175 -15
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Response widgets for the auto-graded non-choice kinds: numerical,
|
|
2
|
+
// matching and dropdown. Framework-owned: eject with
|
|
3
|
+
// `qb reveal framework/components/question-response.tsx`.
|
|
4
|
+
|
|
5
|
+
import { Check, X } from 'lucide-react';
|
|
6
|
+
import type { BaseQuestion, QuestionOption, QuizAnswer } from '../../core/types.ts';
|
|
7
|
+
import { getPairingSlots, parseNumericAnswer, questionKind, toPairings } from '../../core/types.ts';
|
|
8
|
+
import { MarkdownContent } from './markdown-content';
|
|
9
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
|
10
|
+
import { cn } from '../lib/utils.ts';
|
|
11
|
+
|
|
12
|
+
export const optionLetter = (index: number): string => String.fromCharCode(65 + index);
|
|
13
|
+
|
|
14
|
+
/** Format the expected value of a numerical question, e.g. "71.667 (± 0.01) I/Os". */
|
|
15
|
+
export function formatNumericAnswer(question: BaseQuestion): string {
|
|
16
|
+
const numeric = question.numeric;
|
|
17
|
+
if (!numeric) return '';
|
|
18
|
+
const tolerance = numeric.tolerance ? ` (± ${numeric.tolerance})` : '';
|
|
19
|
+
const unit = numeric.unit ? ` ${numeric.unit}` : '';
|
|
20
|
+
return `${numeric.value.toLocaleString('en-US', { maximumFractionDigits: 10 })}${tolerance}${unit}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function NumericalResponse({
|
|
24
|
+
question,
|
|
25
|
+
answer,
|
|
26
|
+
checked,
|
|
27
|
+
correct,
|
|
28
|
+
onChange,
|
|
29
|
+
}: {
|
|
30
|
+
question: BaseQuestion;
|
|
31
|
+
answer: QuizAnswer | undefined;
|
|
32
|
+
checked: boolean;
|
|
33
|
+
correct: boolean | null;
|
|
34
|
+
onChange: (value: string) => void;
|
|
35
|
+
}) {
|
|
36
|
+
const text = typeof answer === 'string' ? answer : '';
|
|
37
|
+
const invalid = text.trim() !== '' && parseNumericAnswer(text) === null;
|
|
38
|
+
const inputId = `quiz-numeric-${question.id}`;
|
|
39
|
+
return (
|
|
40
|
+
<div className="mt-6">
|
|
41
|
+
<label htmlFor={inputId} className="text-sm font-semibold text-gray-900">
|
|
42
|
+
Your answer
|
|
43
|
+
</label>
|
|
44
|
+
<div className="mt-2 flex items-center gap-2">
|
|
45
|
+
<input
|
|
46
|
+
id={inputId}
|
|
47
|
+
type="text"
|
|
48
|
+
inputMode="decimal"
|
|
49
|
+
autoComplete="off"
|
|
50
|
+
value={text}
|
|
51
|
+
onChange={(event) => onChange(event.target.value)}
|
|
52
|
+
placeholder="Enter a number"
|
|
53
|
+
aria-invalid={invalid || (checked && correct === false) || undefined}
|
|
54
|
+
aria-describedby={invalid ? `${inputId}-hint` : undefined}
|
|
55
|
+
className={cn(
|
|
56
|
+
'h-10 w-48 rounded-lg border bg-white px-3 font-mono text-base tabular-nums text-gray-900 outline-none placeholder:font-sans placeholder:text-gray-400 focus-visible:ring-2 focus-visible:ring-gray-900/20',
|
|
57
|
+
checked && correct === true && 'border-green-600 bg-green-50',
|
|
58
|
+
checked && correct === false && 'border-red-600 bg-red-50',
|
|
59
|
+
!checked && 'border-gray-300',
|
|
60
|
+
)}
|
|
61
|
+
/>
|
|
62
|
+
{question.numeric?.unit && <span className="text-sm text-gray-600">{question.numeric.unit}</span>}
|
|
63
|
+
</div>
|
|
64
|
+
{invalid && (
|
|
65
|
+
<p id={`${inputId}-hint`} className="mt-1.5 text-sm text-amber-800">
|
|
66
|
+
That doesn't look like a number — it will be marked incorrect.
|
|
67
|
+
</p>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function OptionSelect({
|
|
74
|
+
id,
|
|
75
|
+
options,
|
|
76
|
+
value,
|
|
77
|
+
lettered,
|
|
78
|
+
onChange,
|
|
79
|
+
ariaLabel,
|
|
80
|
+
state,
|
|
81
|
+
}: {
|
|
82
|
+
id: string;
|
|
83
|
+
options: readonly QuestionOption[];
|
|
84
|
+
value: string | undefined;
|
|
85
|
+
lettered: boolean;
|
|
86
|
+
onChange: (value: string) => void;
|
|
87
|
+
ariaLabel: string;
|
|
88
|
+
state: 'idle' | 'correct' | 'incorrect' | 'missing';
|
|
89
|
+
}) {
|
|
90
|
+
return (
|
|
91
|
+
<Select value={value ?? ''} onValueChange={onChange}>
|
|
92
|
+
<SelectTrigger
|
|
93
|
+
id={id}
|
|
94
|
+
aria-label={ariaLabel}
|
|
95
|
+
className={cn(
|
|
96
|
+
'h-auto min-h-9 w-full min-w-48 whitespace-normal bg-white text-left sm:w-72',
|
|
97
|
+
state === 'correct' && 'border-green-600 bg-green-50',
|
|
98
|
+
(state === 'incorrect' || state === 'missing') && 'border-red-600 bg-red-50',
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
<SelectValue placeholder="Choose…" />
|
|
102
|
+
</SelectTrigger>
|
|
103
|
+
<SelectContent position="popper" className="max-w-[min(36rem,calc(100vw-2rem))]">
|
|
104
|
+
{options.map((option, index) => (
|
|
105
|
+
<SelectItem key={option.id} value={option.id} className="whitespace-normal">
|
|
106
|
+
{lettered && <span className="font-semibold text-gray-500">{optionLetter(index)}.</span>}
|
|
107
|
+
<MarkdownContent inline content={option.text} />
|
|
108
|
+
</SelectItem>
|
|
109
|
+
))}
|
|
110
|
+
</SelectContent>
|
|
111
|
+
</Select>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Matching rows or dropdown blanks: one select per slot, graded per slot once checked. */
|
|
116
|
+
export function PairingResponse({
|
|
117
|
+
question,
|
|
118
|
+
answer,
|
|
119
|
+
checked,
|
|
120
|
+
onChange,
|
|
121
|
+
}: {
|
|
122
|
+
question: BaseQuestion;
|
|
123
|
+
answer: QuizAnswer | undefined;
|
|
124
|
+
checked: boolean;
|
|
125
|
+
onChange: (value: Record<string, string>) => void;
|
|
126
|
+
}) {
|
|
127
|
+
const kind = questionKind(question);
|
|
128
|
+
const matching = kind === 'matching';
|
|
129
|
+
const slots = getPairingSlots(question);
|
|
130
|
+
const pairings = toPairings(question, answer);
|
|
131
|
+
const answeredCount = Object.keys(pairings).length;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="mt-6">
|
|
135
|
+
<p id={`quiz-pairing-label-${question.id}`} className="text-sm font-semibold text-gray-900">
|
|
136
|
+
{matching ? 'Match each item to an option' : 'Fill in each blank'}
|
|
137
|
+
<span className="ml-2 font-normal text-gray-500">
|
|
138
|
+
{answeredCount}/{slots.length} answered
|
|
139
|
+
</span>
|
|
140
|
+
</p>
|
|
141
|
+
|
|
142
|
+
{matching && (
|
|
143
|
+
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
|
144
|
+
<p className="m-0 text-xs font-semibold uppercase tracking-wide text-gray-500">Options</p>
|
|
145
|
+
<ol className="m-0 mt-2 grid list-none grid-cols-[minmax(0,1fr)] gap-x-6 gap-y-1 p-0 sm:grid-cols-[repeat(2,minmax(0,1fr))]">
|
|
146
|
+
{(question.options ?? []).map((option, index) => (
|
|
147
|
+
<li key={option.id} className="flex items-start gap-2 text-sm text-gray-800">
|
|
148
|
+
<span className="w-5 shrink-0 font-semibold text-gray-500">{optionLetter(index)}.</span>
|
|
149
|
+
<MarkdownContent inline content={option.text} className="min-w-0 flex-1 overflow-x-auto" />
|
|
150
|
+
</li>
|
|
151
|
+
))}
|
|
152
|
+
</ol>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
<ol aria-labelledby={`quiz-pairing-label-${question.id}`} className="m-0 mt-3 grid list-none grid-cols-[minmax(0,1fr)] gap-2 p-0">
|
|
157
|
+
{slots.map((slot, index) => {
|
|
158
|
+
const chosen = pairings[slot.id];
|
|
159
|
+
const isCorrect = chosen === slot.correctId;
|
|
160
|
+
const state = !checked ? 'idle' : chosen === undefined ? 'missing' : isCorrect ? 'correct' : 'incorrect';
|
|
161
|
+
const correctIndex = slot.options.findIndex((o) => o.id === slot.correctId);
|
|
162
|
+
const correctOption = slot.options[correctIndex];
|
|
163
|
+
const selectId = `quiz-slot-${question.id}-${slot.id}`;
|
|
164
|
+
const label = slot.label?.trim() ? slot.label : `Blank ${index + 1}`;
|
|
165
|
+
return (
|
|
166
|
+
<li
|
|
167
|
+
key={slot.id}
|
|
168
|
+
className={cn(
|
|
169
|
+
'flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center',
|
|
170
|
+
state === 'idle' && 'border-gray-200 bg-white',
|
|
171
|
+
state === 'correct' && 'border-green-300 bg-green-50/50',
|
|
172
|
+
(state === 'incorrect' || state === 'missing') && 'border-red-300 bg-red-50/50',
|
|
173
|
+
)}
|
|
174
|
+
>
|
|
175
|
+
<div className="flex min-w-0 flex-1 items-start gap-2">
|
|
176
|
+
<span className="mt-0.5 w-6 shrink-0 text-sm font-semibold tabular-nums text-gray-500">{index + 1}.</span>
|
|
177
|
+
<label htmlFor={selectId} className="min-w-0 flex-1 overflow-x-auto text-base leading-7 text-gray-900">
|
|
178
|
+
<MarkdownContent inline content={label} />
|
|
179
|
+
</label>
|
|
180
|
+
</div>
|
|
181
|
+
<div className="flex shrink-0 flex-col gap-1 pl-8 sm:pl-0">
|
|
182
|
+
<div className="flex items-center gap-2">
|
|
183
|
+
<OptionSelect
|
|
184
|
+
id={selectId}
|
|
185
|
+
options={slot.options}
|
|
186
|
+
value={chosen}
|
|
187
|
+
lettered={matching}
|
|
188
|
+
ariaLabel={`Answer for ${matching ? 'item' : 'blank'} ${index + 1}`}
|
|
189
|
+
state={state}
|
|
190
|
+
onChange={(value) => onChange({ ...pairings, [slot.id]: value })}
|
|
191
|
+
/>
|
|
192
|
+
{state === 'correct' && <Check aria-label="Correct" className="size-5 shrink-0 text-green-700" />}
|
|
193
|
+
{(state === 'incorrect' || state === 'missing') && (
|
|
194
|
+
<X aria-label={state === 'missing' ? 'Not answered' : 'Incorrect'} className="size-5 shrink-0 text-red-700" />
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
{(state === 'incorrect' || state === 'missing') && correctOption && (
|
|
198
|
+
<p className="m-0 flex items-baseline gap-1 text-sm text-red-900">
|
|
199
|
+
<span className="shrink-0">Correct:</span>
|
|
200
|
+
{matching && <span className="font-semibold">{optionLetter(correctIndex)}.</span>}
|
|
201
|
+
<MarkdownContent inline content={correctOption.text} />
|
|
202
|
+
</p>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
</li>
|
|
206
|
+
);
|
|
207
|
+
})}
|
|
208
|
+
</ol>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -49,7 +49,7 @@ export function QuizList() {
|
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
return (
|
|
52
|
-
<div className="grid gap-8">
|
|
52
|
+
<div className="grid grid-cols-[minmax(0,1fr)] gap-8">
|
|
53
53
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
54
54
|
<div>
|
|
55
55
|
<h1 className="m-0 text-3xl font-bold tracking-tight text-gray-900">My quizzes</h1>
|
|
@@ -69,7 +69,7 @@ export function QuizList() {
|
|
|
69
69
|
Could not delete that quiz. Please try again.
|
|
70
70
|
</p>
|
|
71
71
|
)}
|
|
72
|
-
<div className="grid grid-cols-
|
|
72
|
+
<div className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-[repeat(2,minmax(0,1fr))]">
|
|
73
73
|
{quizzes.map((quiz) => {
|
|
74
74
|
const questions = quiz.questionIds
|
|
75
75
|
.map((id) => bank.questionById.get(id))
|
|
@@ -85,7 +85,7 @@ export function QuizList() {
|
|
|
85
85
|
params={{ quizId: quiz.id }}
|
|
86
86
|
className="block rounded-xl border border-gray-200 bg-white p-5 transition-colors hover:border-gray-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"
|
|
87
87
|
>
|
|
88
|
-
<h2 className="m-0 pr-16 text-lg font-semibold text-gray-900">{quiz.title}</h2>
|
|
88
|
+
<h2 className="m-0 break-words pr-16 text-lg font-semibold text-gray-900">{quiz.title}</h2>
|
|
89
89
|
<p className="mt-2 text-sm text-gray-700">
|
|
90
90
|
{quiz.questionIds.length} {quiz.questionIds.length === 1 ? 'question' : 'questions'}
|
|
91
91
|
</p>
|
|
@@ -7,9 +7,12 @@ import { useBank } from '../../core/config.tsx';
|
|
|
7
7
|
import type { QuestionStatus, QuestionStatusMap, QuizAnswer, QuizSession } from '../../core/types.ts';
|
|
8
8
|
import {
|
|
9
9
|
getCorrectIds,
|
|
10
|
+
gradeAnswer,
|
|
10
11
|
isAnswerCorrect,
|
|
11
12
|
isAnswerProvided,
|
|
12
13
|
isChoiceQuestion,
|
|
14
|
+
isGradeableQuestion,
|
|
15
|
+
isPairingQuestion,
|
|
13
16
|
questionKind,
|
|
14
17
|
questionKindLabels,
|
|
15
18
|
scoreAnswers,
|
|
@@ -19,13 +22,12 @@ import { Badge } from './ui/badge';
|
|
|
19
22
|
import { Button } from './ui/button';
|
|
20
23
|
import { Input } from './ui/input';
|
|
21
24
|
import { MarkdownContent } from './markdown-content';
|
|
25
|
+
import { formatNumericAnswer, NumericalResponse, optionLetter, PairingResponse } from './question-response';
|
|
22
26
|
import { setQuestionStatus, getQuestionStatuses } from '../lib/question-status-storage.ts';
|
|
23
27
|
import { questionStatusLabels } from '../lib/question-filter.ts';
|
|
24
28
|
import { getQuiz, saveQuizLocally, saveQuizProgress } from '../lib/quiz-storage.ts';
|
|
25
29
|
import { cn } from '../lib/utils.ts';
|
|
26
30
|
|
|
27
|
-
const optionLetter = (index: number): string => String.fromCharCode(65 + index);
|
|
28
|
-
|
|
29
31
|
const questionStatusStyles: Record<QuestionStatus, string> = {
|
|
30
32
|
unanswered: 'border-gray-300 bg-gray-50 text-gray-700',
|
|
31
33
|
revisit: 'border-amber-300 bg-amber-50 text-amber-900',
|
|
@@ -111,7 +113,7 @@ export function QuizPage() {
|
|
|
111
113
|
);
|
|
112
114
|
};
|
|
113
115
|
|
|
114
|
-
const
|
|
116
|
+
const setGradedAnswer = (questionId: string, value: QuizAnswer) => {
|
|
115
117
|
setChecked((current) => (current[questionId] ? { ...current, [questionId]: false } : current));
|
|
116
118
|
setSession((current) =>
|
|
117
119
|
current ? { ...current, completed: false, answers: { ...current.answers, [questionId]: value } } : current,
|
|
@@ -122,9 +124,9 @@ export function QuizPage() {
|
|
|
122
124
|
if (!activeQuestion) return;
|
|
123
125
|
const current = toSelectedIds(activeQuestion, session?.answers[questionId]);
|
|
124
126
|
if (single) {
|
|
125
|
-
|
|
127
|
+
setGradedAnswer(questionId, current[0] === optionId ? [] : [optionId]);
|
|
126
128
|
} else {
|
|
127
|
-
|
|
129
|
+
setGradedAnswer(
|
|
128
130
|
questionId,
|
|
129
131
|
current.includes(optionId) ? current.filter((id) => id !== optionId) : [...current, optionId],
|
|
130
132
|
);
|
|
@@ -184,17 +186,19 @@ export function QuizPage() {
|
|
|
184
186
|
const suggestedAnswer = bank.answers[activeQuestion.id];
|
|
185
187
|
const isSuggestedAnswerShown = shownAnswerId === activeQuestion.id;
|
|
186
188
|
const choice = isChoiceQuestion(activeQuestion);
|
|
189
|
+
const gradeable = isGradeableQuestion(activeQuestion);
|
|
187
190
|
const kind = questionKind(activeQuestion);
|
|
188
191
|
const selectedIds = toSelectedIds(activeQuestion, answer);
|
|
189
192
|
const hasSelection = isAnswerProvided(activeQuestion, answer);
|
|
190
193
|
const isChecked = checked[activeQuestion.id] === true;
|
|
191
194
|
const correctness = isChecked ? isAnswerCorrect(activeQuestion, answer) : null;
|
|
195
|
+
const grade = isChecked ? gradeAnswer(activeQuestion, answer) : null;
|
|
192
196
|
const correctIds = getCorrectIds(activeQuestion);
|
|
193
197
|
const activeStatus: QuestionStatus = statusMap[activeQuestion.id] ?? 'unanswered';
|
|
194
198
|
const score = scoreAnswers(quizQuestions, session.answers);
|
|
195
199
|
|
|
196
200
|
return (
|
|
197
|
-
<div className="mx-auto grid max-w-3xl gap-6">
|
|
201
|
+
<div className="mx-auto grid w-full max-w-3xl grid-cols-[minmax(0,1fr)] gap-6">
|
|
198
202
|
<Link to="/quizzes" className="text-sm font-semibold text-gray-600 hover:text-gray-900">
|
|
199
203
|
Back to My quizzes
|
|
200
204
|
</Link>
|
|
@@ -227,7 +231,7 @@ export function QuizPage() {
|
|
|
227
231
|
</form>
|
|
228
232
|
) : (
|
|
229
233
|
<div className="flex min-w-0 items-center gap-2">
|
|
230
|
-
<h1 className="m-0 text-3xl font-bold tracking-tight text-gray-900">{session.title}</h1>
|
|
234
|
+
<h1 className="m-0 min-w-0 break-words text-3xl font-bold tracking-tight text-gray-900">{session.title}</h1>
|
|
231
235
|
<Button type="button" variant="ghost" size="icon-xs" aria-label="Edit quiz title" title="Edit quiz title" onClick={startEditingTitle}>
|
|
232
236
|
<Pencil aria-hidden="true" />
|
|
233
237
|
</Button>
|
|
@@ -289,13 +293,24 @@ export function QuizPage() {
|
|
|
289
293
|
<ol className="mt-3 grid gap-1.5">
|
|
290
294
|
{quizQuestions.map((question, index) => {
|
|
291
295
|
const result = isAnswerCorrect(question, session.answers[question.id]);
|
|
296
|
+
const parts = isPairingQuestion(question) ? gradeAnswer(question, session.answers[question.id]) : null;
|
|
297
|
+
const resultLabel =
|
|
298
|
+
result === null
|
|
299
|
+
? isGradeableQuestion(question)
|
|
300
|
+
? 'Unanswered'
|
|
301
|
+
: 'Written'
|
|
302
|
+
: result
|
|
303
|
+
? 'Correct'
|
|
304
|
+
: parts
|
|
305
|
+
? `Incorrect · ${parts.earned}/${parts.possible}`
|
|
306
|
+
: 'Incorrect';
|
|
292
307
|
return (
|
|
293
308
|
<li key={question.id}>
|
|
294
309
|
<button
|
|
295
310
|
type="button"
|
|
296
311
|
onClick={() => goToQuestion(index)}
|
|
297
312
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-gray-100"
|
|
298
|
-
aria-label={`Review question ${index + 1}: ${question.title} — ${
|
|
313
|
+
aria-label={`Review question ${index + 1}: ${question.title} — ${resultLabel.toLowerCase()}`}
|
|
299
314
|
>
|
|
300
315
|
<span
|
|
301
316
|
aria-hidden="true"
|
|
@@ -309,9 +324,7 @@ export function QuizPage() {
|
|
|
309
324
|
{result === true ? <Check className="size-3" /> : result === false ? <X className="size-3" /> : index + 1}
|
|
310
325
|
</span>
|
|
311
326
|
<span className="min-w-0 flex-1 truncate text-gray-900">{question.title}</span>
|
|
312
|
-
<span className="shrink-0 text-xs text-gray-500">
|
|
313
|
-
{result === null ? 'Written' : result ? 'Correct' : 'Incorrect'}
|
|
314
|
-
</span>
|
|
327
|
+
<span className="shrink-0 text-xs text-gray-500">{resultLabel}</span>
|
|
315
328
|
</button>
|
|
316
329
|
</li>
|
|
317
330
|
);
|
|
@@ -320,19 +333,19 @@ export function QuizPage() {
|
|
|
320
333
|
</section>
|
|
321
334
|
)}
|
|
322
335
|
|
|
323
|
-
<section className="rounded-xl border border-gray-200 bg-white p-5">
|
|
336
|
+
<section className="min-w-0 rounded-xl border border-gray-200 bg-white p-5">
|
|
324
337
|
<div className="flex flex-wrap items-center gap-2">
|
|
325
|
-
<h2 className="m-0 flex-1 text-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
|
|
338
|
+
<h2 className="m-0 min-w-0 flex-1 break-words text-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
|
|
326
339
|
<Badge variant="outline">{questionKindLabels[kind]}</Badge>
|
|
327
340
|
<Badge variant="outline" className={questionStatusStyles[activeStatus]}>
|
|
328
341
|
{questionStatusLabels[activeStatus]}
|
|
329
342
|
</Badge>
|
|
330
343
|
</div>
|
|
331
|
-
<p className="mt-1 font-mono text-xs text-gray-500">{activeQuestion.id}</p>
|
|
344
|
+
<p className="mt-1 break-all font-mono text-xs text-gray-500">{activeQuestion.id}</p>
|
|
332
345
|
{activeQuestion.tags.length > 0 && (
|
|
333
346
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
334
347
|
{activeQuestion.tags.map((tag) => (
|
|
335
|
-
<Badge key={tag} variant="secondary">
|
|
348
|
+
<Badge key={tag} variant="secondary" className="max-w-full shrink whitespace-normal break-words">
|
|
336
349
|
{tag}
|
|
337
350
|
</Badge>
|
|
338
351
|
))}
|
|
@@ -345,7 +358,7 @@ export function QuizPage() {
|
|
|
345
358
|
<p id="quiz-choices-label" className="text-sm font-semibold text-gray-900">
|
|
346
359
|
{kind === 'single' ? 'Choose one answer' : 'Choose all that apply'}
|
|
347
360
|
</p>
|
|
348
|
-
<div role={kind === 'single' ? 'radiogroup' : 'group'} aria-labelledby="quiz-choices-label" className="mt-2 grid gap-2">
|
|
361
|
+
<div role={kind === 'single' ? 'radiogroup' : 'group'} aria-labelledby="quiz-choices-label" className="mt-2 grid grid-cols-[minmax(0,1fr)] gap-2">
|
|
349
362
|
{(activeQuestion.options ?? []).map((option, index) => {
|
|
350
363
|
const selected = selectedIds.includes(option.id);
|
|
351
364
|
const isCorrectOption = correctIds.includes(option.id);
|
|
@@ -385,7 +398,7 @@ export function QuizPage() {
|
|
|
385
398
|
>
|
|
386
399
|
{optionLetter(index)}
|
|
387
400
|
</span>
|
|
388
|
-
<MarkdownContent content={option.text} className="min-w-0 flex-1 text-base leading-7" />
|
|
401
|
+
<MarkdownContent content={option.text} className="min-w-0 flex-1 overflow-x-auto text-base leading-7" />
|
|
389
402
|
{showCorrectness && isCorrectOption && (
|
|
390
403
|
<Badge variant="outline" className="shrink-0 border-green-600 bg-white text-green-800">
|
|
391
404
|
Correct
|
|
@@ -401,6 +414,39 @@ export function QuizPage() {
|
|
|
401
414
|
})}
|
|
402
415
|
</div>
|
|
403
416
|
|
|
417
|
+
</div>
|
|
418
|
+
) : kind === 'numerical' ? (
|
|
419
|
+
<NumericalResponse
|
|
420
|
+
question={activeQuestion}
|
|
421
|
+
answer={answer}
|
|
422
|
+
checked={isChecked}
|
|
423
|
+
correct={correctness}
|
|
424
|
+
onChange={(value) => setGradedAnswer(activeQuestion.id, value)}
|
|
425
|
+
/>
|
|
426
|
+
) : isPairingQuestion(activeQuestion) ? (
|
|
427
|
+
<PairingResponse
|
|
428
|
+
question={activeQuestion}
|
|
429
|
+
answer={answer}
|
|
430
|
+
checked={isChecked}
|
|
431
|
+
onChange={(value) => setGradedAnswer(activeQuestion.id, value)}
|
|
432
|
+
/>
|
|
433
|
+
) : (
|
|
434
|
+
<div className="mt-6">
|
|
435
|
+
<label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
|
|
436
|
+
Your answer
|
|
437
|
+
</label>
|
|
438
|
+
<textarea
|
|
439
|
+
id="quiz-answer"
|
|
440
|
+
value={typeof answer === 'string' ? answer : ''}
|
|
441
|
+
onChange={(event) => updateWrittenAnswer(event.target.value)}
|
|
442
|
+
placeholder="Write your working or notes here..."
|
|
443
|
+
className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-gray-300 bg-white p-3 text-base leading-relaxed text-gray-900 outline-none placeholder:text-gray-400"
|
|
444
|
+
/>
|
|
445
|
+
</div>
|
|
446
|
+
)}
|
|
447
|
+
|
|
448
|
+
{gradeable && (
|
|
449
|
+
<div>
|
|
404
450
|
{isChecked && correctness !== null && (
|
|
405
451
|
<div
|
|
406
452
|
role="status"
|
|
@@ -411,12 +457,16 @@ export function QuizPage() {
|
|
|
411
457
|
>
|
|
412
458
|
{correctness
|
|
413
459
|
? 'Correct — nice work.'
|
|
414
|
-
:
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
460
|
+
: kind === 'numerical'
|
|
461
|
+
? `Not quite. Expected ${formatNumericAnswer(activeQuestion)}.`
|
|
462
|
+
: isPairingQuestion(activeQuestion) && grade
|
|
463
|
+
? `Not quite — ${grade.earned} of ${grade.possible} correct. Corrections are shown on each ${kind === 'matching' ? 'item' : 'blank'}.`
|
|
464
|
+
: `Not quite. Correct answer${correctIds.length > 1 ? 's' : ''}: ${correctIds
|
|
465
|
+
.map((id) => {
|
|
466
|
+
const index = (activeQuestion.options ?? []).findIndex((o) => o.id === id);
|
|
467
|
+
return index >= 0 ? optionLetter(index) : id;
|
|
468
|
+
})
|
|
469
|
+
.join(', ')}`}
|
|
420
470
|
</div>
|
|
421
471
|
)}
|
|
422
472
|
|
|
@@ -436,24 +486,11 @@ export function QuizPage() {
|
|
|
436
486
|
)}
|
|
437
487
|
</div>
|
|
438
488
|
</div>
|
|
439
|
-
) : (
|
|
440
|
-
<div className="mt-6">
|
|
441
|
-
<label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
|
|
442
|
-
Your answer
|
|
443
|
-
</label>
|
|
444
|
-
<textarea
|
|
445
|
-
id="quiz-answer"
|
|
446
|
-
value={typeof answer === 'string' ? answer : ''}
|
|
447
|
-
onChange={(event) => updateWrittenAnswer(event.target.value)}
|
|
448
|
-
placeholder="Write your working or notes here..."
|
|
449
|
-
className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-gray-300 bg-white p-3 text-base leading-relaxed text-gray-900 outline-none placeholder:text-gray-400"
|
|
450
|
-
/>
|
|
451
|
-
</div>
|
|
452
489
|
)}
|
|
453
490
|
|
|
454
|
-
{suggestedAnswer && (
|
|
491
|
+
{suggestedAnswer && (gradeable ? isSuggestedAnswerShown : true) && (
|
|
455
492
|
<div className="mt-5">
|
|
456
|
-
{!
|
|
493
|
+
{!gradeable && (
|
|
457
494
|
<Button
|
|
458
495
|
variant="outline"
|
|
459
496
|
aria-expanded={isSuggestedAnswerShown}
|
|
@@ -466,7 +503,7 @@ export function QuizPage() {
|
|
|
466
503
|
{isSuggestedAnswerShown && (
|
|
467
504
|
<section id="suggested-answer" className="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
468
505
|
<h3 className="m-0 text-base font-semibold text-gray-900">
|
|
469
|
-
{
|
|
506
|
+
{gradeable ? 'Explanation' : 'Suggested answer'}
|
|
470
507
|
</h3>
|
|
471
508
|
<MarkdownContent content={suggestedAnswer} className="mt-3 text-sm leading-relaxed text-gray-800" />
|
|
472
509
|
</section>
|
|
@@ -40,7 +40,7 @@ export const saveProgress = mutation({
|
|
|
40
40
|
args: {
|
|
41
41
|
quizId: v.id('quizzes'),
|
|
42
42
|
title: v.string(),
|
|
43
|
-
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
|
|
43
|
+
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
|
|
44
44
|
currentQuestion: v.number(),
|
|
45
45
|
completed: v.boolean(),
|
|
46
46
|
},
|
|
@@ -53,7 +53,7 @@ export const setQuestions = mutation({
|
|
|
53
53
|
args: {
|
|
54
54
|
quizId: v.id('quizzes'),
|
|
55
55
|
questionIds: v.array(v.string()),
|
|
56
|
-
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
|
|
56
|
+
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
|
|
57
57
|
currentQuestion: v.number(),
|
|
58
58
|
},
|
|
59
59
|
handler: async (ctx, { quizId, questionIds, answers, currentQuestion }) => {
|
|
@@ -5,7 +5,7 @@ export default defineSchema({
|
|
|
5
5
|
quizzes: defineTable({
|
|
6
6
|
title: v.string(),
|
|
7
7
|
questionIds: v.array(v.string()),
|
|
8
|
-
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
|
|
8
|
+
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
|
|
9
9
|
currentQuestion: v.number(),
|
|
10
10
|
completed: v.boolean(),
|
|
11
11
|
createdAt: v.number(),
|
package/framework/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { QuizList } from './components/quiz-list.tsx';
|
|
|
7
7
|
export { AppShell } from './components/app-shell.tsx';
|
|
8
8
|
export { FilterBuilder } from './components/filter-builder.tsx';
|
|
9
9
|
export { MarkdownContent } from './components/markdown-content.tsx';
|
|
10
|
+
export { NumericalResponse, PairingResponse, formatNumericAnswer } from './components/question-response.tsx';
|
|
10
11
|
|
|
11
12
|
export {
|
|
12
13
|
createFilterFields,
|
|
@@ -358,7 +358,7 @@ export function matchesFilters(
|
|
|
358
358
|
: active.some((rule) => matchesRule(rule, fields, fieldDefs, question, status));
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
-
/** Free-text search across id/title/prompt/tags/options plus `searchable` custom fields. */
|
|
361
|
+
/** Free-text search across id/title/prompt/tags/options/items/blanks plus `searchable` custom fields. */
|
|
362
362
|
export function matchesSearch(
|
|
363
363
|
question: BaseQuestion,
|
|
364
364
|
query: string,
|
|
@@ -372,6 +372,8 @@ export function matchesSearch(
|
|
|
372
372
|
question.prompt,
|
|
373
373
|
question.tags.join(' '),
|
|
374
374
|
(question.options ?? []).map((o) => o.text).join(' '),
|
|
375
|
+
(question.items ?? []).map((item) => item.text).join(' '),
|
|
376
|
+
(question.blanks ?? []).map((blank) => [blank.label ?? '', ...blank.options.map((o) => o.text)].join(' ')).join(' '),
|
|
375
377
|
];
|
|
376
378
|
for (const def of fieldDefs) {
|
|
377
379
|
if (!def.searchable) continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "question-bank-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Framework-style question bank: TanStack Start + TanStack Table grid, quizzes, and filter DSL. Bring your own bank.config.ts + content/. Eject any framework/ file with `qb reveal` (Vike-style).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|