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