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.
@@ -0,0 +1,171 @@
1
+ // Framework-owned: eject with `qb reveal framework/components/quiz-question-editor.tsx`.
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { ArrowDown, ArrowUp, GripVertical, Shuffle, Trash2 } from 'lucide-react';
5
+ import { useBank } from '../../core/config.tsx';
6
+ import type { QuizSession } from '../../core/types.ts';
7
+ import { Button } from './ui/button';
8
+ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from './ui/sheet';
9
+ import { cn } from '../lib/utils.ts';
10
+
11
+ const move = (ids: string[], from: number, to: number) => {
12
+ const next = [...ids];
13
+ const [item] = next.splice(from, 1);
14
+ if (item !== undefined) next.splice(to, 0, item);
15
+ return next;
16
+ };
17
+
18
+ export function QuizQuestionEditor({
19
+ quiz,
20
+ onOpenChange,
21
+ onSave,
22
+ }: {
23
+ quiz: QuizSession | null;
24
+ onOpenChange: (open: boolean) => void;
25
+ onSave: (quiz: QuizSession, questionIds: string[]) => Promise<void>;
26
+ }) {
27
+ const bank = useBank();
28
+ const [ids, setIds] = useState<string[]>([]);
29
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
30
+ const [saving, setSaving] = useState(false);
31
+ const [saveError, setSaveError] = useState(false);
32
+
33
+ useEffect(() => {
34
+ if (!quiz) return;
35
+ setIds(quiz.questionIds);
36
+ setSaveError(false);
37
+ }, [quiz]);
38
+
39
+ const changed = quiz !== null && (ids.length !== quiz.questionIds.length || ids.some((id, index) => id !== quiz.questionIds[index]));
40
+
41
+ const shuffle = () => {
42
+ setIds((current) => {
43
+ if (current.length < 2) return current;
44
+ const next = [...current];
45
+ for (let i = next.length - 1; i > 0; i--) {
46
+ const j = Math.floor(Math.random() * (i + 1));
47
+ [next[i], next[j]] = [next[j] as string, next[i] as string];
48
+ }
49
+ return next;
50
+ });
51
+ };
52
+
53
+ const save = async () => {
54
+ if (!quiz) return;
55
+ setSaving(true);
56
+ setSaveError(false);
57
+ try {
58
+ await onSave(quiz, ids);
59
+ onOpenChange(false);
60
+ } catch {
61
+ setSaveError(true);
62
+ } finally {
63
+ setSaving(false);
64
+ }
65
+ };
66
+
67
+ return (
68
+ <Sheet open={quiz !== null} onOpenChange={onOpenChange}>
69
+ <SheetContent className="w-full gap-0 sm:max-w-md">
70
+ <SheetHeader className="border-b">
71
+ <SheetTitle>Edit questions</SheetTitle>
72
+ <SheetDescription>{quiz?.title}. Drag to reorder, or remove questions you no longer want.</SheetDescription>
73
+ <div className="mt-2 flex items-center justify-between gap-2">
74
+ <span className="text-sm text-muted-foreground">
75
+ {ids.length} {ids.length === 1 ? 'question' : 'questions'}
76
+ </span>
77
+ <Button
78
+ type="button"
79
+ variant="outline"
80
+ size="sm"
81
+ onClick={shuffle}
82
+ disabled={ids.length < 2}
83
+ aria-label="Shuffle question order"
84
+ title="Shuffle question order"
85
+ >
86
+ <Shuffle aria-hidden="true" />
87
+ Shuffle
88
+ </Button>
89
+ </div>
90
+ </SheetHeader>
91
+ <ol className="m-0 min-h-0 flex-1 list-none overflow-y-auto p-2">
92
+ {ids.map((id, index) => (
93
+ <li
94
+ key={id}
95
+ draggable
96
+ onDragStart={(event) => {
97
+ setDragIndex(index);
98
+ event.dataTransfer.effectAllowed = 'move';
99
+ }}
100
+ onDragOver={(event) => {
101
+ event.preventDefault();
102
+ if (dragIndex === null || dragIndex === index) return;
103
+ setIds((current) => move(current, dragIndex, index));
104
+ setDragIndex(index);
105
+ }}
106
+ onDragEnd={() => setDragIndex(null)}
107
+ className={cn(
108
+ 'flex items-center gap-2 rounded-md border border-transparent px-2 py-2 hover:bg-muted',
109
+ dragIndex === index && 'border-gray-300 bg-muted opacity-60',
110
+ )}
111
+ >
112
+ <GripVertical aria-hidden="true" className="size-4 shrink-0 cursor-grab text-muted-foreground" />
113
+ <span className="w-6 shrink-0 text-right text-sm tabular-nums text-muted-foreground">{index + 1}</span>
114
+ <span className="min-w-0 flex-1 truncate text-sm">{bank.questionById.get(id)?.title ?? id}</span>
115
+ <Button
116
+ type="button"
117
+ variant="ghost"
118
+ size="icon-xs"
119
+ aria-label="Move up"
120
+ disabled={index === 0}
121
+ onClick={() => setIds((current) => move(current, index, index - 1))}
122
+ >
123
+ <ArrowUp aria-hidden="true" />
124
+ </Button>
125
+ <Button
126
+ type="button"
127
+ variant="ghost"
128
+ size="icon-xs"
129
+ aria-label="Move down"
130
+ disabled={index === ids.length - 1}
131
+ onClick={() => setIds((current) => move(current, index, index + 1))}
132
+ >
133
+ <ArrowDown aria-hidden="true" />
134
+ </Button>
135
+ <Button
136
+ type="button"
137
+ variant="ghost"
138
+ size="icon-xs"
139
+ aria-label="Remove question"
140
+ className="text-red-600 hover:bg-red-50 hover:text-red-700"
141
+ onClick={() => setIds((current) => current.filter((item) => item !== id))}
142
+ >
143
+ <Trash2 aria-hidden="true" />
144
+ </Button>
145
+ </li>
146
+ ))}
147
+ {ids.length === 0 && (
148
+ <li className="px-2 py-8 text-center text-sm text-muted-foreground">
149
+ No questions left. Delete the quiz instead if you no longer need it.
150
+ </li>
151
+ )}
152
+ </ol>
153
+ <SheetFooter className="border-t">
154
+ {saveError && (
155
+ <p role="alert" className="m-0 text-sm text-red-700">
156
+ Could not save changes. Please try again.
157
+ </p>
158
+ )}
159
+ <div className="flex justify-end gap-2">
160
+ <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
161
+ Cancel
162
+ </Button>
163
+ <Button type="button" onClick={save} disabled={!changed || ids.length === 0 || saving}>
164
+ {saving ? 'Saving...' : 'Save'}
165
+ </Button>
166
+ </div>
167
+ </SheetFooter>
168
+ </SheetContent>
169
+ </Sheet>
170
+ );
171
+ }
@@ -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.string()),
43
+ answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
44
44
  currentQuestion: v.number(),
45
45
  completed: v.boolean(),
46
46
  },
@@ -48,3 +48,15 @@ export const saveProgress = mutation({
48
48
  await ctx.db.patch(quizId, { title, answers, currentQuestion, completed })
49
49
  },
50
50
  })
51
+
52
+ export const setQuestions = mutation({
53
+ args: {
54
+ quizId: v.id('quizzes'),
55
+ questionIds: v.array(v.string()),
56
+ answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
57
+ currentQuestion: v.number(),
58
+ },
59
+ handler: async (ctx, { quizId, questionIds, answers, currentQuestion }) => {
60
+ await ctx.db.patch(quizId, { questionIds, answers, currentQuestion })
61
+ },
62
+ })
@@ -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.string()),
8
+ answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
9
9
  currentQuestion: v.number(),
10
10
  completed: v.boolean(),
11
11
  createdAt: v.number(),
@@ -2,16 +2,22 @@
2
2
  // Framework-owned: eject with `qb reveal framework/lib/convex-api.ts`.
3
3
 
4
4
  import { makeFunctionReference } from 'convex/server';
5
- import type { QuestionStatus, QuizSession } from '../../core/types.ts';
5
+ import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
6
6
 
7
7
  type CreateQuizArgs = { title: string; questionIds: string[] };
8
8
  type ProgressArgs = {
9
9
  quizId: string;
10
10
  title: string;
11
- answers: Record<string, string>;
11
+ answers: Record<string, QuizAnswer>;
12
12
  currentQuestion: number;
13
13
  completed: boolean;
14
14
  };
15
+ type SetQuestionsArgs = {
16
+ quizId: string;
17
+ questionIds: string[];
18
+ answers: Record<string, QuizAnswer>;
19
+ currentQuestion: number;
20
+ };
15
21
  type QuizDocument = QuizSession & { _id: string; _creationTime: number };
16
22
  type QuestionStatusDocument = {
17
23
  _id: string;
@@ -28,6 +34,7 @@ export const convexApi = {
28
34
  list: makeFunctionReference<'query', Record<string, never>, QuizDocument[]>('quizzes:list'),
29
35
  remove: makeFunctionReference<'mutation', { quizId: string }, null>('quizzes:remove'),
30
36
  saveProgress: makeFunctionReference<'mutation', ProgressArgs, null>('quizzes:saveProgress'),
37
+ setQuestions: makeFunctionReference<'mutation', SetQuestionsArgs, null>('quizzes:setQuestions'),
31
38
  },
32
39
  questionStatuses: {
33
40
  list: makeFunctionReference<'query', Record<string, never>, QuestionStatusDocument[]>('questionStatuses:list'),
@@ -3,7 +3,7 @@
3
3
  // by config-defined `BankFieldDef[]` plus the three built-ins.
4
4
  // Framework-owned: eject with `qb reveal framework/lib/question-filter.ts`.
5
5
 
6
- import { questionStatuses, type BankFieldDef, type BankFilterOperatorId, type BaseQuestion, type QuestionStatus } from '../../core/types.ts';
6
+ import { questionKind, questionKindLabels, questionKinds, questionStatuses, type BankFieldDef, type BankFilterOperatorId, type BaseQuestion, type QuestionStatus } from '../../core/types.ts';
7
7
 
8
8
  export type FilterMatch = 'and' | 'or';
9
9
 
@@ -101,6 +101,13 @@ function builtinFields(): FilterField[] {
101
101
  { id: 'title', label: 'Question', type: 'text', operators: textOperators, placeholder: 'Type a value…' },
102
102
  { id: 'id', label: 'ID', type: 'text', operators: textOperators, placeholder: 'Type a value…' },
103
103
  { id: 'tags', label: 'Tags', type: 'multiselect', operators: multiSelectOperators },
104
+ {
105
+ id: 'kind',
106
+ label: 'Type',
107
+ type: 'select',
108
+ operators: selectOperators,
109
+ options: questionKinds.map((kind) => ({ value: kind, label: questionKindLabels[kind] })),
110
+ },
104
111
  {
105
112
  id: 'status',
106
113
  label: 'Status',
@@ -128,7 +135,11 @@ function distinctStrings(values: Array<string | number | null | readonly string[
128
135
  * Build filter fields from bank config. Select/multiselect options default to
129
136
  * the distinct values across questions unless the field declares `options`.
130
137
  */
131
- export function createFilterFields(questions: readonly BaseQuestion[], fieldDefs: readonly BankFieldDef[]): FilterField[] {
138
+ export function createFilterFields(
139
+ questions: readonly BaseQuestion[],
140
+ fieldDefs: readonly BankFieldDef[],
141
+ hideTagsColumn = false,
142
+ ): FilterField[] {
132
143
  const customs: FilterField[] = fieldDefs.map((def) => {
133
144
  let options: readonly FilterOption[] | undefined;
134
145
  if (def.options) {
@@ -156,7 +167,7 @@ export function createFilterFields(questions: readonly BaseQuestion[], fieldDefs
156
167
  };
157
168
  });
158
169
 
159
- const builtins = builtinFields();
170
+ const builtins = builtinFields().filter((field) => !(hideTagsColumn && field.id === 'tags'));
160
171
  // Attach tag options derived from the bank (like the original).
161
172
  const tags = [...new Set(questions.flatMap((q) => q.tags))].sort((a, b) => a.localeCompare(b));
162
173
  const withTags = builtins.map((field) =>
@@ -220,8 +231,8 @@ function resolveFieldValue(
220
231
  if (ruleField === 'title') return question.title;
221
232
  if (ruleField === 'id') return question.id;
222
233
  if (ruleField === 'tags') return question.tags;
223
- // status is handled by matchesRule before reaching here. Any other id
224
- // is stale (no def, not a built-in) — match nothing.
234
+ // status/kind are handled by matchesRule before reaching here. Any other
235
+ // id is stale (no def, not a built-in) — match nothing.
225
236
  return null;
226
237
  }
227
238
  return def.getValue(question);
@@ -323,6 +334,7 @@ export function matchesRule(
323
334
  ): boolean {
324
335
  const type = filterFieldType(fields, rule.field);
325
336
  if (rule.field === 'status') return matchSelect(status, rule);
337
+ if (rule.field === 'kind') return matchSelect(questionKind(question), rule);
326
338
  const def = fieldDefs.find((d) => d.id === rule.field);
327
339
  const raw = resolveFieldValue(def, rule.field, question);
328
340
  if (type === 'text') return matchText(toText(raw), rule);
@@ -346,7 +358,7 @@ export function matchesFilters(
346
358
  : active.some((rule) => matchesRule(rule, fields, fieldDefs, question, status));
347
359
  }
348
360
 
349
- /** Free-text search across id/title/prompt/tags plus `searchable` custom fields. */
361
+ /** Free-text search across id/title/prompt/tags/options plus `searchable` custom fields. */
350
362
  export function matchesSearch(
351
363
  question: BaseQuestion,
352
364
  query: string,
@@ -354,7 +366,13 @@ export function matchesSearch(
354
366
  ): boolean {
355
367
  const q = query.trim().toLowerCase();
356
368
  if (!q) return true;
357
- const parts = [question.id, question.title, question.prompt, question.tags.join(' ')];
369
+ const parts = [
370
+ question.id,
371
+ question.title,
372
+ question.prompt,
373
+ question.tags.join(' '),
374
+ (question.options ?? []).map((o) => o.text).join(' '),
375
+ ];
358
376
  for (const def of fieldDefs) {
359
377
  if (!def.searchable) continue;
360
378
  const raw = def.getValue(question);
@@ -104,6 +104,28 @@ export async function saveQuizProgress(
104
104
  });
105
105
  }
106
106
 
107
+ // Replaces a quiz's question list (reorder / remove / append). Answers for
108
+ // removed questions are dropped and the current position is kept on the same
109
+ // question where possible.
110
+ export async function setQuizQuestions(namespace: string, session: QuizSession, questionIds: string[]): Promise<QuizSession> {
111
+ const kept = new Set(questionIds);
112
+ const answers = Object.fromEntries(Object.entries(session.answers).filter(([id]) => kept.has(id)));
113
+ const currentId = session.questionIds[session.currentQuestion];
114
+ const currentIndex = currentId === undefined ? -1 : questionIds.indexOf(currentId);
115
+ const currentQuestion = currentIndex >= 0 ? currentIndex : Math.min(session.currentQuestion, Math.max(questionIds.length - 1, 0));
116
+ const updated: QuizSession = { ...session, questionIds, answers, currentQuestion };
117
+ if (convexClient && !updated.id.startsWith('local_')) {
118
+ await convexClient.mutation(convexApi.quizzes.setQuestions, {
119
+ quizId: updated.id,
120
+ questionIds,
121
+ answers,
122
+ currentQuestion,
123
+ });
124
+ }
125
+ saveQuizLocally(namespace, updated);
126
+ return updated;
127
+ }
128
+
107
129
  export async function listQuizzes(namespace: string): Promise<QuizSession[]> {
108
130
  const localQuizzes = readLocalQuizzes(namespace);
109
131
  if (!convexClient) return localQuizzes;
@@ -0,0 +1,58 @@
1
+ /* shadcn-style theme tokens consumed by question-bank-framework UI components.
2
+ * Import this from your app stylesheet (after tailwindcss) instead of
3
+ * copy-pasting the tokens, so every bank inherits upstream token changes:
4
+ *
5
+ * @import 'tailwindcss';
6
+ * @source '../node_modules/question-bank-framework';
7
+ * @import 'question-bank-framework/framework/styles/theme.css';
8
+ */
9
+
10
+ @theme inline {
11
+ --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
12
+ --font-display: 'Fraunces', Georgia, serif;
13
+ --font-mono: 'DM Mono', ui-monospace, monospace;
14
+ --radius-sm: calc(var(--radius) - 4px);
15
+ --radius-md: calc(var(--radius) - 2px);
16
+ --radius-lg: var(--radius);
17
+ --radius-xl: calc(var(--radius) + 4px);
18
+ --color-background: var(--background);
19
+ --color-foreground: var(--foreground);
20
+ --color-card: var(--card);
21
+ --color-card-foreground: var(--card-foreground);
22
+ --color-popover: var(--popover);
23
+ --color-popover-foreground: var(--popover-foreground);
24
+ --color-primary: var(--primary);
25
+ --color-primary-foreground: var(--primary-foreground);
26
+ --color-secondary: var(--secondary);
27
+ --color-secondary-foreground: var(--secondary-foreground);
28
+ --color-muted: var(--muted);
29
+ --color-muted-foreground: var(--muted-foreground);
30
+ --color-accent: var(--accent);
31
+ --color-accent-foreground: var(--accent-foreground);
32
+ --color-destructive: var(--destructive);
33
+ --color-border: var(--border);
34
+ --color-input: var(--input);
35
+ --color-ring: var(--ring);
36
+ }
37
+
38
+ :root {
39
+ --radius: 0.625rem;
40
+ --background: #ffffff;
41
+ --foreground: #111827;
42
+ --card: #ffffff;
43
+ --card-foreground: #111827;
44
+ --popover: #ffffff;
45
+ --popover-foreground: #111827;
46
+ --primary: #25564c;
47
+ --primary-foreground: #ffffff;
48
+ --secondary: #f1f5f2;
49
+ --secondary-foreground: #263a2f;
50
+ --muted: #f3f4f6;
51
+ --muted-foreground: #6b7280;
52
+ --accent: #eef3ef;
53
+ --accent-foreground: #263a2f;
54
+ --destructive: #b91c1c;
55
+ --border: #d1d5db;
56
+ --input: #d1d5db;
57
+ --ring: #78a589;
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "question-bank-framework",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
@@ -62,7 +62,6 @@
62
62
  "version-packages": "changeset version",
63
63
  "release": "pnpm build:bin && changeset publish",
64
64
  "reveal": "node ./bin/qb.js reveal",
65
- "template:check": "node ./bin/qb.js check-template",
66
65
  "build:bin": "tsc bin/qb.ts --ignoreConfig --outDir bin --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --types node && chmod +x bin/qb.js"
67
66
  }
68
67
  }
@@ -3,4 +3,6 @@
3
3
 
4
4
  export const answers: Record<string, string> = {
5
5
  '2019-s1-q01': String.raw`**(a)** Not well-formed: several sibling top-level elements, so no single root.`,
6
+ '2019-s1-mc01': String.raw`Only **B** has a single root element with matching open and close tags. A never closes, C closes in the wrong order, and D is not an element at all.`,
7
+ '2019-s1-mc02': String.raw`Remove or correct physically impossible values (**A**), always investigate before discarding (**C**), and document any imputation (**D**). Deleting everything above the mean (**B**) discards roughly half of legitimate data.`,
6
8
  };
@@ -1,6 +1,7 @@
1
1
  [
2
2
  {
3
3
  "id": "2019-s1-q01",
4
+ "kind": "written",
4
5
  "year": 2019,
5
6
  "semester": "S1",
6
7
  "number": 1,
@@ -12,6 +13,7 @@
12
13
  },
13
14
  {
14
15
  "id": "2019-s1-q02",
16
+ "kind": "written",
15
17
  "year": 2019,
16
18
  "semester": "S1",
17
19
  "number": 2,
@@ -23,6 +25,7 @@
23
25
  },
24
26
  {
25
27
  "id": "ioa-001",
28
+ "kind": "written",
26
29
  "year": 2026,
27
30
  "semester": "S2",
28
31
  "number": 1,
@@ -31,5 +34,43 @@
31
34
  "prompt": "What is your research question, and what result would count as evidence?",
32
35
  "source": "assignment-ioa",
33
36
  "tags": ["Assignment questions", "Justification"]
37
+ },
38
+ {
39
+ "id": "2019-s1-mc01",
40
+ "kind": "single",
41
+ "year": 2019,
42
+ "semester": "S1",
43
+ "number": 3,
44
+ "marks": 1,
45
+ "title": "Well-formed XML",
46
+ "prompt": "Which of the following XML documents is well-formed?",
47
+ "options": [
48
+ { "id": "a", "text": "`<subject>COMP20008<subject>`" },
49
+ { "id": "b", "text": "`<subject>COMP20008</subject>`" },
50
+ { "id": "c", "text": "`<subject><topic>COMP20008</subject></topic>`" },
51
+ { "id": "d", "text": "`COMP20008`" }
52
+ ],
53
+ "correctIds": ["b"],
54
+ "source": "2019-s1.md",
55
+ "tags": ["Data fundamentals", "Data, formats & databases"]
56
+ },
57
+ {
58
+ "id": "2019-s1-mc02",
59
+ "kind": "multiple",
60
+ "year": 2019,
61
+ "semester": "S1",
62
+ "number": 4,
63
+ "marks": 2,
64
+ "title": "Outlier handling",
65
+ "prompt": "A temperature sensor reports occasional extreme spikes. Which of the following are reasonable outlier-handling steps? **Select all that apply.**",
66
+ "options": [
67
+ { "id": "a", "text": "Remove spikes that are physically impossible for the sensor" },
68
+ { "id": "b", "text": "Delete every reading above the mean" },
69
+ { "id": "c", "text": "Flag spikes and investigate whether they coincide with known events" },
70
+ { "id": "d", "text": "Replace spikes with interpolated values and document the change" }
71
+ ],
72
+ "correctIds": ["a", "c", "d"],
73
+ "source": "2019-s1.md",
74
+ "tags": ["Data preparation", "Data quality & preparation"]
34
75
  }
35
76
  ]
@@ -0,0 +1,7 @@
1
+ @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap');
2
+ @import 'katex/dist/katex.min.css';
3
+ @import 'tailwindcss';
4
+ @import 'tw-animate-css';
5
+ @import 'question-bank-framework/framework/styles/theme.css';
6
+
7
+ @source '../../node_modules/question-bank-framework';