feat: facet filters on the question bank, category page, sanitised tags
Question bank filters
The panel was a dialog inside a panel inside an aside, with raw checkbox lists
truncated at `.slice(0, 40)` — so most of the vocabulary was simply unreachable.
Replaced with the same facet rows the test builder uses: Status, Difficulty,
Systems, Disciplines, Diseases, Symptoms, Articles, each opening a search +
checklist panel and summarising as "Name +N". Tag lists show the most-used first
and reach the long tail by search instead of hiding it.
Category management page (/categories)
Renaming, reparenting and delete-with-move used to live inside that filter
panel. They now have their own page: a searchable tree with question and
subcategory counts, create-under-parent, and inline delete that rehomes the
questions. A category is never offered its own descendant as a parent, and one
with subcategories refuses deletion rather than orphaning them.
Tag vocabulary sanitised (scripts/sanitize_tags.py, idempotent, --apply to write)
The tags were model-generated per question, so the same concept recurred with
different casing and pluralisation. Applied to production, after a table backup:
83 renamed (Adhd→ADHD, Ige→IgE, 46,Xx→46,XX)
75 merged (Absence Seizures→Absence Seizure, Food Allergies→Food Allergy)
17 disease→keyword 27 unused deleted 6859 → 6740 tags
Symptom reclassification matches whole names only. Substring matching moved
"Whooping Cough" and "Rocky Mountain Spotted Fever" out of diseases, so the rule
now requires an exact match, and genuinely ambiguous terms ("seizure",
"jaundice", "murmur") are left alone rather than guessed at — misfiling a
diagnosis as a symptom is worse than an untidy vocabulary.
Test builder on mobile
The sticky bar's `margin: 4px -16px -100px` guessed the page's own padding and
overflowed when it differed; it now bleeds to the viewport instead. The bar was
also wrapping into three ragged rows — modes now span the top and Refresh sits
beside Create. Long facet names truncate rather than widening a row into a
sideways scroll, and panel rows got comfortable tap targets.
Tests: 9 new for the category page (reparent, descendant guard, delete-with-move,
default-to-uncategorized, subcategory refusal, create, search, server refusal);
the three category tests move off the bank suite with it. Full suites green:
96 backend, 133 frontend, build clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA
This commit is contained in:
parent
519f2e572a
commit
878e61c69b
10 changed files with 776 additions and 183 deletions
199
backend/scripts/sanitize_tags.py
Normal file
199
backend/scripts/sanitize_tags.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""Clean the tag vocabulary so it can serve as a real category system.
|
||||
|
||||
The tags were produced per-question by a model, so the same concept appears
|
||||
several times with different casing, punctuation and pluralisation
|
||||
("Absence Seizure" / "Absence Seizures", "Abo Incompatibility"), symptoms are
|
||||
filed as diseases, and a long tail of one-off keywords clutters every picker.
|
||||
|
||||
What it does, in order:
|
||||
1. normalise names — collapse whitespace, fix medical acronym casing
|
||||
2. merge duplicates that share a canonical key, repointing links
|
||||
3. reclassify symptom-shaped entries from `disease` to `keyword`
|
||||
4. delete tags no question uses
|
||||
|
||||
Idempotent, and a dry run by default:
|
||||
|
||||
docker compose exec backend python -m scripts.sanitize_tags # report
|
||||
docker compose exec backend python -m scripts.sanitize_tags --apply # write
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from app.database import SessionLocal
|
||||
|
||||
# Rendered by title-casing, which mangles medical acronyms and genetics notation.
|
||||
ACRONYMS = {
|
||||
"Abo": "ABO", "Adhd": "ADHD", "Aids": "AIDS", "Als": "ALS", "Aml": "AML",
|
||||
"All": "ALL", "Ards": "ARDS", "Asd": "ASD", "Avsd": "AVSD", "Bpd": "BPD",
|
||||
"Cf": "CF", "Chd": "CHD", "Cmv": "CMV", "Cns": "CNS", "Copd": "COPD",
|
||||
"Csf": "CSF", "Ct": "CT", "Dka": "DKA", "Dmd": "DMD", "Dsd": "DSD",
|
||||
"Ebv": "EBV", "Ecg": "ECG", "Eeg": "EEG", "Gerd": "GERD", "Gbs": "GBS",
|
||||
"Hiv": "HIV", "Hsp": "HSP", "Hus": "HUS", "Iga": "IgA", "Igе": "IgE",
|
||||
"Ige": "IgE", "Itp": "ITP", "Iugr": "IUGR", "Ivh": "IVH", "Jia": "JIA",
|
||||
"Mri": "MRI", "Nec": "NEC", "Nsaid": "NSAID", "Ocd": "OCD", "Pda": "PDA",
|
||||
"Ptsd": "PTSD", "Rsv": "RSV", "Sids": "SIDS", "Sle": "SLE", "Ssri": "SSRI",
|
||||
"Tof": "TOF", "Uti": "UTI", "Vsd": "VSD", "Xx": "XX", "Xy": "XY", "Xyy": "XYY",
|
||||
"Dna": "DNA", "Rna": "RNA", "Gi": "GI", "Icu": "ICU", "Nicu": "NICU", "Picu": "PICU",
|
||||
}
|
||||
|
||||
# Presenting features, matched WHOLE — never as a substring.
|
||||
#
|
||||
# Substring matching cannot do this safely: "Whooping Cough" is pertussis and
|
||||
# "Rocky Mountain Spotted Fever" is a disease, yet both contain a symptom word.
|
||||
# Misfiling a diagnosis as a symptom is worse than an untidy vocabulary, so only
|
||||
# an exact name moves, and ambiguous terms ("seizure", "jaundice", "murmur")
|
||||
# are not listed at all — they name diagnoses as often as symptoms in paediatrics.
|
||||
SYMPTOM_NAMES = {
|
||||
"abdominal distention", "abdominal distension", "abdominal pain",
|
||||
"back pain", "chest pain", "constipation", "cyanosis", "diarrhea",
|
||||
"diarrhoea", "dizziness", "dysphagia", "dyspnea", "dysuria",
|
||||
"failure to thrive", "fatigue", "fever", "hematuria", "hepatomegaly",
|
||||
"irritability", "joint pain", "lethargy", "limp", "lymphadenopathy",
|
||||
"pallor", "proteinuria", "pruritus", "rash", "sore throat", "splenomegaly",
|
||||
"stridor", "syncope", "vomiting", "weight loss", "wheeze", "wheezing",
|
||||
}
|
||||
|
||||
# Plural forms that are the same concept as their singular.
|
||||
IRREGULAR_SINGULARS = {"ies": "y", "ses": "sis", "s": ""}
|
||||
|
||||
|
||||
def normalise(name: str) -> str:
|
||||
"""Tidy a tag for display: single spaces, sane acronym casing."""
|
||||
clean = re.sub(r"\s+", " ", (name or "").strip()).strip(" ,;.-")
|
||||
if not clean:
|
||||
return ""
|
||||
words = []
|
||||
for word in clean.split(" "):
|
||||
# Genetics notation like "46,Xx" needs each comma-part fixed.
|
||||
if "," in word:
|
||||
words.append(",".join(ACRONYMS.get(part, part) for part in word.split(",")))
|
||||
else:
|
||||
words.append(ACRONYMS.get(word, word))
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
def canonical_key(name: str) -> str:
|
||||
"""Key under which near-duplicates collapse: lowercase, unpunctuated, singular."""
|
||||
key = re.sub(r"[^a-z0-9 ]+", "", (name or "").lower()).strip()
|
||||
key = re.sub(r"\s+", " ", key)
|
||||
if not key:
|
||||
return ""
|
||||
head, _, last = key.rpartition(" ")
|
||||
for suffix, replacement in IRREGULAR_SINGULARS.items():
|
||||
if last.endswith(suffix) and len(last) > len(suffix) + 2:
|
||||
last = last[: -len(suffix)] + replacement
|
||||
break
|
||||
return f"{head} {last}".strip()
|
||||
|
||||
|
||||
def looks_like_symptom(name: str) -> bool:
|
||||
"""True only when the tag *is* a presenting feature, not merely contains one."""
|
||||
return canonical_key(name) in {canonical_key(term) for term in SYMPTOM_NAMES}
|
||||
|
||||
|
||||
def main():
|
||||
apply_changes = "--apply" in sys.argv
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(sa_text(
|
||||
"SELECT id, name, type FROM question_tags ORDER BY id")).fetchall()
|
||||
uses = dict(db.execute(sa_text(
|
||||
"SELECT tag_id, COUNT(*) FROM question_tag_links GROUP BY tag_id")).fetchall())
|
||||
|
||||
renamed, merged, reclassified, deleted = [], [], [], []
|
||||
# Within a type, everything sharing a canonical key becomes one tag.
|
||||
groups: dict[tuple[str, str], list] = defaultdict(list)
|
||||
for tag_id, name, tag_type in rows:
|
||||
key = canonical_key(name)
|
||||
if not key:
|
||||
deleted.append((tag_id, name, "blank name"))
|
||||
continue
|
||||
groups[(tag_type, key)].append((tag_id, name))
|
||||
|
||||
for (tag_type, _key), members in groups.items():
|
||||
# Keep the most-used tag; on a tie prefer the lowest id for stability.
|
||||
members.sort(key=lambda m: (-uses.get(m[0], 0), m[0]))
|
||||
keep_id, keep_name = members[0]
|
||||
tidy = normalise(keep_name)
|
||||
if tidy and tidy != keep_name:
|
||||
renamed.append((keep_id, keep_name, tidy))
|
||||
if apply_changes:
|
||||
db.execute(sa_text("UPDATE question_tags SET name = :n WHERE id = :i"),
|
||||
{"n": tidy, "i": keep_id})
|
||||
|
||||
for dup_id, dup_name in members[1:]:
|
||||
merged.append((dup_id, dup_name, keep_id, tidy or keep_name))
|
||||
if apply_changes:
|
||||
# Move links, skipping any that would duplicate an existing pair.
|
||||
db.execute(sa_text("""
|
||||
UPDATE question_tag_links SET tag_id = :keep
|
||||
WHERE tag_id = :dup AND question_id NOT IN (
|
||||
SELECT question_id FROM question_tag_links WHERE tag_id = :keep)
|
||||
"""), {"keep": keep_id, "dup": dup_id})
|
||||
db.execute(sa_text("""
|
||||
UPDATE flashcard_tag_links SET tag_id = :keep
|
||||
WHERE tag_id = :dup AND flashcard_id NOT IN (
|
||||
SELECT flashcard_id FROM flashcard_tag_links WHERE tag_id = :keep)
|
||||
"""), {"keep": keep_id, "dup": dup_id})
|
||||
db.execute(sa_text("DELETE FROM question_tag_links WHERE tag_id = :dup"), {"dup": dup_id})
|
||||
db.execute(sa_text("DELETE FROM flashcard_tag_links WHERE tag_id = :dup"), {"dup": dup_id})
|
||||
db.execute(sa_text("DELETE FROM question_tags WHERE id = :dup"), {"dup": dup_id})
|
||||
|
||||
# A presenting feature filed as a disease belongs with the keywords.
|
||||
if tag_type == "disease" and looks_like_symptom(tidy or keep_name):
|
||||
reclassified.append((keep_id, tidy or keep_name))
|
||||
if apply_changes:
|
||||
# Only if the keyword vocabulary does not already hold it.
|
||||
clash = db.execute(sa_text(
|
||||
"SELECT id FROM question_tags WHERE type='keyword' AND lower(name)=lower(:n)"),
|
||||
{"n": tidy or keep_name}).first()
|
||||
if clash:
|
||||
db.execute(sa_text("""
|
||||
UPDATE question_tag_links SET tag_id = :keep
|
||||
WHERE tag_id = :dup AND question_id NOT IN (
|
||||
SELECT question_id FROM question_tag_links WHERE tag_id = :keep)
|
||||
"""), {"keep": clash[0], "dup": keep_id})
|
||||
db.execute(sa_text("DELETE FROM question_tag_links WHERE tag_id = :dup"), {"dup": keep_id})
|
||||
db.execute(sa_text("DELETE FROM question_tags WHERE id = :dup"), {"dup": keep_id})
|
||||
else:
|
||||
db.execute(sa_text("UPDATE question_tags SET type='keyword' WHERE id = :i"),
|
||||
{"i": keep_id})
|
||||
|
||||
# Anything nothing references is dead weight in every picker.
|
||||
orphans = db.execute(sa_text("""
|
||||
SELECT id, name, type FROM question_tags t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM question_tag_links l WHERE l.tag_id = t.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM flashcard_tag_links f WHERE f.tag_id = t.id)
|
||||
""")).fetchall()
|
||||
for tag_id, name, tag_type in orphans:
|
||||
deleted.append((tag_id, name, f"unused {tag_type}"))
|
||||
if apply_changes:
|
||||
db.execute(sa_text("DELETE FROM question_tags WHERE id = :i"), {"i": tag_id})
|
||||
|
||||
if apply_changes:
|
||||
db.commit()
|
||||
|
||||
print(f"{'APPLIED' if apply_changes else 'DRY RUN'}")
|
||||
print(f" tags before : {len(rows)}")
|
||||
print(f" renamed : {len(renamed)}")
|
||||
print(f" merged away : {len(merged)}")
|
||||
print(f" disease→keyword : {len(reclassified)}")
|
||||
print(f" deleted (unused) : {len(deleted)}")
|
||||
print(f" tags after : {len(rows) - len(merged) - len(deleted)}")
|
||||
for old_id, old, new in renamed[:10]:
|
||||
print(f" rename {old!r} -> {new!r}")
|
||||
for _dup_id, dup, _keep_id, keep in merged[:10]:
|
||||
print(f" merge {dup!r} -> {keep!r}")
|
||||
for _tid, name in reclassified[:10]:
|
||||
print(f" symptom {name!r} disease -> keyword")
|
||||
if not apply_changes:
|
||||
print("\n Re-run with --apply to write these changes.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -18,6 +18,7 @@ const AccountPage = lazy(() => import('./pages/AccountPage'))
|
|||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
|
||||
const QuestionManagerPage = lazy(() => import('./pages/QuestionManagerPage'))
|
||||
const CategoriesPage = lazy(() => import('./pages/CategoriesPage'))
|
||||
const AnalysisPage = lazy(() => import('./pages/AnalysisPage'))
|
||||
const JobsPage = lazy(() => import('./pages/JobsPage'))
|
||||
const TrashPage = lazy(() => import('./pages/TrashPage'))
|
||||
|
|
@ -114,6 +115,7 @@ function AppRoutes() {
|
|||
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
|
||||
<Route path="/jobs" element={<JobsPage />} />
|
||||
<Route path="/trash" element={<TrashPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
48
frontend/src/pages/CategoriesPage.css
Normal file
48
frontend/src/pages/CategoriesPage.css
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/* Category management — the tree, with rename, reparent and delete-with-move.
|
||||
Mobile-first: rows stack and actions wrap under the name. */
|
||||
|
||||
.cat-page { max-width: 900px; margin: 0 auto; padding-bottom: 40px; }
|
||||
.cat-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.cat-header h1 { margin: 0 0 4px; font-size: 1.35rem; }
|
||||
.cat-header p { margin: 0; color: var(--text-muted); font-size: 0.87rem; }
|
||||
|
||||
.cat-toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 12px; }
|
||||
.cat-toolbar input, .cat-toolbar select {
|
||||
padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text); font-size: 0.88rem;
|
||||
}
|
||||
.cat-search { flex: 1; min-width: 180px; }
|
||||
.cat-count { margin-left: auto; font-size: 0.8rem; color: var(--text-muted); }
|
||||
|
||||
.cat-tree { list-style: none; margin: 0; padding: 0; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.cat-tree .cat-tree { border: 0; border-radius: 0; background: none; }
|
||||
.cat-row {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 11px 14px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cat-row:hover { background: var(--bg); }
|
||||
.cat-name { font-size: 0.92rem; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.cat-badge {
|
||||
font-size: 0.7rem; font-weight: 600; border-radius: 20px; padding: 2px 9px;
|
||||
background: var(--bg); color: var(--text-muted); border: 1px solid var(--border);
|
||||
}
|
||||
.cat-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.cat-child { padding-left: 22px; border-left: 2px solid var(--border); margin-left: 14px; }
|
||||
|
||||
.cat-edit { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; width: 100%; padding: 4px 0; }
|
||||
.cat-edit input, .cat-edit select {
|
||||
padding: 7px 11px; border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text); font-size: 0.87rem;
|
||||
}
|
||||
.cat-edit input { flex: 1; min-width: 150px; }
|
||||
.cat-confirm { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%; font-size: 0.84rem; color: var(--wrong-fg); padding: 4px 0; }
|
||||
.cat-error { color: var(--wrong-fg); font-size: 0.84rem; margin: 10px 0 0; }
|
||||
.cat-empty { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 28px; text-align: center; color: var(--text-muted); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cat-row { align-items: flex-start; }
|
||||
.cat-actions { margin-left: 0; width: 100%; }
|
||||
.cat-actions .btn { flex: 1; }
|
||||
.cat-child { padding-left: 12px; margin-left: 8px; }
|
||||
.cat-count { margin-left: 0; }
|
||||
}
|
||||
222
frontend/src/pages/CategoriesPage.jsx
Normal file
222
frontend/src/pages/CategoriesPage.jsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import './CategoriesPage.css'
|
||||
|
||||
const apiError = (err, fallback) => {
|
||||
const detail = err?.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) return detail.map(d => d?.msg).filter(Boolean).join('; ') || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const [editing, setEditing] = useState(null) // category id being renamed/reparented
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [draftParent, setDraftParent] = useState('')
|
||||
const [deleting, setDeleting] = useState(null) // category id awaiting confirmation
|
||||
const [moveTo, setMoveTo] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newParent, setNewParent] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true)
|
||||
api.get('/question-categories/')
|
||||
.then(res => setCategories(Array.isArray(res.data) ? res.data : []))
|
||||
.catch(err => setError(apiError(err, 'Could not load categories')))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const childrenOf = useMemo(() => {
|
||||
const map = {}
|
||||
for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat)
|
||||
for (const list of Object.values(map)) list.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return map
|
||||
}, [categories])
|
||||
|
||||
const matches = useCallback((cat) => {
|
||||
if (!query.trim()) return true
|
||||
const needle = query.trim().toLowerCase()
|
||||
if (cat.name.toLowerCase().includes(needle)) return true
|
||||
return (childrenOf[cat.id] || []).some(matches)
|
||||
}, [query, childrenOf])
|
||||
|
||||
// A category cannot become its own descendant's child.
|
||||
const descendantIds = useCallback((id) => {
|
||||
const out = new Set()
|
||||
const walk = (parent) => {
|
||||
for (const child of childrenOf[parent] || []) { out.add(child.id); walk(child.id) }
|
||||
}
|
||||
walk(id)
|
||||
return out
|
||||
}, [childrenOf])
|
||||
|
||||
const startEdit = (cat) => {
|
||||
setDeleting(null)
|
||||
setEditing(cat.id)
|
||||
setDraftName(cat.name)
|
||||
setDraftParent(cat.parent_id ?? '')
|
||||
}
|
||||
|
||||
const save = async (cat) => {
|
||||
const name = draftName.trim()
|
||||
if (!name) { setError('A category needs a name'); return }
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.patch(`/question-categories/${cat.id}`, {
|
||||
name, description: cat.description ?? null,
|
||||
parent_id: draftParent === '' ? null : Number(draftParent),
|
||||
})
|
||||
setEditing(null)
|
||||
load()
|
||||
} catch (err) { setError(apiError(err, 'Could not save this category')) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const remove = async (cat) => {
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.delete(`/question-categories/${cat.id}`,
|
||||
{ params: moveTo === '' ? {} : { move_to: Number(moveTo) } })
|
||||
setDeleting(null); setMoveTo('')
|
||||
load()
|
||||
} catch (err) { setError(apiError(err, 'Could not delete this category')) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
const name = newName.trim()
|
||||
if (!name) { setError('A category needs a name'); return }
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
await api.post('/question-categories/', {
|
||||
name, description: null, parent_id: newParent === '' ? null : Number(newParent),
|
||||
})
|
||||
setCreating(false); setNewName(''); setNewParent('')
|
||||
load()
|
||||
} catch (err) { setError(apiError(err, 'Could not create this category')) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const parentOptions = (excludeId) => {
|
||||
const blocked = excludeId ? new Set([excludeId, ...descendantIds(excludeId)]) : new Set()
|
||||
return categories.filter(c => !blocked.has(c.id))
|
||||
}
|
||||
|
||||
const renderTree = (parentId, depth = 0) => {
|
||||
const branch = (childrenOf[parentId] || []).filter(matches)
|
||||
if (!branch.length) return null
|
||||
return (
|
||||
<ul className={`cat-tree${depth > 0 ? ' cat-child' : ''}`}>
|
||||
{branch.map(cat => (
|
||||
<li key={cat.id}>
|
||||
<div className="cat-row">
|
||||
{editing === cat.id ? (
|
||||
<div className="cat-edit">
|
||||
<input value={draftName} autoFocus aria-label={`Name for ${cat.name}`}
|
||||
onChange={e => setDraftName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(cat); if (e.key === 'Escape') setEditing(null) }} />
|
||||
<select value={draftParent} aria-label={`Parent for ${cat.name}`}
|
||||
onChange={e => setDraftParent(e.target.value)}>
|
||||
<option value="">No parent (top level)</option>
|
||||
{parentOptions(cat.id).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => save(cat)}>Save category</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="cat-name">{cat.name}</span>
|
||||
<span className="cat-badge">{cat.question_count} question{cat.question_count === 1 ? '' : 's'}</span>
|
||||
{(childrenOf[cat.id] || []).length > 0 && (
|
||||
<span className="cat-badge">{childrenOf[cat.id].length} sub</span>
|
||||
)}
|
||||
<span className="cat-actions">
|
||||
<button className="btn btn-secondary btn-sm"
|
||||
aria-label={`Edit category ${cat.name}`} onClick={() => startEdit(cat)}>Edit</button>
|
||||
<button className="btn btn-secondary btn-sm"
|
||||
aria-label={`Delete category ${cat.name}`}
|
||||
onClick={() => { setEditing(null); setDeleting(cat.id); setMoveTo('') }}>Delete</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{deleting === cat.id && (
|
||||
<div className="cat-confirm" role="alert">
|
||||
{(childrenOf[cat.id] || []).length > 0
|
||||
? <span>Move its {childrenOf[cat.id].length} subcategor{childrenOf[cat.id].length === 1 ? 'y' : 'ies'} first.</span>
|
||||
: <>
|
||||
<span>Delete “{cat.name}”? Move its {cat.question_count} question{cat.question_count === 1 ? '' : 's'} to:</span>
|
||||
<select value={moveTo} aria-label={`Move questions from ${cat.name} to`}
|
||||
onChange={e => setMoveTo(e.target.value)}>
|
||||
<option value="">Uncategorized</option>
|
||||
{parentOptions(cat.id).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-danger btn-sm" disabled={busy}
|
||||
onClick={() => remove(cat)}>Delete category</button>
|
||||
</>}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setDeleting(null)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderTree(cat.id, depth + 1)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cat-page">
|
||||
<div className="cat-header">
|
||||
<div>
|
||||
<h1>Categories</h1>
|
||||
<p>Organise the system tree: rename, move under a different parent, or delete and rehome its questions.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Link className="btn btn-secondary" to="/question-bank">Question bank</Link>
|
||||
<button className="btn btn-primary" onClick={() => setCreating(v => !v)}>+ New category</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="cat-edit" style={{ marginBottom: 12 }}>
|
||||
<input value={newName} autoFocus placeholder="Category name…" aria-label="New category name"
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') create() }} />
|
||||
<select value={newParent} aria-label="New category parent" onChange={e => setNewParent(e.target.value)}>
|
||||
<option value="">No parent (top level)</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={create}>Create</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setCreating(false)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cat-toolbar">
|
||||
<input className="cat-search" value={query} onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search categories…" aria-label="Search categories" />
|
||||
<span className="cat-count">{categories.length} categories</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="cat-error" role="alert">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner" /> Loading…</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="cat-empty">No categories yet.</div>
|
||||
) : (
|
||||
renderTree(0) || <div className="cat-empty">No categories match “{query}”.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
frontend/src/pages/CategoriesPage.test.jsx
Normal file
112
frontend/src/pages/CategoriesPage.test.jsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { expect, it, vi, beforeEach } from 'vitest'
|
||||
import CategoriesPage from './CategoriesPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() } }))
|
||||
|
||||
const CATS = [
|
||||
{ id: 1, name: 'Root', parent_id: null, question_count: 2, description: null },
|
||||
{ id: 2, name: 'Child', parent_id: 1, question_count: 5, description: null },
|
||||
{ id: 3, name: 'Other', parent_id: null, question_count: 0, description: null },
|
||||
]
|
||||
|
||||
const mockCats = (cats = CATS) => api.get.mockResolvedValue({ data: cats })
|
||||
|
||||
beforeEach(() => { vi.clearAllMocks(); mockCats() })
|
||||
|
||||
const mount = () => render(<MemoryRouter><CategoriesPage /></MemoryRouter>)
|
||||
|
||||
it('renders the tree with question and subcategory counts', async () => {
|
||||
mount()
|
||||
expect(await screen.findByText('Root')).toBeInTheDocument()
|
||||
expect(screen.getByText('Child')).toBeInTheDocument()
|
||||
expect(screen.getByText('5 questions')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 sub')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reparents a category and reloads', async () => {
|
||||
mount()
|
||||
await screen.findByText('Child')
|
||||
api.patch.mockResolvedValue({ data: {} })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit category Child' }))
|
||||
await userEvent.selectOptions(screen.getByLabelText('Parent for Child'), '3')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save category' }))
|
||||
|
||||
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/question-categories/2',
|
||||
{ name: 'Child', description: null, parent_id: 3 }))
|
||||
})
|
||||
|
||||
it('never offers a category its own descendant as a parent', async () => {
|
||||
mount()
|
||||
await screen.findByText('Root')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit category Root' }))
|
||||
const parent = screen.getByLabelText('Parent for Root')
|
||||
// Root cannot sit under Child (its own descendant) or under itself.
|
||||
expect([...parent.options].map(o => o.text)).toEqual(['No parent (top level)', 'Other'])
|
||||
})
|
||||
|
||||
it('deletes a leaf category, rehoming its questions', async () => {
|
||||
mount()
|
||||
await screen.findByText('Child')
|
||||
api.delete.mockResolvedValue({})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete category Child' }))
|
||||
await userEvent.selectOptions(screen.getByLabelText('Move questions from Child to'), '3')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete category' }))
|
||||
|
||||
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/2',
|
||||
{ params: { move_to: 3 } }))
|
||||
})
|
||||
|
||||
it('defaults to uncategorized when no destination is picked', async () => {
|
||||
mount()
|
||||
await screen.findByText('Child')
|
||||
api.delete.mockResolvedValue({})
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete category Child' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete category' }))
|
||||
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/2', { params: {} }))
|
||||
})
|
||||
|
||||
it('refuses to delete a category that still has subcategories', async () => {
|
||||
mount()
|
||||
await screen.findByText('Root')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete category Root' }))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Move its 1 subcategory first')
|
||||
expect(screen.queryByRole('button', { name: 'Delete category' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a category under a chosen parent', async () => {
|
||||
mount()
|
||||
await screen.findByText('Root')
|
||||
api.post.mockResolvedValue({ data: { id: 9 } })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '+ New category' }))
|
||||
await userEvent.type(screen.getByLabelText('New category name'), 'Neonatology')
|
||||
await userEvent.selectOptions(screen.getByLabelText('New category parent'), '1')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/question-categories/',
|
||||
{ name: 'Neonatology', description: null, parent_id: 1 }))
|
||||
})
|
||||
|
||||
it('filters the tree by search, keeping parents of matches', async () => {
|
||||
mount()
|
||||
await screen.findByText('Root')
|
||||
await userEvent.type(screen.getByLabelText('Search categories'), 'child')
|
||||
expect(screen.getByText('Child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Root')).toBeInTheDocument() // kept: it holds the match
|
||||
expect(screen.queryByText('Other')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('surfaces a server refusal', async () => {
|
||||
mount()
|
||||
await screen.findByText('Child')
|
||||
api.patch.mockRejectedValue({ response: { data: { detail: 'A category cannot be its own ancestor' } } })
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit category Child' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save category' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('cannot be its own ancestor')
|
||||
})
|
||||
|
|
@ -129,16 +129,19 @@
|
|||
.custom-test-note { color: var(--text-muted); font-size: 0.8rem; line-height: 1.6; margin: 8px 0 0; }
|
||||
.custom-test-error { color: var(--wrong-fg); font-size: 0.84rem; margin: 8px 0 0; }
|
||||
|
||||
/* ── Sticky bottom bar: mode and Start stay reachable without scrolling ── */
|
||||
/* ── Sticky bottom bar: mode and Start stay reachable without scrolling ──
|
||||
Full-bleed via the viewport, not by guessing the page's own padding: a
|
||||
hardcoded negative margin overflowed whenever the container padding differed. */
|
||||
.custom-test-bar {
|
||||
grid-column: 1 / -1;
|
||||
position: sticky; bottom: 0; z-index: 40;
|
||||
margin: 4px -16px -100px; padding: 0 16px;
|
||||
margin-top: 4px;
|
||||
margin-inline: calc(50% - 50vw);
|
||||
padding-inline: max(16px, calc(50vw - 560px));
|
||||
background: var(--card-bg); border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.custom-test-bar-inner {
|
||||
max-width: 1120px; margin: 0 auto;
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 12px 0 calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
|
@ -245,16 +248,27 @@
|
|||
.custom-test-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.custom-test { padding-bottom: 120px; }
|
||||
.custom-test { padding-bottom: 132px; padding-inline: 2px; }
|
||||
.custom-test-top { grid-template-columns: 1fr; }
|
||||
.custom-test-reset { justify-self: start; padding-left: 0; }
|
||||
.custom-test-card > h2 { padding: 13px 14px; font-size: 1rem; }
|
||||
.custom-test-card-pad, .custom-test-adaptive, .custom-test-countbox { padding: 12px 14px; }
|
||||
.facet-row { padding: 13px 14px; gap: 10px; }
|
||||
|
||||
.facet-overlay { align-items: flex-end; }
|
||||
.facet-panel { width: 100%; height: 88vh; border-radius: 16px 16px 0 0; }
|
||||
.facet-chip { max-width: 40vw; }
|
||||
.facet-panel-body label { padding: 12px 2px; } /* comfortable tap targets */
|
||||
|
||||
/* Long names must truncate, never widen the row into a sideways scroll. */
|
||||
.facet-row-summary { min-width: 0; flex: 1; justify-content: flex-end; }
|
||||
.facet-chip, .facet-row-value { min-width: 0; max-width: none; }
|
||||
|
||||
/* Two tidy rows: modes across the top, then Refresh and Create together. */
|
||||
.custom-test-bar-label { display: none; }
|
||||
.custom-test-bar-inner { padding: 10px 0 calc(10px + env(safe-area-inset-bottom)); }
|
||||
.custom-test-modes { flex: 1; }
|
||||
.custom-test-bar-inner { padding: 10px 0 calc(10px + env(safe-area-inset-bottom)); gap: 8px; }
|
||||
.custom-test-modes { flex: 1 0 100%; }
|
||||
.custom-test-modes label { flex: 1; }
|
||||
.custom-test-modes span { text-align: center; padding: 9px 10px; }
|
||||
.custom-test-start { width: 100%; margin-left: 0; }
|
||||
.custom-test-modes span { text-align: center; padding: 10px; }
|
||||
.custom-test-bar-inner > .btn-secondary { flex: 0 0 auto; }
|
||||
.custom-test-start { flex: 1; width: auto; margin-left: 0; min-width: 0; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,3 +47,13 @@ details[open] > .category-tree-branch .category-tree-chevron { transform: rotate
|
|||
.bank-filters-overlay { align-items: flex-end; }
|
||||
.bank-filters-panel { width: 100%; height: 88vh; border-radius: 16px 16px 0 0; }
|
||||
}
|
||||
|
||||
/* ── Filter facets ────────────────────────────────────────────────── */
|
||||
.bank-filters-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.bank-filters-summary { margin-left: auto; font-size: .8rem; color: var(--text-muted); }
|
||||
.bank-facets { margin-bottom: 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--card-bg); }
|
||||
.bank-facets .facet-row:first-child { border-top: 0; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.bank-filters-summary { margin-left: 0; width: 100%; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useAuth } from '../context/AuthContext'
|
|||
import api from '../api/client'
|
||||
import Dialog from '../components/Dialog'
|
||||
import CategoryTree from '../components/CategoryTree'
|
||||
import FacetPicker, { FacetRow } from '../components/FacetPicker'
|
||||
import './CustomQuizPage.css' // facet row + picker panel styles
|
||||
import { useDialog } from '../hooks/useDialog'
|
||||
|
||||
const TeachChat = lazy(() => import('../components/TeachChat'))
|
||||
|
|
@ -11,6 +13,14 @@ const RichEditor = lazy(() => import('../components/RichEditor'))
|
|||
import QuestionReadingLinks from '../components/QuestionReadingLinks'
|
||||
import { QuestionEditModal, CreateQuestionModal } from '../components/QuestionEditors'
|
||||
|
||||
const DIFFICULTY_LABEL = { '': 'Any', easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
/** "All", one name, or the first name plus a +N badge. */
|
||||
const summarise = (names) => ({
|
||||
summary: names.length === 0 ? 'All' : names[0],
|
||||
extra: Math.max(0, names.length - 1),
|
||||
})
|
||||
|
||||
function apiError(err, fallback) {
|
||||
const detail = err?.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
|
|
@ -299,6 +309,7 @@ export default function QuestionBankPage() {
|
|||
const [pageSize, setPageSize] = useState(50)
|
||||
const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [] })
|
||||
const [selectedTagIds, setSelectedTagIds] = useState([])
|
||||
const [openFacet, setOpenFacet] = useState(null)
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [showCreateQuestion, setShowCreateQuestion] = useState(false)
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
|
|
@ -347,6 +358,58 @@ export default function QuestionBankPage() {
|
|||
return () => clearTimeout(debounceRef.current)
|
||||
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, showMyQuestions, pageSize, tagIdsKey, difficulty, bankArticleIds.join(',')])
|
||||
|
||||
const subjectTags = tags.subjects || []
|
||||
const diseaseTags = tags.diseases || []
|
||||
const keywordTags = tags.keywords || []
|
||||
const nameOf = (list, id) => list.find(t => t.id === id)?.name
|
||||
const selectedIn = (list) => selectedTagIds.map(id => nameOf(list, id)).filter(Boolean)
|
||||
|
||||
const systemsFacet = summarise(filterCatIds.map(id => categories.find(c => c.id === id)?.name).filter(Boolean))
|
||||
const disciplinesFacet = summarise(selectedIn(subjectTags))
|
||||
const diseasesFacet = summarise(selectedIn(diseaseTags))
|
||||
const symptomsFacet = summarise(selectedIn(keywordTags))
|
||||
const articlesFacet = summarise(bankArticleIds.map(id => articles.find(a => a.id === id)?.title).filter(Boolean))
|
||||
const statusFacet = showFavorites ? { summary: 'Saved', active: true }
|
||||
: showMyQuestions ? { summary: 'My questions', active: true }
|
||||
: showUncategorized ? { summary: 'Uncategorized', active: true }
|
||||
: { summary: 'All', active: false }
|
||||
|
||||
const activeFilterCount = filterCatIds.length + selectedTagIds.length + bankArticleIds.length
|
||||
+ (difficulty ? 1 : 0) + (statusFacet.active ? 1 : 0)
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilterCatIds([]); setSelectedTagIds([]); setBankArticleIds([]); setDifficulty('')
|
||||
setShowFavorites(false); setShowMyQuestions(false); setShowUncategorized(false)
|
||||
}
|
||||
|
||||
const setStatus = (value) => {
|
||||
setShowFavorites(value === 'favorites')
|
||||
setShowMyQuestions(value === 'mine')
|
||||
setShowUncategorized(value === 'uncategorized')
|
||||
}
|
||||
|
||||
/** Checklist for a tag vocabulary: most-used first, everything reachable by search. */
|
||||
const tagChecklist = (list, query) => {
|
||||
const shown = query
|
||||
? list.filter(t => t.name.toLowerCase().includes(query))
|
||||
: list.slice(0, 60) // the tail is long; search reaches it
|
||||
if (!shown.length) return <p className="facet-panel-empty">Nothing matches that search.</p>
|
||||
return (
|
||||
<>
|
||||
{shown.map(tag => (
|
||||
<label key={tag.id}>
|
||||
<input type="checkbox" checked={selectedTagIds.includes(tag.id)} onChange={() => toggleTag(tag.id)} />
|
||||
{tag.name}
|
||||
<span className="facet-panel-count">{tag.count}</span>
|
||||
</label>
|
||||
))}
|
||||
{!query && list.length > shown.length && (
|
||||
<p className="facet-panel-empty">{list.length - shown.length} more — search to narrow.</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
setSelectedTagIds(prev => {
|
||||
const n = prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
|
||||
|
|
@ -643,118 +706,30 @@ export default function QuestionBankPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter side box (AMBOSS-style) */}
|
||||
{/* Filters — one row per facet, each opening a search + checklist panel. */}
|
||||
<div className="bank-layout">
|
||||
<div className="bank-filters-bar">
|
||||
<button type="button" className="btn btn-secondary btn-sm" aria-expanded={filtersOpen} onClick={() => setFiltersOpen(true)}>
|
||||
⚙ Filters{(filterCatIds.length || showFavorites || showUncategorized || showMyQuestions || difficulty || bankArticleIds.length || selectedTagIds.length) ? ' ●' : ''}
|
||||
<button type="button" className="btn btn-secondary btn-sm" aria-expanded={filtersOpen}
|
||||
onClick={() => setFiltersOpen(v => !v)}>
|
||||
⚙ Filters{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
|
||||
</button>
|
||||
{activeFilterCount > 0 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={resetFilters}>Reset</button>
|
||||
)}
|
||||
<span className="bank-filters-summary">{total} question{total !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="bank-filters-overlay" onClick={e => e.target === e.currentTarget && setFiltersOpen(false)}>
|
||||
<div className="bank-filters-panel" role="dialog" aria-label="Question filters">
|
||||
<header className="bank-filters-header">
|
||||
<h2>Filters</h2>
|
||||
<button type="button" aria-label="Close filters" onClick={() => setFiltersOpen(false)}>✕</button>
|
||||
</header>
|
||||
<div className="bank-filters-body">
|
||||
<aside className="bank-filters card">
|
||||
<h2 className="bank-filters-sr">Filters</h2>
|
||||
<div className="bank-state-buttons">
|
||||
<button className={`btn btn-sm ${filterCatIds.length === 0 && !showUncategorized && !showFavorites && !showMyQuestions ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setFilterCatIds([]); setShowUncategorized(false); setShowFavorites(false); setShowMyQuestions(false) }}>All ({total})</button>
|
||||
<button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setShowFavorites(v => !v); setFilterCatIds([]); setShowUncategorized(false) }}>⭐ Favorites ({favorites.length})</button>
|
||||
<button className={`btn btn-sm ${showMyQuestions ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setShowMyQuestions(v => !v); setFilterCatIds([]); setShowUncategorized(false); setShowFavorites(false) }}>My Questions</button>
|
||||
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setShowUncategorized(v => !v); setFilterCatIds([]); setShowFavorites(false); setShowMyQuestions(false) }}>Uncategorized</button>
|
||||
</div>
|
||||
<label className="bank-difficulty">Difficulty
|
||||
<select value={difficulty} onChange={e => setDifficulty(e.target.value)}>
|
||||
<option value="">Any</option><option value="easy">Easy</option>
|
||||
<option value="medium">Medium</option><option value="hard">Hard</option>
|
||||
</select>
|
||||
</label>
|
||||
<h3>Articles</h3>
|
||||
<div className="bank-articles">
|
||||
{articles.map(article => (
|
||||
<label key={article.id}>
|
||||
<input type="checkbox" checked={bankArticleIds.includes(article.id)}
|
||||
onChange={e => setBankArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
|
||||
{article.title}
|
||||
</label>
|
||||
))}
|
||||
{articles.length === 0 && <p style={{ color: 'var(--text-muted)', fontSize: '.8rem' }}>No articles yet.</p>}
|
||||
</div>
|
||||
<h3>Exams</h3>
|
||||
<label className="custom-test-exam"><input type="checkbox" checked readOnly /> Pediatrics Boards</label>
|
||||
<h3>Disciplines</h3>
|
||||
<div className="custom-test-tags">
|
||||
{(tags.subjects || []).slice(0, 40).map(tag => (
|
||||
<label key={`d-${tag.id}`}>
|
||||
<input type="checkbox" checked={selectedTagIds.includes(tag.id)} onChange={() => toggleTag(tag.id)} />
|
||||
{tag.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<h3>Symptoms & keywords</h3>
|
||||
<div className="custom-test-tags">
|
||||
{(tags.keywords || []).slice(0, 40).map(tag => (
|
||||
<label key={`s-${tag.id}`}>
|
||||
<input type="checkbox" checked={selectedTagIds.includes(tag.id)} onChange={() => toggleTag(tag.id)} />
|
||||
{tag.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<h3>Categories</h3>
|
||||
<div className="bank-category-list">
|
||||
{categories.map(cat => {
|
||||
const isActive = filterCatIds.includes(cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="bank-category-row">
|
||||
<button className={`bank-category-chip ${isActive ? 'active' : ''}`}
|
||||
onClick={() => { setFilterCatIds(prev => prev.includes(cat.id) ? prev.filter(c => c !== cat.id) : [...prev, cat.id]); setShowUncategorized(false); setShowFavorites(false) }}>
|
||||
{(cat.breadcrumbs || []).map(c => c.name).join(' › ') || cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
|
||||
</button>
|
||||
{isModerator && <button className="btn btn-secondary btn-sm" aria-label={`Edit category ${cat.name}`} onClick={() => { setEditingCategory(cat); setNewCatName(cat.name); setCatParent(cat.parent_id || ''); setShowCatForm(true) }}>Edit</button>}
|
||||
{isModerator && <button aria-label={`Delete category ${cat.name}`} 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>
|
||||
{selectedTagIds.map(tid => {
|
||||
const allTags = [...(tags.subjects || []), ...(tags.diseases || []), ...(tags.keywords || [])]
|
||||
const tag = allTags.find(t => t.id === tid)
|
||||
if (!tag) return null
|
||||
const color = (tags.subjects || []).find(t => t.id === tid) ? '#8b5cf6' : (tags.diseases || []).find(t => t.id === tid) ? '#ef4444' : '#0ea5e9'
|
||||
return (
|
||||
<button key={`tag-${tid}`} onClick={() => toggleTag(tid)}
|
||||
style={{ background: color, color: '#fff', border: 'none', fontSize: '0.72rem', padding: '3px 10px', borderRadius: 12, cursor: 'pointer', fontWeight: 600 }}>
|
||||
{tag.name} ✕
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{selectedTagIds.length > 0 && (
|
||||
<button onClick={() => setSelectedTagIds([])}
|
||||
style={{ background: 'none', border: 'none', fontSize: '0.72rem', color: 'var(--text-muted)', cursor: 'pointer', textDecoration: 'underline' }}>
|
||||
Clear tags
|
||||
</button>
|
||||
)}
|
||||
{(tags.subjects?.length > 0 || tags.diseases?.length > 0 || tags.keywords?.length > 0) && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setShowTags(v => !v)}
|
||||
style={{ fontSize: '0.78rem', marginBottom: showTags ? 6 : 0 }}>
|
||||
🏷 Browse Tags {showTags ? '▲' : '▼'}
|
||||
</button>
|
||||
{showTags && <TagBrowser tags={tags} selectedTagIds={selectedTagIds} toggleTag={toggleTag} />}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div className="facet-list bank-facets">
|
||||
<FacetRow label="Status" summary={statusFacet.summary} extra={0} chip={statusFacet.active}
|
||||
onOpen={() => setOpenFacet('status')} />
|
||||
<FacetRow label="Difficulty" summary={DIFFICULTY_LABEL[difficulty]} extra={0}
|
||||
chip={!!difficulty} onOpen={() => setOpenFacet('difficulty')} />
|
||||
<FacetRow label="Systems" {...systemsFacet} onOpen={() => setOpenFacet('systems')} />
|
||||
<FacetRow label="Disciplines" {...disciplinesFacet} onOpen={() => setOpenFacet('disciplines')} />
|
||||
<FacetRow label="Diseases" {...diseasesFacet} onOpen={() => setOpenFacet('diseases')} />
|
||||
<FacetRow label="Symptoms" {...symptomsFacet} onOpen={() => setOpenFacet('symptoms')} />
|
||||
<FacetRow label="Articles" {...articlesFacet} onOpen={() => setOpenFacet('articles')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -901,6 +876,83 @@ export default function QuestionBankPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FacetPicker title="Status" open={openFacet === 'status'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setStatus('all')} helper="Narrow to a slice of the bank.">
|
||||
{() => [['all', `All (${total})`], ['favorites', `Saved (${favorites.length})`],
|
||||
['mine', 'My questions'], ['uncategorized', 'Uncategorized']].map(([value, label]) => (
|
||||
<label key={value}>
|
||||
<input type="radio" name="bank-status"
|
||||
checked={(value === 'all' && !statusFacet.active)
|
||||
|| (value === 'favorites' && showFavorites)
|
||||
|| (value === 'mine' && showMyQuestions)
|
||||
|| (value === 'uncategorized' && showUncategorized)}
|
||||
onChange={() => setStatus(value)} />
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</FacetPicker>
|
||||
|
||||
<FacetPicker title="Difficulty" open={openFacet === 'difficulty'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setDifficulty('')} helper="Applies to every question listed.">
|
||||
{() => ['', 'easy', 'medium', 'hard'].map(value => (
|
||||
<label key={value || 'any'}>
|
||||
<input type="radio" name="bank-difficulty" checked={difficulty === value}
|
||||
onChange={() => setDifficulty(value)} />
|
||||
{DIFFICULTY_LABEL[value]}
|
||||
</label>
|
||||
))}
|
||||
</FacetPicker>
|
||||
|
||||
<FacetPicker title="Systems" open={openFacet === 'systems'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setFilterCatIds([])}
|
||||
helper="By default all systems are included unless filters are selected.">
|
||||
{query => {
|
||||
const shown = categories.filter(c => !query || c.name.toLowerCase().includes(query))
|
||||
if (!shown.length) return <p className="facet-panel-empty">Nothing matches that search.</p>
|
||||
return shown.map(cat => (
|
||||
<label key={cat.id}>
|
||||
<input type="checkbox" checked={filterCatIds.includes(cat.id)}
|
||||
onChange={e => setFilterCatIds(ids => e.target.checked ? [...ids, cat.id] : ids.filter(id => id !== cat.id))} />
|
||||
{(cat.breadcrumbs || []).map(c => c.name).join(' › ') || cat.name}
|
||||
<span className="facet-panel-count">{cat.question_count}</span>
|
||||
</label>
|
||||
))
|
||||
}}
|
||||
</FacetPicker>
|
||||
|
||||
<FacetPicker title="Disciplines" open={openFacet === 'disciplines'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setSelectedTagIds(ids => ids.filter(id => !subjectTags.some(t => t.id === id)))}
|
||||
helper="Specialty areas, most used first.">
|
||||
{query => tagChecklist(subjectTags, query)}
|
||||
</FacetPicker>
|
||||
|
||||
<FacetPicker title="Diseases" open={openFacet === 'diseases'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setSelectedTagIds(ids => ids.filter(id => !diseaseTags.some(t => t.id === id)))}
|
||||
helper="Named conditions, most used first.">
|
||||
{query => tagChecklist(diseaseTags, query)}
|
||||
</FacetPicker>
|
||||
|
||||
<FacetPicker title="Symptoms" open={openFacet === 'symptoms'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setSelectedTagIds(ids => ids.filter(id => !keywordTags.some(t => t.id === id)))}
|
||||
helper="Presenting features and keywords, most used first.">
|
||||
{query => tagChecklist(keywordTags, query)}
|
||||
</FacetPicker>
|
||||
|
||||
<FacetPicker title="Articles" open={openFacet === 'articles'} onClose={() => setOpenFacet(null)}
|
||||
onReset={() => setBankArticleIds([])} helper="Questions linked to a topic article.">
|
||||
{query => {
|
||||
const shown = articles.filter(a => !query || (a.title || '').toLowerCase().includes(query))
|
||||
if (!shown.length) return <p className="facet-panel-empty">No articles match.</p>
|
||||
return shown.map(article => (
|
||||
<label key={article.id}>
|
||||
<input type="checkbox" checked={bankArticleIds.includes(article.id)}
|
||||
onChange={e => setBankArticleIds(ids => e.target.checked ? [...ids, article.id] : ids.filter(id => id !== article.id))} />
|
||||
{article.title}
|
||||
</label>
|
||||
))
|
||||
}}
|
||||
</FacetPicker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,73 +136,6 @@ describe('QuestionBankPage review regressions', () => {
|
|||
expect(await screen.findByText('Quiz title is invalid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reloads active parent results after moving its child', async () => {
|
||||
let moved = false
|
||||
const cats = [
|
||||
{ id: 1, name: 'Root', question_count: 1, breadcrumbs: [{ id: 1, name: 'Root' }] },
|
||||
{ id: 2, name: 'Child', parent_id: 1, question_count: 1, breadcrumbs: [{ id: 1, name: 'Root' }, { id: 2, name: 'Child' }] },
|
||||
{ id: 3, name: 'Other', question_count: 0, breadcrumbs: [{ id: 3, name: 'Other' }] },
|
||||
]
|
||||
const initial = api.get.getMockImplementation()
|
||||
api.get.mockImplementation((url, options) => {
|
||||
if (url === '/question-categories/') return Promise.resolve({ data: cats })
|
||||
if (url === '/questions/bank') {
|
||||
const questions = moved && options?.params?.category_ids === '1' ? [] : [{ id: 10, question_text: 'Child question', options: ['Yes', 'No'], question_type: 'mcq', correct_answer: 'Yes' }]
|
||||
return Promise.resolve({ data: { questions, total: questions.length } })
|
||||
}
|
||||
return initial(url)
|
||||
})
|
||||
api.patch.mockImplementation(() => { moved = true; return Promise.resolve({ data: {} }) })
|
||||
renderPage()
|
||||
await openFilters()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Root (1)', exact: true }))
|
||||
await screen.findByText('Child question')
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/questions/bank', expect.objectContaining({ params: expect.objectContaining({ category_ids: '1' }) })))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit category Child' }))
|
||||
await userEvent.selectOptions(screen.getByLabelText('Parent category'), '3')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save category' }))
|
||||
await waitFor(() => expect(screen.queryByText('Child question')).not.toBeInTheDocument())
|
||||
expect(await screen.findByText('0 questions total')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers relocation even when visible count is zero', async () => {
|
||||
const initial = api.get.getMockImplementation()
|
||||
api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: [
|
||||
{ id: 1, name: 'Hidden assignments', question_count: 0 }, { id: 3, name: 'Destination', question_count: 0 },
|
||||
] }) : initial(url))
|
||||
api.delete.mockResolvedValue({})
|
||||
renderPage()
|
||||
await openFilters()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Delete category Hidden assignments' }))
|
||||
expect(screen.getByText(/including private and course questions excluded/)).toBeInTheDocument()
|
||||
await userEvent.selectOptions(screen.getByLabelText('Move all assigned questions to:'), '3')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete category', exact: true }))
|
||||
await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/1', { params: { move_to: 3 } }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('QuestionBankPage category hierarchy', () => {
|
||||
it('edits parent assignment with breadcrumb choices and excludes descendants', async () => {
|
||||
vi.clearAllMocks()
|
||||
const cats = [
|
||||
{ id: 1, name: 'Root', parent_id: null, question_count: 2, breadcrumbs: [{ id: 1, name: 'Root' }] },
|
||||
{ id: 2, name: 'Child', parent_id: 1, question_count: 2, breadcrumbs: [{ id: 1, name: 'Root' }, { id: 2, name: 'Child' }] },
|
||||
{ id: 3, name: 'Other', parent_id: null, question_count: 0, breadcrumbs: [{ id: 3, name: 'Other' }] },
|
||||
]
|
||||
mockInitialRequests()
|
||||
const initialGet = api.get.getMockImplementation()
|
||||
api.get.mockImplementation(url => url === '/question-categories/' ? Promise.resolve({ data: cats }) : initialGet(url))
|
||||
api.patch = vi.fn().mockResolvedValue({ data: {} })
|
||||
renderPage()
|
||||
expect(screen.queryByRole('link', { name: 'Create Custom Test' })).not.toBeInTheDocument()
|
||||
await openFilters()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Edit category Root' }))
|
||||
const parent = screen.getByLabelText('Parent category')
|
||||
expect([...parent.options].map(o => o.text)).toEqual(['No parent (root)', 'Other'])
|
||||
await userEvent.selectOptions(parent, '3')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save category' }))
|
||||
await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/question-categories/1', { name: 'Root', parent_id: 3, description: null }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('QuestionBankPage edit modal multi-category', () => {
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ export default function QuestionManagerPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="qm-header-actions">
|
||||
<Link className="btn btn-secondary" to="/categories">Categories</Link>
|
||||
<Link className="btn btn-secondary" to="/question-bank">Open question bank</Link>
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>+ New question</button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue