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