fix: Reading is published, Editorial has its own address, and repeat works
Reading shows nobody drafts, not even an admin. An educator's unfinished
work sat among the published shelf with a tag on it, which made Reading
and Editorial two views of one list and left you unsure which you were
looking at. The list is published-only for everybody now, and the tag
and its style are gone with it — the badge stays on an article's own
page, where a draft can still be opened.
And Editorial has its own URL. /editorial/articles/:id renders the same
page, but the crumb reads "Editorial" and goes back to the queue.
Opening an article from the queue used to land on Reading's address, so
the only way out was the top of the published library — you lost your
place in the queue to look at one draft. Drafting from the reading page
lands there too, because a new draft is editorial work from the moment
it exists.
References from PubMed are fields, not a sentence. Every other
reference on an article is {title, author, pages} and the reader reads
those keys, so the flat line the PubMed path wrote drew as six blank
rows under a References heading: the DKA draft cited six real papers
and appeared to cite none. A paper now fills journal, year and PMID
instead of pages, and the PMID is a link to the record. Rows written
before this pull themselves apart on the way out rather than being
rewritten in the database, so the drafts that already exist heal
themselves.
Repeat session has never worked. The dialog asked the bank for mode
"study" — the name of the route it lands on — and the bank has "timed"
and "learning", so every repeat came back 422 and the dialog reported
its own house message, "Could not build that session", because the
detail was a list rather than a string. Both fixed: the right mode, and
a server that says something is quoted rather than swallowed.
And the objective named in "your performance analysis for Pediatrics
Boards" opens the objective picker. It was a link to the account page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
2bc1b9fbd8
commit
dbfdff90a1
18 changed files with 282 additions and 37 deletions
|
|
@ -47,11 +47,55 @@ class ArticleSection(BaseModel):
|
|||
|
||||
|
||||
class ArticleReference(BaseModel):
|
||||
"""Where a fact came from. Named sources, not markers in the prose."""
|
||||
"""Where a fact came from. Named sources, not markers in the prose.
|
||||
|
||||
Two kinds share the shape. A book has an author and the pages a fact is
|
||||
on; a paper has a journal, a year and a PMID somebody can look up. The
|
||||
fields a kind does not use stay empty rather than each kind getting its
|
||||
own list, because the reader draws one reference list.
|
||||
"""
|
||||
|
||||
title: str = Field(min_length=1, max_length=300)
|
||||
author: str | None = Field(default=None, max_length=300)
|
||||
pages: list[int] = Field(default_factory=list, max_length=40)
|
||||
journal: str | None = Field(default=None, max_length=150)
|
||||
year: str | None = Field(default=None, max_length=10)
|
||||
pmid: str | None = Field(default=None, max_length=20)
|
||||
url: str | None = Field(default=None, max_length=300)
|
||||
|
||||
|
||||
#: "Barski L, et al. Management of diabetic ketoacidosis. Eur J Intern Med.
|
||||
#: 2023. PMID: 37419787." — the shape the PubMed path used to write as one
|
||||
#: flat line, before it wrote the same fields everything else writes.
|
||||
_PMID_TAIL = re.compile(r"\s*PMID:\s*(\d+)\.?\s*$")
|
||||
|
||||
|
||||
def _reference_json(entry) -> dict:
|
||||
"""One reference, in the shape the reader draws.
|
||||
|
||||
Rows written before references were structured are single strings, and the
|
||||
reader reads `ref.title` — so those drew as blank list items under a
|
||||
References heading, which is worse than no heading at all. Pulled apart
|
||||
here rather than rewritten in the database: the read path is the one place
|
||||
both old and new rows pass through.
|
||||
"""
|
||||
if isinstance(entry, dict):
|
||||
return entry
|
||||
line = str(entry or "").strip()
|
||||
if not line:
|
||||
return {}
|
||||
found = _PMID_TAIL.search(line)
|
||||
pmid = found.group(1) if found else None
|
||||
return {
|
||||
"title": (_PMID_TAIL.sub("", line).strip() or line)[:300],
|
||||
"author": None, "pages": [],
|
||||
"pmid": pmid,
|
||||
"url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/" if pmid else None,
|
||||
}
|
||||
|
||||
|
||||
def _references_json(rows) -> list[dict]:
|
||||
return [ref for ref in (_reference_json(row) for row in (rows or [])) if ref]
|
||||
|
||||
|
||||
class ArticleWrite(BaseModel):
|
||||
|
|
@ -236,7 +280,7 @@ def _article_json(article: Article) -> dict:
|
|||
"section_id": article.section_id,
|
||||
"user_id": article.user_id,
|
||||
"status": article.status,
|
||||
"references": article.references_json or [],
|
||||
"references": _references_json(article.references_json),
|
||||
"variants": article_service.available_variants(article),
|
||||
"generated_by": article.generated_by,
|
||||
"reviewed_at": article.reviewed_at,
|
||||
|
|
@ -271,7 +315,14 @@ def list_articles(
|
|||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Published articles for everyone; educators additionally see drafts."""
|
||||
"""The published library. Drafts are Editorial's business, not Reading's.
|
||||
|
||||
An educator used to see their drafts mixed into the shelf here, marked
|
||||
with a tag — which made Reading and Editorial two views of the same list
|
||||
and left an admin unsure which one he was looking at. Reading is what a
|
||||
learner would see. Unfinished work is in Editorial, where it can be
|
||||
worked on.
|
||||
"""
|
||||
query = db.query(Article).filter(Article.deleted_at.is_(None))
|
||||
if category_id:
|
||||
query = query.filter(Article.category_id == category_id)
|
||||
|
|
@ -291,8 +342,7 @@ def list_articles(
|
|||
articles = query.order_by(Article.updated_at.desc()).all()
|
||||
if q and q.strip():
|
||||
articles.sort(key=lambda article: rank_of.get(article.id, len(rank_of)))
|
||||
if not current_user.is_moderator:
|
||||
articles = [a for a in articles if a.status == "published"]
|
||||
articles = [a for a in articles if a.status == "published"]
|
||||
return [_article_card_json(a) for a in articles]
|
||||
|
||||
|
||||
|
|
@ -1120,7 +1170,7 @@ def read_revision(article_id: int, revision_id: int, db: Session = Depends(get_d
|
|||
raise HTTPException(403, "Not your article")
|
||||
return {"id": revision.id, "title": revision.title, "summary": revision.summary,
|
||||
"content": revision.content, "sections": revision.sections,
|
||||
"references": revision.references_json or [], "created_at": revision.created_at}
|
||||
"references": _references_json(revision.references_json), "created_at": revision.created_at}
|
||||
|
||||
|
||||
@router.post("/{article_id}/revisions/{revision_id}/restore")
|
||||
|
|
|
|||
|
|
@ -209,10 +209,21 @@ def for_prompt(results: list[dict]) -> str:
|
|||
return "\n\n---\n\n".join(blocks)
|
||||
|
||||
|
||||
def as_references(results: list[dict]) -> list[str]:
|
||||
"""One line each, in the shape an article's reference list already uses."""
|
||||
lines = []
|
||||
for record in results:
|
||||
parts = [p for p in (record["authors"], record["title"], record["journal"], record["year"]) if p]
|
||||
lines.append(". ".join(parts).rstrip(".") + f". PMID: {record['pmid']}.")
|
||||
return lines
|
||||
def as_references(results: list[dict]) -> list[dict]:
|
||||
"""In the shape an article's reference list already uses.
|
||||
|
||||
Fields, not a sentence. Every other reference on an article is
|
||||
{title, author, pages, ...} and the reader draws those fields — so a flat
|
||||
line came out as a blank row under a References heading. A paper fills the
|
||||
journal, year and PMID instead of the pages, and the PMID is what makes it
|
||||
checkable.
|
||||
"""
|
||||
return [{
|
||||
"title": record["title"].rstrip("."),
|
||||
"author": record["authors"] or None,
|
||||
"pages": [],
|
||||
"journal": record["journal"] or None,
|
||||
"year": record["year"] or None,
|
||||
"pmid": record["pmid"],
|
||||
"url": record["url"],
|
||||
} for record in results]
|
||||
|
|
|
|||
|
|
@ -1151,9 +1151,14 @@ def generate_article_draft(self, job_id: str, user_id: int, topic: str,
|
|||
# exists, with a PMID somebody can look up.
|
||||
if references:
|
||||
kept = list(article.references_json or [])
|
||||
for line in references:
|
||||
if line not in kept:
|
||||
kept.append(line)
|
||||
# By PMID, not by equality: a refined draft searches PubMed again
|
||||
# and the same paper comes back with the same id, so comparing
|
||||
# whole records would stack duplicates every run.
|
||||
have = {r.get("pmid") for r in kept if isinstance(r, dict) and r.get("pmid")}
|
||||
for record in references:
|
||||
if record["pmid"] not in have:
|
||||
kept.append(record)
|
||||
have.add(record["pmid"])
|
||||
article.references_json = kept
|
||||
db.commit()
|
||||
# A draft that is not indexed is a draft nobody can find. Every writer of
|
||||
|
|
|
|||
|
|
@ -122,6 +122,53 @@ class ArticleAiTests(unittest.TestCase):
|
|||
patch.stopall()
|
||||
self.redis.reset_mock()
|
||||
|
||||
def test_pubmed_references_are_fields_the_reader_draws(self):
|
||||
"""A paper is stored like a book: fields, not one flat sentence.
|
||||
|
||||
Everything else on an article is {title, author, pages}, and the reader
|
||||
reads those keys — so the line PubMed used to write appeared as a blank
|
||||
row under a References heading. A draft with six real papers looked
|
||||
like a draft with none.
|
||||
"""
|
||||
from app.services import pubmed
|
||||
records = [{
|
||||
'pmid': '37419787', 'title': 'Management of diabetic ketoacidosis.',
|
||||
'journal': 'Eur J Intern Med', 'year': '2023',
|
||||
'authors': 'Barski L, Golbets E, et al',
|
||||
'url': 'https://pubmed.ncbi.nlm.nih.gov/37419787/', 'abstract': '',
|
||||
}]
|
||||
reference, = pubmed.as_references(records)
|
||||
self.assertEqual(reference['title'], 'Management of diabetic ketoacidosis')
|
||||
self.assertEqual(reference['pmid'], '37419787')
|
||||
self.assertEqual(reference['journal'], 'Eur J Intern Med')
|
||||
self.assertEqual(reference['pages'], [])
|
||||
|
||||
# And a row written before this pulls itself apart on the way out,
|
||||
# rather than being rewritten in the database.
|
||||
healed = articles._reference_json(
|
||||
'Barski L, et al. Management of diabetic ketoacidosis.. '
|
||||
'Eur J Intern Med. 2023. PMID: 37419787.')
|
||||
self.assertEqual(healed['pmid'], '37419787')
|
||||
self.assertNotIn('PMID', healed['title'])
|
||||
self.assertEqual(articles._reference_json({'title': 'A book'}), {'title': 'A book'})
|
||||
|
||||
def test_reading_shows_nobody_drafts(self):
|
||||
"""Reading is the published library, Editorial is the unfinished work.
|
||||
|
||||
An educator saw their own drafts filed among the published articles
|
||||
with a tag on them, which made Reading and Editorial two views of one
|
||||
list. Not even an admin sees a draft here.
|
||||
"""
|
||||
self.bank.db.add(Article(slug='half-written', title='Half written',
|
||||
content='Body', sections=[], user_id=3, status='draft'))
|
||||
self.bank.db.add(Article(slug='finished', title='Finished',
|
||||
content='Body', sections=[], user_id=3, status='published'))
|
||||
self.bank.db.commit()
|
||||
listed = self.client.get('/articles/').json()
|
||||
slugs = [a['slug'] for a in listed]
|
||||
self.assertIn('finished', slugs)
|
||||
self.assertNotIn('half-written', slugs)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -235,6 +235,12 @@ function AppRoutes() {
|
|||
{/* Cross-references in article prose address a topic by slug, which
|
||||
outlives a numeric id and is what an educator actually writes. */}
|
||||
<Route path="/articles/s/:slug" element={<ArticlePage />} />
|
||||
{/* The same page at Editorial's own address. An article reached
|
||||
from the queue is being worked on, not read, and the trail out
|
||||
of it should go back to the queue — which it cannot do if the
|
||||
two are the same URL. */}
|
||||
<Route path="/editorial/articles/:id" element={<ArticlePage />} />
|
||||
<Route path="/editorial/articles/s/:slug" element={<ArticlePage />} />
|
||||
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
|
||||
{/* One front door. The dashboard's contents are sections of
|
||||
Settings now; the old address still works for anyone who
|
||||
|
|
|
|||
|
|
@ -287,6 +287,18 @@ export default function ArticleReader({
|
|||
{(ref.pages || []).length > 0 && (
|
||||
<span className="article-ref-pages"> · p. {ref.pages.join(', ')}</span>
|
||||
)}
|
||||
{/* A paper rather than a book: the journal and the year read
|
||||
like a citation, and the PMID is the part somebody can
|
||||
actually go and check. */}
|
||||
{(ref.journal || ref.year) && (
|
||||
<span className="article-ref-pages">
|
||||
{' · '}{[ref.journal, ref.year].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
{ref.pmid && (
|
||||
<a className="article-ref-pmid" href={ref.url || `https://pubmed.ncbi.nlm.nih.gov/${ref.pmid}/`}
|
||||
target="_blank" rel="noopener noreferrer">PMID {ref.pmid}</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
|
|
|||
|
|
@ -103,11 +103,6 @@
|
|||
.cc-row:hover .cc-icon { fill: var(--primary); }
|
||||
.cc-row-article:hover .cc-icon-article { fill: none; }
|
||||
|
||||
.cc-draft {
|
||||
flex-shrink: 0; font-size: 0.64rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; padding: 1px 7px; border-radius: 10px;
|
||||
background: #fef3c7; color: #92400e;
|
||||
}
|
||||
.cc-empty { padding: 14px; font-size: 0.83rem; color: var(--text-muted); }
|
||||
|
||||
/* Never wider than what contains it: a flex or grid item defaults to
|
||||
|
|
|
|||
|
|
@ -124,7 +124,6 @@ export default function CategoryColumns({
|
|||
onClick={() => onOpenArticle?.(article)}>
|
||||
<ArticleIcon />
|
||||
<span className="cc-name">{article.title}</span>
|
||||
{article.status !== 'published' && <span className="cc-draft">Draft</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -142,3 +142,12 @@
|
|||
.exo-overlay { padding: 0; }
|
||||
.exo-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* The objective, named inside a heading and clickable there. A button, because
|
||||
it opens the picker rather than going anywhere — dressed as the link it
|
||||
replaced so the sentence still reads as one. */
|
||||
.exam-switcher-link {
|
||||
padding: 0; border: 0; border-bottom: 2px solid currentColor; background: none;
|
||||
font: inherit; color: var(--primary); cursor: pointer;
|
||||
}
|
||||
.exam-switcher-link:hover { opacity: .8; }
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ function rememberRecent(id) {
|
|||
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
|
||||
} catch { /* private browsing, or storage turned off */ }
|
||||
}
|
||||
export default function ExamSwitcher({ onChange, inline = false }) {
|
||||
export default function ExamSwitcher({ onChange, inline = false, link = false, label }) {
|
||||
const [exams, setExams] = useState([])
|
||||
const [activeId, setActiveId] = useState(null)
|
||||
const [menu, setMenu] = useState(false)
|
||||
|
|
@ -194,6 +194,21 @@ export default function ExamSwitcher({ onChange, inline = false }) {
|
|||
</div>
|
||||
) : null
|
||||
|
||||
// Named inside a sentence — "your performance analysis for Pediatrics
|
||||
// Boards". It read as a link and went to the account page, which is a long
|
||||
// way from what somebody clicking the name of their objective wants: the
|
||||
// objective. No short menu here either; the sentence is the trigger.
|
||||
if (link) {
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="exam-switcher-link" onClick={() => setOpen(true)}>
|
||||
{label || (active ? active.name : 'Choose an objective')}
|
||||
</button>
|
||||
{dialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// In a settings panel there is room to say the thing plainly, and the panel
|
||||
// clips anything that hangs out of it — so the short menu, which is a
|
||||
// navbar affordance, is skipped and the picker opens directly.
|
||||
|
|
|
|||
|
|
@ -61,17 +61,28 @@ export default function RepeatSession({ title, rows, onClose }) {
|
|||
// Shuffled, so repeating twice is not the same order twice.
|
||||
const ids = [...pool].sort(() => Math.random() - 0.5).slice(0, count)
|
||||
const res = await api.post('/questions/from-bank', {
|
||||
title: `${title} (repetition)`,
|
||||
// 200 characters, and a repeated repetition grows the title each time.
|
||||
title: `${title} (repetition)`.slice(0, 200),
|
||||
question_ids: ids,
|
||||
mode: 'study',
|
||||
// The bank calls this "learning" — the untimed mode, with the answer
|
||||
// and the explanation as you go. It was sent as "study", which is what
|
||||
// the *route* is called, and the server rejected every repeat with a
|
||||
// validation error the dialog then reported as "Could not build that
|
||||
// session". Nobody could repeat anything.
|
||||
mode: 'learning',
|
||||
// Practice, not a new measurement: it is analysed on its own page but
|
||||
// left out of the figures that say how much of the bank you know.
|
||||
is_repetition: true,
|
||||
})
|
||||
navigate(`/study/${res.data.id}?start=1`)
|
||||
} catch (err) {
|
||||
// A validation error arrives as a list of objects, and printing the
|
||||
// house message for it is how a wrong field name stayed invisible.
|
||||
const detail = err?.response?.data?.detail
|
||||
setError(typeof detail === 'string' ? detail : 'Could not build that session')
|
||||
const said = typeof detail === 'string' ? detail
|
||||
: Array.isArray(detail) ? detail.map(d => d?.msg).filter(Boolean).join('; ')
|
||||
: ''
|
||||
setError(said || 'Could not build that session')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
frontend/src/components/RepeatSession.test.jsx
Normal file
54
frontend/src/components/RepeatSession.test.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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 } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import RepeatSession from './RepeatSession'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { post: vi.fn() } }))
|
||||
|
||||
const navigate = vi.fn()
|
||||
vi.mock('react-router-dom', async () => ({
|
||||
...await vi.importActual('react-router-dom'),
|
||||
useNavigate: () => navigate,
|
||||
}))
|
||||
|
||||
const ROWS = [
|
||||
{ question_id: 11, status: 'incorrect' },
|
||||
{ question_id: 12, status: 'skipped' },
|
||||
{ question_id: 13, status: 'correct' },
|
||||
]
|
||||
|
||||
const show = () => render(
|
||||
<MemoryRouter>
|
||||
<RepeatSession title="Custom test from Sep 12" rows={ROWS} onClose={() => {}} />
|
||||
</MemoryRouter>)
|
||||
|
||||
describe('repeating a session', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('asks the bank for the mode the bank has', async () => {
|
||||
// "study" is the name of the route you land on; the bank's untimed mode is
|
||||
// called "learning", and sending the wrong one failed every repeat with a
|
||||
// validation error the dialog reported as "Could not build that session".
|
||||
api.post.mockResolvedValue({ data: { id: 77 } })
|
||||
show()
|
||||
await userEvent.click(screen.getByRole('button', { name: /Start 2 questions/ }))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalled())
|
||||
const body = api.post.mock.calls[0][1]
|
||||
expect(body.mode).toBe('learning')
|
||||
expect(body.is_repetition).toBe(true)
|
||||
expect([...body.question_ids].sort()).toEqual([11, 12])
|
||||
expect(navigate).toHaveBeenCalledWith('/study/77?start=1')
|
||||
})
|
||||
|
||||
it('says what the server said, even when it says it as a list', async () => {
|
||||
api.post.mockRejectedValue({ response: { data: { detail: [
|
||||
{ msg: "Input should be 'timed' or 'learning'" },
|
||||
] } } })
|
||||
show()
|
||||
await userEvent.click(screen.getByRole('button', { name: /Start 2 questions/ }))
|
||||
expect(await screen.findByRole('alert'))
|
||||
.toHaveTextContent("Input should be 'timed' or 'learning'")
|
||||
})
|
||||
})
|
||||
|
|
@ -142,8 +142,6 @@
|
|||
.an-detail-actions .btn { flex: 1; text-align: center; }
|
||||
}
|
||||
|
||||
.an-exam { color: var(--primary); text-decoration: none; border-bottom: 2px solid currentColor; }
|
||||
.an-exam:hover { opacity: .8; }
|
||||
|
||||
/* How much of the bank a grouping can actually see. */
|
||||
.an-coverage-note {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import CategoryPerformance from '../components/CategoryPerformance'
|
|||
import Donut from '../components/Donut'
|
||||
import LineChart from '../components/LineChart'
|
||||
import AnalysisShell, { AnalysisTabs, ANALYSIS_TABS } from '../components/AnalysisShell'
|
||||
import ExamSwitcher from '../components/ExamSwitcher'
|
||||
import { SessionAnalysis } from './AnalysisSessionPage'
|
||||
import './AnalysisPage.css'
|
||||
|
||||
|
|
@ -484,7 +485,8 @@ export default function AnalysisPage() {
|
|||
<div className="an-header">
|
||||
<h1>
|
||||
Your performance analysis
|
||||
{data?.exam_name && <> for <Link to="/settings" className="an-exam">{data.exam_name}</Link></>}
|
||||
{data?.exam_name && <> for <ExamSwitcher link label={data.exam_name}
|
||||
onChange={() => window.location.reload()} /></>}
|
||||
</h1>
|
||||
<p>
|
||||
Built from your own answers — where you stand, and what to study next.
|
||||
|
|
|
|||
|
|
@ -375,6 +375,13 @@
|
|||
}
|
||||
.article-ref-title { color: var(--text); font-weight: 600; }
|
||||
.article-ref-pages { font-variant-numeric: tabular-nums; }
|
||||
/* The checkable part of a paper. Set apart from the citation so the eye finds
|
||||
it, and a link because it goes somewhere. */
|
||||
.article-ref-pmid {
|
||||
margin-left: 8px; font-size: .78rem; font-weight: 600;
|
||||
color: var(--primary); text-decoration: none; white-space: nowrap;
|
||||
}
|
||||
.article-ref-pmid:hover { text-decoration: underline; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* Four things in a bar this narrow is three too many; the rail and the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { SplitViewProvider } from '../context/SplitViewContext'
|
||||
|
|
@ -90,7 +90,7 @@ export default function ArticlesPage() {
|
|||
try {
|
||||
const res = await api.post('/articles/', { title, slug: slug.trim().toLowerCase(), content: '', sections: [] })
|
||||
setShowCreate(false)
|
||||
navigate(`/articles/${res.data.id}?edit=1`)
|
||||
navigate(`/editorial/articles/${res.data.id}?edit=1`)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Could not create article')
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ export default function ArticlesPage() {
|
|||
// Straight into the draft. It finished into a list otherwise, and
|
||||
// the educator who asked for it was left looking at a panel that
|
||||
// had closed and a library that looked unchanged.
|
||||
if (job.data.article_id) { navigate(`/articles/${job.data.article_id}?edit=1`); return }
|
||||
if (job.data.article_id) { navigate(`/editorial/articles/${job.data.article_id}?edit=1`); return }
|
||||
load()
|
||||
}
|
||||
else if (job.data.status === 'failed') { setAiStatus(''); setError(`Drafting failed: ${job.data.error || 'unknown error'}`) }
|
||||
|
|
@ -233,7 +233,7 @@ export default function ArticlesPage() {
|
|||
<div className="articles-grid">
|
||||
{articles.map(article => (
|
||||
<Link key={article.id} to={`/articles/${article.id}`} className="article-card">
|
||||
<h3>{article.title} <DraftBadge status={article.status} /></h3>
|
||||
<h3>{article.title}</h3>
|
||||
{/* Flattened, not rendered: the card is itself a link, and a
|
||||
live cross-reference inside one would eat the click that
|
||||
was meant to open the article. Printed raw it showed the
|
||||
|
|
@ -269,6 +269,13 @@ export function ArticlePage() {
|
|||
// page, so there is one loading path rather than two.
|
||||
const [id, setId] = useState(idParam || null)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
/* Which door you came in by, written into the address rather than guessed
|
||||
from a referrer. An article opened from the editorial queue used to be the
|
||||
same URL as an article opened from Reading, so the trail out of it said
|
||||
"Reading" and an educator halfway through a queue was put back at the top
|
||||
of the published library. Same page, same component; the way back and the
|
||||
first crumb follow the path. */
|
||||
const inEditorial = useLocation().pathname.startsWith('/editorial')
|
||||
const [article, setArticle] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
|
@ -445,7 +452,9 @@ export function ArticlePage() {
|
|||
|
||||
const breadcrumbs = (
|
||||
<nav className="breadcrumbs" aria-label="Breadcrumb">
|
||||
<Link to="/articles">Reading</Link>
|
||||
{inEditorial
|
||||
? <Link to="/editorial">Editorial</Link>
|
||||
: <Link to="/articles">Reading</Link>}
|
||||
{(article.category_breadcrumbs || []).map(crumb => <span key={crumb.id}> / {crumb.name}</span>)}
|
||||
<span> / {article.title}</span>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ describe('topic reading', () => {
|
|||
{ id: 4, name: 'Cardiology', parent_id: null, question_count: 7 },
|
||||
]
|
||||
const filed = { ...article, category_id: 3 }
|
||||
const loose = { id: 9, title: 'Unfiled note', slug: 'unfiled', status: 'draft', category_id: null, sections: [] }
|
||||
const loose = { id: 9, title: 'Unfiled note', slug: 'unfiled', status: 'published', category_id: null, sections: [] }
|
||||
api.get.mockImplementation(url => {
|
||||
if (url === '/question-categories/') return Promise.resolve({ data: cats })
|
||||
if (url === '/articles/') return Promise.resolve({ data: [filed, loose] })
|
||||
|
|
@ -58,7 +58,8 @@ describe('topic reading', () => {
|
|||
// which would otherwise have no heading to be reachable from.
|
||||
expect(await screen.findByRole('button', { name: /Neurology/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Unfiled note/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('Draft')).toBeInTheDocument()
|
||||
// No Draft tag to look for: this page is the published library, and
|
||||
// unfinished work is in Editorial. The server sends none either way.
|
||||
expect(screen.queryByRole('button', { name: /Seizures/ })).not.toBeInTheDocument()
|
||||
|
||||
// Cardiology has seven questions and no reading. This browser opens
|
||||
|
|
@ -102,6 +103,20 @@ describe('topic reading', () => {
|
|||
expect(document.querySelector('.cc-columns')).toBeNull()
|
||||
})
|
||||
|
||||
it('leads back to Editorial when Editorial is where you came from', async () => {
|
||||
// Same page, two doors. An article opened from the editorial queue used to
|
||||
// share its URL with an article opened from Reading, so the only trail out
|
||||
// said "Reading" — and an educator halfway through the queue was put back
|
||||
// at the top of the published library with nothing to return to.
|
||||
render(<MemoryRouter initialEntries={['/editorial/articles/1']}>
|
||||
<Routes><Route path="/editorial/articles/:id" element={<ArticlePage />} /></Routes>
|
||||
</MemoryRouter>)
|
||||
await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })
|
||||
const trail = screen.getByRole('navigation', { name: 'Breadcrumb' })
|
||||
expect(within(trail).getByRole('link', { name: 'Editorial' })).toHaveAttribute('href', '/editorial')
|
||||
expect(within(trail).queryByRole('link', { name: 'Reading' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders sections and breadcrumbs, and practises rather than revealing questions', async () => {
|
||||
render(<MemoryRouter initialEntries={['/articles/1']}><Routes><Route path="/articles/:id" element={<ArticlePage />} /></Routes></MemoryRouter>)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument()
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export default function EditorialPage() {
|
|||
things to *do* to an article, and a queue whose rows
|
||||
open the reader makes an editor press Edit on every
|
||||
one of them. */}
|
||||
<Link className="ed-title" to={`/articles/${article.id}?edit=1`}>{article.title}</Link>
|
||||
<Link className="ed-title" to={`/editorial/articles/${article.id}?edit=1`}>{article.title}</Link>
|
||||
<span className={`ed-status is-${article.status}`}>{article.status.replace('_', ' ')}</span>
|
||||
{(article.variants || []).length > 0 && (
|
||||
<span className="ed-variants">{article.variants.length} views</span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue