question-bank-framework 0.0.0-stage → 0.1.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 +105 -2
- package/bin/qb.js +119 -0
- package/bin/qb.ts +128 -0
- package/core/config.tsx +52 -0
- package/core/index.ts +35 -0
- package/core/types.ts +256 -0
- package/framework/components/app-shell.tsx +54 -0
- package/framework/components/filter-builder.tsx +471 -0
- package/framework/components/markdown-content.tsx +109 -0
- package/framework/components/question-bank.tsx +690 -0
- package/framework/components/quiz-list.tsx +118 -0
- package/framework/components/quiz-page.tsx +454 -0
- package/framework/components/ui/badge.tsx +47 -0
- package/framework/components/ui/button.tsx +63 -0
- package/framework/components/ui/checkbox.tsx +29 -0
- package/framework/components/ui/dropdown-menu.tsx +256 -0
- package/framework/components/ui/input.tsx +20 -0
- package/framework/components/ui/popover.tsx +88 -0
- package/framework/components/ui/select.tsx +187 -0
- package/framework/components/ui/sheet.tsx +140 -0
- package/framework/components/ui/table.tsx +121 -0
- package/framework/convex/questionStatuses.ts +30 -0
- package/framework/convex/quizzes.ts +50 -0
- package/framework/convex/schema.ts +18 -0
- package/framework/index.ts +26 -0
- package/framework/lib/convex-api.ts +36 -0
- package/framework/lib/question-filter-store.ts +116 -0
- package/framework/lib/question-filter.ts +376 -0
- package/framework/lib/question-status-storage.ts +117 -0
- package/framework/lib/quiz-storage.ts +118 -0
- package/framework/lib/utils.ts +6 -0
- package/package.json +66 -4
- package/template/bank.config.ts +72 -0
- package/template/content/answers.ts +8 -0
- package/template/content/questions.json +76 -0
- package/template/src/routes/__root.tsx +44 -0
- package/template/src/routes/index.tsx +4 -0
- package/template/src/routes/quiz.$quizId.tsx +4 -0
- package/template/src/routes/quizzes.tsx +4 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
import { XIcon } from "lucide-react"
|
|
4
|
+
import { Dialog as SheetPrimitive } from "radix-ui"
|
|
5
|
+
|
|
6
|
+
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
|
7
|
+
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function SheetTrigger({
|
|
11
|
+
...props
|
|
12
|
+
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
|
13
|
+
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function SheetClose({
|
|
17
|
+
...props
|
|
18
|
+
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
|
19
|
+
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function SheetPortal({
|
|
23
|
+
...props
|
|
24
|
+
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
|
25
|
+
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function SheetOverlay({
|
|
29
|
+
className,
|
|
30
|
+
...props
|
|
31
|
+
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
|
32
|
+
return (
|
|
33
|
+
<SheetPrimitive.Overlay
|
|
34
|
+
data-slot="sheet-overlay"
|
|
35
|
+
className={cn(
|
|
36
|
+
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
|
37
|
+
className
|
|
38
|
+
)}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function SheetContent({
|
|
45
|
+
className,
|
|
46
|
+
children,
|
|
47
|
+
side = "right",
|
|
48
|
+
showCloseButton = true,
|
|
49
|
+
...props
|
|
50
|
+
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
|
51
|
+
side?: "top" | "right" | "bottom" | "left"
|
|
52
|
+
showCloseButton?: boolean
|
|
53
|
+
}) {
|
|
54
|
+
return (
|
|
55
|
+
<SheetPortal>
|
|
56
|
+
<SheetOverlay />
|
|
57
|
+
<SheetPrimitive.Content
|
|
58
|
+
data-slot="sheet-content"
|
|
59
|
+
className={cn(
|
|
60
|
+
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
|
61
|
+
side === "right" &&
|
|
62
|
+
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
|
63
|
+
side === "left" &&
|
|
64
|
+
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
|
65
|
+
side === "top" &&
|
|
66
|
+
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
|
67
|
+
side === "bottom" &&
|
|
68
|
+
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
|
69
|
+
className
|
|
70
|
+
)}
|
|
71
|
+
{...props}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
{showCloseButton && (
|
|
75
|
+
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
|
76
|
+
<XIcon className="size-4" />
|
|
77
|
+
<span className="sr-only">Close</span>
|
|
78
|
+
</SheetPrimitive.Close>
|
|
79
|
+
)}
|
|
80
|
+
</SheetPrimitive.Content>
|
|
81
|
+
</SheetPortal>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
data-slot="sheet-header"
|
|
89
|
+
className={cn("flex flex-col gap-1.5 p-4", className)}
|
|
90
|
+
{...props}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
data-slot="sheet-footer"
|
|
99
|
+
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
|
100
|
+
{...props}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function SheetTitle({
|
|
106
|
+
className,
|
|
107
|
+
...props
|
|
108
|
+
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
|
109
|
+
return (
|
|
110
|
+
<SheetPrimitive.Title
|
|
111
|
+
data-slot="sheet-title"
|
|
112
|
+
className={cn("font-semibold text-foreground", className)}
|
|
113
|
+
{...props}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function SheetDescription({
|
|
119
|
+
className,
|
|
120
|
+
...props
|
|
121
|
+
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
|
122
|
+
return (
|
|
123
|
+
<SheetPrimitive.Description
|
|
124
|
+
data-slot="sheet-description"
|
|
125
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
126
|
+
{...props}
|
|
127
|
+
/>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export {
|
|
132
|
+
Sheet,
|
|
133
|
+
SheetTrigger,
|
|
134
|
+
SheetClose,
|
|
135
|
+
SheetContent,
|
|
136
|
+
SheetHeader,
|
|
137
|
+
SheetFooter,
|
|
138
|
+
SheetTitle,
|
|
139
|
+
SheetDescription,
|
|
140
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from '../../lib/utils.ts'
|
|
5
|
+
|
|
6
|
+
type TableProps = React.ComponentProps<"table"> & {
|
|
7
|
+
containerRef?: React.Ref<HTMLDivElement>
|
|
8
|
+
containerClassName?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function Table({ className, containerRef, containerClassName, ...props }: TableProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
ref={containerRef}
|
|
15
|
+
data-slot="table-container"
|
|
16
|
+
className={cn("relative w-full overflow-x-auto", containerClassName)}
|
|
17
|
+
>
|
|
18
|
+
<table
|
|
19
|
+
data-slot="table"
|
|
20
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|
28
|
+
return (
|
|
29
|
+
<thead
|
|
30
|
+
data-slot="table-header"
|
|
31
|
+
className={cn("[&_tr]:border-b", className)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|
38
|
+
return (
|
|
39
|
+
<tbody
|
|
40
|
+
data-slot="table-body"
|
|
41
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|
48
|
+
return (
|
|
49
|
+
<tfoot
|
|
50
|
+
data-slot="table-footer"
|
|
51
|
+
className={cn(
|
|
52
|
+
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
|
53
|
+
className
|
|
54
|
+
)}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|
61
|
+
return (
|
|
62
|
+
<tr
|
|
63
|
+
data-slot="table-row"
|
|
64
|
+
className={cn(
|
|
65
|
+
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
|
66
|
+
className
|
|
67
|
+
)}
|
|
68
|
+
{...props}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|
74
|
+
return (
|
|
75
|
+
<th
|
|
76
|
+
data-slot="table-head"
|
|
77
|
+
className={cn(
|
|
78
|
+
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
79
|
+
className
|
|
80
|
+
)}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|
87
|
+
return (
|
|
88
|
+
<td
|
|
89
|
+
data-slot="table-cell"
|
|
90
|
+
className={cn(
|
|
91
|
+
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
92
|
+
className
|
|
93
|
+
)}
|
|
94
|
+
{...props}
|
|
95
|
+
/>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function TableCaption({
|
|
100
|
+
className,
|
|
101
|
+
...props
|
|
102
|
+
}: React.ComponentProps<"caption">) {
|
|
103
|
+
return (
|
|
104
|
+
<caption
|
|
105
|
+
data-slot="table-caption"
|
|
106
|
+
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
107
|
+
{...props}
|
|
108
|
+
/>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export {
|
|
113
|
+
Table,
|
|
114
|
+
TableHeader,
|
|
115
|
+
TableBody,
|
|
116
|
+
TableFooter,
|
|
117
|
+
TableHead,
|
|
118
|
+
TableRow,
|
|
119
|
+
TableCell,
|
|
120
|
+
TableCaption,
|
|
121
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mutationGeneric as mutation, queryGeneric as query } from 'convex/server'
|
|
2
|
+
import { v } from 'convex/values'
|
|
3
|
+
|
|
4
|
+
const statusValidator = v.union(v.literal('unanswered'), v.literal('revisit'), v.literal('complete'))
|
|
5
|
+
|
|
6
|
+
export const list = query({
|
|
7
|
+
args: {},
|
|
8
|
+
handler: async (ctx) => await ctx.db.query('questionStatuses').collect(),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export const set = mutation({
|
|
12
|
+
args: {
|
|
13
|
+
questionId: v.string(),
|
|
14
|
+
status: statusValidator,
|
|
15
|
+
},
|
|
16
|
+
handler: async (ctx, { questionId, status }) => {
|
|
17
|
+
const existing = await ctx.db.query('questionStatuses')
|
|
18
|
+
.withIndex('by_question_id', (q) => q.eq('questionId', questionId))
|
|
19
|
+
.first()
|
|
20
|
+
const updatedAt = Date.now()
|
|
21
|
+
|
|
22
|
+
if (existing) {
|
|
23
|
+
await ctx.db.patch(existing._id, { status, updatedAt })
|
|
24
|
+
} else {
|
|
25
|
+
await ctx.db.insert('questionStatuses', { questionId, status, updatedAt })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return updatedAt
|
|
29
|
+
},
|
|
30
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mutationGeneric as mutation, queryGeneric as query } from 'convex/server'
|
|
2
|
+
import { v } from 'convex/values'
|
|
3
|
+
|
|
4
|
+
export const create = mutation({
|
|
5
|
+
args: {
|
|
6
|
+
title: v.string(),
|
|
7
|
+
questionIds: v.array(v.string()),
|
|
8
|
+
},
|
|
9
|
+
handler: async (ctx, { title, questionIds }) => {
|
|
10
|
+
const createdAt = Date.now()
|
|
11
|
+
return await ctx.db.insert('quizzes', {
|
|
12
|
+
title,
|
|
13
|
+
questionIds,
|
|
14
|
+
answers: {},
|
|
15
|
+
currentQuestion: 0,
|
|
16
|
+
completed: false,
|
|
17
|
+
createdAt,
|
|
18
|
+
})
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export const get = query({
|
|
23
|
+
args: { quizId: v.id('quizzes') },
|
|
24
|
+
handler: async (ctx, { quizId }) => await ctx.db.get(quizId),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const list = query({
|
|
28
|
+
args: {},
|
|
29
|
+
handler: async (ctx) => await ctx.db.query('quizzes').withIndex('by_created_at').order('desc').collect(),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const remove = mutation({
|
|
33
|
+
args: { quizId: v.id('quizzes') },
|
|
34
|
+
handler: async (ctx, { quizId }) => {
|
|
35
|
+
await ctx.db.delete(quizId)
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const saveProgress = mutation({
|
|
40
|
+
args: {
|
|
41
|
+
quizId: v.id('quizzes'),
|
|
42
|
+
title: v.string(),
|
|
43
|
+
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
|
|
44
|
+
currentQuestion: v.number(),
|
|
45
|
+
completed: v.boolean(),
|
|
46
|
+
},
|
|
47
|
+
handler: async (ctx, { quizId, title, answers, currentQuestion, completed }) => {
|
|
48
|
+
await ctx.db.patch(quizId, { title, answers, currentQuestion, completed })
|
|
49
|
+
},
|
|
50
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineSchema, defineTable } from 'convex/server'
|
|
2
|
+
import { v } from 'convex/values'
|
|
3
|
+
|
|
4
|
+
export default defineSchema({
|
|
5
|
+
quizzes: defineTable({
|
|
6
|
+
title: v.string(),
|
|
7
|
+
questionIds: v.array(v.string()),
|
|
8
|
+
answers: v.record(v.string(), v.union(v.string(), v.array(v.string()))),
|
|
9
|
+
currentQuestion: v.number(),
|
|
10
|
+
completed: v.boolean(),
|
|
11
|
+
createdAt: v.number(),
|
|
12
|
+
}).index('by_created_at', ['createdAt']),
|
|
13
|
+
questionStatuses: defineTable({
|
|
14
|
+
questionId: v.string(),
|
|
15
|
+
status: v.union(v.literal('unanswered'), v.literal('revisit'), v.literal('complete')),
|
|
16
|
+
updatedAt: v.number(),
|
|
17
|
+
}).index('by_question_id', ['questionId']),
|
|
18
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Public framework surface. Import from "question-bank-framework/framework".
|
|
2
|
+
// Every file under framework/ is ejectable via `qb reveal <path>`.
|
|
3
|
+
|
|
4
|
+
export { QuestionBank } from './components/question-bank.tsx';
|
|
5
|
+
export { QuizPage } from './components/quiz-page.tsx';
|
|
6
|
+
export { QuizList } from './components/quiz-list.tsx';
|
|
7
|
+
export { AppShell } from './components/app-shell.tsx';
|
|
8
|
+
export { FilterBuilder } from './components/filter-builder.tsx';
|
|
9
|
+
export { MarkdownContent } from './components/markdown-content.tsx';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
createFilterFields,
|
|
13
|
+
matchesFilters,
|
|
14
|
+
matchesSearch,
|
|
15
|
+
questionStatusLabels,
|
|
16
|
+
defaultOperatorByType,
|
|
17
|
+
fieldTypeLabels,
|
|
18
|
+
operatorLabels,
|
|
19
|
+
} from './lib/question-filter.ts';
|
|
20
|
+
export type { FilterField, FilterMatch, FilterOperatorId, FilterRule } from './lib/question-filter.ts';
|
|
21
|
+
|
|
22
|
+
export { createQuestionFilterStore, useQuestionFilterStore } from './lib/question-filter-store.ts';
|
|
23
|
+
export { createQuiz, deleteQuiz, getQuiz, listQuizzes, saveQuizLocally, saveQuizProgress } from './lib/quiz-storage.ts';
|
|
24
|
+
export { getQuestionStatuses, setQuestionStatus } from './lib/question-status-storage.ts';
|
|
25
|
+
export { convexApi } from './lib/convex-api.ts';
|
|
26
|
+
export { cn } from './lib/utils.ts';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Typed Convex function references. No value imports — safe to eject.
|
|
2
|
+
// Framework-owned: eject with `qb reveal framework/lib/convex-api.ts`.
|
|
3
|
+
|
|
4
|
+
import { makeFunctionReference } from 'convex/server';
|
|
5
|
+
import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
|
|
6
|
+
|
|
7
|
+
type CreateQuizArgs = { title: string; questionIds: string[] };
|
|
8
|
+
type ProgressArgs = {
|
|
9
|
+
quizId: string;
|
|
10
|
+
title: string;
|
|
11
|
+
answers: Record<string, QuizAnswer>;
|
|
12
|
+
currentQuestion: number;
|
|
13
|
+
completed: boolean;
|
|
14
|
+
};
|
|
15
|
+
type QuizDocument = QuizSession & { _id: string; _creationTime: number };
|
|
16
|
+
type QuestionStatusDocument = {
|
|
17
|
+
_id: string;
|
|
18
|
+
_creationTime: number;
|
|
19
|
+
questionId: string;
|
|
20
|
+
status: QuestionStatus;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const convexApi = {
|
|
25
|
+
quizzes: {
|
|
26
|
+
create: makeFunctionReference<'mutation', CreateQuizArgs, string>('quizzes:create'),
|
|
27
|
+
get: makeFunctionReference<'query', { quizId: string }, QuizDocument | null>('quizzes:get'),
|
|
28
|
+
list: makeFunctionReference<'query', Record<string, never>, QuizDocument[]>('quizzes:list'),
|
|
29
|
+
remove: makeFunctionReference<'mutation', { quizId: string }, null>('quizzes:remove'),
|
|
30
|
+
saveProgress: makeFunctionReference<'mutation', ProgressArgs, null>('quizzes:saveProgress'),
|
|
31
|
+
},
|
|
32
|
+
questionStatuses: {
|
|
33
|
+
list: makeFunctionReference<'query', Record<string, never>, QuestionStatusDocument[]>('questionStatuses:list'),
|
|
34
|
+
set: makeFunctionReference<'mutation', { questionId: string; status: QuestionStatus }, number>('questionStatuses:set'),
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Persisted filter UI state, namespaced per bank.
|
|
2
|
+
// Framework-owned: eject with `qb reveal framework/lib/question-filter-store.ts`.
|
|
3
|
+
|
|
4
|
+
import { create } from 'zustand';
|
|
5
|
+
import { persist } from 'zustand/middleware';
|
|
6
|
+
import {
|
|
7
|
+
defaultOperatorByType,
|
|
8
|
+
filterFieldType,
|
|
9
|
+
type FilterField,
|
|
10
|
+
type FilterMatch,
|
|
11
|
+
type FilterOperatorId,
|
|
12
|
+
type FilterRule,
|
|
13
|
+
} from './question-filter.ts';
|
|
14
|
+
|
|
15
|
+
type QuestionFilterValues = {
|
|
16
|
+
search: string;
|
|
17
|
+
filters: FilterRule[];
|
|
18
|
+
filterMatch: FilterMatch;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type QuestionFilterActions = {
|
|
22
|
+
setSearch: (search: string) => void;
|
|
23
|
+
addFilter: (field: string, operator?: FilterOperatorId) => void;
|
|
24
|
+
updateFilter: (id: string, patch: Partial<Omit<FilterRule, 'id'>>) => void;
|
|
25
|
+
removeFilter: (id: string) => void;
|
|
26
|
+
clearFilters: () => void;
|
|
27
|
+
setFilterMatch: (match: FilterMatch) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type QuestionFilterStore = QuestionFilterValues & QuestionFilterActions;
|
|
31
|
+
|
|
32
|
+
let filterIdCounter = 0;
|
|
33
|
+
|
|
34
|
+
const createFilterId = () => {
|
|
35
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
36
|
+
if (uuid) return uuid;
|
|
37
|
+
filterIdCounter += 1;
|
|
38
|
+
return `filter-${Date.now()}-${filterIdCounter}`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The store is created per bank namespace so two banks in one origin don't
|
|
43
|
+
* share filter state. Call once per app (e.g. in your BankProvider setup).
|
|
44
|
+
*/
|
|
45
|
+
export function createQuestionFilterStore(namespace: string, fields: readonly FilterField[]) {
|
|
46
|
+
return create<QuestionFilterStore>()(
|
|
47
|
+
persist(
|
|
48
|
+
(set) => ({
|
|
49
|
+
search: '',
|
|
50
|
+
filters: [],
|
|
51
|
+
filterMatch: 'and',
|
|
52
|
+
setSearch: (search) => set({ search }),
|
|
53
|
+
addFilter: (field, operator) =>
|
|
54
|
+
set((state) => ({
|
|
55
|
+
filters: [
|
|
56
|
+
...state.filters,
|
|
57
|
+
{
|
|
58
|
+
id: createFilterId(),
|
|
59
|
+
field,
|
|
60
|
+
operator: operator ?? defaultOperatorByType[filterFieldType(fields, field)],
|
|
61
|
+
value: [],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
})),
|
|
65
|
+
updateFilter: (id, patch) =>
|
|
66
|
+
set((state) => ({
|
|
67
|
+
filters: state.filters.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)),
|
|
68
|
+
})),
|
|
69
|
+
removeFilter: (id) =>
|
|
70
|
+
set((state) => ({
|
|
71
|
+
filters: state.filters.filter((rule) => rule.id !== id),
|
|
72
|
+
})),
|
|
73
|
+
clearFilters: () => set({ filters: [] }),
|
|
74
|
+
setFilterMatch: (filterMatch) => set({ filterMatch }),
|
|
75
|
+
}),
|
|
76
|
+
{
|
|
77
|
+
name: `${namespace}-question-bank-filters`,
|
|
78
|
+
version: 2,
|
|
79
|
+
skipHydration: true,
|
|
80
|
+
partialize: ({ search, filters, filterMatch }) => ({ search, filters, filterMatch }),
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Default singleton for single-bank apps. Create via `createQuestionFilterStore` for multi-bank. */
|
|
87
|
+
export const useQuestionFilterStore = create<QuestionFilterStore>()(
|
|
88
|
+
persist(
|
|
89
|
+
(set) => ({
|
|
90
|
+
search: '',
|
|
91
|
+
filters: [],
|
|
92
|
+
filterMatch: 'and' as FilterMatch,
|
|
93
|
+
setSearch: (search: string) => set({ search }),
|
|
94
|
+
addFilter: (field: string, operator?: FilterOperatorId) =>
|
|
95
|
+
set((state) => ({
|
|
96
|
+
filters: [...state.filters, { id: createFilterId(), field, operator: operator ?? 'contains', value: [] }],
|
|
97
|
+
})),
|
|
98
|
+
updateFilter: (id: string, patch: Partial<Omit<FilterRule, 'id'>>) =>
|
|
99
|
+
set((state) => ({
|
|
100
|
+
filters: state.filters.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule)),
|
|
101
|
+
})),
|
|
102
|
+
removeFilter: (id: string) =>
|
|
103
|
+
set((state) => ({
|
|
104
|
+
filters: state.filters.filter((rule) => rule.id !== id),
|
|
105
|
+
})),
|
|
106
|
+
clearFilters: () => set({ filters: [] }),
|
|
107
|
+
setFilterMatch: (filterMatch: FilterMatch) => set({ filterMatch }),
|
|
108
|
+
}),
|
|
109
|
+
{
|
|
110
|
+
name: 'question-bank-filters',
|
|
111
|
+
version: 2,
|
|
112
|
+
skipHydration: true,
|
|
113
|
+
partialize: ({ search, filters, filterMatch }) => ({ search, filters, filterMatch }),
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
);
|