feat: one search across reading, questions, cards and images
Five corpora were each searchable from their own page, which meant knowing which of five pages held the thing you were looking for before you could look for it. `GET /search` runs them together. Visibility is never re-implemented here. Questions go through the same bank predicate and exam scope as the question bank, articles through the same draft rule, cards through deck ownership, images through library grants. A search page with its own idea of who may see what is how private content leaks, so the tests that matter are the boundary ones: a peer's search reaches neither another user's unshared question nor their deck, and a draft is invisible to everyone but the educator who wrote it. A section hit is reported under its article, not beside it — ten sections of one article are one result with ten places to start reading, not ten results burying everything else. This is what the section index was backfilled for; each one links straight to that section. Results are grouped by kind rather than interleaved by score. A question and an article are different kinds of answer, and a single ranked list makes you read every row to work out which kind each one is. Snippets show the window around the match rather than the opening of the document, because every document's opening looks the same. A question found only by the semantic ranker says so. The header box has two ways out: pick a suggestion and go straight to that article, or press Enter and search everything. Suggestions are lexical and prefix-first — a typeahead is finishing the word you are typing, and a semantic neighbour of half a word is noise — and debounced 180ms so typing is not a request per keystroke. One corpus failing is logged and returned as a gap in the answer rather than a failed page. 154 backend, 163 frontend green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
This commit is contained in:
parent
362c48926b
commit
885e8be417
13 changed files with 799 additions and 8 deletions
|
|
@ -11,7 +11,7 @@ from app.logging_config import setup_logging
|
|||
setup_logging(settings.LOG_LEVEL)
|
||||
from app.database import engine, Base, SessionLocal
|
||||
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
|
||||
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media
|
||||
from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search
|
||||
from app.utils.auth import get_password_hash
|
||||
from app.utils.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
|
@ -642,6 +642,7 @@ app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
|
|||
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
|
||||
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
|
||||
app.include_router(study_tools.router, prefix="/api/study-tools", tags=["study-tools"])
|
||||
app.include_router(search.router, prefix="/api/search", tags=["search"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
211
backend/app/routers/search.py
Normal file
211
backend/app/routers/search.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""One query across everything a learner can see.
|
||||
|
||||
Each corpus already has its own retrieval and its own rules about who may see
|
||||
what. This endpoint runs them together and returns one answer, rather than
|
||||
making somebody guess which of five pages holds the thing they are looking for.
|
||||
|
||||
Two decisions worth stating:
|
||||
|
||||
* Visibility is never re-implemented here. Questions go through the same bank
|
||||
predicate and exam scope as the question bank, articles through the same draft
|
||||
rule, cards through deck ownership, images through library grants. A search
|
||||
page that had its own idea of who may see what is how private content leaks.
|
||||
* A section match is reported under its article, not beside it. Ten sections of
|
||||
one article are one result with ten places to start reading, not ten results
|
||||
that bury everything else.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.article import Article, ArticleSectionIndex
|
||||
from app.models.flashcard import Flashcard, FlashcardDeck
|
||||
from app.models.media import MediaAsset
|
||||
from app.models.question import Question
|
||||
from app.models.user import User
|
||||
from app.routers.media import readable_libraries
|
||||
from app.services.quiz_builder import bank_query, exam_scope_predicate
|
||||
from app.services.search_service import hybrid_ids
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
KINDS = ("article", "question", "flashcard", "media")
|
||||
# Per-corpus retrieval pool. Wider than what is shown, because visibility
|
||||
# filtering happens after ranking and can empty a page that had hits.
|
||||
POOL = 60
|
||||
|
||||
|
||||
def _strip_markup(value: str | None) -> str:
|
||||
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", value or "")
|
||||
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
text = re.sub(r"^\s{0,3}#{1,6}\s*", "", text, flags=re.M)
|
||||
text = re.sub(r"[*_`>|]", " ", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def _snippet(value: str | None, query: str, width: int = 180) -> str:
|
||||
"""A window of the text around the first query word that appears in it.
|
||||
|
||||
Showing the opening of every document makes results look identical; showing
|
||||
where the match is tells you whether it is the one you meant.
|
||||
"""
|
||||
text = _strip_markup(value)
|
||||
if not text:
|
||||
return ""
|
||||
for word in sorted((w for w in re.findall(r"\w{4,}", query.lower())), key=len, reverse=True):
|
||||
found = text.lower().find(word)
|
||||
if found >= 0:
|
||||
start = max(0, found - width // 3)
|
||||
piece = text[start:start + width]
|
||||
return ("…" if start else "") + piece.strip() + ("…" if start + width < len(text) else "")
|
||||
return text[:width] + ("…" if len(text) > width else "")
|
||||
|
||||
|
||||
def _ordered(rows, ranked: list[int]):
|
||||
"""Rows back in the order retrieval put them, not the order the DB returned."""
|
||||
position = {row_id: index for index, row_id in enumerate(ranked)}
|
||||
return sorted(rows, key=lambda row: position.get(row.id, len(position)))
|
||||
|
||||
|
||||
def _articles(db, user, q, limit):
|
||||
ranked, _ = hybrid_ids(db, q, "article", limit=POOL)
|
||||
# A section match belongs to its article, so both rankers feed one result.
|
||||
section_ranked, _ = hybrid_ids(db, q, "article_section", limit=POOL)
|
||||
sections = db.query(ArticleSectionIndex).filter(
|
||||
ArticleSectionIndex.id.in_(section_ranked)).all() if section_ranked else []
|
||||
by_article: dict[int, list] = {}
|
||||
for section in sections:
|
||||
by_article.setdefault(section.article_id, []).append(section)
|
||||
|
||||
wanted = list(dict.fromkeys([*ranked, *by_article.keys()]))
|
||||
if not wanted:
|
||||
return []
|
||||
rows = db.query(Article).filter(Article.id.in_(wanted)).all()
|
||||
if not user.is_moderator:
|
||||
rows = [a for a in rows if a.status == "published" or a.user_id == user.id]
|
||||
results = []
|
||||
for article in _ordered(rows, wanted)[:limit]:
|
||||
results.append({
|
||||
"id": article.id,
|
||||
"slug": article.slug,
|
||||
"title": article.title,
|
||||
"snippet": _snippet(article.summary or article.content, q),
|
||||
"status": article.status,
|
||||
"section_count": len(article.sections or []),
|
||||
"sections": [
|
||||
{"section_id": s.section_id, "title": s.title, "snippet": _snippet(s.content, q)}
|
||||
for s in by_article.get(article.id, [])[:4]
|
||||
],
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _questions(db, user, q, limit):
|
||||
ranked, semantic = hybrid_ids(db, q, "question", limit=POOL)
|
||||
if not ranked:
|
||||
return []
|
||||
query = bank_query(db, user).filter(Question.id.in_(ranked))
|
||||
scope = exam_scope_predicate(db, user)
|
||||
if scope is not None:
|
||||
query = query.filter(scope)
|
||||
rows = _ordered(query.all(), ranked)[:limit]
|
||||
return [{
|
||||
"id": row.id,
|
||||
"snippet": _snippet(row.question_text, q),
|
||||
"difficulty": row.difficulty,
|
||||
# Worth saying: a hit nobody's words predicted came from the meaning.
|
||||
"match": "semantic" if row.id in semantic else "keyword",
|
||||
} for row in rows]
|
||||
|
||||
|
||||
def _flashcards(db, user, q, limit):
|
||||
ranked, _ = hybrid_ids(db, q, "flashcard", limit=POOL)
|
||||
if not ranked:
|
||||
return []
|
||||
own = [d.id for d in db.query(FlashcardDeck.id).filter(
|
||||
FlashcardDeck.user_id == user.id, FlashcardDeck.deleted_at.is_(None)).all()]
|
||||
if not own:
|
||||
return []
|
||||
rows = db.query(Flashcard).filter(
|
||||
Flashcard.id.in_(ranked), Flashcard.deck_id.in_(own)).all()
|
||||
return [{"id": row.id, "deck_id": row.deck_id, "front": row.front,
|
||||
"snippet": _snippet(row.back, q)} for row in _ordered(rows, ranked)[:limit]]
|
||||
|
||||
|
||||
def _media(db, user, q, limit):
|
||||
ranked, _ = hybrid_ids(db, q, "media", limit=POOL)
|
||||
if not ranked:
|
||||
return []
|
||||
query = db.query(MediaAsset).filter(MediaAsset.id.in_(ranked))
|
||||
scope = readable_libraries(db, user)
|
||||
if scope is not None:
|
||||
query = query.filter(MediaAsset.library_id.in_(scope or {0}))
|
||||
rows = _ordered(query.all(), ranked)[:limit]
|
||||
return [{"id": row.id, "path": row.path, "title": row.title,
|
||||
"snippet": _snippet(row.caption or row.alt_text, q)} for row in rows]
|
||||
|
||||
|
||||
FINDERS = {"article": _articles, "question": _questions,
|
||||
"flashcard": _flashcards, "media": _media}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def search_everything(
|
||||
q: str = Query("", max_length=500),
|
||||
kinds: str | None = Query(None, description="Comma-separated subset of article,question,flashcard,media"),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Search every corpus at once, grouped by what was found."""
|
||||
query_text = (q or "").strip()
|
||||
wanted = [k for k in (kinds.split(",") if kinds else KINDS) if k in FINDERS]
|
||||
empty = {"query": query_text, "results": {kind: [] for kind in wanted}, "total": 0}
|
||||
if len(query_text) < 2:
|
||||
return empty
|
||||
|
||||
results = {}
|
||||
for kind in wanted:
|
||||
try:
|
||||
results[kind] = FINDERS[kind](db, current_user, query_text, limit)
|
||||
except Exception:
|
||||
# One corpus failing is a gap in the answer, not the end of it.
|
||||
log.warning("Search failed for %s", kind, exc_info=True)
|
||||
results[kind] = []
|
||||
return {"query": query_text, "results": results,
|
||||
"total": sum(len(rows) for rows in results.values())}
|
||||
|
||||
|
||||
@router.get("/suggest")
|
||||
def suggest(
|
||||
q: str = Query("", max_length=200),
|
||||
limit: int = Query(6, ge=1, le=12),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Titles for a typeahead — cheap enough to run on every keystroke.
|
||||
|
||||
Deliberately lexical and prefix-based: a typeahead is finishing the word you
|
||||
are typing, and a semantic neighbour of half a word is noise.
|
||||
"""
|
||||
query_text = (q or "").strip()
|
||||
if len(query_text) < 2:
|
||||
return {"suggestions": []}
|
||||
pattern = f"%{query_text.lower()}%"
|
||||
rows = db.execute(sa_text("""
|
||||
SELECT id, slug, title, status, user_id FROM articles
|
||||
WHERE lower(title) LIKE :pattern
|
||||
ORDER BY CASE WHEN lower(title) LIKE :prefix THEN 0 ELSE 1 END, length(title), title
|
||||
LIMIT :limit
|
||||
"""), {"pattern": pattern, "prefix": f"{query_text.lower()}%", "limit": limit * 2}).fetchall()
|
||||
visible = [r for r in rows
|
||||
if r.status == "published" or current_user.is_moderator or r.user_id == current_user.id]
|
||||
return {"suggestions": [{"kind": "article", "id": r.id, "slug": r.slug, "title": r.title}
|
||||
for r in visible[:limit]]}
|
||||
117
backend/tests/test_global_search.py
Normal file
117
backend/tests/test_global_search.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""One query across every corpus, on disposable SQLite; no network or AI.
|
||||
|
||||
The point of these tests is the boundary, not the ranking: a search page that
|
||||
had its own idea of who may see what is how private content leaks.
|
||||
"""
|
||||
import unittest
|
||||
|
||||
import test_quiz_builder as fixtures
|
||||
from app.models.article import Article, ArticleSectionIndex
|
||||
from app.models.flashcard import Flashcard, FlashcardDeck
|
||||
from app.models.question import Question
|
||||
from app.routers import search
|
||||
|
||||
|
||||
class GlobalSearchTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bank = fixtures.BuilderTests()
|
||||
self.bank.setUp()
|
||||
self.client = self.bank.client
|
||||
self.client.app.include_router(search.router, prefix='/search')
|
||||
self.db = self.bank.db
|
||||
|
||||
self.db.add_all([
|
||||
Article(id=1, slug='febrile-seizures', title='Febrile seizures',
|
||||
summary='A seizure with fever in a young child', content='Intro',
|
||||
sections=[{"id": "a" * 32, "slug": "workup", "title": "Initial workup", "content": "Lumbar puncture"}],
|
||||
status='published', user_id=3),
|
||||
Article(id=2, slug='draft-topic', title='Febrile draft', summary='Unpublished febrile notes',
|
||||
sections=[], status='draft', user_id=3),
|
||||
])
|
||||
self.db.add(ArticleSectionIndex(id=1, article_id=1, section_id='a' * 32,
|
||||
title='Initial workup', content='Lumbar puncture in a febrile infant'))
|
||||
self.db.add_all([
|
||||
FlashcardDeck(id=1, title='Mine', user_id=1),
|
||||
FlashcardDeck(id=2, title='Theirs', user_id=2),
|
||||
])
|
||||
self.db.flush()
|
||||
self.db.add_all([
|
||||
Flashcard(id=1, deck_id=1, front='Febrile seizure duration', back='Under 15 minutes'),
|
||||
Flashcard(id=2, deck_id=2, front='Febrile seizure age', back='6 months to 5 years'),
|
||||
])
|
||||
self.db.query(Question).filter(Question.id == 1).update(
|
||||
{"question_text": "A child with a febrile seizure lasting two minutes"})
|
||||
self.db.query(Question).filter(Question.id == 3).update(
|
||||
{"question_text": "A private febrile seizure question"})
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.bank.tearDown()
|
||||
|
||||
def find(self, q='febrile', **params):
|
||||
response = self.client.get('/search', params={'q': q, **params})
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
return response.json()
|
||||
|
||||
def test_one_query_answers_from_every_corpus(self):
|
||||
self.bank.user = self.bank.owner
|
||||
data = self.find()
|
||||
self.assertEqual(data['query'], 'febrile')
|
||||
self.assertIn(1, [row['id'] for row in data['results']['article']])
|
||||
self.assertIn(1, [row['id'] for row in data['results']['question']])
|
||||
self.assertIn(1, [row['id'] for row in data['results']['flashcard']])
|
||||
self.assertEqual(data['total'], sum(len(v) for v in data['results'].values()))
|
||||
|
||||
def test_a_section_hit_is_reported_under_its_article(self):
|
||||
self.bank.user = self.bank.owner
|
||||
article = next(row for row in self.find('lumbar')['results']['article'] if row['id'] == 1)
|
||||
# Ten sections of one article are one result with ten places to start,
|
||||
# not ten results burying everything else.
|
||||
self.assertEqual([s['title'] for s in article['sections']], ['Initial workup'])
|
||||
self.assertEqual(article['sections'][0]['section_id'], 'a' * 32)
|
||||
|
||||
def test_drafts_stay_invisible_to_everyone_but_their_educator(self):
|
||||
self.bank.user = self.bank.owner
|
||||
self.assertNotIn(2, [row['id'] for row in self.find()['results']['article']])
|
||||
self.bank.user = self.bank.mod
|
||||
self.assertIn(2, [row['id'] for row in self.find()['results']['article']])
|
||||
|
||||
def test_private_questions_and_other_peoples_decks_are_not_searchable(self):
|
||||
self.bank.user = self.bank.peer
|
||||
data = self.find()
|
||||
# Question 3 belongs to another user and is not shared.
|
||||
self.assertNotIn(3, [row['id'] for row in data['results']['question']])
|
||||
# Deck 1 belongs to the owner; the peer's search must not reach into it.
|
||||
self.assertNotIn(1, [row['id'] for row in data['results']['flashcard']])
|
||||
|
||||
def test_a_query_too_short_to_mean_anything_asks_nothing(self):
|
||||
self.bank.user = self.bank.owner
|
||||
data = self.find('f')
|
||||
self.assertEqual(data['total'], 0)
|
||||
self.assertEqual(data['results']['article'], [])
|
||||
|
||||
def test_kinds_narrows_the_answer(self):
|
||||
self.bank.user = self.bank.owner
|
||||
data = self.find(kinds='article')
|
||||
self.assertEqual(set(data['results']), {'article'})
|
||||
|
||||
def test_a_snippet_shows_where_the_match_is_without_markup(self):
|
||||
self.db.query(Article).filter(Article.id == 1).update(
|
||||
{"summary": None, "content": "## Heading\n\nSee  the **febrile** child."})
|
||||
self.db.commit()
|
||||
self.bank.user = self.bank.owner
|
||||
snippet = next(row for row in self.find()['results']['article'] if row['id'] == 1)['snippet']
|
||||
self.assertIn('febrile', snippet)
|
||||
self.assertNotIn('##', snippet)
|
||||
self.assertNotIn('/uploads/', snippet)
|
||||
|
||||
def test_suggestions_prefer_a_prefix_and_respect_drafts(self):
|
||||
self.bank.user = self.bank.owner
|
||||
titles = [s['title'] for s in self.client.get(
|
||||
'/search/suggest', params={'q': 'febrile'}).json()['suggestions']]
|
||||
self.assertEqual(titles, ['Febrile seizures'])
|
||||
self.bank.user = self.bank.mod
|
||||
titles = [s['title'] for s in self.client.get(
|
||||
'/search/suggest', params={'q': 'febrile'}).json()['suggestions']]
|
||||
self.assertIn('Febrile draft', titles)
|
||||
self.assertEqual(self.client.get('/search/suggest', params={'q': 'f'}).json()['suggestions'], [])
|
||||
16
docs/TODO.md
16
docs/TODO.md
|
|
@ -12,18 +12,22 @@ Updated 2026-09-10.
|
|||
- [ ] **AI Mode (RAG chat)** — see "AI Mode design" below. Needs: conversation +
|
||||
message tables, the retrieval step, the ID-citation contract, and the
|
||||
chat UI with a thread rail.
|
||||
- [ ] **Global search page** — one query across questions, articles, sections,
|
||||
cards and media. Results grouped by article with the matching *sections*
|
||||
listed beneath (section index already exists). Typeahead with "Go to" and
|
||||
"Search for". Search / AI Mode toggle.
|
||||
- [x] **Global search page** — done 2026-09-10. `GET /search` runs every corpus
|
||||
at once and `/search` groups the answer by kind, with section hits listed
|
||||
under the article they belong to and linked to that section. A header box
|
||||
offers "go to this article" or "search everything". Each corpus keeps its
|
||||
own visibility rules — bank predicate and exam scope for questions, the
|
||||
draft rule for articles, deck ownership for cards, library grants for
|
||||
images. The Search / AI Mode toggle waits on AI Mode.
|
||||
|
||||
## UI fixes raised 2026-09-10
|
||||
|
||||
- [x] **Quiz/test categories removed** — done.
|
||||
- [x] **Sessions list shows only a few** — done, with a link to full history.
|
||||
- [x] **Analysis session rail full-height** — done.
|
||||
- [ ] **Articles page layout** — poor; AMBOSS stacks two menus for this. Check
|
||||
the live site before rebuilding.
|
||||
- [x] **Articles page layout** — done 2026-09-10. The page is the column
|
||||
browser itself: topics and the articles filed under them share a column,
|
||||
separated by icon, one column per level opened.
|
||||
- [x] **Systems facet duplicates** — done 2026-09-10. 491 redundant "(Parent)"
|
||||
suffixes stripped and 3 sibling pairs merged. Nesting still to do.
|
||||
- [x] **"⚙ Filters2948 questions"** — done. The stylesheet was never imported.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
|
|||
const LandingPage = lazy(() => import('./pages/LandingPage'))
|
||||
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
|
||||
const ArticlesPage = lazy(() => import('./pages/ArticlesPage'))
|
||||
const SearchPage = lazy(() => import('./pages/SearchPage'))
|
||||
const ArticlePage = lazy(() => import('./pages/ArticlesPage').then(m => ({ default: m.ArticlePage })))
|
||||
const PublicQuizPage = lazy(() => import('./pages/PublicQuizPage'))
|
||||
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
|
||||
|
|
@ -99,6 +100,7 @@ function AppRoutes() {
|
|||
<Route path="/analysis" element={<AnalysisPage />} />
|
||||
<Route path="/questions/manage" element={<QuestionManagerPage />} />
|
||||
<Route path="/flashcards" element={<FlashcardsPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/articles" element={<ArticlesPage />} />
|
||||
<Route path="/articles/:id" element={<ArticlePage />} />
|
||||
{/* Cross-references in article prose address a topic by slug, which
|
||||
|
|
|
|||
38
frontend/src/components/GlobalSearch.css
Normal file
38
frontend/src/components/GlobalSearch.css
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* Header search box and its typeahead. */
|
||||
|
||||
.gs { position: relative; flex: 1; max-width: 420px; min-width: 0; }
|
||||
|
||||
.gs-input {
|
||||
width: 100%; min-height: 38px; padding: 8px 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.12); color: var(--navbar-fg);
|
||||
font: inherit; font-size: 0.86rem;
|
||||
}
|
||||
.gs-input::placeholder { color: var(--navbar-fg); opacity: 0.65; }
|
||||
.gs-input:focus { outline: none; background: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.5); }
|
||||
|
||||
.gs-menu {
|
||||
position: absolute; z-index: 60; top: calc(100% + 6px); left: 0; right: 0;
|
||||
list-style: none; margin: 0; padding: 4px;
|
||||
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.18);
|
||||
max-height: 60vh; overflow-y: auto;
|
||||
}
|
||||
.gs-option {
|
||||
display: flex; align-items: baseline; gap: 8px; width: 100%;
|
||||
min-height: 40px; padding: 8px 10px; border: 0; border-radius: 7px;
|
||||
background: none; font: inherit; font-size: 0.86rem; color: var(--text);
|
||||
text-align: left; cursor: pointer;
|
||||
}
|
||||
.gs-option.is-active, .gs-option:hover { background: var(--bg); }
|
||||
.gs-option-kind {
|
||||
flex-shrink: 0; font-size: 0.63rem; font-weight: 700; letter-spacing: 0.05em;
|
||||
text-transform: uppercase; color: var(--text-subtle);
|
||||
}
|
||||
.gs-option-title { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
/* Always last, always available: not every search is for an article title. */
|
||||
.gs-all { border-top: 1px solid var(--border); border-radius: 0 0 7px 7px; color: var(--primary); font-weight: 600; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.gs { display: none; }
|
||||
}
|
||||
82
frontend/src/components/GlobalSearch.jsx
Normal file
82
frontend/src/components/GlobalSearch.jsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import './GlobalSearch.css'
|
||||
|
||||
/**
|
||||
* The search box in the header.
|
||||
*
|
||||
* Two ways out, deliberately: pick a suggestion to go straight to that article,
|
||||
* or press Enter to search everything. A typeahead that only offered titles
|
||||
* would strand anybody looking for a question or a card.
|
||||
*
|
||||
* Suggestions are lexical and prefix-first, because a typeahead is finishing the
|
||||
* word you are typing; a semantic neighbour of half a word is noise.
|
||||
*/
|
||||
export default function GlobalSearch() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [items, setItems] = useState([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [active, setActive] = useState(-1)
|
||||
const box = useRef(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim().length < 2) { setItems([]); return }
|
||||
const timer = setTimeout(() => {
|
||||
api.get('/search/suggest', { params: { q: query.trim() } })
|
||||
.then(res => { setItems(res.data?.suggestions || []); setActive(-1) })
|
||||
.catch(() => setItems([]))
|
||||
}, 180) // A pause, not a keystroke: typing must not be a request each.
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
const away = (event) => { if (!box.current?.contains(event.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', away)
|
||||
return () => document.removeEventListener('mousedown', away)
|
||||
}, [])
|
||||
|
||||
const go = (path) => { setOpen(false); setQuery(''); navigate(path) }
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'ArrowDown') { event.preventDefault(); setActive(i => Math.min(i + 1, items.length - 1)); setOpen(true) }
|
||||
else if (event.key === 'ArrowUp') { event.preventDefault(); setActive(i => Math.max(i - 1, -1)) }
|
||||
else if (event.key === 'Escape') { setOpen(false) }
|
||||
else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (active >= 0 && items[active]) go(`/articles/${items[active].id}`)
|
||||
else if (query.trim().length >= 2) go(`/search?q=${encodeURIComponent(query.trim())}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gs" ref={box}>
|
||||
<input className="gs-input" value={query} role="searchbox" aria-label="Search PedsHub"
|
||||
placeholder="Search PedsHub" autoComplete="off"
|
||||
onChange={e => { setQuery(e.target.value); setOpen(true) }}
|
||||
onFocus={() => setOpen(true)} onKeyDown={onKeyDown} />
|
||||
{open && query.trim().length >= 2 && (
|
||||
<ul className="gs-menu" role="listbox">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${item.kind}-${item.id}`}>
|
||||
<button type="button" role="option" aria-selected={index === active}
|
||||
className={`gs-option${index === active ? ' is-active' : ''}`}
|
||||
onMouseEnter={() => setActive(index)}
|
||||
onClick={() => go(`/articles/${item.id}`)}>
|
||||
<span className="gs-option-kind">Reading</span>
|
||||
<span className="gs-option-title">{item.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<button type="button" className={`gs-option gs-all${active === -1 ? ' is-active' : ''}`}
|
||||
onClick={() => go(`/search?q=${encodeURIComponent(query.trim())}`)}>
|
||||
Search everything for “{query.trim()}”
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
frontend/src/components/GlobalSearch.test.jsx
Normal file
70
frontend/src/components/GlobalSearch.test.jsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import GlobalSearch from './GlobalSearch'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
|
||||
|
||||
const Where = () => { const l = useLocation(); return <span data-testid="where">{l.pathname}{l.search}</span> }
|
||||
|
||||
const mount = () => render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<GlobalSearch />
|
||||
<Routes><Route path="*" element={<Where />} /></Routes>
|
||||
</MemoryRouter>)
|
||||
|
||||
describe('header search', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.get.mockResolvedValue({ data: { suggestions: [
|
||||
{ kind: 'article', id: 4, slug: 'asthma', title: 'Asthma in children' },
|
||||
] } })
|
||||
})
|
||||
|
||||
it('waits for a pause before asking, and asks nothing for one letter', async () => {
|
||||
mount()
|
||||
await userEvent.type(screen.getByRole('searchbox'), 'a')
|
||||
await new Promise(r => setTimeout(r, 250))
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
|
||||
await userEvent.type(screen.getByRole('searchbox'), 'sthma')
|
||||
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/search/suggest', { params: { q: 'asthma' } }))
|
||||
// One request for the whole word, not one per keystroke.
|
||||
expect(api.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('goes straight to a suggestion, or searches everything', async () => {
|
||||
mount()
|
||||
await userEvent.type(screen.getByRole('searchbox'), 'asthma')
|
||||
await screen.findByRole('option', { name: /Asthma in children/ })
|
||||
await userEvent.click(screen.getByRole('option', { name: /Asthma in children/ }))
|
||||
expect(screen.getByTestId('where')).toHaveTextContent('/articles/4')
|
||||
})
|
||||
|
||||
it('offers searching everything even when a title matches', async () => {
|
||||
mount()
|
||||
await userEvent.type(screen.getByRole('searchbox'), 'asthma')
|
||||
await screen.findByRole('option', { name: /Asthma in children/ })
|
||||
await userEvent.click(screen.getByRole('button', { name: /Search everything/ }))
|
||||
expect(screen.getByTestId('where')).toHaveTextContent('/search?q=asthma')
|
||||
})
|
||||
|
||||
it('Enter searches everything unless a suggestion is highlighted', async () => {
|
||||
mount()
|
||||
const input = screen.getByRole('searchbox')
|
||||
await userEvent.type(input, 'asthma')
|
||||
await screen.findByRole('option', { name: /Asthma in children/ })
|
||||
await userEvent.keyboard('{Enter}')
|
||||
expect(screen.getByTestId('where')).toHaveTextContent('/search?q=asthma')
|
||||
})
|
||||
|
||||
it('arrow keys pick a suggestion, and Enter follows it', async () => {
|
||||
mount()
|
||||
await userEvent.type(screen.getByRole('searchbox'), 'asthma')
|
||||
await screen.findByRole('option', { name: /Asthma in children/ })
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}')
|
||||
expect(screen.getByTestId('where')).toHaveTextContent('/articles/4')
|
||||
})
|
||||
})
|
||||
|
|
@ -3,6 +3,7 @@ import { Link, useLocation } from 'react-router-dom'
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../api/client'
|
||||
import ExamSwitcher from './ExamSwitcher'
|
||||
import GlobalSearch from './GlobalSearch'
|
||||
|
||||
function JobsBadge({ jobs }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -116,6 +117,7 @@ export default function Navbar({ onSignIn, onRegister }) {
|
|||
<div className="container navbar-inner">
|
||||
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link>
|
||||
{user && <ExamSwitcher onChange={() => window.location.reload()} />}
|
||||
{user && <GlobalSearch />}
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ body {
|
|||
|
||||
/* ── Navbar ─────────────────────────────────────────────────── */
|
||||
.navbar { background: var(--navbar-bg); color: var(--navbar-fg); padding: 0 0 0; margin-bottom: 32px; position: sticky; top: 0; z-index: 50; }
|
||||
.navbar .navbar-inner { display: flex; justify-content: space-between; align-items: center; height: 52px; }
|
||||
.navbar .navbar-inner { display: flex; justify-content: space-between; align-items: center; gap: 14px; height: 52px; }
|
||||
.navbar .logo { font-size: 1.15rem; font-weight: 700; color: #60a5fa; text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; }
|
||||
[data-theme="markdown"] .navbar .logo { color: #d4a96a; }
|
||||
.nav-desktop { display: flex; gap: 4px; align-items: center; }
|
||||
|
|
|
|||
51
frontend/src/pages/SearchPage.css
Normal file
51
frontend/src/pages/SearchPage.css
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/* One query across everything, grouped by what was found. */
|
||||
|
||||
.search-page { max-width: 820px; margin: 0 auto; padding-bottom: 48px; }
|
||||
|
||||
.search-hero { display: flex; gap: 10px; margin-bottom: 14px; }
|
||||
.search-input {
|
||||
flex: 1; min-width: 0; min-height: 48px; padding: 12px 16px;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
background: var(--input-bg); color: var(--text); font-size: 1rem;
|
||||
}
|
||||
.search-input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: var(--primary); }
|
||||
|
||||
.search-hint { color: var(--text-muted); font-size: 0.88rem; line-height: 1.6; margin: 18px 0 0; }
|
||||
.search-error { color: var(--wrong-fg); font-size: 0.86rem; }
|
||||
.search-count { color: var(--text-muted); font-size: 0.84rem; margin: 0 0 8px; }
|
||||
|
||||
.search-group { margin-top: 22px; }
|
||||
.search-group h2 {
|
||||
display: flex; align-items: baseline; gap: 8px; margin: 0 0 6px;
|
||||
font-size: 0.76rem; font-weight: 700; letter-spacing: 0.07em;
|
||||
text-transform: uppercase; color: var(--text-subtle);
|
||||
}
|
||||
.search-group-count { font-size: 0.72rem; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
.search-result { padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||
.search-result-title { font-size: 0.98rem; font-weight: 650; color: var(--primary); text-decoration: none; }
|
||||
.search-result-title:hover { text-decoration: underline; }
|
||||
.search-snippet { margin: 4px 0 0; font-size: 0.86rem; line-height: 1.55; color: var(--text-muted); }
|
||||
|
||||
.search-badge, .search-draft {
|
||||
margin-left: 8px; font-size: 0.64rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; padding: 1px 7px; border-radius: 10px;
|
||||
background: var(--option-sel-bg); color: var(--primary);
|
||||
}
|
||||
.search-draft { background: #fef3c7; color: #92400e; }
|
||||
|
||||
/* Sections sit under the article they belong to, indented, because they are
|
||||
places to start reading it — not separate results. */
|
||||
.search-sections { list-style: none; margin: 8px 0 0; padding-left: 12px; border-left: 2px solid var(--border); }
|
||||
.search-sections li { padding: 4px 0; font-size: 0.86rem; }
|
||||
.search-sections a { color: var(--text); text-decoration: none; font-weight: 600; }
|
||||
.search-sections a:hover { color: var(--primary); }
|
||||
.search-sections .search-snippet { display: inline; margin: 0; }
|
||||
|
||||
.search-media { list-style: none; margin: 0; padding: 0; }
|
||||
.search-media li { padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 0.88rem; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.search-hero { flex-direction: column; }
|
||||
.search-hero .btn { width: 100%; min-height: 44px; }
|
||||
}
|
||||
139
frontend/src/pages/SearchPage.jsx
Normal file
139
frontend/src/pages/SearchPage.jsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import './SearchPage.css'
|
||||
|
||||
const GROUPS = [
|
||||
{ kind: 'article', label: 'Reading', empty: 'No articles match.' },
|
||||
{ kind: 'question', label: 'Questions', empty: 'No questions match.' },
|
||||
{ kind: 'flashcard', label: 'Cards', empty: 'No cards match.' },
|
||||
{ kind: 'media', label: 'Images', empty: 'No images match.' },
|
||||
]
|
||||
|
||||
/**
|
||||
* One query across everything.
|
||||
*
|
||||
* Grouped by what was found rather than interleaved by score: a question and an
|
||||
* article are different kinds of answer, and a single ranked list makes you read
|
||||
* every row to work out which kind each one is.
|
||||
*
|
||||
* A section match is shown under its article. Ten sections of one article are
|
||||
* one result with ten places to start, not ten results burying everything else.
|
||||
*/
|
||||
export default function SearchPage() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const query = params.get('q') || ''
|
||||
const [draft, setDraft] = useState(query)
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const seq = useRef(0)
|
||||
|
||||
const run = useCallback((text) => {
|
||||
if (text.trim().length < 2) { setData(null); return }
|
||||
const mine = ++seq.current
|
||||
setLoading(true); setError('')
|
||||
api.get('/search', { params: { q: text.trim(), limit: 8 } })
|
||||
.then(res => { if (mine === seq.current) setData(res.data) })
|
||||
.catch(() => { if (mine === seq.current) setError('Search is unavailable right now.') })
|
||||
.finally(() => { if (mine === seq.current) setLoading(false) })
|
||||
}, [])
|
||||
|
||||
useEffect(() => { setDraft(query); run(query) }, [query, run])
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
setParams(draft.trim() ? { q: draft.trim() } : {})
|
||||
}
|
||||
|
||||
const groups = data ? GROUPS.filter(g => data.results[g.kind]) : []
|
||||
const found = data?.total || 0
|
||||
|
||||
return (
|
||||
<div className="search-page">
|
||||
<form className="search-hero" onSubmit={submit} role="search">
|
||||
<input className="search-input" value={draft} autoFocus
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
placeholder="Search reading, questions, cards and images"
|
||||
aria-label="Search everything" />
|
||||
<button className="btn btn-primary" type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
{!query && (
|
||||
<p className="search-hint">
|
||||
Search finds meaning as well as words, so “fever in a returning traveller”
|
||||
reaches the right reading even when the article never uses that phrase.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="search-error" role="alert">{error}</p>}
|
||||
{loading && <div className="loading"><div className="spinner" /></div>}
|
||||
|
||||
{!loading && data && (
|
||||
<>
|
||||
<p className="search-count" role="status">
|
||||
{found === 0 ? `Nothing matches “${data.query}”.` : `${found} result${found === 1 ? '' : 's'} for “${data.query}”`}
|
||||
</p>
|
||||
|
||||
{groups.map(group => {
|
||||
const rows = data.results[group.kind]
|
||||
if (!rows.length) return null
|
||||
return (
|
||||
<section key={group.kind} className="search-group" aria-labelledby={`group-${group.kind}`}>
|
||||
<h2 id={`group-${group.kind}`}>{group.label}<span className="search-group-count">{rows.length}</span></h2>
|
||||
|
||||
{group.kind === 'article' && rows.map(row => (
|
||||
<div key={row.id} className="search-result">
|
||||
<Link className="search-result-title" to={`/articles/${row.id}`}>{row.title}</Link>
|
||||
{row.status !== 'published' && <span className="search-draft">Draft</span>}
|
||||
{row.snippet && <p className="search-snippet">{row.snippet}</p>}
|
||||
{/* Where inside the article the match is — the point of
|
||||
indexing sections rather than whole documents. */}
|
||||
{row.sections.length > 0 && (
|
||||
<ul className="search-sections">
|
||||
{row.sections.map(sec => (
|
||||
<li key={sec.section_id}>
|
||||
<Link to={`/articles/${row.id}?section=${sec.section_id}`}>{sec.title}</Link>
|
||||
{sec.snippet && <span className="search-snippet"> — {sec.snippet}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{group.kind === 'question' && rows.map(row => (
|
||||
<div key={row.id} className="search-result">
|
||||
<Link className="search-result-title" to={`/question-bank?q=${encodeURIComponent(data.query)}`}>
|
||||
Question #{row.id}
|
||||
</Link>
|
||||
{row.match === 'semantic' && <span className="search-badge" title="Found by meaning, not by wording">meaning</span>}
|
||||
<p className="search-snippet">{row.snippet}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{group.kind === 'flashcard' && rows.map(row => (
|
||||
<div key={row.id} className="search-result">
|
||||
<Link className="search-result-title" to={`/flashcards/${row.deck_id}/study`}>{row.front}</Link>
|
||||
{row.snippet && <p className="search-snippet">{row.snippet}</p>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{group.kind === 'media' && (
|
||||
<ul className="search-media">
|
||||
{rows.map(row => (
|
||||
<li key={row.id}>
|
||||
<span className="search-result-title">{row.title || `Image #${row.id}`}</span>
|
||||
{row.snippet && <span className="search-snippet"> — {row.snippet}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
frontend/src/pages/SearchPage.test.jsx
Normal file
74
frontend/src/pages/SearchPage.test.jsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import SearchPage from './SearchPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn() } }))
|
||||
|
||||
const results = {
|
||||
query: 'febrile seizure',
|
||||
total: 3,
|
||||
results: {
|
||||
article: [{
|
||||
id: 1, slug: 'febrile-seizures', title: 'Febrile seizures', status: 'published',
|
||||
snippet: '…a seizure with fever…', section_count: 4,
|
||||
sections: [{ section_id: 'a'.repeat(32), title: 'Initial workup', snippet: '…lumbar puncture…' }],
|
||||
}],
|
||||
question: [{ id: 12, snippet: 'A 2-year-old with a brief generalised seizure…', difficulty: 'medium', match: 'semantic' }],
|
||||
flashcard: [{ id: 5, deck_id: 2, front: 'Simple vs complex', snippet: 'Under 15 minutes…' }],
|
||||
media: [],
|
||||
},
|
||||
}
|
||||
|
||||
const mount = (path = '/search?q=febrile+seizure') => render(
|
||||
<MemoryRouter initialEntries={[path]}><Routes><Route path="/search" element={<SearchPage />} /></Routes></MemoryRouter>)
|
||||
|
||||
describe('global search', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); api.get.mockResolvedValue({ data: results }) })
|
||||
|
||||
it('groups results by kind rather than interleaving them', async () => {
|
||||
mount()
|
||||
expect(await screen.findByRole('heading', { name: /Reading/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /Questions/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /Cards/ })).toBeInTheDocument()
|
||||
// A group with nothing in it is not drawn at all.
|
||||
expect(screen.queryByRole('heading', { name: /Images/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('3 results for “febrile seizure”')
|
||||
})
|
||||
|
||||
it('lists a matching section under its article, linked to that section', async () => {
|
||||
mount()
|
||||
const article = (await screen.findByText('Febrile seizures')).closest('.search-result')
|
||||
const section = within(article).getByRole('link', { name: 'Initial workup' })
|
||||
expect(section).toHaveAttribute('href', `/articles/1?section=${'a'.repeat(32)}`)
|
||||
expect(within(article).getByText(/lumbar puncture/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when a hit came from meaning rather than wording', async () => {
|
||||
mount()
|
||||
const question = (await screen.findByText(/2-year-old/)).closest('.search-result')
|
||||
expect(within(question).getByText('meaning')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks for nothing until there is something to ask', async () => {
|
||||
mount('/search')
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
await userEvent.type(screen.getByLabelText('Search everything'), 'a')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Search' }))
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an empty result honestly', async () => {
|
||||
api.get.mockResolvedValue({ data: { query: 'zzz', total: 0, results: { article: [], question: [], flashcard: [], media: [] } } })
|
||||
mount('/search?q=zzz')
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Nothing matches “zzz”')
|
||||
})
|
||||
|
||||
it('surfaces a failure instead of showing an empty page', async () => {
|
||||
api.get.mockRejectedValue(new Error('down'))
|
||||
mount()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('unavailable')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue