diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index e50d731..63cd3be 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -10,3 +10,25 @@ .custom-test-settings input, .custom-test-settings select { width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--input-bg); color: var(--text); } .custom-test form > button { margin: 8px 8px 0 0; } .custom-test [role=alert] { color: var(--wrong-fg); } +.custom-test-layout { display: grid; grid-template-columns: 300px 1fr; gap: 16px; align-items: start; border: none; background: none; box-shadow: none; padding: 0; } +.custom-test-filters { padding: 14px; } +.custom-test-filters h2 { margin: 0 0 4px; font-size: 1rem; } +.custom-test-filters p { color: var(--text-muted); font-size: .78rem; margin: 0 0 10px; } +.custom-test-filters-toggle { display: none; } +.custom-test-tree { list-style: none; margin: 8px 0; padding: 0; max-height: 60vh; overflow-y: auto; } +.custom-test-tree ul { list-style: none; margin: 0 0 0 16px; padding: 0; } +.custom-test-tree li { margin: 2px 0; } +.custom-test-tree label { display: flex; gap: 6px; align-items: baseline; font-size: .84rem; cursor: pointer; } +.custom-test-tree summary { cursor: pointer; list-style: none; } +.custom-test-tree summary::-webkit-details-marker { display: none; } +.custom-test-branch::before { content: '▸ '; font-size: .7rem; color: var(--text-muted); } +details[open] > .custom-test-branch::before { content: '▾ '; } +.custom-test-main { display: flex; flex-direction: column; gap: 10px; } +.custom-test-settings { display: flex; flex-direction: column; gap: 10px; padding: 16px; } +.custom-test-settings label { display: flex; flex-direction: column; gap: 4px; font-size: .84rem; } +@media (max-width: 760px) { + .custom-test-layout { grid-template-columns: 1fr; } + .custom-test-filters-toggle { display: inline-block; margin-bottom: 8px; } + .custom-test-filters-body { display: none; } + .custom-test-filters-body.open { display: block; } +} diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index 92168ab..22a9b72 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -67,52 +67,90 @@ export default function CustomQuizPage() { } finally { setSubmitting(false) } } + const [catSearch, setCatSearch] = useState('') + const [filtersOpen, setFiltersOpen] = useState(true) + const childrenOf = {} + for (const cat of categories) { + ;(childrenOf[cat.parent_id || 0] ||= []).push(cat) + } + const visibleCategories = categories.filter(cat => + [cat.name, ...(cat.breadcrumbs || []).map(b => b.name)].join(' ').toLowerCase().includes(catSearch.toLowerCase())) + const descendantSelected = (cat) => { + const ids = [] + const walk = (id) => { for (const child of childrenOf[id] || []) { ids.push(child.id); walk(child.id) } } + walk(cat.id) + return ids.some(id => categoryIds.includes(id)) + } + const renderTree = (parentId) => { + const branch = (childrenOf[parentId] || []).filter(cat => visibleCategories.includes(cat)) + if (!branch.length) return null + return + } + return (
← Quizzes

Create Custom Test

Choose questions from your bank, {user?.name || 'learner'}.

-
-
- Categories -

Parent categories include all their subcategories.

-
- {categories.map(cat => ( - - ))} + +
-
- - - - - {adaptive &&

Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.

} - - - {mode === 'timed' && } + +
+
+ + + + + {adaptive &&

Adaptive picks your weakest topics first: it prefers unanswered questions, then recycles older incorrect ones, and moves between weak areas instead of repeating one.

} + + + {mode === 'timed' && } +
+

Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, including skipped questions.

+ +

{ready ? `${available} questions available` : 'Counting available questions…'}

+ {ready && available === 0 &&

No questions match these filters.

} + {ready && !validCount && available > 0 &&

Choose 1–{Math.min(200, available)} questions.

} + {countError &&

{countError}

} + + {error &&

{error}

} +
-

Unused means no completed, nonexpired bank attempt outcome. Incorrect uses your latest outcome, including skipped questions.

- -

{ready ? `${available} questions available` : 'Counting available questions…'}

- {ready && available === 0 &&

No questions match these filters.

} - {ready && !validCount && available > 0 &&

Choose 1–{Math.min(200, available)} questions.

} - {countError &&

{countError}

} - - {error &&

{error}

} -
) diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx index ce0ebd9..59e9bdb 100644 --- a/frontend/src/pages/CustomQuizPage.test.jsx +++ b/frontend/src/pages/CustomQuizPage.test.jsx @@ -33,7 +33,7 @@ describe('CustomQuizPage', () => { expect(screen.getByLabelText(/Share with/)).not.toBeChecked() expect(screen.queryByLabelText(/Time limit/)).not.toBeInTheDocument() await userEvent.click(screen.getByLabelText('Pediatrics (30)')) - await userEvent.click(screen.getByLabelText('Pediatrics › Neonatal (10)')) + await userEvent.click(screen.getByLabelText('Neonatal (10)')) await userEvent.selectOptions(screen.getByLabelText('Question state'), 'incorrect') await userEvent.selectOptions(screen.getByLabelText('Mode'), 'timed') fireEvent.change(screen.getByLabelText(/Time limit/), { target: { value: '15' } })