feat: one screen for editing every taxonomy axis
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017acfNLsJpnkvH3sCZSjMJM
This commit is contained in:
parent
d509d3db52
commit
086d5605a5
4 changed files with 635 additions and 154 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<span className="cat-actions">
|
||||
{row.isHeading ? (
|
||||
<span className="cat-badge is-system">system</span>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-secondary btn-sm"
|
||||
aria-label={`Edit ${row.name}`} onClick={() => startEdit(row)}>Edit</button>
|
||||
<button className="btn btn-secondary btn-sm"
|
||||
aria-label={`Add questions to ${row.name}`} onClick={() => startAttach(row)}>+ Questions</button>
|
||||
<button className="btn btn-secondary btn-sm"
|
||||
aria-label={`Delete ${row.name}`}
|
||||
onClick={() => { setEditing(null); setAttaching(null); setDeleting(row.id); setMoveTo('') }}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
const renderPanels = (row) => (
|
||||
<>
|
||||
{deleting === row.id && (
|
||||
<div className="cat-confirm" role="alert">
|
||||
{/* 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) ? (
|
||||
<span>Move its {childrenOf[row.id].length} subcategor{childrenOf[row.id].length === 1 ? 'y' : 'ies'} first.</span>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
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:`}
|
||||
</span>
|
||||
<select value={moveTo} aria-label={`Move questions from ${row.name} to`}
|
||||
onChange={e => setMoveTo(e.target.value)}>
|
||||
<option value="">Nowhere — just unfile them</option>
|
||||
{parentOptions(row.id).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-danger btn-sm" disabled={busy} onClick={() => remove(row)}>
|
||||
Delete {facet.singular}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setDeleting(null)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attaching === row.id && (
|
||||
<div className="cat-attach">
|
||||
<div className="cat-attach-search">
|
||||
<input value={attachQuery} autoFocus placeholder="Search questions…"
|
||||
aria-label={`Search questions to add to ${row.name}`}
|
||||
onChange={e => setAttachQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') searchQuestions() }} />
|
||||
<button className="btn btn-secondary btn-sm" disabled={attachSearching}
|
||||
onClick={searchQuestions}>{attachSearching ? 'Searching…' : 'Search'}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setAttaching(null)}>Cancel</button>
|
||||
</div>
|
||||
{attachResults.length > 0 && (
|
||||
<ul className="cat-attach-list">
|
||||
{attachResults.map(q => (
|
||||
<li key={q.id}>
|
||||
<label>
|
||||
<input type="checkbox" checked={attachPicked.includes(q.id)}
|
||||
onChange={e => setAttachPicked(prev =>
|
||||
e.target.checked ? [...prev, q.id] : prev.filter(id => id !== q.id))} />
|
||||
<span>{(q.question_text || '').slice(0, 140)}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{attachPicked.length > 0 && (
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => attach(row)}>
|
||||
Add {attachPicked.length} question{attachPicked.length === 1 ? '' : 's'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderEditor = (row) => (
|
||||
<div className="cat-edit">
|
||||
<input value={draftName} autoFocus aria-label={`Name for ${row.name}`}
|
||||
onChange={e => setDraftName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(row); if (e.key === 'Escape') setEditing(null) }} />
|
||||
<select value={draftParent} aria-label={`Parent for ${row.name}`}
|
||||
onChange={e => setDraftParent(e.target.value)}>
|
||||
<option value="">No parent (top level)</option>
|
||||
{parentOptions(row.id).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => save(row)}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// 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 <div className="cat-empty">Nothing matches “{query}”.</div>
|
||||
return (
|
||||
<ul className="cat-tree">
|
||||
{hits.slice(0, SEARCH_LIMIT).map(row => {
|
||||
const path = trail(row)
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<div className="cat-row">
|
||||
{editing === row.id ? renderEditor(row) : (
|
||||
<>
|
||||
<span className="cat-label">
|
||||
<span className="cat-name">{row.name}</span>
|
||||
{path.length > 1 && <span className="cat-trail">{path.slice(0, -1).join(' › ')}</span>}
|
||||
</span>
|
||||
<span className="cat-badge">{row.count}</span>
|
||||
{renderActions(row)}
|
||||
</>
|
||||
)}
|
||||
{renderPanels(row)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{hits.length > SEARCH_LIMIT && (
|
||||
<li className="cat-more">{hits.length - SEARCH_LIMIT} more — keep typing to narrow.</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<ul className={`cat-tree${depth > 0 ? ' cat-child' : ''}`}>
|
||||
{branch.map(cat => (
|
||||
<li key={cat.id}>
|
||||
<div className="cat-row">
|
||||
{editing === cat.id ? (
|
||||
<div className="cat-edit">
|
||||
<input value={draftName} autoFocus aria-label={`Name for ${cat.name}`}
|
||||
onChange={e => setDraftName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(cat); if (e.key === 'Escape') setEditing(null) }} />
|
||||
<select value={draftParent} aria-label={`Parent for ${cat.name}`}
|
||||
onChange={e => setDraftParent(e.target.value)}>
|
||||
<option value="">No parent (top level)</option>
|
||||
{parentOptions(cat.id).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => save(cat)}>Save category</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="cat-name">{cat.name}</span>
|
||||
<span className="cat-badge" title="Questions filed directly here">
|
||||
{cat.question_count} direct
|
||||
</span>
|
||||
{(childrenOf[cat.id] || []).length > 0 && (
|
||||
<>
|
||||
<span className="cat-badge" title="Questions here and in everything beneath">
|
||||
{rollup(cat)} total
|
||||
</span>
|
||||
<span className="cat-badge">{childrenOf[cat.id].length} sub</span>
|
||||
</>
|
||||
)}
|
||||
{cat.question_count === 0 && (childrenOf[cat.id] || []).length === 0 && (
|
||||
<span className="cat-badge is-empty">Empty</span>
|
||||
)}
|
||||
<span className="cat-actions">
|
||||
<button className="btn btn-secondary btn-sm"
|
||||
aria-label={`Edit category ${cat.name}`} onClick={() => startEdit(cat)}>Edit</button>
|
||||
<button className="btn btn-secondary btn-sm"
|
||||
aria-label={`Delete category ${cat.name}`}
|
||||
onClick={() => { setEditing(null); setDeleting(cat.id); setMoveTo('') }}>Delete</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{shown.map(row => {
|
||||
const kids = childrenOf[row.id] || []
|
||||
const isOpen = !!expanded[row.id]
|
||||
return (
|
||||
<li key={row.id}>
|
||||
<div className={`cat-row${isOpen ? ' is-open' : ''}`}>
|
||||
{kids.length > 0 ? (
|
||||
<button type="button" className="cat-expand" aria-expanded={isOpen}
|
||||
aria-label={`${isOpen ? 'Collapse' : 'Expand'} ${row.name}`}
|
||||
onClick={() => setExpanded(prev => ({ ...prev, [row.id]: !prev[row.id] }))}>
|
||||
<span className="cat-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
) : <span className="cat-expand is-leaf" aria-hidden="true" />}
|
||||
|
||||
{deleting === cat.id && (
|
||||
<div className="cat-confirm" role="alert">
|
||||
{(childrenOf[cat.id] || []).length > 0
|
||||
? <span>Move its {childrenOf[cat.id].length} subcategor{childrenOf[cat.id].length === 1 ? 'y' : 'ies'} first.</span>
|
||||
: <>
|
||||
<span>Delete “{cat.name}”? Move its {cat.question_count} question{cat.question_count === 1 ? '' : 's'} to:</span>
|
||||
<select value={moveTo} aria-label={`Move questions from ${cat.name} to`}
|
||||
onChange={e => setMoveTo(e.target.value)}>
|
||||
<option value="">Uncategorized</option>
|
||||
{parentOptions(cat.id).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-danger btn-sm" disabled={busy}
|
||||
onClick={() => remove(cat)}>Delete category</button>
|
||||
</>}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setDeleting(null)}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderTree(cat.id, depth + 1)}
|
||||
{editing === row.id ? renderEditor(row) : (
|
||||
<>
|
||||
<span className="cat-name">{row.name}</span>
|
||||
{!row.isHeading && <span className="cat-badge" title="Questions filed directly here">
|
||||
{row.count} direct
|
||||
</span>}
|
||||
{kids.length > 0 && (
|
||||
<>
|
||||
<span className="cat-badge" title="Questions here and in everything beneath">
|
||||
{rollup[row.id] ?? row.count} total
|
||||
</span>
|
||||
<span className="cat-badge">{kids.length} sub</span>
|
||||
</>
|
||||
)}
|
||||
{!row.isHeading && row.count === 0 && kids.length === 0 && (
|
||||
<span className="cat-badge is-empty">Empty</span>
|
||||
)}
|
||||
{renderActions(row)}
|
||||
</>
|
||||
)}
|
||||
{renderPanels(row)}
|
||||
</div>
|
||||
{isOpen && renderTree(row.id, depth + 1)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{depth === 0 && branch.length > ROOT_LIMIT && (
|
||||
<li className="cat-more">
|
||||
{branch.length - ROOT_LIMIT} more top-level {facet.label.toLowerCase()} — search to find one.
|
||||
</li>
|
||||
))}
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
const facetCount = (f) => {
|
||||
if (f.source === 'category') return categories.length
|
||||
return (tags[f.group] || []).length
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cat-page">
|
||||
<div className="cat-header">
|
||||
<div>
|
||||
<h1>Categories</h1>
|
||||
<p>Organise the system tree: rename, move under a different parent, or delete and rehome its questions.</p>
|
||||
<h1>Taxonomy</h1>
|
||||
<p>Every axis a question can be filed under. Anything added here shows up in the question bank and quiz builder straight away.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div className="cat-header-actions">
|
||||
<Link className="btn btn-secondary" to="/question-bank">Question bank</Link>
|
||||
<button className="btn btn-primary" onClick={() => setCreating(v => !v)}>+ New category</button>
|
||||
<button className="btn btn-primary" onClick={() => setCreating(v => !v)}>
|
||||
+ New {facet.singular}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cat-facets" role="tablist" aria-label="Taxonomy axis">
|
||||
{FACETS.map(f => (
|
||||
<button key={f.key} role="tab" aria-selected={f.key === facetKey}
|
||||
className={`cat-facet${f.key === facetKey ? ' is-active' : ''}`}
|
||||
onClick={() => setFacetKey(f.key)}>
|
||||
{f.label}
|
||||
<span className="cat-facet-count">{facetCount(f)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="cat-blurb">{facet.blurb}</p>
|
||||
|
||||
{creating && (
|
||||
<div className="cat-edit" style={{ marginBottom: 12 }}>
|
||||
<input value={newName} autoFocus placeholder="Category name…" aria-label="New category name"
|
||||
<div className="cat-edit cat-create">
|
||||
<input value={newName} autoFocus placeholder={`${facet.singular} name…`}
|
||||
aria-label={`New ${facet.singular} name`}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') create() }} />
|
||||
<select value={newParent} aria-label="New category parent" onChange={e => setNewParent(e.target.value)}>
|
||||
<select value={newParent} aria-label={`New ${facet.singular} parent`}
|
||||
onChange={e => setNewParent(e.target.value)}>
|
||||
<option value="">No parent (top level)</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
{parentOptions(null).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={create}>Create</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setCreating(false)}>Cancel</button>
|
||||
|
|
@ -227,18 +503,20 @@ export default function CategoriesPage() {
|
|||
|
||||
<div className="cat-toolbar">
|
||||
<input className="cat-search" value={query} onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search categories…" aria-label="Search categories" />
|
||||
<span className="cat-count">{categories.length} categories</span>
|
||||
placeholder={`Search ${facet.label.toLowerCase()}…`}
|
||||
aria-label={`Search ${facet.label.toLowerCase()}`} />
|
||||
<span className="cat-count">{rows.filter(r => !r.isHeading).length} {facet.label.toLowerCase()}</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="cat-error" role="alert">{error}</p>}
|
||||
{notice && <p className="cat-notice" role="status">{notice}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading"><div className="spinner" /> Loading…</div>
|
||||
) : categories.length === 0 ? (
|
||||
<div className="cat-empty">No categories yet.</div>
|
||||
) : (
|
||||
renderTree(0) || <div className="cat-empty">No categories match “{query}”.</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="cat-empty">No {facet.label.toLowerCase()} yet.</div>
|
||||
) : needle ? renderSearch() : (
|
||||
renderTree(0) || <div className="cat-empty">No {facet.label.toLowerCase()} yet.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(<MemoryRouter><CategoriesPage /></MemoryRouter>)
|
||||
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 }))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export default function QuestionManagerPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="qm-header-actions">
|
||||
<Link className="btn btn-secondary" to="/categories">Categories</Link>
|
||||
<Link className="btn btn-secondary" to="/categories">Taxonomy</Link>
|
||||
<Link className="btn btn-secondary" to="/question-bank">Open question bank</Link>
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>+ New question</button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue