Fix question bank: select-all, bulk category, page size, category delete

Bugs fixed:
- 'Select all' now fetches ALL matching IDs from server (GET /questions/bank/ids)
  not just the currently loaded page — works for 268 questions
- Bulk category assign: fixed 'Remove category' value ('0' → 'remove'),
  now shows error message on failure instead of silent fail
- Search mode (Keyword/Semantic/Hybrid) moved to a clearly labelled dropdown,
  visually separated from category filter chips — eliminates confusion
- Category chips now have a 'Filter:' label to distinguish from other buttons

New features:
- Page size selector: 50 / 100 / 200 / All — loads the requested count in one shot
- Bulk actions moved to its own clearly labelled bar (only shown for moderators)
- Category delete with questions: prompts user to choose target category before deleting
  (uses new move_to param on DELETE /question-categories/{id})
- GET /questions/bank/ids endpoint for server-side select-all

Backend:
- DELETE /question-categories/{id}?move_to={id}: move questions to another category
  before deletion instead of always leaving them uncategorized

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 02:55:25 +02:00
parent 96f95ed062
commit 287d3a72f4
3 changed files with 148 additions and 47 deletions

View file

@ -80,16 +80,24 @@ def update_question_category(
@router.delete("/{cat_id}", status_code=204)
def delete_question_category(
cat_id: int,
move_to: int | None = None, # optional: move questions to this category instead of uncategorizing
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
cat = db.query(QuestionCategory).filter(QuestionCategory.id == cat_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
# Unassign questions
db.query(Question).filter(Question.question_category_id == cat_id).update(
{"question_category_id": None}
)
if move_to is not None:
target = db.query(QuestionCategory).filter(QuestionCategory.id == move_to).first()
if not target:
raise HTTPException(status_code=404, detail="Target category not found")
db.query(Question).filter(Question.question_category_id == cat_id).update(
{"question_category_id": move_to}
)
else:
db.query(Question).filter(Question.question_category_id == cat_id).update(
{"question_category_id": None}
)
db.delete(cat)
db.commit()

View file

@ -34,6 +34,34 @@ def set_question_category(
return {"question_id": question_id, "question_category_id": category_id}
@router.get("/bank/ids")
def get_bank_ids(
q: str | None = Query(None),
quiz_id: int | None = Query(None),
category_id: int | None = Query(None),
uncategorized: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return just IDs for all matching questions (for server-side select-all)."""
query = db.query(Question.id)
if quiz_id:
query = query.filter(Question.quiz_id == quiz_id)
if category_id is not None:
query = query.filter(Question.question_category_id == category_id)
if uncategorized:
query = query.filter(Question.question_category_id.is_(None))
if q and q.strip():
phrase = q.strip()
query = query.filter(
or_(
Question.question_text.ilike(f"%{phrase}%"),
cast(Question.options, String).ilike(f"%{phrase}%"),
)
)
return [row[0] for row in query.all()]
@router.get("/bank")
def get_question_bank(
q: str | None = Query(None),

View file

@ -235,19 +235,21 @@ export default function QuestionBankPage() {
const [newCatName, setNewCatName] = useState('')
const [showCatForm, setShowCatForm] = useState(false)
const [assignCatId, setAssignCatId] = useState('')
const [bulkError, setBulkError] = useState('')
const [pageSize, setPageSize] = useState(50)
const debounceRef = useRef(null)
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const LIMIT = 50
const LIMIT = pageSize
useEffect(() => {
api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {})
}, [])
const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, mode = searchMode) => {
const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, mode = searchMode, size = pageSize) => {
setLoading(true)
try {
const params = { limit: LIMIT, offset: off, search_mode: mode }
const params = { limit: size === 'all' ? 5000 : size, offset: off, search_mode: mode }
if (query.trim()) params.q = query.trim()
if (catId) params.category_id = parseInt(catId)
if (uncatOnly) params.uncategorized = true
@ -260,29 +262,48 @@ export default function QuestionBankPage() {
useEffect(() => {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, searchMode), 300)
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, searchMode, pageSize), 300)
return () => clearTimeout(debounceRef.current)
}, [searchQuery, filterCatId, showUncategorized, searchMode])
}, [searchQuery, filterCatId, showUncategorized, searchMode, pageSize])
const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
const selectAll = () => setSelectedIds(new Set(questions.map(q => q.id)))
const selectAll = async () => {
// Fetch ALL matching IDs from server (not just loaded page)
try {
const params = {}
if (searchQuery.trim()) params.q = searchQuery.trim()
if (filterCatId) params.category_id = parseInt(filterCatId)
if (showUncategorized) params.uncategorized = true
const res = await api.get('/questions/bank/ids', { params })
setSelectedIds(new Set(res.data))
} catch {
// Fallback to loaded questions
setSelectedIds(new Set(questions.map(q => q.id)))
}
}
const clearSelection = () => setSelectedIds(new Set())
const bulkAssignCategory = async () => {
if (!assignCatId && assignCatId !== '0') return
const catIdVal = assignCatId === '0' ? null : parseInt(assignCatId)
if (!assignCatId) return
setBulkError('')
const catIdVal = assignCatId === 'remove' ? null : parseInt(assignCatId)
try {
await api.post('/questions/bulk-category', {
question_ids: [...selectedIds],
category_id: catIdVal,
})
const catName = catIdVal ? categories.find(c => c.id === catIdVal)?.name ?? null : null
setQuestions(prev => prev.map(q => selectedIds.has(q.id)
? { ...q, question_category_id: catIdVal, question_category_name: categories.find(c => c.id === catIdVal)?.name ?? null }
? { ...q, question_category_id: catIdVal, question_category_name: catName }
: q))
clearSelection()
setAssignCatId('')
api.get('/question-categories/').then(res => setCategories(res.data))
} catch { }
} catch (err) {
setBulkError(err.response?.data?.detail || 'Failed to assign category')
}
}
const addCategory = async () => {
@ -295,9 +316,31 @@ export default function QuestionBankPage() {
}
const deleteCategory = async (catId) => {
if (!confirm('Delete this question category? Questions will become uncategorized.')) return
const cat = categories.find(c => c.id === catId)
const otherCats = categories.filter(c => c.id !== catId)
const count = cat?.question_count || 0
if (count === 0) {
if (!confirm(`Delete category "${cat?.name}"?`)) return
try {
await api.delete(`/question-categories/${catId}`)
setCategories(prev => prev.filter(c => c.id !== catId))
if (parseInt(filterCatId) === catId) setFilterCatId('')
} catch { }
return
}
// Has questions ask where to move them
const options = ['Leave uncategorized', ...otherCats.map(c => c.name)]
const choice = window.prompt(
`"${cat?.name}" has ${count} question${count !== 1 ? 's' : ''}.\n\nWhere should they go?\n${options.map((o, i) => `${i}: ${o}`).join('\n')}\n\nEnter number (or Cancel to abort):`
)
if (choice === null) return
const idx = parseInt(choice)
if (isNaN(idx) || idx < 0 || idx >= options.length) return
const moveTo = idx === 0 ? undefined : otherCats[idx - 1]?.id
try {
await api.delete(`/question-categories/${catId}`)
await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} })
setCategories(prev => prev.filter(c => c.id !== catId))
if (parseInt(filterCatId) === catId) setFilterCatId('')
loadQuestions(searchQuery, 0, '', showUncategorized)
@ -340,60 +383,82 @@ export default function QuestionBankPage() {
)}
</div>
{/* Category chips */}
{/* Category filter chips */}
{categories.length > 0 && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em', alignSelf: 'center', marginRight: 4 }}>Filter:</span>
<button className={`btn btn-sm ${!filterCatId && !showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(''); setShowUncategorized(false) }}>All</button>
onClick={() => { setFilterCatId(''); setShowUncategorized(false) }}>All ({total})</button>
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowUncategorized(v => !v); setFilterCatId('') }}>Uncategorized</button>
{categories.map(cat => (
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false) }}>
{cat.name} <span style={{ opacity: 0.7 }}>({cat.question_count})</span>
{cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
</button>
{isModerator && <button onClick={() => deleteCategory(cat.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.75rem', padding: '2px 4px' }}
{isModerator && <button onClick={() => deleteCategory(cat.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>}
</div>
))}
</div>
)}
{/* Search + bulk actions */}
{/* Search row */}
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8' }}>🔍</span>
<input type="text" placeholder="Search questions..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
style={{ width: '100%', padding: '9px 13px 9px 36px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
{[['keyword','Keyword'],['semantic','Semantic'],['hybrid','Hybrid']].map(([val, label]) => (
<button key={val} className={`btn btn-sm ${searchMode === val ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setSearchMode(val)} title={val === 'keyword' ? 'Exact word match' : val === 'semantic' ? 'Meaning-based (AI)' : 'Both combined'}>
{label}
</button>
))}
{/* Search mode — clearly labelled separately from category chips */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.8rem', color: 'var(--text-muted)' }}>
<span>Search:</span>
<select value={searchMode} onChange={e => setSearchMode(e.target.value)}
style={{ padding: '5px 8px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="hybrid">Keyword + Semantic</option>
<option value="keyword">Keyword only</option>
<option value="semantic">Semantic (AI)</option>
</select>
</div>
{/* Page size */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.8rem', color: 'var(--text-muted)' }}>
<span>Show:</span>
<select value={pageSize} onChange={e => setPageSize(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
style={{ padding: '5px 8px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
<option value="all">All</option>
</select>
</div>
{isModerator && (
<>
<button className="btn btn-sm btn-secondary" onClick={selectAll} disabled={questions.length === 0}>Select all</button>
{selectedIds.size > 0 && <button className="btn btn-sm btn-secondary" onClick={clearSelection}>Clear ({selectedIds.size})</button>}
{selectedIds.size > 0 && (
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<select value={assignCatId} onChange={e => setAssignCatId(e.target.value)}
style={{ padding: '6px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="">Assign category</option>
<option value="0">Remove category</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button className="btn btn-sm btn-primary" onClick={bulkAssignCategory} disabled={!assignCatId}>Apply</button>
</div>
)}
</>
)}
</div>
{/* Bulk actions row — only shown for moderators, clearly separated */}
{isModerator && (
<div style={{ display: 'flex', gap: 8, marginBottom: 10, flexWrap: 'wrap', alignItems: 'center', padding: '8px 12px', background: 'var(--bg)', borderRadius: 8 }}>
<button className="btn btn-sm btn-secondary" onClick={selectAll} disabled={loading}>
Select all {total > 0 ? `(${total})` : ''}
</button>
{selectedIds.size > 0 && (
<>
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{selectedIds.size} selected</span>
<button className="btn btn-sm btn-secondary" onClick={clearSelection}>Clear</button>
<select value={assignCatId} onChange={e => setAssignCatId(e.target.value)}
style={{ padding: '5px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.82rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="">Move to category</option>
<option value="remove"> Remove category</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<button className="btn btn-sm btn-primary" onClick={bulkAssignCategory} disabled={!assignCatId}>Apply</button>
<button className="btn btn-sm btn-primary" onClick={() => setShowCreateQuiz(true)}>Create Quiz</button>
</>
)}
{bulkError && <span style={{ fontSize: '0.8rem', color: 'var(--wrong-fg)' }}>{bulkError}</span>}
</div>
)}
{/* Question list */}
{loading && questions.length === 0 && <div className="loading"><div className="spinner" /></div>}