question-bank-framework 0.2.0 → 0.4.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 +14 -0
- package/core/config.tsx +14 -0
- package/core/index.ts +19 -1
- package/core/types.ts +153 -1
- package/framework/components/question-bank.tsx +181 -25
- package/framework/components/quiz-list.tsx +52 -16
- package/framework/components/quiz-page.tsx +298 -33
- package/framework/components/quiz-question-editor.tsx +171 -0
- package/framework/convex/quizzes.ts +13 -1
- package/framework/convex/schema.ts +1 -1
- package/framework/lib/convex-api.ts +9 -2
- package/framework/lib/question-filter.ts +25 -7
- package/framework/lib/quiz-storage.ts +22 -0
- package/framework/styles/theme.css +58 -0
- package/package.json +1 -2
- package/template/content/answers.ts +2 -0
- package/template/content/questions.json +41 -0
- package/template/src/routes/styles.css +7 -0
package/README.md
CHANGED
|
@@ -24,6 +24,20 @@ Your app owns:
|
|
|
24
24
|
- `content/questions.json`, `content/answers.ts`, `content/figures/`
|
|
25
25
|
- Thin TanStack Start routes in `src/routes/` (see `template/src/routes/`)
|
|
26
26
|
wrapping `<BankProvider bank={bank}>` + `<AppShell brand>` + framework pages.
|
|
27
|
+
- `src/routes/styles.css` (scaffolded by `qb init`, imported as `?url` from
|
|
28
|
+
`__root.tsx`). It must list the framework as a Tailwind source — v4 ignores
|
|
29
|
+
`node_modules` by default, so without this none of the grid/badge/filter
|
|
30
|
+
utilities are generated and the app renders unstyled:
|
|
31
|
+
|
|
32
|
+
```css
|
|
33
|
+
@import 'tailwindcss';
|
|
34
|
+
@import 'question-bank-framework/framework/styles/theme.css';
|
|
35
|
+
@source '../node_modules/question-bank-framework'; /* relative to this file */
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Pass `hideTagsColumn: true` to `defineBank` when tags merely duplicate
|
|
39
|
+
dedicated fields (every tag equals a topic/type value already in its own
|
|
40
|
+
column). Tags still feed free-text search.
|
|
27
41
|
|
|
28
42
|
## Reveal (eject) files
|
|
29
43
|
|
package/core/config.tsx
CHANGED
|
@@ -31,8 +31,22 @@ export type {
|
|
|
31
31
|
BankSiteConfig,
|
|
32
32
|
BaseQuestion,
|
|
33
33
|
DefineBankInput,
|
|
34
|
+
QuestionKind,
|
|
35
|
+
QuestionOption,
|
|
34
36
|
QuestionStatus,
|
|
35
37
|
QuestionStatusMap,
|
|
38
|
+
QuizAnswer,
|
|
36
39
|
QuizSession,
|
|
37
40
|
} from './types.ts';
|
|
41
|
+
export {
|
|
42
|
+
getCorrectIds,
|
|
43
|
+
isAnswerCorrect,
|
|
44
|
+
isAnswerProvided,
|
|
45
|
+
isChoiceQuestion,
|
|
46
|
+
questionKind,
|
|
47
|
+
questionKindLabels,
|
|
48
|
+
questionKinds,
|
|
49
|
+
scoreAnswers,
|
|
50
|
+
toSelectedIds,
|
|
51
|
+
} from './types.ts';
|
|
38
52
|
export { questionStatuses } from './types.ts';
|
package/core/index.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
// Re-export the stable core: types + BankContext/useBank/defineBank.
|
|
2
2
|
// Framework code (ejectable) lives under framework/.
|
|
3
3
|
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
BankContext,
|
|
6
|
+
BankProvider,
|
|
7
|
+
useBank,
|
|
8
|
+
defineBank,
|
|
9
|
+
getCorrectIds,
|
|
10
|
+
isAnswerCorrect,
|
|
11
|
+
isAnswerProvided,
|
|
12
|
+
isChoiceQuestion,
|
|
13
|
+
questionKind,
|
|
14
|
+
questionKindLabels,
|
|
15
|
+
questionKinds,
|
|
16
|
+
questionStatuses,
|
|
17
|
+
scoreAnswers,
|
|
18
|
+
toSelectedIds,
|
|
19
|
+
} from './config.tsx';
|
|
5
20
|
export type {
|
|
6
21
|
Bank,
|
|
7
22
|
BankFieldDef,
|
|
@@ -11,7 +26,10 @@ export type {
|
|
|
11
26
|
BankSiteConfig,
|
|
12
27
|
BaseQuestion,
|
|
13
28
|
DefineBankInput,
|
|
29
|
+
QuestionKind,
|
|
30
|
+
QuestionOption,
|
|
14
31
|
QuestionStatus,
|
|
15
32
|
QuestionStatusMap,
|
|
33
|
+
QuizAnswer,
|
|
16
34
|
QuizSession,
|
|
17
35
|
} from './config.tsx';
|
package/core/types.ts
CHANGED
|
@@ -8,11 +8,41 @@ export const questionStatuses = ['unanswered', 'revisit', 'complete'] as const;
|
|
|
8
8
|
export type QuestionStatus = (typeof questionStatuses)[number];
|
|
9
9
|
export type QuestionStatusMap = Record<string, QuestionStatus>;
|
|
10
10
|
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Question kinds: written (free response) vs choice (auto-gradeable).
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export type QuestionKind = 'written' | 'single' | 'multiple';
|
|
16
|
+
|
|
17
|
+
export const questionKinds: readonly QuestionKind[] = ['written', 'single', 'multiple'];
|
|
18
|
+
|
|
19
|
+
export const questionKindLabels: Record<QuestionKind, string> = {
|
|
20
|
+
written: 'Written',
|
|
21
|
+
single: 'Single choice',
|
|
22
|
+
multiple: 'Multiple choice',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface QuestionOption {
|
|
26
|
+
/** Stable within the question, e.g. "a", "b", "c". */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Option body. Markdown supported. */
|
|
29
|
+
text: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Written answers are free text; choice answers are selected option ids. */
|
|
33
|
+
export type QuizAnswer = string | string[];
|
|
34
|
+
|
|
11
35
|
export interface BaseQuestion {
|
|
12
36
|
id: string;
|
|
13
37
|
title: string;
|
|
14
38
|
prompt: string;
|
|
15
39
|
tags: string[];
|
|
40
|
+
/** Defaults to 'written' when omitted. Choice kinds require options + correctIds. */
|
|
41
|
+
kind?: QuestionKind;
|
|
42
|
+
/** Choice options. Required for single/multiple, forbidden for written. */
|
|
43
|
+
options?: QuestionOption[];
|
|
44
|
+
/** Correct option ids. Single choice requires exactly one. */
|
|
45
|
+
correctIds?: string[];
|
|
16
46
|
// Subjects extend this interface with their own fields; see template/bank.config.ts.
|
|
17
47
|
}
|
|
18
48
|
|
|
@@ -20,7 +50,7 @@ export interface QuizSession {
|
|
|
20
50
|
id: string;
|
|
21
51
|
title: string;
|
|
22
52
|
questionIds: string[];
|
|
23
|
-
answers: Record<string,
|
|
53
|
+
answers: Record<string, QuizAnswer>;
|
|
24
54
|
currentQuestion: number;
|
|
25
55
|
completed: boolean;
|
|
26
56
|
createdAt: number;
|
|
@@ -99,6 +129,13 @@ export interface DefineBankInput<Q extends BaseQuestion = BaseQuestion> {
|
|
|
99
129
|
answers?: Record<string, string>;
|
|
100
130
|
/** Default sort for the grid, e.g. [{ id: "exam", desc: true }]. */
|
|
101
131
|
defaultSorting?: Array<{ id: string; desc?: boolean }>;
|
|
132
|
+
/**
|
|
133
|
+
* Hide the built-in Tags column (and the Tags filter option). Useful when
|
|
134
|
+
* tags merely duplicate dedicated fields — e.g. every tag equals a
|
|
135
|
+
* topic/type value already shown in its own column. Tags still feed
|
|
136
|
+
* free-text search. Defaults to false.
|
|
137
|
+
*/
|
|
138
|
+
hideTagsColumn?: boolean;
|
|
102
139
|
/** Extra per-question figures, resolved by prompt `` etc. */
|
|
103
140
|
publicPath?: string;
|
|
104
141
|
}
|
|
@@ -114,9 +151,124 @@ export interface Bank extends DefineBankInput<BaseQuestion> {
|
|
|
114
151
|
* access, no casts or coercion helpers.
|
|
115
152
|
*/
|
|
116
153
|
export function defineBank<Q extends BaseQuestion>(input: DefineBankInput<Q>): Bank {
|
|
154
|
+
for (const question of input.questions) {
|
|
155
|
+
validateQuestion(question);
|
|
156
|
+
}
|
|
117
157
|
return {
|
|
118
158
|
...input,
|
|
119
159
|
answers: input.answers ?? {},
|
|
120
160
|
questionById: new Map(input.questions.map((q) => [q.id, q])),
|
|
121
161
|
};
|
|
122
162
|
}
|
|
163
|
+
|
|
164
|
+
function validateQuestion(question: BaseQuestion): void {
|
|
165
|
+
const kind: QuestionKind = question.kind ?? 'written';
|
|
166
|
+
if (!questionKinds.includes(kind)) {
|
|
167
|
+
throw new Error(`Question "${question.id}" has unknown kind "${question.kind}".`);
|
|
168
|
+
}
|
|
169
|
+
if (kind === 'written') {
|
|
170
|
+
if (question.options !== undefined) {
|
|
171
|
+
throw new Error(`Question "${question.id}" is written and must not define options.`);
|
|
172
|
+
}
|
|
173
|
+
if (question.correctIds !== undefined) {
|
|
174
|
+
throw new Error(`Question "${question.id}" is written and must not define correctIds.`);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const options = question.options ?? [];
|
|
179
|
+
if (options.length < 2) {
|
|
180
|
+
throw new Error(`Question "${question.id}" (${kind}) needs at least 2 options.`);
|
|
181
|
+
}
|
|
182
|
+
const seen = new Set<string>();
|
|
183
|
+
for (const option of options) {
|
|
184
|
+
if (!option.id || seen.has(option.id)) {
|
|
185
|
+
throw new Error(`Question "${question.id}" has a duplicate or empty option id "${option.id}".`);
|
|
186
|
+
}
|
|
187
|
+
seen.add(option.id);
|
|
188
|
+
}
|
|
189
|
+
const correctIds = question.correctIds ?? [];
|
|
190
|
+
if (kind === 'single' && correctIds.length !== 1) {
|
|
191
|
+
throw new Error(`Question "${question.id}" (single) needs exactly one correctId.`);
|
|
192
|
+
}
|
|
193
|
+
if (kind === 'multiple' && correctIds.length < 1) {
|
|
194
|
+
throw new Error(`Question "${question.id}" (multiple) needs at least one correctId.`);
|
|
195
|
+
}
|
|
196
|
+
for (const id of correctIds) {
|
|
197
|
+
if (!seen.has(id)) {
|
|
198
|
+
throw new Error(`Question "${question.id}" has unknown correctId "${id}".`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Choice helpers. All UI + scoring goes through these so option storage
|
|
205
|
+
// (ids) stays decoupled from presentation (letters, markdown).
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/** Effective kind: questions without `kind` are written. */
|
|
209
|
+
export function questionKind(question: BaseQuestion): QuestionKind {
|
|
210
|
+
return question.kind ?? 'written';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function isChoiceQuestion(question: BaseQuestion): boolean {
|
|
214
|
+
const kind = questionKind(question);
|
|
215
|
+
return kind === 'single' || kind === 'multiple';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Correct option ids. Empty for written questions. */
|
|
219
|
+
export function getCorrectIds(question: BaseQuestion): string[] {
|
|
220
|
+
return isChoiceQuestion(question) ? [...(question.correctIds ?? [])] : [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Normalize a stored answer to selected option ids — tolerates legacy strings. */
|
|
224
|
+
export function toSelectedIds(question: BaseQuestion, answer: QuizAnswer | undefined): string[] {
|
|
225
|
+
if (answer === undefined) return [];
|
|
226
|
+
const ids =
|
|
227
|
+
typeof answer === 'string' ? (answer === '' ? [] : [answer]) : answer.filter((id) => typeof id === 'string');
|
|
228
|
+
if (!isChoiceQuestion(question)) return ids;
|
|
229
|
+
const valid = new Set((question.options ?? []).map((o) => o.id));
|
|
230
|
+
return ids.filter((id) => valid.has(id));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Whether the user has provided an answer (non-blank text, or ≥1 selection). */
|
|
234
|
+
export function isAnswerProvided(question: BaseQuestion, answer: QuizAnswer | undefined): boolean {
|
|
235
|
+
if (answer === undefined) return false;
|
|
236
|
+
if (typeof answer === 'string') return answer.trim().length > 0;
|
|
237
|
+
if (!isChoiceQuestion(question)) return answer.some((id) => id.trim().length > 0);
|
|
238
|
+
const valid = new Set((question.options ?? []).map((o) => o.id));
|
|
239
|
+
return answer.some((id) => valid.has(id));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Grade a choice answer. Returns null for written questions or when
|
|
244
|
+
* unanswered. Single: exactly the correct id. Multiple: exact set match
|
|
245
|
+
* (order-insensitive, no extras, no omissions).
|
|
246
|
+
*/
|
|
247
|
+
export function isAnswerCorrect(question: BaseQuestion, answer: QuizAnswer | undefined): boolean | null {
|
|
248
|
+
if (!isChoiceQuestion(question)) return null;
|
|
249
|
+
if (!isAnswerProvided(question, answer)) return null;
|
|
250
|
+
const selected = new Set(toSelectedIds(question, answer));
|
|
251
|
+
const correct = new Set(getCorrectIds(question));
|
|
252
|
+
if (selected.size !== correct.size) return false;
|
|
253
|
+
for (const id of selected) {
|
|
254
|
+
if (!correct.has(id)) return false;
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Score a set of questions against answers: { correct, gradeable, answered }. */
|
|
260
|
+
export function scoreAnswers(
|
|
261
|
+
questions: readonly BaseQuestion[],
|
|
262
|
+
answers: Record<string, QuizAnswer>,
|
|
263
|
+
): { correct: number; gradeable: number; answered: number } {
|
|
264
|
+
let correct = 0;
|
|
265
|
+
let gradeable = 0;
|
|
266
|
+
let answered = 0;
|
|
267
|
+
for (const question of questions) {
|
|
268
|
+
if (isAnswerProvided(question, answers[question.id])) answered += 1;
|
|
269
|
+
if (!isChoiceQuestion(question)) continue;
|
|
270
|
+
gradeable += 1;
|
|
271
|
+
if (isAnswerCorrect(question, answers[question.id]) === true) correct += 1;
|
|
272
|
+
}
|
|
273
|
+
return { correct, gradeable, answered };
|
|
274
|
+
}
|
|
@@ -17,14 +17,15 @@ import {
|
|
|
17
17
|
} from '@tanstack/react-table';
|
|
18
18
|
import { Search } from 'lucide-react';
|
|
19
19
|
import { useBank } from '../../core/config.tsx';
|
|
20
|
-
import type { BaseQuestion, QuestionStatus, QuestionStatusMap } from '../../core/types.ts';
|
|
20
|
+
import type { BaseQuestion, QuestionKind, QuestionStatus, QuestionStatusMap, QuizSession } from '../../core/types.ts';
|
|
21
|
+
import { isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
|
|
21
22
|
import { Badge } from './ui/badge';
|
|
22
23
|
import { Button } from './ui/button';
|
|
23
24
|
import { Checkbox } from './ui/checkbox';
|
|
24
25
|
import { Input } from './ui/input';
|
|
25
26
|
import { FilterBuilder } from './filter-builder';
|
|
26
27
|
import { MarkdownContent } from './markdown-content';
|
|
27
|
-
import { Popover, PopoverAnchor, PopoverContent } from './ui/popover';
|
|
28
|
+
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from './ui/popover';
|
|
28
29
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
|
29
30
|
import {
|
|
30
31
|
createFilterFields,
|
|
@@ -36,7 +37,7 @@ import {
|
|
|
36
37
|
import { useQuestionFilterStore } from '../lib/question-filter-store.ts';
|
|
37
38
|
import { cn } from '../lib/utils.ts';
|
|
38
39
|
import { getQuestionStatuses } from '../lib/question-status-storage.ts';
|
|
39
|
-
import { createQuiz } from '../lib/quiz-storage.ts';
|
|
40
|
+
import { createQuiz, listQuizzes, setQuizQuestions } from '../lib/quiz-storage.ts';
|
|
40
41
|
|
|
41
42
|
type DragSelection = {
|
|
42
43
|
value: boolean;
|
|
@@ -59,6 +60,14 @@ const questionStatusStyles: Record<QuestionStatus, string> = {
|
|
|
59
60
|
complete: 'border-green-300 bg-green-50 text-green-800',
|
|
60
61
|
};
|
|
61
62
|
|
|
63
|
+
const questionKindStyles: Record<QuestionKind, string> = {
|
|
64
|
+
written: 'border-gray-300 bg-gray-50 text-gray-700',
|
|
65
|
+
single: 'border-sky-300 bg-sky-50 text-sky-900',
|
|
66
|
+
multiple: 'border-violet-300 bg-violet-50 text-violet-900',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const optionLetter = (index: number): string => String.fromCharCode(65 + index);
|
|
70
|
+
|
|
62
71
|
function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
|
|
63
72
|
const [open, setOpen] = useState(false);
|
|
64
73
|
const openTimeout = useRef<number | null>(null);
|
|
@@ -163,6 +172,18 @@ function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
|
|
|
163
172
|
>
|
|
164
173
|
<div className="px-4 py-3">
|
|
165
174
|
<MarkdownContent content={question.prompt} />
|
|
175
|
+
{isChoiceQuestion(question) && (
|
|
176
|
+
<ol className="mt-3 space-y-1.5">
|
|
177
|
+
{(question.options ?? []).map((option, index) => (
|
|
178
|
+
<li key={option.id} className="flex items-start gap-2 text-sm text-gray-800">
|
|
179
|
+
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-gray-50 text-[11px] font-bold text-gray-600">
|
|
180
|
+
{optionLetter(index)}
|
|
181
|
+
</span>
|
|
182
|
+
<MarkdownContent content={option.text} className="min-w-0 flex-1 text-sm leading-6" />
|
|
183
|
+
</li>
|
|
184
|
+
))}
|
|
185
|
+
</ol>
|
|
186
|
+
)}
|
|
166
187
|
</div>
|
|
167
188
|
</PopoverContent>
|
|
168
189
|
</Popover>
|
|
@@ -193,6 +214,10 @@ export function QuestionBank() {
|
|
|
193
214
|
(bank.defaultSorting ?? [{ id: 'id' }]).map((s) => ({ id: s.id, desc: s.desc ?? false })),
|
|
194
215
|
);
|
|
195
216
|
const [creatingQuiz, setCreatingQuiz] = useState(false);
|
|
217
|
+
const [addToQuizOpen, setAddToQuizOpen] = useState(false);
|
|
218
|
+
const [existingQuizzes, setExistingQuizzes] = useState<QuizSession[] | null>(null);
|
|
219
|
+
const [addingToQuizId, setAddingToQuizId] = useState<string | null>(null);
|
|
220
|
+
const [addToQuizError, setAddToQuizError] = useState(false);
|
|
196
221
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
|
197
222
|
const rowSelectionRef = useRef(rowSelection);
|
|
198
223
|
rowSelectionRef.current = rowSelection;
|
|
@@ -200,7 +225,18 @@ export function QuestionBank() {
|
|
|
200
225
|
const pendingCheckboxChange = useRef<{ rowId: string; value: boolean } | null>(null);
|
|
201
226
|
const suppressRowClick = useRef<string | null>(null);
|
|
202
227
|
|
|
203
|
-
const filterFields = useMemo(
|
|
228
|
+
const filterFields = useMemo(
|
|
229
|
+
() => createFilterFields(bank.questions, bank.fields, bank.hideTagsColumn ?? false),
|
|
230
|
+
[bank],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Size the ID column to the data: fits typical ids on one line, caps
|
|
234
|
+
// monsters (they wrap via break-all). Mono averages ~8.4px/char at
|
|
235
|
+
// text-sm, plus px-4 padding. e.g. econ ≈168, info hits the 320 cap.
|
|
236
|
+
const idWidth = useMemo(() => {
|
|
237
|
+
const longest = bank.questions.reduce((n, q) => Math.max(n, q.id.length), 0);
|
|
238
|
+
return Math.min(320, Math.max(120, Math.ceil(longest * 8.4 + 32)));
|
|
239
|
+
}, [bank.questions]);
|
|
204
240
|
|
|
205
241
|
const handleAddFilter = (field: string) => {
|
|
206
242
|
const type = filterFields.find((f) => f.id === field)?.type ?? 'text';
|
|
@@ -258,7 +294,7 @@ export function QuestionBank() {
|
|
|
258
294
|
</div>
|
|
259
295
|
);
|
|
260
296
|
}
|
|
261
|
-
return <span className="text-sm text-foreground">{formatCellValue(raw)}</span>;
|
|
297
|
+
return <span className="whitespace-normal break-words text-sm text-foreground">{formatCellValue(raw)}</span>;
|
|
262
298
|
},
|
|
263
299
|
enableSorting: sortable,
|
|
264
300
|
size: width,
|
|
@@ -314,28 +350,58 @@ export function QuestionBank() {
|
|
|
314
350
|
{
|
|
315
351
|
accessorKey: 'id',
|
|
316
352
|
header: 'ID',
|
|
317
|
-
cell: ({ row }) =>
|
|
318
|
-
|
|
353
|
+
cell: ({ row }) => (
|
|
354
|
+
<span className="whitespace-normal break-all font-mono text-sm text-muted-foreground">{row.original.id}</span>
|
|
355
|
+
),
|
|
356
|
+
size: idWidth,
|
|
319
357
|
},
|
|
320
358
|
{
|
|
359
|
+
// Sole absorbent column: no fixed size, so it takes up slack on wide
|
|
360
|
+
// screens instead of leaving dead space. Table min-width (below)
|
|
361
|
+
// always reserves 300px for it, so it can never collapse — and text
|
|
362
|
+
// wraps, so it can never paint over neighbours either.
|
|
321
363
|
accessorKey: 'title',
|
|
322
364
|
header: 'Question',
|
|
323
365
|
cell: ({ row }) => <QuestionPreviewPopover question={row.original} />,
|
|
324
|
-
size: 340,
|
|
325
366
|
},
|
|
367
|
+
// Absorbent like title: no fixed size, takes up slack on wide screens.
|
|
368
|
+
// minTableWidth (below) always reserves 200px for it, so it can
|
|
369
|
+
// never collapse to zero the way it did when the fixed columns
|
|
370
|
+
// already summed past the old 1200px floor.
|
|
371
|
+
// Hidden entirely when the bank sets hideTagsColumn (tags that merely
|
|
372
|
+
// duplicate dedicated fields).
|
|
373
|
+
...(bank.hideTagsColumn
|
|
374
|
+
? []
|
|
375
|
+
: [
|
|
376
|
+
{
|
|
377
|
+
id: 'tags',
|
|
378
|
+
accessorFn: (row) => row.tags.join(', '),
|
|
379
|
+
header: 'Tags',
|
|
380
|
+
cell: ({ row }) => (
|
|
381
|
+
<div className="flex min-w-0 flex-wrap gap-1">
|
|
382
|
+
{row.original.tags.map((tag) => (
|
|
383
|
+
<Badge key={tag} variant="secondary" className="min-w-0 whitespace-normal break-words">
|
|
384
|
+
{tag}
|
|
385
|
+
</Badge>
|
|
386
|
+
))}
|
|
387
|
+
</div>
|
|
388
|
+
),
|
|
389
|
+
} satisfies ColumnDef<BaseQuestion>,
|
|
390
|
+
]),
|
|
326
391
|
{
|
|
327
|
-
id: '
|
|
328
|
-
accessorFn: (row) => row
|
|
329
|
-
header: '
|
|
330
|
-
cell: ({ row }) =>
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
392
|
+
id: 'kind',
|
|
393
|
+
accessorFn: (row) => questionKind(row),
|
|
394
|
+
header: 'Type',
|
|
395
|
+
cell: ({ row }) => {
|
|
396
|
+
const kind = questionKind(row.original);
|
|
397
|
+
const count = row.original.options?.length ?? 0;
|
|
398
|
+
return (
|
|
399
|
+
<Badge variant="outline" className={questionKindStyles[kind]} title={count > 0 ? `${count} options` : undefined}>
|
|
400
|
+
{questionKindLabels[kind]}
|
|
401
|
+
</Badge>
|
|
402
|
+
);
|
|
403
|
+
},
|
|
404
|
+
size: 132,
|
|
339
405
|
},
|
|
340
406
|
...customs,
|
|
341
407
|
{
|
|
@@ -353,7 +419,22 @@ export function QuestionBank() {
|
|
|
353
419
|
size: 108,
|
|
354
420
|
},
|
|
355
421
|
];
|
|
356
|
-
}, [bank.fields, questionStatusMap]);
|
|
422
|
+
}, [bank.fields, bank.hideTagsColumn, idWidth, questionStatusMap]);
|
|
423
|
+
|
|
424
|
+
// Floor the table width at the sum of fixed columns plus 300px for the
|
|
425
|
+
// absorbent title and 200px for the absorbent tags, so neither can ever
|
|
426
|
+
// collapse to zero (which squeezed content over neighbouring columns).
|
|
427
|
+
const minTableWidth = useMemo(() => {
|
|
428
|
+
let total = 0;
|
|
429
|
+
for (const column of columns) {
|
|
430
|
+
// ColumnDef inputs use accessorKey instead of id for the id/title columns.
|
|
431
|
+
const key = column.id ?? (column as { accessorKey?: string }).accessorKey;
|
|
432
|
+
if (key === 'title') total += 300;
|
|
433
|
+
else if (key === 'tags') total += 200;
|
|
434
|
+
else total += column.size ?? 150;
|
|
435
|
+
}
|
|
436
|
+
return Math.max(1200, total);
|
|
437
|
+
}, [columns]);
|
|
357
438
|
|
|
358
439
|
const table = useReactTable({
|
|
359
440
|
data: filteredQuestions,
|
|
@@ -512,6 +593,32 @@ export function QuestionBank() {
|
|
|
512
593
|
}
|
|
513
594
|
};
|
|
514
595
|
|
|
596
|
+
const openAddToQuiz = (open: boolean) => {
|
|
597
|
+
setAddToQuizOpen(open);
|
|
598
|
+
if (!open) return;
|
|
599
|
+
setExistingQuizzes(null);
|
|
600
|
+
setAddToQuizError(false);
|
|
601
|
+
listQuizzes(bank.namespace).then(setExistingQuizzes, () => setExistingQuizzes([]));
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const addToQuiz = async (quiz: QuizSession) => {
|
|
605
|
+
const existing = new Set(quiz.questionIds);
|
|
606
|
+
const additions = bank.questions.filter((question) => rowSelection[question.id] && !existing.has(question.id));
|
|
607
|
+
setAddingToQuizId(quiz.id);
|
|
608
|
+
setAddToQuizError(false);
|
|
609
|
+
try {
|
|
610
|
+
if (additions.length > 0) {
|
|
611
|
+
await setQuizQuestions(bank.namespace, quiz, [...quiz.questionIds, ...additions.map((question) => question.id)]);
|
|
612
|
+
}
|
|
613
|
+
setAddToQuizOpen(false);
|
|
614
|
+
await navigate({ to: '/quiz/$quizId', params: { quizId: quiz.id } });
|
|
615
|
+
} catch {
|
|
616
|
+
setAddToQuizError(true);
|
|
617
|
+
} finally {
|
|
618
|
+
setAddingToQuizId(null);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
|
|
515
622
|
return (
|
|
516
623
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
517
624
|
<div className="border-b bg-background px-4 py-3">
|
|
@@ -544,14 +651,16 @@ export function QuestionBank() {
|
|
|
544
651
|
</div>
|
|
545
652
|
</div>
|
|
546
653
|
|
|
547
|
-
<Table containerRef={tableContainerRef} containerClassName="min-h-0 flex-1 overflow-auto" className="
|
|
654
|
+
<Table containerRef={tableContainerRef} containerClassName="min-h-0 flex-1 overflow-auto" className="table-fixed" style={{ minWidth: minTableWidth }}>
|
|
548
655
|
<TableHeader>
|
|
549
656
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
550
657
|
<TableRow key={headerGroup.id}>
|
|
551
658
|
{headerGroup.headers.map((header) => (
|
|
552
659
|
<TableHead
|
|
553
660
|
key={header.id}
|
|
554
|
-
|
|
661
|
+
// Title and tags are the absorbent columns (no width → take up
|
|
662
|
+
// slack on wide screens). minTableWidth below floors them.
|
|
663
|
+
style={header.column.id === 'tags' || header.column.id === 'title' ? undefined : { width: header.getSize() }}
|
|
555
664
|
className="sticky top-0 z-10 h-12 border-b bg-muted px-4 text-sm font-semibold text-foreground"
|
|
556
665
|
>
|
|
557
666
|
{header.isPlaceholder ? null : header.column.getCanSort() ? (
|
|
@@ -598,7 +707,10 @@ export function QuestionBank() {
|
|
|
598
707
|
{row.getVisibleCells().map((cell) => (
|
|
599
708
|
<TableCell
|
|
600
709
|
key={cell.id}
|
|
601
|
-
className={cn(
|
|
710
|
+
className={cn(
|
|
711
|
+
'border-b px-4 py-2 align-middle',
|
|
712
|
+
cell.column.id === 'title' && 'whitespace-normal break-words',
|
|
713
|
+
)}
|
|
602
714
|
onClick={cell.column.id === 'select' ? (event) => event.stopPropagation() : undefined}
|
|
603
715
|
>
|
|
604
716
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
@@ -642,7 +754,51 @@ export function QuestionBank() {
|
|
|
642
754
|
<Button variant="ghost" className="text-base" onClick={() => setRowSelection({})}>
|
|
643
755
|
Clear
|
|
644
756
|
</Button>
|
|
645
|
-
<div className="ml-auto">
|
|
757
|
+
<div className="ml-auto flex gap-2">
|
|
758
|
+
<Popover open={addToQuizOpen} onOpenChange={openAddToQuiz}>
|
|
759
|
+
<PopoverTrigger asChild>
|
|
760
|
+
<Button variant="outline" className="text-base">
|
|
761
|
+
Add to quiz
|
|
762
|
+
</Button>
|
|
763
|
+
</PopoverTrigger>
|
|
764
|
+
<PopoverContent side="top" align="end" className="w-80 p-1">
|
|
765
|
+
{existingQuizzes === null ? (
|
|
766
|
+
<p className="m-0 px-3 py-4 text-center text-sm text-muted-foreground">Loading quizzes...</p>
|
|
767
|
+
) : existingQuizzes.length === 0 ? (
|
|
768
|
+
<p className="m-0 px-3 py-4 text-center text-sm text-muted-foreground">No quizzes yet. Use "Make quiz" instead.</p>
|
|
769
|
+
) : (
|
|
770
|
+
<ul className="m-0 max-h-72 list-none overflow-y-auto p-0">
|
|
771
|
+
{existingQuizzes.map((quiz) => {
|
|
772
|
+
const newCount = selectedIds.filter((id) => !quiz.questionIds.includes(id)).length;
|
|
773
|
+
return (
|
|
774
|
+
<li key={quiz.id}>
|
|
775
|
+
<button
|
|
776
|
+
type="button"
|
|
777
|
+
disabled={addingToQuizId !== null}
|
|
778
|
+
onClick={() => addToQuiz(quiz)}
|
|
779
|
+
className="flex w-full flex-col items-start rounded-sm px-3 py-2 text-left hover:bg-muted focus-visible:bg-muted focus-visible:outline-none disabled:opacity-50"
|
|
780
|
+
>
|
|
781
|
+
<span className="text-sm font-medium">{quiz.title}</span>
|
|
782
|
+
<span className="text-xs text-muted-foreground">
|
|
783
|
+
{addingToQuizId === quiz.id
|
|
784
|
+
? 'Adding...'
|
|
785
|
+
: `${quiz.questionIds.length} ${quiz.questionIds.length === 1 ? 'question' : 'questions'} • ${
|
|
786
|
+
newCount === 0 ? 'all selected already included' : `+${newCount} new`
|
|
787
|
+
}`}
|
|
788
|
+
</span>
|
|
789
|
+
</button>
|
|
790
|
+
</li>
|
|
791
|
+
);
|
|
792
|
+
})}
|
|
793
|
+
</ul>
|
|
794
|
+
)}
|
|
795
|
+
{addToQuizError && (
|
|
796
|
+
<p role="alert" className="m-0 px-3 py-2 text-sm text-red-700">
|
|
797
|
+
Could not add to that quiz. Please try again.
|
|
798
|
+
</p>
|
|
799
|
+
)}
|
|
800
|
+
</PopoverContent>
|
|
801
|
+
</Popover>
|
|
646
802
|
<Button className="text-base" onClick={makeQuiz} disabled={creatingQuiz}>
|
|
647
803
|
{creatingQuiz ? 'Making quiz...' : 'Make quiz'}
|
|
648
804
|
</Button>
|