From 086d5605a5624d9376cd4da1abf268cebb09a9b0 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 10:51:25 +0200 Subject: [PATCH] feat: one screen for editing every taxonomy axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tags API could create, rename, reparent, delete and attach questions, but nothing in the interface called it — an admin could only change the taxonomy through curl. This is the screen for it. /categories becomes Taxonomy, with five tabs: Topics, Systems, Symptoms, Diseases, Subjects. Topics come from question_categories and the other four from question_tags split by type, but that split is an implementation detail and not something to make somebody navigate around, so it is one screen. Symptoms is 4,262 rows. The old page drew the whole tree on load, which for that facet is a second of layout and a wall nobody can read, so the tree now opens closed with a chevron per branch, the root list is capped at 150, and searching flattens to matches with the trail that leads to them. Two details the tabs have to get right: * a symptom filed under a system has a parent that is not a symptom, so the system is drawn in as a heading — otherwise 726 of them lose their root and vanish. Headings are not editable from that tab; systems belong to Systems. * deleting differs by table. A category with children is refused server-side; a tag's children rise to its parent. The confirmation says which you get rather than promising one and doing the other. "+ Questions" on any row searches the bank and attaches what you check — POST /tags/{id}/questions for a tag, /questions/bulk-category for a topic. Both land in the pickers immediately, since the bank and quiz builder read the same endpoints this page writes to. 16 tests on this page (up from 10), 143 frontend green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM --- frontend/src/pages/CategoriesPage.css | 74 +++ frontend/src/pages/CategoriesPage.jsx | 528 ++++++++++++++++----- frontend/src/pages/CategoriesPage.test.jsx | 185 ++++++-- frontend/src/pages/QuestionManagerPage.jsx | 2 +- 4 files changed, 635 insertions(+), 154 deletions(-) diff --git a/frontend/src/pages/CategoriesPage.css b/frontend/src/pages/CategoriesPage.css index c865e68..a52678f 100644 --- a/frontend/src/pages/CategoriesPage.css +++ b/frontend/src/pages/CategoriesPage.css @@ -57,3 +57,77 @@ font-size: .82rem; color: var(--text-muted); line-height: 1.5; } } + +/* Facet tabs — the axis you are curating. + They scroll sideways rather than wrapping onto three lines on a phone. */ +.cat-facets { + display: flex; gap: 6px; margin-bottom: 10px; padding-bottom: 4px; + overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin; +} +.cat-facet { + display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; + min-height: 40px; padding: 8px 14px; border-radius: 999px; cursor: pointer; + border: 1px solid var(--border); background: var(--card-bg); color: var(--text); + font: inherit; font-size: 0.86rem; font-weight: 600; white-space: nowrap; +} +.cat-facet:hover { border-color: var(--primary); } +.cat-facet.is-active { background: var(--primary); border-color: var(--primary); color: #fff; } +.cat-facet-count { + font-size: 0.72rem; font-weight: 600; padding: 1px 7px; border-radius: 999px; + background: var(--bg); color: var(--text-muted); +} +.cat-facet.is-active .cat-facet-count { background: rgba(255,255,255,0.22); color: #fff; } +.cat-blurb { margin: 0 0 12px; font-size: 0.83rem; color: var(--text-muted); } + +.cat-header-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.cat-create { margin-bottom: 12px; } + +/* Expanding is its own control: opening a branch must not look like editing it. */ +.cat-expand { + flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; + width: 30px; min-height: 34px; padding: 0; cursor: pointer; font: inherit; + background: none; border: 1px solid transparent; border-radius: 8px; color: var(--text-muted); +} +.cat-expand:hover { background: var(--bg); border-color: var(--border); color: var(--primary); } +.cat-expand:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; } +.cat-expand.is-leaf { pointer-events: none; } +.cat-chevron { display: inline-block; font-size: 1.1rem; line-height: 1; transition: transform 0.15s ease; } +.cat-expand[aria-expanded='true'] .cat-chevron { transform: rotate(90deg); } +.cat-row.is-open { background: var(--bg); } + +.cat-label { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.cat-trail { font-size: 0.73rem; font-weight: 400; color: var(--text-muted); overflow-wrap: anywhere; } +.cat-badge.is-system { background: var(--option-sel-bg); color: var(--primary); border-color: var(--primary); } + +.cat-more { + padding: 10px 14px; font-size: 0.8rem; color: var(--text-muted); + border-bottom: 1px solid var(--border); +} +.cat-notice { color: var(--primary); font-size: 0.84rem; margin: 10px 0 0; } + +/* Attaching questions without leaving the row you are filing them into. */ +.cat-attach { width: 100%; padding: 8px 0 4px; display: flex; flex-direction: column; gap: 8px; } +.cat-attach-search { display: flex; gap: 8px; flex-wrap: wrap; } +.cat-attach-search input { + flex: 1; min-width: 160px; padding: 7px 11px; font-size: 0.87rem; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +.cat-attach-list { + list-style: none; margin: 0; padding: 0; max-height: 260px; overflow-y: auto; + border: 1px solid var(--border); border-radius: 8px; background: var(--bg); +} +.cat-attach-list li { border-bottom: 1px solid var(--border); } +.cat-attach-list li:last-child { border-bottom: 0; } +.cat-attach-list label { + display: flex; gap: 10px; align-items: flex-start; padding: 9px 11px; + cursor: pointer; font-size: 0.83rem; line-height: 1.45; +} +.cat-attach-list input[type='checkbox'] { width: 17px; height: 17px; margin: 1px 0 0; flex-shrink: 0; } + +@media (max-width: 640px) { + .cat-facet { padding: 8px 12px; font-size: 0.82rem; } + .cat-header-actions { width: 100%; } + .cat-header-actions .btn { flex: 1; } + .cat-attach-search .btn { flex: 1; } +} diff --git a/frontend/src/pages/CategoriesPage.jsx b/frontend/src/pages/CategoriesPage.jsx index 4f6de2d..fe4cf23 100644 --- a/frontend/src/pages/CategoriesPage.jsx +++ b/frontend/src/pages/CategoriesPage.jsx @@ -10,55 +10,138 @@ const apiError = (err, fallback) => { return fallback } +/** + * The five axes a question can be filed under. + * + * Topics live in their own table (question_categories); the other four are rows + * in question_tags separated by `type`. They are edited on one screen because to + * whoever is curating them they are the same job — the storage split is an + * implementation detail, not something to make somebody navigate around. + */ +const FACETS = [ + { key: 'topics', label: 'Topics', singular: 'topic', source: 'category', + blurb: 'The discipline tree — Cardiology, Neonatology, and what sits beneath them.' }, + { key: 'systems', label: 'Systems', singular: 'system', source: 'tag', type: 'system', group: 'systems', + blurb: 'Organ systems, deliberately flat. Symptoms and diseases hang off these.' }, + { key: 'symptoms', label: 'Symptoms', singular: 'symptom', source: 'tag', type: 'keyword', group: 'keywords', + blurb: 'Presenting complaints, grouped by the system they present in.' }, + { key: 'diseases', label: 'Diseases', singular: 'disease', source: 'tag', type: 'disease', group: 'diseases', + blurb: 'Named conditions.' }, + { key: 'subjects', label: 'Subjects', singular: 'subject', source: 'tag', type: 'subject', group: 'subjects', + blurb: 'Broad exam subjects.' }, +] + +// Symptoms alone run to four thousand rows. Drawing them all costs a second of +// layout and gives nobody anything to read, so the tree opens closed and the +// root list is capped — search is the way through a list this size. +const ROOT_LIMIT = 150 +const SEARCH_LIMIT = 100 + export default function CategoriesPage() { + const [facetKey, setFacetKey] = useState('topics') const [categories, setCategories] = useState([]) + const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [], systems: [] }) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [notice, setNotice] = useState('') const [query, setQuery] = useState('') + const [expanded, setExpanded] = useState({}) - const [editing, setEditing] = useState(null) // category id being renamed/reparented + const [editing, setEditing] = useState(null) const [draftName, setDraftName] = useState('') const [draftParent, setDraftParent] = useState('') - const [deleting, setDeleting] = useState(null) // category id awaiting confirmation + const [deleting, setDeleting] = useState(null) const [moveTo, setMoveTo] = useState('') const [creating, setCreating] = useState(false) const [newName, setNewName] = useState('') const [newParent, setNewParent] = useState('') const [busy, setBusy] = useState(false) + // Attaching questions to whichever entry is open for it. + const [attaching, setAttaching] = useState(null) + const [attachQuery, setAttachQuery] = useState('') + const [attachResults, setAttachResults] = useState([]) + const [attachPicked, setAttachPicked] = useState([]) + const [attachSearching, setAttachSearching] = useState(false) + + const facet = FACETS.find(f => f.key === facetKey) || FACETS[0] + 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'))) + Promise.all([ + api.get('/question-categories/').catch(() => ({ data: [] })), + api.get('/tags').catch(() => ({ data: {} })), + ]) + .then(([cats, tagRes]) => { + setCategories(Array.isArray(cats.data) ? cats.data : []) + const t = tagRes.data || {} + setTags({ + subjects: t.subjects || [], diseases: t.diseases || [], + keywords: t.keywords || [], systems: t.systems || [], + }) + }) + .catch(err => setError(apiError(err, 'Could not load the taxonomy'))) .finally(() => setLoading(false)) }, []) useEffect(() => { load() }, [load]) + // Clearing per-facet state on a tab change, so an open editor or a half-typed + // search does not follow you onto a different axis. + useEffect(() => { + setQuery(''); setExpanded({}); setEditing(null); setDeleting(null) + setCreating(false); setAttaching(null); setError(''); setNotice('') + }, [facetKey]) + + /** Rows for the open facet, normalised to one shape whichever table they came from. */ + const rows = useMemo(() => { + if (facet.source === 'category') { + return categories.map(c => ({ + id: c.id, name: c.name, parent_id: c.parent_id || null, + count: c.question_count || 0, description: c.description ?? null, + })) + } + const own = (tags[facet.group] || []).map(t => ({ + id: t.id, name: t.name, parent_id: t.parent_id || null, count: t.count || 0, + })) + if (facet.group === 'systems') return own + // A symptom filed under a system has a parent that is not in this list, so + // the system is drawn in as a heading — otherwise 726 of them vanish. + const ownIds = new Set(own.map(r => r.id)) + const usedSystems = new Set(own.map(r => r.parent_id).filter(id => id && !ownIds.has(id))) + const headings = (tags.systems || []) + .filter(s => usedSystems.has(s.id)) + .map(s => ({ id: s.id, name: s.name, parent_id: null, count: 0, isHeading: true })) + return [...headings, ...own] + }, [facet, categories, tags]) + const childrenOf = useMemo(() => { const map = {} - for (const cat of categories) (map[cat.parent_id || 0] ||= []).push(cat) + const known = new Set(rows.map(r => r.id)) + for (const row of rows) { + // A parent outside this facet means the row is a root here, not an orphan. + const parent = row.parent_id && known.has(row.parent_id) ? row.parent_id : 0 + ;(map[parent] ||= []).push(row) + } for (const list of Object.values(map)) list.sort((a, b) => a.name.localeCompare(b.name)) return map - }, [categories]) + }, [rows]) - /** Questions in a category plus everything beneath it. */ - const rollup = useCallback((cat) => { - let total = cat.question_count || 0 - for (const child of childrenOf[cat.id] || []) total += rollup(child) - return total + const byId = useMemo(() => Object.fromEntries(rows.map(r => [r.id, r])), [rows]) + + /** Questions here plus everything beneath — the number the filter will return. */ + const rollup = useMemo(() => { + const totals = {} + const walk = (row) => { + let sum = row.count || 0 + for (const child of childrenOf[row.id] || []) sum += walk(child) + totals[row.id] = sum + return sum + } + for (const root of childrenOf[0] || []) walk(root) + return totals }, [childrenOf]) - - 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) => { @@ -68,152 +151,345 @@ export default function CategoriesPage() { return out }, [childrenOf]) - const startEdit = (cat) => { - setDeleting(null) - setEditing(cat.id) - setDraftName(cat.name) - setDraftParent(cat.parent_id ?? '') + const trail = useCallback((row) => { + const parts = [] + let cursor = row + let guard = 0 + while (cursor && guard++ < 8) { parts.unshift(cursor.name); cursor = byId[cursor.parent_id] } + return parts + }, [byId]) + + const parentOptions = useCallback((excludeId) => { + const blocked = excludeId ? new Set([excludeId, ...descendantIds(excludeId)]) : new Set() + const same = rows.filter(r => !r.isHeading && !blocked.has(r.id)) + if (facet.source === 'category' || facet.group === 'systems') return same + // Anything may sit under a system; that is how symptoms get grouped. + const systems = (tags.systems || []) + .filter(s => !blocked.has(s.id)) + .map(s => ({ id: s.id, name: `${s.name} (system)` })) + return [...systems, ...same] + }, [rows, facet, tags, descendantIds]) + + const endpoint = facet.source === 'category' ? '/question-categories' : '/tags' + + const startEdit = (row) => { + setDeleting(null); setAttaching(null) + setEditing(row.id); setDraftName(row.name); setDraftParent(row.parent_id ?? '') } - const save = async (cat) => { + const save = async (row) => { const name = draftName.trim() - if (!name) { setError('A category needs a name'); return } + if (!name) { setError(`A ${facet.singular} 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')) } + const parent_id = draftParent === '' ? null : Number(draftParent) + if (facet.source === 'category') { + await api.patch(`${endpoint}/${row.id}`, { name, description: row.description ?? null, parent_id }) + } else { + await api.patch(`${endpoint}/${row.id}`, { name, parent_id }) + } + setEditing(null); load() + } catch (err) { setError(apiError(err, `Could not save this ${facet.singular}`)) } finally { setBusy(false) } } - const remove = async (cat) => { + const remove = async (row) => { setBusy(true); setError('') try { - await api.delete(`/question-categories/${cat.id}`, + await api.delete(`${endpoint}/${row.id}`, { params: moveTo === '' ? {} : { move_to: Number(moveTo) } }) - setDeleting(null); setMoveTo('') - load() - } catch (err) { setError(apiError(err, 'Could not delete this category')) } + setDeleting(null); setMoveTo(''); load() + } catch (err) { setError(apiError(err, `Could not delete this ${facet.singular}`)) } finally { setBusy(false) } } const create = async () => { const name = newName.trim() - if (!name) { setError('A category needs a name'); return } + if (!name) { setError(`A ${facet.singular} needs a name`); return } setBusy(true); setError('') try { - await api.post('/question-categories/', { - name, description: null, parent_id: newParent === '' ? null : Number(newParent), - }) + const parent_id = newParent === '' ? null : Number(newParent) + if (facet.source === 'category') { + await api.post(`${endpoint}/`, { name, description: null, parent_id }) + } else { + await api.post(`${endpoint}/`, { name, type: facet.type, parent_id, sort_order: 100 }) + } setCreating(false); setNewName(''); setNewParent('') + setNotice(`Added “${name}”. It is in every picker now.`) load() - } catch (err) { setError(apiError(err, 'Could not create this category')) } + } catch (err) { setError(apiError(err, `Could not create this ${facet.singular}`)) } 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 startAttach = (row) => { + setEditing(null); setDeleting(null) + setAttaching(row.id); setAttachQuery(''); setAttachResults([]); setAttachPicked([]) + } + + const searchQuestions = async () => { + if (!attachQuery.trim()) return + setAttachSearching(true); setError('') + try { + const res = await api.get('/questions/bank', { params: { q: attachQuery.trim(), limit: 25 } }) + setAttachResults(res.data?.questions || res.data?.items || []) + } catch (err) { setError(apiError(err, 'Could not search questions')) } + finally { setAttachSearching(false) } + } + + const attach = async (row) => { + if (!attachPicked.length) return + setBusy(true); setError('') + try { + if (facet.source === 'category') { + await api.post('/questions/bulk-category', + { question_ids: attachPicked, category_id: row.id }) + } else { + await api.post(`/tags/${row.id}/questions`, { question_ids: attachPicked }) + } + setNotice(`Added ${attachPicked.length} question${attachPicked.length === 1 ? '' : 's'} to “${row.name}”.`) + setAttaching(null); setAttachPicked([]); setAttachResults([]) + load() + } catch (err) { setError(apiError(err, 'Could not attach those questions')) } + finally { setBusy(false) } + } + + // Deleting a category with subcategories is refused server-side; tags instead + // pull their children up a level, so only one facet needs the guard. + const blockedByChildren = (row) => + facet.source === 'category' && (childrenOf[row.id] || []).length > 0 + + const needle = query.trim().toLowerCase() + + const renderActions = (row) => ( + + {row.isHeading ? ( + system + ) : ( + <> + + + + + )} + + ) + + const renderPanels = (row) => ( + <> + {deleting === row.id && ( +
+ {/* The two tables behave differently on delete and the confirmation has + to say which you are getting: a category with children is refused by + the server, whereas a tag's children rise to its parent. */} + {blockedByChildren(row) ? ( + Move its {childrenOf[row.id].length} subcategor{childrenOf[row.id].length === 1 ? 'y' : 'ies'} first. + ) : ( + <> + + Delete “{row.name}”? + {(childrenOf[row.id] || []).length > 0 && + ` Its ${childrenOf[row.id].length} child entr${childrenOf[row.id].length === 1 ? 'y moves' : 'ies move'} up a level.`} + {` Move its ${row.count} question${row.count === 1 ? '' : 's'} to:`} + + + + + )} + +
+ )} + + {attaching === row.id && ( +
+
+ setAttachQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') searchQuestions() }} /> + + +
+ {attachResults.length > 0 && ( +
    + {attachResults.map(q => ( +
  • + +
  • + ))} +
+ )} + {attachPicked.length > 0 && ( + + )} +
+ )} + + ) + + const renderEditor = (row) => ( +
+ setDraftName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') save(row); if (e.key === 'Escape') setEditing(null) }} /> + + + +
+ ) + + // Searching flattens to the matching rows with their trail: when you type a + // name you want the row, not the branch you would have had to walk to reach it. + const renderSearch = () => { + const hits = rows.filter(r => !r.isHeading && r.name.toLowerCase().includes(needle)) + if (!hits.length) return
Nothing matches “{query}”.
+ return ( + + ) } const renderTree = (parentId, depth = 0) => { - const branch = (childrenOf[parentId] || []).filter(matches) + const branch = childrenOf[parentId] || [] if (!branch.length) return null + const shown = depth === 0 ? branch.slice(0, ROOT_LIMIT) : branch return ( ) } + const facetCount = (f) => { + if (f.source === 'category') return categories.length + return (tags[f.group] || []).length + } + return (
-

Categories

-

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

+

Taxonomy

+

Every axis a question can be filed under. Anything added here shows up in the question bank and quiz builder straight away.

-
+
Question bank - +
+
+ {FACETS.map(f => ( + + ))} +
+

{facet.blurb}

+ {creating && ( -
- + setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') create() }} /> - setNewParent(e.target.value)}> - {categories.map(c => )} + {parentOptions(null).map(c => )} @@ -227,18 +503,20 @@ export default function CategoriesPage() {
setQuery(e.target.value)} - placeholder="Search categories…" aria-label="Search categories" /> - {categories.length} categories + placeholder={`Search ${facet.label.toLowerCase()}…`} + aria-label={`Search ${facet.label.toLowerCase()}`} /> + {rows.filter(r => !r.isHeading).length} {facet.label.toLowerCase()}
{error &&

{error}

} + {notice &&

{notice}

} {loading ? (
Loading…
- ) : categories.length === 0 ? ( -
No categories yet.
- ) : ( - renderTree(0) ||
No categories match “{query}”.
+ ) : rows.length === 0 ? ( +
No {facet.label.toLowerCase()} yet.
+ ) : needle ? renderSearch() : ( + renderTree(0) ||
No {facet.label.toLowerCase()} yet.
)}
) diff --git a/frontend/src/pages/CategoriesPage.test.jsx b/frontend/src/pages/CategoriesPage.test.jsx index 98edbc5..b4f8dcd 100644 --- a/frontend/src/pages/CategoriesPage.test.jsx +++ b/frontend/src/pages/CategoriesPage.test.jsx @@ -13,18 +13,34 @@ const CATS = [ { id: 3, name: 'Other', parent_id: null, question_count: 0, description: null }, ] -const mockCats = (cats = CATS) => api.get.mockResolvedValue({ data: cats }) +const TAGS = { + systems: [ + { id: 10, name: 'Respiratory System', parent_id: null, count: 0 }, + { id: 11, name: 'Cardiovascular System', parent_id: null, count: 0 }, + ], + // Cough sits under a system; Fatigue has no home yet. + keywords: [ + { id: 20, name: 'Cough', parent_id: 10, count: 12 }, + { id: 21, name: 'Fatigue', parent_id: null, count: 3 }, + ], + diseases: [{ id: 30, name: 'Asthma', parent_id: null, count: 7 }], + subjects: [{ id: 40, name: 'Pediatrics', parent_id: null, count: 99 }], +} -beforeEach(() => { vi.clearAllMocks(); mockCats() }) +const mockApi = (cats = CATS, tags = TAGS) => api.get.mockImplementation((url) => { + if (url === '/question-categories/') return Promise.resolve({ data: cats }) + if (url === '/tags') return Promise.resolve({ data: tags }) + return Promise.resolve({ data: {} }) +}) + +beforeEach(() => { vi.clearAllMocks(); mockApi() }) const mount = () => render() +const openFacet = (name) => userEvent.click(screen.getByRole('tab', { name: new RegExp(`^${name}`) })) it('shows direct and rolled-up counts so the shape of the tree is visible', async () => { mount() - expect(await screen.findByText('Root')).toBeInTheDocument() - expect(screen.getByText('Child')).toBeInTheDocument() - - const root = screen.getByText('Root').closest('.cat-row') + const root = (await screen.findByText('Root')).closest('.cat-row') expect(within(root).getByText('2 direct')).toBeInTheDocument() // Root holds 2 itself and Child holds 5, so everything beneath is 7. expect(within(root).getByText('7 total')).toBeInTheDocument() @@ -36,14 +52,27 @@ it('shows direct and rolled-up counts so the shape of the tree is visible', asyn expect(within(other).queryByText(/total/)).not.toBeInTheDocument() }) +it('opens closed, so a four-thousand-row facet is not drawn at once', async () => { + mount() + await screen.findByText('Root') + expect(screen.queryByText('Child')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Expand Root' })) + expect(screen.getByText('Child')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Collapse Root' })) + expect(screen.queryByText('Child')).not.toBeInTheDocument() +}) + it('reparents a category and reloads', async () => { mount() - await screen.findByText('Child') + await screen.findByText('Root') api.patch.mockResolvedValue({ data: {} }) + await userEvent.click(screen.getByRole('button', { name: 'Expand Root' })) - await userEvent.click(screen.getByRole('button', { name: 'Edit category Child' })) + await userEvent.click(screen.getByRole('button', { name: 'Edit Child' })) await userEvent.selectOptions(screen.getByLabelText('Parent for Child'), '3') - await userEvent.click(screen.getByRole('button', { name: 'Save category' })) + await userEvent.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(api.patch).toHaveBeenCalledWith('/question-categories/2', { name: 'Child', description: null, parent_id: 3 })) @@ -52,7 +81,7 @@ it('reparents a category and reloads', async () => { 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' })) + await userEvent.click(screen.getByRole('button', { name: 'Edit 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']) @@ -60,32 +89,34 @@ it('never offers a category its own descendant as a parent', async () => { it('deletes a leaf category, rehoming its questions', async () => { mount() - await screen.findByText('Child') + await screen.findByText('Root') api.delete.mockResolvedValue({}) + await userEvent.click(screen.getByRole('button', { name: 'Expand Root' })) - await userEvent.click(screen.getByRole('button', { name: 'Delete category Child' })) + await userEvent.click(screen.getByRole('button', { name: 'Delete Child' })) await userEvent.selectOptions(screen.getByLabelText('Move questions from Child to'), '3') - await userEvent.click(screen.getByRole('button', { name: 'Delete category' })) + await userEvent.click(screen.getByRole('button', { name: 'Delete topic' })) await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/question-categories/2', { params: { move_to: 3 } })) }) -it('defaults to uncategorized when no destination is picked', async () => { +it('leaves the questions unfiled when no destination is picked', async () => { mount() - await screen.findByText('Child') + await screen.findByText('Root') api.delete.mockResolvedValue({}) - await userEvent.click(screen.getByRole('button', { name: 'Delete category Child' })) - await userEvent.click(screen.getByRole('button', { name: 'Delete category' })) + await userEvent.click(screen.getByRole('button', { name: 'Expand Root' })) + await userEvent.click(screen.getByRole('button', { name: 'Delete Child' })) + await userEvent.click(screen.getByRole('button', { name: 'Delete topic' })) 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' })) + await userEvent.click(screen.getByRole('button', { name: 'Delete Root' })) expect(screen.getByRole('alert')).toHaveTextContent('Move its 1 subcategory first') - expect(screen.queryByRole('button', { name: 'Delete category' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Delete topic' })).not.toBeInTheDocument() }) it('creates a category under a chosen parent', async () => { @@ -93,29 +124,127 @@ it('creates a category under a chosen parent', async () => { 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: '+ New topic' })) + await userEvent.type(screen.getByLabelText('New topic name'), 'Neonatology') + await userEvent.selectOptions(screen.getByLabelText('New topic 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 () => { +it('searching flattens to the matches with the trail that leads to them', async () => { mount() await screen.findByText('Root') - await userEvent.type(screen.getByLabelText('Search categories'), 'child') + await userEvent.type(screen.getByLabelText('Search topics'), 'child') expect(screen.getByText('Child')).toBeInTheDocument() - expect(screen.getByText('Root')).toBeInTheDocument() // kept: it holds the match + expect(screen.getByText('Root')).toBeInTheDocument() // shown as the trail + expect(screen.getByText('Root')).toHaveClass('cat-trail') expect(screen.queryByText('Other')).not.toBeInTheDocument() }) it('surfaces a server refusal', async () => { mount() - await screen.findByText('Child') + await screen.findByText('Root') 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' })) + await userEvent.click(screen.getByRole('button', { name: 'Edit Root' })) + await userEvent.click(screen.getByRole('button', { name: 'Save' })) expect(await screen.findByRole('alert')).toHaveTextContent('cannot be its own ancestor') }) + +// ---- tag facets ---- + +it('shows symptoms under the system they present in', async () => { + mount() + await screen.findByText('Root') + await openFacet('Symptoms') + + // The system is drawn in as a heading, or its 726 children would have no root. + const heading = (await screen.findByText('Respiratory System')).closest('.cat-row') + expect(within(heading).getByText('system')).toBeInTheDocument() + // A heading is not editable from this tab — it belongs to the Systems tab. + expect(within(heading).queryByRole('button', { name: /^Edit/ })).not.toBeInTheDocument() + + expect(screen.queryByText('Cough')).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Expand Respiratory System' })) + expect(screen.getByText('Cough')).toBeInTheDocument() + + // A system nobody files under is not drawn at all. + expect(screen.queryByText('Cardiovascular System')).not.toBeInTheDocument() + // An ungrouped symptom stays at the top level rather than disappearing. + expect(screen.getByText('Fatigue')).toBeInTheDocument() +}) + +it('creates a symptom under a system, which the tags API allows', async () => { + mount() + await screen.findByText('Root') + await openFacet('Symptoms') + api.post.mockResolvedValue({ data: { id: 99 } }) + + await userEvent.click(screen.getByRole('button', { name: '+ New symptom' })) + await userEvent.type(screen.getByLabelText('New symptom name'), 'Stridor') + await userEvent.selectOptions(screen.getByLabelText('New symptom parent'), '10') + await userEvent.click(screen.getByRole('button', { name: 'Create' })) + + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tags/', + { name: 'Stridor', type: 'keyword', parent_id: 10, sort_order: 100 })) + expect(await screen.findByRole('status')).toHaveTextContent('in every picker now') +}) + +it('offers systems as parents on the symptom tab', async () => { + mount() + await screen.findByText('Root') + await openFacet('Symptoms') + await userEvent.click(await screen.findByRole('button', { name: '+ New symptom' })) + const parent = screen.getByLabelText('New symptom parent') + expect([...parent.options].map(o => o.text)).toContain('Respiratory System (system)') + expect([...parent.options].map(o => o.text)).toContain('Fatigue') +}) + +it('deletes a tag, saying its children rise rather than refusing', async () => { + mount() + await screen.findByText('Root') + await openFacet('Symptoms') + api.delete.mockResolvedValue({}) + + await userEvent.click(await screen.findByRole('button', { name: 'Delete Fatigue' })) + expect(screen.getByRole('alert')).toHaveTextContent('Move its 3 questions to') + await userEvent.click(screen.getByRole('button', { name: 'Delete symptom' })) + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/tags/21', { params: {} })) +}) + +it('attaches searched questions to a tag', async () => { + mount() + await screen.findByText('Root') + await openFacet('Diseases') + api.get.mockResolvedValueOnce({ data: { questions: [ + { id: 5, question_text: 'A 4-year-old with wheeze…' }, + { id: 6, question_text: 'An infant with stridor…' }, + ] } }) + api.post.mockResolvedValue({ data: { added: 1 } }) + + await userEvent.click(await screen.findByRole('button', { name: 'Add questions to Asthma' })) + await userEvent.type(screen.getByLabelText('Search questions to add to Asthma'), 'wheeze') + await userEvent.click(screen.getByRole('button', { name: 'Search' })) + + await userEvent.click(await screen.findByRole('checkbox', { name: /wheeze/ })) + await userEvent.click(screen.getByRole('button', { name: 'Add 1 question' })) + + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/tags/30/questions', { question_ids: [5] })) +}) + +it('files questions into a topic through the category endpoint instead', async () => { + mount() + await screen.findByText('Root') + api.get.mockResolvedValueOnce({ data: { questions: [{ id: 5, question_text: 'A newborn…' }] } }) + api.post.mockResolvedValue({ data: { updated: 1 } }) + + await userEvent.click(screen.getByRole('button', { name: 'Add questions to Root' })) + await userEvent.type(screen.getByLabelText('Search questions to add to Root'), 'newborn') + await userEvent.click(screen.getByRole('button', { name: 'Search' })) + await userEvent.click(await screen.findByRole('checkbox', { name: /newborn/ })) + await userEvent.click(screen.getByRole('button', { name: 'Add 1 question' })) + + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/questions/bulk-category', + { question_ids: [5], category_id: 1 })) +}) diff --git a/frontend/src/pages/QuestionManagerPage.jsx b/frontend/src/pages/QuestionManagerPage.jsx index 6b1f5f7..b7db629 100644 --- a/frontend/src/pages/QuestionManagerPage.jsx +++ b/frontend/src/pages/QuestionManagerPage.jsx @@ -149,7 +149,7 @@ export default function QuestionManagerPage() {

- Categories + Taxonomy Open question bank