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
|
||||
* will be asked from anyway.
|
||||
*/
|
||||
export default function SearchOverlay({ open, onClose }) {
|
||||
const [tab, setTab] = useState('search')
|
||||
export default function SearchOverlay({ open, onClose, tab: opensOn = 'search' }) {
|
||||
const [tab, setTab] = useState(opensOn)
|
||||
const [query, setQuery] = useState('')
|
||||
const [items, setItems] = useState([])
|
||||
const [active, setActive] = useState(-1)
|
||||
|
|
@ -47,13 +47,17 @@ export default function SearchOverlay({ open, onClose }) {
|
|||
|
||||
useEffect(() => {
|
||||
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())
|
||||
// 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
|
||||
// nothing at all.
|
||||
const timer = requestAnimationFrame(() => field.current?.focus())
|
||||
return () => cancelAnimationFrame(timer)
|
||||
}, [open])
|
||||
}, [open, opensOn])
|
||||
|
||||
useEffect(() => {
|
||||
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