question-bank-framework 0.3.0 → 0.5.0
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 +126 -38
- 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 gap-x-6 gap-y-1 p-0 sm:grid-cols-2">
|
|
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" />
|
|
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 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 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>
|
|
@@ -4,12 +4,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
4
4
|
import { Link, useParams } from '@tanstack/react-router';
|
|
5
5
|
import { Check, Pencil, X } from 'lucide-react';
|
|
6
6
|
import { useBank } from '../../core/config.tsx';
|
|
7
|
-
import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
|
|
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,11 +22,23 @@ 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';
|
|
22
|
-
import {
|
|
25
|
+
import { formatNumericAnswer, NumericalResponse, optionLetter, PairingResponse } from './question-response';
|
|
26
|
+
import { setQuestionStatus, getQuestionStatuses } from '../lib/question-status-storage.ts';
|
|
27
|
+
import { questionStatusLabels } from '../lib/question-filter.ts';
|
|
23
28
|
import { getQuiz, saveQuizLocally, saveQuizProgress } from '../lib/quiz-storage.ts';
|
|
24
29
|
import { cn } from '../lib/utils.ts';
|
|
25
30
|
|
|
26
|
-
const
|
|
31
|
+
const questionStatusStyles: Record<QuestionStatus, string> = {
|
|
32
|
+
unanswered: 'border-gray-300 bg-gray-50 text-gray-700',
|
|
33
|
+
revisit: 'border-amber-300 bg-amber-50 text-amber-900',
|
|
34
|
+
complete: 'border-green-300 bg-green-50 text-green-800',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const questionStatusDotStyles: Record<QuestionStatus, string> = {
|
|
38
|
+
unanswered: 'border-gray-300 bg-white text-gray-600 hover:border-gray-500',
|
|
39
|
+
revisit: 'border-amber-400 bg-amber-100 text-amber-900 hover:border-amber-600',
|
|
40
|
+
complete: 'border-green-500 bg-green-100 text-green-900 hover:border-green-700',
|
|
41
|
+
};
|
|
27
42
|
|
|
28
43
|
export function QuizPage() {
|
|
29
44
|
const bank = useBank();
|
|
@@ -35,6 +50,7 @@ export function QuizPage() {
|
|
|
35
50
|
const [checked, setChecked] = useState<Record<string, boolean>>({});
|
|
36
51
|
const [editingTitle, setEditingTitle] = useState(false);
|
|
37
52
|
const [titleDraft, setTitleDraft] = useState('');
|
|
53
|
+
const [statusMap, setStatusMap] = useState<QuestionStatusMap>({});
|
|
38
54
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
|
39
55
|
const quizQuestions = useMemo(
|
|
40
56
|
() =>
|
|
@@ -53,6 +69,9 @@ export function QuizPage() {
|
|
|
53
69
|
}).finally(() => {
|
|
54
70
|
if (active) setLoading(false);
|
|
55
71
|
});
|
|
72
|
+
getQuestionStatuses(bank.namespace).then((statuses) => {
|
|
73
|
+
if (active) setStatusMap(statuses);
|
|
74
|
+
});
|
|
56
75
|
return () => {
|
|
57
76
|
active = false;
|
|
58
77
|
};
|
|
@@ -94,7 +113,7 @@ export function QuizPage() {
|
|
|
94
113
|
);
|
|
95
114
|
};
|
|
96
115
|
|
|
97
|
-
const
|
|
116
|
+
const setGradedAnswer = (questionId: string, value: QuizAnswer) => {
|
|
98
117
|
setChecked((current) => (current[questionId] ? { ...current, [questionId]: false } : current));
|
|
99
118
|
setSession((current) =>
|
|
100
119
|
current ? { ...current, completed: false, answers: { ...current.answers, [questionId]: value } } : current,
|
|
@@ -105,9 +124,9 @@ export function QuizPage() {
|
|
|
105
124
|
if (!activeQuestion) return;
|
|
106
125
|
const current = toSelectedIds(activeQuestion, session?.answers[questionId]);
|
|
107
126
|
if (single) {
|
|
108
|
-
|
|
127
|
+
setGradedAnswer(questionId, current[0] === optionId ? [] : [optionId]);
|
|
109
128
|
} else {
|
|
110
|
-
|
|
129
|
+
setGradedAnswer(
|
|
111
130
|
questionId,
|
|
112
131
|
current.includes(optionId) ? current.filter((id) => id !== optionId) : [...current, optionId],
|
|
113
132
|
);
|
|
@@ -123,6 +142,7 @@ export function QuizPage() {
|
|
|
123
142
|
const updateQuestionStatus = (status: QuestionStatus) => {
|
|
124
143
|
if (!activeQuestion) return;
|
|
125
144
|
void setQuestionStatus(bank.namespace, activeQuestion.id, status);
|
|
145
|
+
setStatusMap((current) => ({ ...current, [activeQuestion.id]: status }));
|
|
126
146
|
setShownAnswerId(null);
|
|
127
147
|
setSession((current) =>
|
|
128
148
|
current
|
|
@@ -166,16 +186,19 @@ export function QuizPage() {
|
|
|
166
186
|
const suggestedAnswer = bank.answers[activeQuestion.id];
|
|
167
187
|
const isSuggestedAnswerShown = shownAnswerId === activeQuestion.id;
|
|
168
188
|
const choice = isChoiceQuestion(activeQuestion);
|
|
189
|
+
const gradeable = isGradeableQuestion(activeQuestion);
|
|
169
190
|
const kind = questionKind(activeQuestion);
|
|
170
191
|
const selectedIds = toSelectedIds(activeQuestion, answer);
|
|
171
192
|
const hasSelection = isAnswerProvided(activeQuestion, answer);
|
|
172
193
|
const isChecked = checked[activeQuestion.id] === true;
|
|
173
194
|
const correctness = isChecked ? isAnswerCorrect(activeQuestion, answer) : null;
|
|
195
|
+
const grade = isChecked ? gradeAnswer(activeQuestion, answer) : null;
|
|
174
196
|
const correctIds = getCorrectIds(activeQuestion);
|
|
197
|
+
const activeStatus: QuestionStatus = statusMap[activeQuestion.id] ?? 'unanswered';
|
|
175
198
|
const score = scoreAnswers(quizQuestions, session.answers);
|
|
176
199
|
|
|
177
200
|
return (
|
|
178
|
-
<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">
|
|
179
202
|
<Link to="/quizzes" className="text-sm font-semibold text-gray-600 hover:text-gray-900">
|
|
180
203
|
Back to My quizzes
|
|
181
204
|
</Link>
|
|
@@ -208,7 +231,7 @@ export function QuizPage() {
|
|
|
208
231
|
</form>
|
|
209
232
|
) : (
|
|
210
233
|
<div className="flex min-w-0 items-center gap-2">
|
|
211
|
-
<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>
|
|
212
235
|
<Button type="button" variant="ghost" size="icon-xs" aria-label="Edit quiz title" title="Edit quiz title" onClick={startEditingTitle}>
|
|
213
236
|
<Pencil aria-hidden="true" />
|
|
214
237
|
</Button>
|
|
@@ -233,6 +256,35 @@ export function QuizPage() {
|
|
|
233
256
|
)}
|
|
234
257
|
</div>
|
|
235
258
|
|
|
259
|
+
{quizQuestions.length > 1 && (
|
|
260
|
+
<nav aria-label="Questions in this quiz">
|
|
261
|
+
<ol className="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
|
262
|
+
{quizQuestions.map((question, index) => {
|
|
263
|
+
const status = statusMap[question.id] ?? 'unanswered';
|
|
264
|
+
const isActive = index === activeIndex;
|
|
265
|
+
return (
|
|
266
|
+
<li key={question.id}>
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
onClick={() => goToQuestion(index)}
|
|
270
|
+
aria-label={`Go to question ${index + 1}: ${question.title} — ${questionStatusLabels[status]}${isActive ? ' (current question)' : ''}`}
|
|
271
|
+
aria-current={isActive ? 'true' : undefined}
|
|
272
|
+
title={`${question.title} — ${questionStatusLabels[status]}`}
|
|
273
|
+
className={cn(
|
|
274
|
+
'flex size-8 items-center justify-center rounded-md border text-xs font-semibold tabular-nums transition-colors',
|
|
275
|
+
questionStatusDotStyles[status],
|
|
276
|
+
isActive && 'ring-2 ring-gray-900 ring-offset-2',
|
|
277
|
+
)}
|
|
278
|
+
>
|
|
279
|
+
{index + 1}
|
|
280
|
+
</button>
|
|
281
|
+
</li>
|
|
282
|
+
);
|
|
283
|
+
})}
|
|
284
|
+
</ol>
|
|
285
|
+
</nav>
|
|
286
|
+
)}
|
|
287
|
+
|
|
236
288
|
{session.completed && score.gradeable > 0 && (
|
|
237
289
|
<section aria-label="Quiz results" className="rounded-xl border border-gray-200 bg-gray-50 p-5">
|
|
238
290
|
<h2 className="m-0 text-lg font-semibold text-gray-900">
|
|
@@ -241,13 +293,24 @@ export function QuizPage() {
|
|
|
241
293
|
<ol className="mt-3 grid gap-1.5">
|
|
242
294
|
{quizQuestions.map((question, index) => {
|
|
243
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';
|
|
244
307
|
return (
|
|
245
308
|
<li key={question.id}>
|
|
246
309
|
<button
|
|
247
310
|
type="button"
|
|
248
311
|
onClick={() => goToQuestion(index)}
|
|
249
312
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-gray-100"
|
|
250
|
-
aria-label={`Review question ${index + 1}: ${question.title} — ${
|
|
313
|
+
aria-label={`Review question ${index + 1}: ${question.title} — ${resultLabel.toLowerCase()}`}
|
|
251
314
|
>
|
|
252
315
|
<span
|
|
253
316
|
aria-hidden="true"
|
|
@@ -261,9 +324,7 @@ export function QuizPage() {
|
|
|
261
324
|
{result === true ? <Check className="size-3" /> : result === false ? <X className="size-3" /> : index + 1}
|
|
262
325
|
</span>
|
|
263
326
|
<span className="min-w-0 flex-1 truncate text-gray-900">{question.title}</span>
|
|
264
|
-
<span className="shrink-0 text-xs text-gray-500">
|
|
265
|
-
{result === null ? 'Written' : result ? 'Correct' : 'Incorrect'}
|
|
266
|
-
</span>
|
|
327
|
+
<span className="shrink-0 text-xs text-gray-500">{resultLabel}</span>
|
|
267
328
|
</button>
|
|
268
329
|
</li>
|
|
269
330
|
);
|
|
@@ -272,16 +333,19 @@ export function QuizPage() {
|
|
|
272
333
|
</section>
|
|
273
334
|
)}
|
|
274
335
|
|
|
275
|
-
<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">
|
|
276
337
|
<div className="flex flex-wrap items-center gap-2">
|
|
277
|
-
<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>
|
|
278
339
|
<Badge variant="outline">{questionKindLabels[kind]}</Badge>
|
|
340
|
+
<Badge variant="outline" className={questionStatusStyles[activeStatus]}>
|
|
341
|
+
{questionStatusLabels[activeStatus]}
|
|
342
|
+
</Badge>
|
|
279
343
|
</div>
|
|
280
|
-
<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>
|
|
281
345
|
{activeQuestion.tags.length > 0 && (
|
|
282
346
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
283
347
|
{activeQuestion.tags.map((tag) => (
|
|
284
|
-
<Badge key={tag} variant="secondary">
|
|
348
|
+
<Badge key={tag} variant="secondary" className="max-w-full shrink whitespace-normal break-words">
|
|
285
349
|
{tag}
|
|
286
350
|
</Badge>
|
|
287
351
|
))}
|
|
@@ -350,6 +414,39 @@ export function QuizPage() {
|
|
|
350
414
|
})}
|
|
351
415
|
</div>
|
|
352
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>
|
|
353
450
|
{isChecked && correctness !== null && (
|
|
354
451
|
<div
|
|
355
452
|
role="status"
|
|
@@ -360,12 +457,16 @@ export function QuizPage() {
|
|
|
360
457
|
>
|
|
361
458
|
{correctness
|
|
362
459
|
? 'Correct — nice work.'
|
|
363
|
-
:
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
368
|
-
|
|
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(', ')}`}
|
|
369
470
|
</div>
|
|
370
471
|
)}
|
|
371
472
|
|
|
@@ -385,24 +486,11 @@ export function QuizPage() {
|
|
|
385
486
|
)}
|
|
386
487
|
</div>
|
|
387
488
|
</div>
|
|
388
|
-
) : (
|
|
389
|
-
<div className="mt-6">
|
|
390
|
-
<label htmlFor="quiz-answer" className="text-sm font-semibold text-gray-900">
|
|
391
|
-
Your answer
|
|
392
|
-
</label>
|
|
393
|
-
<textarea
|
|
394
|
-
id="quiz-answer"
|
|
395
|
-
value={typeof answer === 'string' ? answer : ''}
|
|
396
|
-
onChange={(event) => updateWrittenAnswer(event.target.value)}
|
|
397
|
-
placeholder="Write your working or notes here..."
|
|
398
|
-
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"
|
|
399
|
-
/>
|
|
400
|
-
</div>
|
|
401
489
|
)}
|
|
402
490
|
|
|
403
|
-
{suggestedAnswer && (
|
|
491
|
+
{suggestedAnswer && (gradeable ? isSuggestedAnswerShown : true) && (
|
|
404
492
|
<div className="mt-5">
|
|
405
|
-
{!
|
|
493
|
+
{!gradeable && (
|
|
406
494
|
<Button
|
|
407
495
|
variant="outline"
|
|
408
496
|
aria-expanded={isSuggestedAnswerShown}
|
|
@@ -415,7 +503,7 @@ export function QuizPage() {
|
|
|
415
503
|
{isSuggestedAnswerShown && (
|
|
416
504
|
<section id="suggested-answer" className="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
417
505
|
<h3 className="m-0 text-base font-semibold text-gray-900">
|
|
418
|
-
{
|
|
506
|
+
{gradeable ? 'Explanation' : 'Suggested answer'}
|
|
419
507
|
</h3>
|
|
420
508
|
<MarkdownContent content={suggestedAnswer} className="mt-3 text-sm leading-relaxed text-gray-800" />
|
|
421
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.0",
|
|
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",
|