fix: ship the three files the overview page imports
`0700e68` and the two commits before it added the overview, its stylesheet and the search-panel context, and committed only the page. App.jsx imports `./context/SearchPanel` and DashboardPage.jsx imports `./DashboardPage.css`, neither of which was in the tree — so master has not built from a clean clone since. It built here because the files exist on this disk. Nothing new: the four files as they already are, plus SearchOverlay's `tab` prop, which is what the context passes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
031de53034
commit
73ab2515f5
4 changed files with 291 additions and 3 deletions
|
|
@ -36,8 +36,8 @@ function remember(query) {
|
||||||
* conversation and lets the answer arrive there, which is where the follow-up
|
* conversation and lets the answer arrive there, which is where the follow-up
|
||||||
* will be asked from anyway.
|
* will be asked from anyway.
|
||||||
*/
|
*/
|
||||||
export default function SearchOverlay({ open, onClose }) {
|
export default function SearchOverlay({ open, onClose, tab: opensOn = 'search' }) {
|
||||||
const [tab, setTab] = useState('search')
|
const [tab, setTab] = useState(opensOn)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
const [active, setActive] = useState(-1)
|
const [active, setActive] = useState(-1)
|
||||||
|
|
@ -47,13 +47,17 @@ export default function SearchOverlay({ open, onClose }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined
|
if (!open) return undefined
|
||||||
|
// Whichever tab the opener asked for, every time it opens. Remembering the
|
||||||
|
// tab left behind would mean a control labelled AI Mode lands on the corpus
|
||||||
|
// search because of something done on another page an hour ago.
|
||||||
|
setTab(opensOn)
|
||||||
setHistory(readHistory())
|
setHistory(readHistory())
|
||||||
// Focused on the next frame: the element does not exist until this render
|
// Focused on the next frame: the element does not exist until this render
|
||||||
// has been painted, and focusing a node that is not in the document does
|
// has been painted, and focusing a node that is not in the document does
|
||||||
// nothing at all.
|
// nothing at all.
|
||||||
const timer = requestAnimationFrame(() => field.current?.focus())
|
const timer = requestAnimationFrame(() => field.current?.focus())
|
||||||
return () => cancelAnimationFrame(timer)
|
return () => cancelAnimationFrame(timer)
|
||||||
}, [open])
|
}, [open, opensOn])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || tab !== 'search' || query.trim().length < 2) { setItems([]); return undefined }
|
if (!open || tab !== 'search' || query.trim().length < 2) { setItems([]); return undefined }
|
||||||
|
|
|
||||||
54
frontend/src/context/SearchPanel.jsx
Normal file
54
frontend/src/context/SearchPanel.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import SearchOverlay from '../components/SearchOverlay'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who may open the search panel: the header, the keyboard, and the hero on the
|
||||||
|
* overview page.
|
||||||
|
*
|
||||||
|
* There is one panel with two tabs, so none of those three is a search of its
|
||||||
|
* own — each is another door to the same one. Two boxes that do nearly the
|
||||||
|
* same thing is how people learn to trust neither, and the header already
|
||||||
|
* settled that argument for itself; the hero would otherwise have reopened it.
|
||||||
|
*
|
||||||
|
* Which tab it lands on is the caller's to say. A control labelled AI Mode is a
|
||||||
|
* promise about where you end up, and landing on the corpus search instead
|
||||||
|
* breaks it.
|
||||||
|
*/
|
||||||
|
const Context = createContext({ open: () => {} })
|
||||||
|
|
||||||
|
export function SearchPanelProvider({ children }) {
|
||||||
|
//: The tab is the open state — null is shut. A separate boolean beside it is
|
||||||
|
//: a second thing that has to agree about whether the panel is on screen.
|
||||||
|
const [tab, setTab] = useState(null)
|
||||||
|
const open = useCallback((which = 'search') => setTab(which), [])
|
||||||
|
const close = useCallback(() => setTab(null), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||||
|
// Taken from the browser deliberately: Ctrl+K is what every tool this
|
||||||
|
// sits beside uses, and a learner who has learned it once should not
|
||||||
|
// have to learn ours.
|
||||||
|
event.preventDefault()
|
||||||
|
open('search')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ open }), [open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
{/* Over whatever is on screen: a question arrives while you are reading
|
||||||
|
something, and having to leave that page to ask is how it gets
|
||||||
|
dropped. */}
|
||||||
|
<SearchOverlay open={tab !== null} tab={tab ?? 'search'} onClose={close} />
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `open('search' | 'ai')`. Does nothing outside the authenticated shell. */
|
||||||
|
export const useSearchPanel = () => useContext(Context)
|
||||||
83
frontend/src/pages/DashboardPage.css
Normal file
83
frontend/src/pages/DashboardPage.css
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/* The signed-in overview: search, the session waiting for you, and a glance
|
||||||
|
at how it is going. The blocks themselves are shared components with their
|
||||||
|
own stylesheets; this file only greets, draws the hero, and decides where
|
||||||
|
things sit. */
|
||||||
|
|
||||||
|
.ov-greeting { margin: 0 0 16px; font-size: 1.5rem; font-weight: 700; color: var(--text); }
|
||||||
|
|
||||||
|
/* --- the hero ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.ov-hero { margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.ov-hero-modes { display: inline-flex; gap: 4px; margin-bottom: 10px; padding: 3px; border-radius: 10px; background: var(--bg); }
|
||||||
|
.ov-hero-modes button {
|
||||||
|
border: 0; background: none; color: var(--text-muted); cursor: pointer;
|
||||||
|
font: inherit; font-size: 0.86rem; font-weight: 600; padding: 7px 16px; border-radius: 8px;
|
||||||
|
}
|
||||||
|
.ov-hero-modes button:hover { color: var(--primary); background: var(--card-bg); }
|
||||||
|
|
||||||
|
/* A door, not a field — it looks like one because that is what people reach
|
||||||
|
for, and clicking anywhere on it opens the panel with the cursor already in
|
||||||
|
the real box. */
|
||||||
|
.ov-hero-field {
|
||||||
|
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||||
|
min-height: 52px; padding: 12px 16px; cursor: pointer; text-align: left;
|
||||||
|
font: inherit; font-size: 1rem; color: var(--text-subtle, var(--text-muted));
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
}
|
||||||
|
.ov-hero-field:hover { border-color: var(--primary); }
|
||||||
|
.ov-hero-field:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||||
|
.ov-hero-icon { font-size: 1.2rem; line-height: 1; color: var(--text-muted); }
|
||||||
|
.ov-hero-prompt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ov-hero-field kbd {
|
||||||
|
flex-shrink: 0; padding: 3px 7px; border-radius: 6px; font: inherit; font-size: 0.7rem;
|
||||||
|
color: var(--text-muted); background: var(--bg); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- progress at a glance ------------------------------------------------ */
|
||||||
|
|
||||||
|
.ov-glance, .ov-first {
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
padding: 16px; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.ov-glance-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
|
||||||
|
.ov-glance-head h2 { margin: 0; font-size: 1.15rem; font-weight: 650; }
|
||||||
|
.ov-glance-head a { font-size: 0.82rem; color: var(--primary); text-decoration: none; white-space: nowrap; }
|
||||||
|
.ov-glance-head a:hover { text-decoration: underline; }
|
||||||
|
.ov-basis { margin: 12px 0 0; font-size: 0.78rem; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* --- a first visit ------------------------------------------------------- */
|
||||||
|
|
||||||
|
.ov-first h2 { margin: 0 0 12px; font-size: 1.15rem; font-weight: 650; }
|
||||||
|
.ov-first-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
|
||||||
|
.ov-first-grid a {
|
||||||
|
display: flex; flex-direction: column; gap: 4px; height: 100%;
|
||||||
|
padding: 13px 14px; border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
color: var(--text); text-decoration: none; background: var(--bg);
|
||||||
|
}
|
||||||
|
.ov-first-grid a:hover { border-color: var(--primary); }
|
||||||
|
.ov-first-grid strong { font-size: 0.92rem; font-weight: 650; }
|
||||||
|
.ov-first-grid span { font-size: 0.8rem; color: var(--text-muted); line-height: 1.45; }
|
||||||
|
|
||||||
|
/* --- where the blocks sit ------------------------------------------------ */
|
||||||
|
|
||||||
|
/* One column until there is room for two, which is the order they are written
|
||||||
|
in and the only order that makes sense on a phone: what to do now, what was
|
||||||
|
left open, then how it is going.
|
||||||
|
|
||||||
|
Two columns puts the ring beside the session plan rather than below two tall
|
||||||
|
panels. Dense packing rather than explicit rows because half these blocks
|
||||||
|
are conditional — a fresh account has no ring and no notebook — and fixed
|
||||||
|
row numbers would leave holes where those would have been. */
|
||||||
|
@media (min-width: 1000px) {
|
||||||
|
.ov-body {
|
||||||
|
display: grid; grid-auto-flow: row dense; align-items: start;
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 20px;
|
||||||
|
}
|
||||||
|
.ov-body > .ps-panel, .ov-body > .cs-panel { grid-column: 1; }
|
||||||
|
.ov-body > .ov-glance, .ov-body > .ov-first, .ov-body > .mynote-card { grid-column: 2; }
|
||||||
|
/* Roughly the width of the column beneath it. A search box the full 1200px
|
||||||
|
with a short prompt at the left end reads as a page that ran out of
|
||||||
|
content, not as a generous target. */
|
||||||
|
.ov-hero { max-width: 720px; }
|
||||||
|
}
|
||||||
147
frontend/src/pages/DashboardPage.test.jsx
Normal file
147
frontend/src/pages/DashboardPage.test.jsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import DashboardPage from './DashboardPage'
|
||||||
|
import { SearchPanelProvider } from '../context/SearchPanel'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
|
||||||
|
vi.mock('../context/AuthContext', () => ({
|
||||||
|
useAuth: () => ({ user: { id: 1, name: 'Ada Lovelace', role: 'user' } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const split = (latest, extra = {}) => ({
|
||||||
|
all: latest,
|
||||||
|
latest,
|
||||||
|
attempts: 3,
|
||||||
|
unique_questions: latest.total,
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
const NOTHING = { correct: 0, correct_with_hints: 0, incorrect: 0, unanswered: 0, answered: 0, total: 0, percent_correct: null }
|
||||||
|
const SOME = { correct: 12, correct_with_hints: 2, incorrect: 6, unanswered: 0, answered: 20, total: 20, percent_correct: 70 }
|
||||||
|
|
||||||
|
const COLD_PLAN = {
|
||||||
|
count: 20, basis: 'cold_start', new_count: 20, review_count: 0, available: 2900,
|
||||||
|
summary: "You haven't finished a session yet, so this is an even spread across the exam blueprint.",
|
||||||
|
length_reason: '20 to start with.',
|
||||||
|
topics: [{ category_id: 1, name: 'Cardiology', count: 20, new_count: 20, review_count: 0, accuracy: null, weight: 60, reason: 'Worth 60% of the exam' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const IN_FLIGHT = [
|
||||||
|
{ quiz_id: 5, title: 'Prepared session', mode: 'learning', state: 'in_progress', answered: 4, total: 20, last_attempt_id: 9 },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Everything the page and its blocks ask for, with the account's state swapped in. */
|
||||||
|
const server = ({ answers = NOTHING, sessions = [], plan = COLD_PLAN } = {}) => {
|
||||||
|
api.get.mockImplementation((url) => {
|
||||||
|
if (url === '/study-tools/answer-split') return Promise.resolve({ data: split(answers) })
|
||||||
|
if (url === '/questions/builder/prepared') return Promise.resolve({ data: plan })
|
||||||
|
if (url === '/quizzes/sessions') return Promise.resolve({ data: sessions })
|
||||||
|
if (url === '/articles/recent') return Promise.resolve({ data: [] })
|
||||||
|
if (url === '/exams/') return Promise.resolve({ data: { exams: [], active_exam_id: null } })
|
||||||
|
if (url === '/mynote') return Promise.resolve({ data: { content: 'Kawasaki criteria', updated_at: null } })
|
||||||
|
return Promise.resolve({ data: {} })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mount = () => render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<SearchPanelProvider><DashboardPage /></SearchPanelProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
server()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('greets the learner by their first name', async () => {
|
||||||
|
mount()
|
||||||
|
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent(/, Ada$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
/* The case anybody evaluating this product sees first, and the one a dashboard
|
||||||
|
built out of statistics gets wrong: an account with nothing behind it. */
|
||||||
|
it('gives a new account something to do rather than a shelf of dashes', async () => {
|
||||||
|
mount()
|
||||||
|
|
||||||
|
// A real session, planned and explained, is the opening act.
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Ready for you' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Start session' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
// And the rest of the building, for somebody who would rather read first.
|
||||||
|
expect(screen.getByRole('heading', { name: 'Other ways to start' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: /Read first/ })).toHaveAttribute('href', '/articles')
|
||||||
|
expect(screen.getByRole('link', { name: /Follow a plan/ })).toHaveAttribute('href', '/study-plans')
|
||||||
|
expect(screen.getByRole('link', { name: /Ask a question/ })).toHaveAttribute('href', '/ai')
|
||||||
|
|
||||||
|
// Nothing that would be empty: no ring drawn from no answers, no half-done
|
||||||
|
// session, no notebook with nothing in it.
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Where you stand' })).not.toBeInTheDocument()
|
||||||
|
expect(document.querySelector('.an-donut')).toBeNull()
|
||||||
|
expect(screen.queryByText('Continue your study')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Open MyNote' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('—')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the ring once there are answers, and keeps the rest on the analysis page', async () => {
|
||||||
|
server({ answers: SOME, sessions: IN_FLIGHT })
|
||||||
|
mount()
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Where you stand' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('70%')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('12 correct')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2 correct after a tip')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('link', { name: 'See full analysis' }))
|
||||||
|
.toHaveAttribute('href', '/sessions?view=performance')
|
||||||
|
|
||||||
|
// A glance, not a second analysis page: the trend, the peer comparison and
|
||||||
|
// the focus-area table stay where they are.
|
||||||
|
expect(screen.queryByText('Performance over time')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Against everyone else')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Other ways to start' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the session left open, without going into it', async () => {
|
||||||
|
server({ answers: SOME, sessions: IN_FLIGHT })
|
||||||
|
mount()
|
||||||
|
const resume = await screen.findByRole('link', { name: 'Resume' })
|
||||||
|
expect(resume).toHaveAttribute('href', '/study/5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the one search panel rather than searching for itself', async () => {
|
||||||
|
mount()
|
||||||
|
await screen.findByRole('heading', { name: 'Ready for you' })
|
||||||
|
// Nothing to type into until the panel is open — the hero is a door.
|
||||||
|
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
|
||||||
|
expect(await screen.findByRole('dialog', { name: 'Search' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('searchbox', { name: 'Search PedsHub' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lands on AI Mode when that is what was asked for', async () => {
|
||||||
|
mount()
|
||||||
|
await screen.findByRole('heading', { name: 'Ready for you' })
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'AI Mode' }))
|
||||||
|
expect(await screen.findByRole('dialog', { name: 'AI Mode' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('searchbox', { name: 'Ask AI Mode' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing about progress it could not load', async () => {
|
||||||
|
api.get.mockImplementation((url) => (
|
||||||
|
url === '/study-tools/answer-split'
|
||||||
|
? Promise.reject(new Error('down'))
|
||||||
|
: Promise.resolve({ data: url === '/questions/builder/prepared' ? COLD_PLAN : [] })
|
||||||
|
))
|
||||||
|
mount()
|
||||||
|
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/study-tools/answer-split'))
|
||||||
|
// Neither the ring nor the first-visit block: one would be drawn from
|
||||||
|
// nothing and the other would call an established account new.
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Where you stand' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('heading', { name: 'Other ways to start' })).not.toBeInTheDocument()
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Ready for you' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue