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:
parent
96f95ed062
commit
287d3a72f4
3 changed files with 148 additions and 47 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue