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 ( + + ) + } + + 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