From 878e61c69bcbfb2bcc944bafe79bf816e537aece Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 00:13:31 +0200 Subject: [PATCH] feat: facet filters on the question bank, category page, sanitised tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014yhHB8Pc7oQqyqn2Vo9DXA --- backend/scripts/sanitize_tags.py | 199 ++++++++++++++ frontend/src/App.jsx | 2 + frontend/src/pages/CategoriesPage.css | 48 ++++ frontend/src/pages/CategoriesPage.jsx | 222 ++++++++++++++++ frontend/src/pages/CategoriesPage.test.jsx | 112 ++++++++ frontend/src/pages/CustomQuizPage.css | 32 ++- frontend/src/pages/QuestionBankPage.css | 10 + frontend/src/pages/QuestionBankPage.jsx | 266 +++++++++++-------- frontend/src/pages/QuestionBankPage.test.jsx | 67 ----- frontend/src/pages/QuestionManagerPage.jsx | 1 + 10 files changed, 776 insertions(+), 183 deletions(-) create mode 100644 backend/scripts/sanitize_tags.py create mode 100644 frontend/src/pages/CategoriesPage.css create mode 100644 frontend/src/pages/CategoriesPage.jsx create mode 100644 frontend/src/pages/CategoriesPage.test.jsx diff --git a/backend/scripts/sanitize_tags.py b/backend/scripts/sanitize_tags.py new file mode 100644 index 0000000..28045b1 --- /dev/null +++ b/backend/scripts/sanitize_tags.py @@ -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()) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 922aea8..739bcd3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> diff --git a/frontend/src/pages/CategoriesPage.css b/frontend/src/pages/CategoriesPage.css new file mode 100644 index 0000000..3ede05b --- /dev/null +++ b/frontend/src/pages/CategoriesPage.css @@ -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; } +} diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx new file mode 100644 index 0000000..3beccc7 --- /dev/null +++ b/frontend/src/pages/CategoriesPage.jsx @@ -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 ( +
    0 ? ' cat-child' : ''}`}> + {branch.map(cat => ( +
  • +
    + {editing === cat.id ? ( +
    + setDraftName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') save(cat); if (e.key === 'Escape') setEditing(null) }} /> + + + +
    + ) : ( + <> + {cat.name} + {cat.question_count} question{cat.question_count === 1 ? '' : 's'} + {(childrenOf[cat.id] || []).length > 0 && ( + {childrenOf[cat.id].length} sub + )} + + + + + + )} + + {deleting === cat.id && ( +
    + {(childrenOf[cat.id] || []).length > 0 + ? Move its {childrenOf[cat.id].length} subcategor{childrenOf[cat.id].length === 1 ? 'y' : 'ies'} first. + : <> + Delete “{cat.name}”? Move its {cat.question_count} question{cat.question_count === 1 ? '' : 's'} to: + + + } + +
    + )} +
    + {renderTree(cat.id, depth + 1)} +
  • + ))} +
+ ) + } + + return ( +
+
+
+

Categories

+

Organise the system tree: rename, move under a different parent, or delete and rehome its questions.

+
+
+ Question bank + +
+
+ + {creating && ( +
+ setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') create() }} /> + + + +
+ )} + +
+ setQuery(e.target.value)} + placeholder="Search categories…" aria-label="Search categories" /> + {categories.length} categories +
+ + {error &&

{error}

} + + {loading ? ( +
Loading…
+ ) : categories.length === 0 ? ( +
No categories yet.
+ ) : ( + renderTree(0) ||
No categories match “{query}”.
+ )} +
+ ) +} diff --git a/frontend/src/pages/CategoriesPage.test.jsx b/frontend/src/pages/CategoriesPage.test.jsx new file mode 100644 index 0000000..69dde28 --- /dev/null +++ b/frontend/src/pages/CategoriesPage.test.jsx @@ -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() + +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') +}) diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index 18fe683..c9b50b3 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -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; } } diff --git a/frontend/src/pages/QuestionBankPage.css b/frontend/src/pages/QuestionBankPage.css index fa86488..e7664b8 100644 --- a/frontend/src/pages/QuestionBankPage.css +++ b/frontend/src/pages/QuestionBankPage.css @@ -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%; } +} diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index cd456c2..e0a2567 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -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

Nothing matches that search.

+ return ( + <> + {shown.map(tag => ( + + ))} + {!query && list.length > shown.length && ( +

{list.length - shown.length} more — search to narrow.

+ )} + + ) + } + 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() { )}
- {/* Filter side box (AMBOSS-style) */} + {/* Filters — one row per facet, each opening a search + checklist panel. */}
- + {activeFilterCount > 0 && ( + + )} + {total} question{total !== 1 ? 's' : ''}
+ {filtersOpen && ( -
e.target === e.currentTarget && setFiltersOpen(false)}> -
-
-

Filters

- -
-
- -
-
+
+ setOpenFacet('status')} /> + setOpenFacet('difficulty')} /> + setOpenFacet('systems')} /> + setOpenFacet('disciplines')} /> + setOpenFacet('diseases')} /> + setOpenFacet('symptoms')} /> + setOpenFacet('articles')} />
)} @@ -901,6 +876,83 @@ export default function QuestionBankPage() { )}
+ + 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]) => ( + + ))} + + + setOpenFacet(null)} + onReset={() => setDifficulty('')} helper="Applies to every question listed."> + {() => ['', 'easy', 'medium', 'hard'].map(value => ( + + ))} + + + 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

Nothing matches that search.

+ return shown.map(cat => ( + + )) + }} +
+ + setOpenFacet(null)} + onReset={() => setSelectedTagIds(ids => ids.filter(id => !subjectTags.some(t => t.id === id)))} + helper="Specialty areas, most used first."> + {query => tagChecklist(subjectTags, query)} + + + setOpenFacet(null)} + onReset={() => setSelectedTagIds(ids => ids.filter(id => !diseaseTags.some(t => t.id === id)))} + helper="Named conditions, most used first."> + {query => tagChecklist(diseaseTags, query)} + + + 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)} + + + 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

No articles match.

+ return shown.map(article => ( + + )) + }} +
) } diff --git a/frontend/src/pages/QuestionBankPage.test.jsx b/frontend/src/pages/QuestionBankPage.test.jsx index f8c468d..2174b40 100644 --- a/frontend/src/pages/QuestionBankPage.test.jsx +++ b/frontend/src/pages/QuestionBankPage.test.jsx @@ -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', () => { diff --git a/frontend/src/pages/QuestionManagerPage.jsx b/frontend/src/pages/QuestionManagerPage.jsx index a2338f3..b87e3ce 100644 --- a/frontend/src/pages/QuestionManagerPage.jsx +++ b/frontend/src/pages/QuestionManagerPage.jsx @@ -149,6 +149,7 @@ export default function QuestionManagerPage() {

+ Categories Open question bank