From dbfdff90a1bc59e187f221689293e48e0ddaa097 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 04:16:46 +0200 Subject: [PATCH] fix: Reading is published, Editorial has its own address, and repeat works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/articles.py | 62 +++++++++++++++++-- backend/app/services/pubmed.py | 25 +++++--- backend/app/tasks/quiz_tasks.py | 11 +++- backend/tests/test_article_ai.py | 47 ++++++++++++++ frontend/src/App.jsx | 6 ++ frontend/src/components/ArticleReader.jsx | 12 ++++ frontend/src/components/CategoryColumns.css | 5 -- frontend/src/components/CategoryColumns.jsx | 1 - frontend/src/components/ExamSwitcher.css | 9 +++ frontend/src/components/ExamSwitcher.jsx | 17 ++++- frontend/src/components/RepeatSession.jsx | 17 ++++- .../src/components/RepeatSession.test.jsx | 54 ++++++++++++++++ frontend/src/pages/AnalysisPage.css | 2 - frontend/src/pages/AnalysisPage.jsx | 4 +- frontend/src/pages/ArticlesPage.css | 7 +++ frontend/src/pages/ArticlesPage.jsx | 19 ++++-- frontend/src/pages/ArticlesPage.test.jsx | 19 +++++- frontend/src/pages/EditorialPage.jsx | 2 +- 18 files changed, 282 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/RepeatSession.test.jsx diff --git a/backend/app/routers/articles.py b/backend/app/routers/articles.py index 077eaa1..1381e8d 100644 --- a/backend/app/routers/articles.py +++ b/backend/app/routers/articles.py @@ -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") diff --git a/backend/app/services/pubmed.py b/backend/app/services/pubmed.py index ffaf548..9268842 100644 --- a/backend/app/services/pubmed.py +++ b/backend/app/services/pubmed.py @@ -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] diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 5e1be0b..b7b8d98 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -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 diff --git a/backend/tests/test_article_ai.py b/backend/tests/test_article_ai.py index 02b506b..c55de78 100644 --- a/backend/tests/test_article_ai.py +++ b/backend/tests/test_article_ai.py @@ -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() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ae5108e..67079ee 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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. */} } /> + {/* 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. */} + } /> + } /> } /> {/* One front door. The dashboard's contents are sections of Settings now; the old address still works for anyone who diff --git a/frontend/src/components/ArticleReader.jsx b/frontend/src/components/ArticleReader.jsx index 4b1fdab..f8f6cdf 100644 --- a/frontend/src/components/ArticleReader.jsx +++ b/frontend/src/components/ArticleReader.jsx @@ -287,6 +287,18 @@ export default function ArticleReader({ {(ref.pages || []).length > 0 && ( · p. {ref.pages.join(', ')} )} + {/* 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) && ( + + {' · '}{[ref.journal, ref.year].filter(Boolean).join(', ')} + + )} + {ref.pmid && ( + PMID {ref.pmid} + )} ))} diff --git a/frontend/src/components/CategoryColumns.css b/frontend/src/components/CategoryColumns.css index 706d858..ace2796 100644 --- a/frontend/src/components/CategoryColumns.css +++ b/frontend/src/components/CategoryColumns.css @@ -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 diff --git a/frontend/src/components/CategoryColumns.jsx b/frontend/src/components/CategoryColumns.jsx index 70de05f..83d96cf 100644 --- a/frontend/src/components/CategoryColumns.jsx +++ b/frontend/src/components/CategoryColumns.jsx @@ -124,7 +124,6 @@ export default function CategoryColumns({ onClick={() => onOpenArticle?.(article)}> {article.title} - {article.status !== 'published' && Draft} ))} diff --git a/frontend/src/components/ExamSwitcher.css b/frontend/src/components/ExamSwitcher.css index 85d3195..446ca7c 100644 --- a/frontend/src/components/ExamSwitcher.css +++ b/frontend/src/components/ExamSwitcher.css @@ -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; } diff --git a/frontend/src/components/ExamSwitcher.jsx b/frontend/src/components/ExamSwitcher.jsx index b94c2ef..100c86d 100644 --- a/frontend/src/components/ExamSwitcher.jsx +++ b/frontend/src/components/ExamSwitcher.jsx @@ -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 }) { ) : 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 ( + <> + + {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. diff --git a/frontend/src/components/RepeatSession.jsx b/frontend/src/components/RepeatSession.jsx index 3d104b1..8eb0c07 100644 --- a/frontend/src/components/RepeatSession.jsx +++ b/frontend/src/components/RepeatSession.jsx @@ -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) } } diff --git a/frontend/src/components/RepeatSession.test.jsx b/frontend/src/components/RepeatSession.test.jsx new file mode 100644 index 0000000..cf5d00e --- /dev/null +++ b/frontend/src/components/RepeatSession.test.jsx @@ -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( + + {}} /> + ) + +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'") + }) +}) diff --git a/frontend/src/pages/AnalysisPage.css b/frontend/src/pages/AnalysisPage.css index 6f469a3..8702663 100644 --- a/frontend/src/pages/AnalysisPage.css +++ b/frontend/src/pages/AnalysisPage.css @@ -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 { diff --git a/frontend/src/pages/AnalysisPage.jsx b/frontend/src/pages/AnalysisPage.jsx index 8416d66..36d0c3b 100644 --- a/frontend/src/pages/AnalysisPage.jsx +++ b/frontend/src/pages/AnalysisPage.jsx @@ -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() {

Your performance analysis - {data?.exam_name && <> for {data.exam_name}} + {data?.exam_name && <> for window.location.reload()} />}

Built from your own answers — where you stand, and what to study next. diff --git a/frontend/src/pages/ArticlesPage.css b/frontend/src/pages/ArticlesPage.css index 3cfc1e5..06c4924 100644 --- a/frontend/src/pages/ArticlesPage.css +++ b/frontend/src/pages/ArticlesPage.css @@ -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 diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index 873287b..bbdf5d1 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -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() {

{articles.map(article => ( -

{article.title}

+

{article.title}

{/* 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 = ( diff --git a/frontend/src/pages/ArticlesPage.test.jsx b/frontend/src/pages/ArticlesPage.test.jsx index c34d8c7..41f35ab 100644 --- a/frontend/src/pages/ArticlesPage.test.jsx +++ b/frontend/src/pages/ArticlesPage.test.jsx @@ -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( + } /> + ) + 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(} />) expect(await screen.findByRole('heading', { level: 1, name: /Febrile seizures/ })).toBeInTheDocument() diff --git a/frontend/src/pages/EditorialPage.jsx b/frontend/src/pages/EditorialPage.jsx index 6f65554..08d178c 100644 --- a/frontend/src/pages/EditorialPage.jsx +++ b/frontend/src/pages/EditorialPage.jsx @@ -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. */} - {article.title} + {article.title} {article.status.replace('_', ' ')} {(article.variants || []).length > 0 && ( {article.variants.length} views