From d1388f3335c39e9e0b94c0a5ae40c54c341ee696 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 02:54:17 +0200 Subject: [PATCH] feat: take a selection off inside the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A facet panel could add a choice but not remove one without finding its checkbox again in a list of several hundred — and Reset, the only alternative, throws away nine choices to undo the tenth. What is picked now shows as chips under the search box, each removable on its own, with Clear all beside them once there is more than one. 248 frontend tests green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/FacetPicker.jsx | 26 +++++++++++++++++++++- frontend/src/pages/CustomQuizPage.css | 25 +++++++++++++++++++++ frontend/src/pages/CustomQuizPage.jsx | 10 +++++++++ frontend/src/pages/CustomQuizPage.test.jsx | 24 +++++++++++++++++++- 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/FacetPicker.jsx b/frontend/src/components/FacetPicker.jsx index 2eb25f0..394247b 100644 --- a/frontend/src/components/FacetPicker.jsx +++ b/frontend/src/components/FacetPicker.jsx @@ -24,7 +24,14 @@ export function FacetRow({ label, summary, extra, chip, onOpen }) { * `children` is called with the current search query so each facet decides how * to filter its own options. */ -export default function FacetPicker({ title, helper, open, onClose, onReset, searchLabel, children }) { +export default function FacetPicker({ + title, helper, open, onClose, onReset, searchLabel, children, + // What is currently picked, as {id, label}. Shown as chips you can take off + // one at a time: without them, unpicking something means finding its checkbox + // again in a list of several hundred, and Reset is the only alternative — + // which throws away the other nine choices to undo the tenth. + selected = [], onRemove, +}) { const [query, setQuery] = useState('') useEffect(() => { @@ -50,6 +57,23 @@ export default function FacetPicker({ title, helper, open, onClose, onReset, sea placeholder="Search" aria-label={searchLabel || `Search ${title}`} /> + {selected.length > 0 && onRemove && ( +
+ {selected.map(item => ( + + ))} + {selected.length > 1 && onReset && ( + + )} +
+ )} + {helper &&

{helper}

}

Include questions from:

diff --git a/frontend/src/pages/CustomQuizPage.css b/frontend/src/pages/CustomQuizPage.css index 925742f..c4329e0 100644 --- a/frontend/src/pages/CustomQuizPage.css +++ b/frontend/src/pages/CustomQuizPage.css @@ -267,3 +267,28 @@ .custom-test-refresh { flex: 0 0 auto; } .custom-test-start { flex: 1; width: auto; margin-left: 0; min-width: 0; } } + +/* What you have picked, inside the picker. Taking one off should not mean + hunting its checkbox back down in a list of several hundred, and Reset is no + answer — it throws away nine choices to undo the tenth. */ +.facet-panel-chosen { + display: flex; flex-wrap: wrap; gap: 6px; + padding: 10px 18px 0; +} +.facet-chosen-chip { + display: inline-flex; align-items: center; gap: 7px; + min-height: 28px; padding: 4px 9px; cursor: pointer; + border: 1px solid var(--option-sel-bd, var(--primary)); border-radius: 999px; + background: var(--option-sel-bg); color: var(--primary); + font: inherit; font-size: 0.78rem; font-weight: 600; +} +.facet-chosen-chip:hover { background: var(--primary); color: #fff; } +.facet-chosen-chip span:first-child { + max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.facet-chosen-clear { + min-height: 28px; padding: 4px 9px; cursor: pointer; + background: none; border: 0; font: inherit; font-size: 0.76rem; + color: var(--text-muted); text-decoration: underline; +} +.facet-chosen-clear:hover { color: var(--wrong-fg); } diff --git a/frontend/src/pages/CustomQuizPage.jsx b/frontend/src/pages/CustomQuizPage.jsx index 6d43f72..e6b39cb 100644 --- a/frontend/src/pages/CustomQuizPage.jsx +++ b/frontend/src/pages/CustomQuizPage.jsx @@ -377,6 +377,8 @@ export default function CustomQuizPage() { {/* ── Facet pickers ────────────────────────────────────────── */} setOpenFacet(null)} onReset={() => setTagIds(ids => ids.filter(id => !systemTags.some(t => t.id === id)))} + selected={systemTags.filter(t => tagIds.includes(t.id)).map(t => ({ id: t.id, label: t.name }))} + onRemove={id => toggleTag(id, false)} helper="Body systems. Flat by nature — there are only so many."> {query => { const shown = systemTags.filter(t => !query || t.name.toLowerCase().includes(query)) @@ -394,6 +396,8 @@ export default function CustomQuizPage() { setOpenFacet(null)} onReset={() => setCategoryIds([])} + selected={categories.filter(c => categoryIds.includes(c.id)).map(c => ({ id: c.id, label: c.name }))} + onRemove={id => toggleCategory(id, false)} helper="Open a system to see what is under it. Choosing one includes everything beneath."> {query => ( setOpenFacet(null)} onReset={() => setTagIds(ids => ids.filter(id => !subjectTags.some(t => t.id === id)))} + selected={subjectTags.filter(t => tagIds.includes(t.id)).map(t => ({ id: t.id, label: t.name }))} + onRemove={id => toggleTag(id, false)} helper="By default, all disciplines are included unless filters are selected."> {query => checkList(subjectTags, t => tagIds.includes(t.id), (t, on) => toggleTag(t.id, on), query)} setOpenFacet(null)} onReset={() => setTagIds(ids => ids.filter(id => !keywordTags.some(t => t.id === id)))} + selected={keywordTags.filter(t => tagIds.includes(t.id)).map(t => ({ id: t.id, label: t.name }))} + onRemove={id => toggleTag(id, false)} helper="By default, all keywords are included unless filters are selected."> {query => checkList(keywordTags, t => tagIds.includes(t.id), (t, on) => toggleTag(t.id, on), query)} setOpenFacet(null)} onReset={() => setArticleIds([])} + selected={articles.filter(a => articleIds.includes(a.id)).map(a => ({ id: a.id, label: a.title }))} + onRemove={id => setArticleIds(ids => ids.filter(x => x !== id))} helper="Pick the reading whose linked questions you want."> {query => articles.length === 0 ?

No articles yet.

diff --git a/frontend/src/pages/CustomQuizPage.test.jsx b/frontend/src/pages/CustomQuizPage.test.jsx index 33a5bce..294c035 100644 --- a/frontend/src/pages/CustomQuizPage.test.jsx +++ b/frontend/src/pages/CustomQuizPage.test.jsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor , within} from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -202,3 +202,25 @@ describe('CustomQuizPage', () => { expect(screen.getByRole('button', { name: 'Create Test' })).toBeDisabled() }) }) + +it('shows what you picked inside the picker, and lets you take one off', async () => { + api.get.mockImplementation(url => { + if (url === '/question-categories/') return Promise.resolve({ data: categories }) + if (url.startsWith('/tags')) return Promise.resolve({ data: TAGS }) + if (url.startsWith('/articles') || url.startsWith('/collections')) return Promise.resolve({ data: [] }) + return Promise.resolve({ data: { count: 30 } }) + }) + renderBuilder() + await userEvent.click(await screen.findByRole('button', { name: /Disciplines/ })) + + const panel = screen.getByRole('dialog', { name: 'Disciplines' }) + await userEvent.click(within(panel).getByRole('checkbox', { name: /Cardiology/ })) + + // The chip is the way back out. Hunting the checkbox down again in a list of + // several hundred is not, and Reset throws away every other choice. + const chip = within(panel).getByRole('button', { name: 'Remove Cardiology' }) + await userEvent.click(chip) + + expect(within(panel).queryByRole('button', { name: 'Remove Cardiology' })).not.toBeInTheDocument() + expect(within(panel).getByRole('checkbox', { name: /Cardiology/ })).not.toBeChecked() +})