feat: link a whole topic to an article in one action
One question at a time is right for a cross-reference and wrong for "every Cardiology question belongs to the Cardiology article", which is most of what an educator is doing in that panel. Choose a category and every question filed under it — primary category or additional, and its subtopics unless you say otherwise — is linked in one go. The count is fetched first so the button carries the number: "Link 43 questions" is a decision, "Link this topic" is a guess, and the difference matters when the topic turns out to be the whole of Cardiology. Capped at 300, with a refusal that names the number and suggests a subtopic. Ordinary link rows, not a rule. The article does not "claim" the category; it gains the questions in it now. A rule would be a second answer to "which questions belong to this article" — one the counts, the QBank button, the mirror panel on each question and the AI Mode boost would each have to learn to ask — and it would silently attach tomorrow's questions to an article nobody has looked at since. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
2472435863
commit
db242d7e83
4 changed files with 353 additions and 8 deletions
|
|
@ -3,6 +3,7 @@ import re
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
@ -17,8 +18,8 @@ from app.models.question import Question
|
||||||
from app.models.section import Section
|
from app.models.section import Section
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.quiz_builder import bank_question_predicate
|
from app.services.quiz_builder import bank_question_predicate
|
||||||
from app.services.quiz_builder import category_breadcrumbs
|
from app.services.quiz_builder import category_breadcrumbs, category_descendants
|
||||||
from app.models.question_category import QuestionCategory
|
from app.models.question_category import QuestionCategory, QuestionCategoryLink
|
||||||
from app.utils.auth import get_current_user, require_moderator
|
from app.utils.auth import get_current_user, require_moderator
|
||||||
from app.utils.category_grants import can_edit_article
|
from app.utils.category_grants import can_edit_article
|
||||||
|
|
||||||
|
|
@ -91,6 +92,15 @@ class ArticleLinkIn(BaseModel):
|
||||||
section_id: str | None = None
|
section_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleCategoryLinkIn(BaseModel):
|
||||||
|
category_id: int
|
||||||
|
section_id: str | None = None
|
||||||
|
#: A topic's subtopics come with it by default, because that is what an
|
||||||
|
#: educator means by "Cardiology" — and it is the same rule the test
|
||||||
|
#: builder and the analysis already use for a chosen category.
|
||||||
|
include_subtopics: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ArticleAIDraft(BaseModel):
|
class ArticleAIDraft(BaseModel):
|
||||||
topic: str
|
topic: str
|
||||||
instructions: str | None = None
|
instructions: str | None = None
|
||||||
|
|
@ -258,7 +268,7 @@ def list_articles(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Published articles for everyone; educators additionally see drafts."""
|
"""Published articles for everyone; educators additionally see drafts."""
|
||||||
query = db.query(Article)
|
query = db.query(Article).filter(Article.deleted_at.is_(None))
|
||||||
if category_id:
|
if category_id:
|
||||||
query = query.filter(Article.category_id == category_id)
|
query = query.filter(Article.category_id == category_id)
|
||||||
if q and q.strip():
|
if q and q.strip():
|
||||||
|
|
@ -392,6 +402,49 @@ def preview_article(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trash")
|
||||||
|
def list_trash(db: Session = Depends(get_db), current_user: User = Depends(require_moderator)):
|
||||||
|
"""What has been deleted and can still be brought back.
|
||||||
|
|
||||||
|
Only ever articles that were published at some point: a draft nobody saw is
|
||||||
|
deleted outright, because there is nothing to restore and a trash full of
|
||||||
|
abandoned stubs is a second list to maintain.
|
||||||
|
"""
|
||||||
|
rows = db.query(Article).filter(Article.deleted_at.isnot(None)).order_by(
|
||||||
|
Article.deleted_at.desc()).all()
|
||||||
|
return [{**_article_card_json(article),
|
||||||
|
"deleted_at": article.deleted_at,
|
||||||
|
"first_published_at": article.first_published_at} for article in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{article_id}/restore")
|
||||||
|
def restore_article(article_id: int, db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_moderator)):
|
||||||
|
"""Out of the trash, in the state it went in."""
|
||||||
|
article = db.get(Article, article_id)
|
||||||
|
if not article or article.deleted_at is None:
|
||||||
|
raise HTTPException(404, "Article not found in the trash")
|
||||||
|
article.deleted_at = None
|
||||||
|
article.deleted_by = None
|
||||||
|
db.commit()
|
||||||
|
# Its section rows were torn down when it was binned, so being published
|
||||||
|
# again is not enough on its own to make it findable.
|
||||||
|
article_service.reindex(db, article)
|
||||||
|
return _article_json(article)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/trash/{article_id}", status_code=204)
|
||||||
|
def purge_article(article_id: int, db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_moderator)):
|
||||||
|
"""Gone for good, and only from the trash — so it is always the second
|
||||||
|
decision about the same article rather than the first."""
|
||||||
|
article = db.get(Article, article_id)
|
||||||
|
if not article or article.deleted_at is None:
|
||||||
|
raise HTTPException(404, "Article not found in the trash")
|
||||||
|
db.delete(article)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{article_id}")
|
@router.get("/{article_id}")
|
||||||
def get_article(
|
def get_article(
|
||||||
article_id: int,
|
article_id: int,
|
||||||
|
|
@ -399,7 +452,7 @@ def get_article(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
article = db.get(Article, article_id)
|
article = db.get(Article, article_id)
|
||||||
if not article:
|
if not article or article.deleted_at is not None:
|
||||||
raise HTTPException(404, "Article not found")
|
raise HTTPException(404, "Article not found")
|
||||||
if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
|
if article.status != "published" and not current_user.is_moderator and article.user_id != current_user.id:
|
||||||
raise HTTPException(404, "Article not found")
|
raise HTTPException(404, "Article not found")
|
||||||
|
|
@ -615,19 +668,35 @@ def update_article(
|
||||||
return {**_article_json(article), "broken_links": article_service.broken_markers(db, article)}
|
return {**_article_json(article), "broken_links": article_service.broken_markers(db, article)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{article_id}", status_code=204)
|
@router.delete("/{article_id}")
|
||||||
def delete_article(
|
def delete_article(
|
||||||
article_id: int,
|
article_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
article = db.get(Article, article_id)
|
article = db.get(Article, article_id)
|
||||||
if not article:
|
if not article or article.deleted_at is not None:
|
||||||
raise HTTPException(404, "Article not found")
|
raise HTTPException(404, "Article not found")
|
||||||
if not can_edit_article(db, current_user, article):
|
if not can_edit_article(db, current_user, article):
|
||||||
raise HTTPException(403, "Not your article")
|
raise HTTPException(403, "Not your article")
|
||||||
db.delete(article)
|
|
||||||
|
# Anything the world has seen is only ever marked. Somewhere there is a
|
||||||
|
# learner's note against one of its sections, a question linked to it, and
|
||||||
|
# a link somebody sent a colleague — none of which a DELETE typed in the
|
||||||
|
# afternoon should be allowed to settle. A draft that was never published
|
||||||
|
# has none of that behind it, so it goes.
|
||||||
|
if article.first_published_at is None:
|
||||||
|
db.delete(article)
|
||||||
|
db.commit()
|
||||||
|
return {"deleted": "permanently"}
|
||||||
|
|
||||||
|
article.deleted_at = datetime.utcnow()
|
||||||
|
article.deleted_by = current_user.id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# Out of search and out of the assistant's shortlist immediately: a binned
|
||||||
|
# article that still answers questions is worse than one still listed.
|
||||||
|
article_service.reindex(db, article)
|
||||||
|
return {"deleted": "to_trash"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{article_id}/publish")
|
@router.post("/{article_id}/publish")
|
||||||
|
|
@ -641,6 +710,10 @@ def publish_article(
|
||||||
if not article:
|
if not article:
|
||||||
raise HTTPException(404, "Article not found")
|
raise HTTPException(404, "Article not found")
|
||||||
article.status = "published" if data.published else "draft"
|
article.status = "published" if data.published else "draft"
|
||||||
|
if data.published and article.first_published_at is None:
|
||||||
|
# Stamped once and never cleared. Unpublishing does not make an article
|
||||||
|
# unseen, so it does not make deleting it safe either.
|
||||||
|
article.first_published_at = datetime.utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
# Publication is what decides whether the body is searchable, so it is also
|
# Publication is what decides whether the body is searchable, so it is also
|
||||||
# what has to build or tear down the section index. Nothing else touches
|
# what has to build or tear down the section index. Nothing else touches
|
||||||
|
|
@ -675,6 +748,104 @@ def link_question(
|
||||||
return {"linked": True}
|
return {"linked": True}
|
||||||
|
|
||||||
|
|
||||||
|
#: How many questions one action may attach. A top-level discipline holds
|
||||||
|
#: hundreds, and "link Cardiology" typed by mistake should be a refusal with a
|
||||||
|
#: number in it rather than nine hundred rows to undo one at a time.
|
||||||
|
MAX_BULK_LINKS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _questions_in_category(db, user, category_id: int, include_subtopics: bool):
|
||||||
|
"""Every question filed under a category, primary or additional.
|
||||||
|
|
||||||
|
Both, because a question's primary category is one fact about it and the
|
||||||
|
extra ones are others — a question filed under Cardiology and also tagged
|
||||||
|
Emergency Medicine is in both, and an educator linking either expects it.
|
||||||
|
"""
|
||||||
|
if not db.query(QuestionCategory.id).filter(QuestionCategory.id == category_id).first():
|
||||||
|
raise HTTPException(404, "Category not found")
|
||||||
|
ids = {category_id}
|
||||||
|
if include_subtopics:
|
||||||
|
ids = category_descendants(db.query(QuestionCategory).all(), ids)
|
||||||
|
return db.query(Question.id).filter(
|
||||||
|
bank_question_predicate(user),
|
||||||
|
or_(Question.question_category_id.in_(ids),
|
||||||
|
Question.id.in_(select(QuestionCategoryLink.question_id).where(
|
||||||
|
QuestionCategoryLink.category_id.in_(ids)))),
|
||||||
|
).order_by(Question.id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{article_id}/links/from-category")
|
||||||
|
def preview_category_link(
|
||||||
|
article_id: int,
|
||||||
|
category_id: int = Query(...),
|
||||||
|
section_id: str | None = Query(None),
|
||||||
|
include_subtopics: bool = Query(True),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_moderator),
|
||||||
|
):
|
||||||
|
"""How many questions linking this category would add, before it is done.
|
||||||
|
|
||||||
|
Asked separately so the button can carry the number. "Link 43 questions"
|
||||||
|
is a decision; "Link this category" is a guess, and the difference matters
|
||||||
|
when the category turns out to be the whole of Cardiology.
|
||||||
|
"""
|
||||||
|
article = db.get(Article, article_id)
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(404, "Article not found")
|
||||||
|
found = {row[0] for row in _questions_in_category(db, current_user, category_id, include_subtopics)}
|
||||||
|
already = {row[0] for row in db.query(QuestionArticleLink.question_id).filter(
|
||||||
|
QuestionArticleLink.article_id == article_id,
|
||||||
|
QuestionArticleLink.section_id == section_id).all()}
|
||||||
|
return {"total": len(found), "already_linked": len(found & already),
|
||||||
|
"would_link": len(found - already), "limit": MAX_BULK_LINKS}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{article_id}/links/from-category")
|
||||||
|
def link_category(
|
||||||
|
article_id: int,
|
||||||
|
data: ArticleCategoryLinkIn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_moderator),
|
||||||
|
):
|
||||||
|
"""Link every question in a category to this article, in one go.
|
||||||
|
|
||||||
|
Ordinary link rows, not a rule: the article does not "claim" the category,
|
||||||
|
it gains the questions that are in it now. A rule would be a second answer
|
||||||
|
to "which questions belong to this article" — one that the counts, the
|
||||||
|
QBank button, the mirror panel on each question and the AI Mode boost would
|
||||||
|
each have to learn to ask — and it would silently attach tomorrow's
|
||||||
|
questions to an article nobody has looked at since. A snapshot is what an
|
||||||
|
educator can see, check and unpick one row at a time.
|
||||||
|
"""
|
||||||
|
article = db.get(Article, article_id)
|
||||||
|
if not article:
|
||||||
|
raise HTTPException(404, "Article not found")
|
||||||
|
if data.section_id is not None and data.section_id not in _section_ids(article):
|
||||||
|
raise HTTPException(400, "Section not found in this article")
|
||||||
|
|
||||||
|
found = [row[0] for row in _questions_in_category(db, current_user, data.category_id, data.include_subtopics)]
|
||||||
|
already = {row[0] for row in db.query(QuestionArticleLink.question_id).filter(
|
||||||
|
QuestionArticleLink.article_id == article_id,
|
||||||
|
QuestionArticleLink.section_id == data.section_id).all()}
|
||||||
|
pending = [question_id for question_id in found if question_id not in already]
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
return {"linked": 0, "skipped": len(found), "total": len(found)}
|
||||||
|
if len(pending) > MAX_BULK_LINKS:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"That category holds {len(pending)} unlinked questions, over the "
|
||||||
|
f"{MAX_BULK_LINKS} one action may attach. Link a subtopic instead.")
|
||||||
|
|
||||||
|
db.bulk_save_objects([
|
||||||
|
QuestionArticleLink(question_id=question_id, article_id=article_id,
|
||||||
|
section_id=data.section_id, user_id=current_user.id)
|
||||||
|
for question_id in pending
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
return {"linked": len(pending), "skipped": len(found) - len(pending), "total": len(found)}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{article_id}/links/{question_id}", status_code=204)
|
@router.delete("/{article_id}/links/{question_id}", status_code=204)
|
||||||
def unlink_question(
|
def unlink_question(
|
||||||
article_id: int,
|
article_id: int,
|
||||||
|
|
@ -864,8 +1035,9 @@ def editorial_queue(db: Session = Depends(get_db),
|
||||||
Each bucket is something an editor can act on today. Counting articles by
|
Each bucket is something an editor can act on today. Counting articles by
|
||||||
status alone would say how many exist, which is not a queue.
|
status alone would say how many exist, which is not a queue.
|
||||||
"""
|
"""
|
||||||
articles = db.query(Article).all()
|
articles = db.query(Article).filter(Article.deleted_at.is_(None)).all()
|
||||||
linked = {row[0] for row in db.query(QuestionArticleLink.article_id).distinct().all()}
|
linked = {row[0] for row in db.query(QuestionArticleLink.article_id).distinct().all()}
|
||||||
|
binned = db.query(Article).filter(Article.deleted_at.isnot(None)).count()
|
||||||
|
|
||||||
def row(article):
|
def row(article):
|
||||||
return {"id": article.id, "slug": article.slug, "title": article.title,
|
return {"id": article.id, "slug": article.slug, "title": article.title,
|
||||||
|
|
@ -888,6 +1060,9 @@ def editorial_queue(db: Session = Depends(get_db),
|
||||||
"draft": sum(1 for a in articles if a.status == "draft"),
|
"draft": sum(1 for a in articles if a.status == "draft"),
|
||||||
"in_review": len(awaiting),
|
"in_review": len(awaiting),
|
||||||
"published": sum(1 for a in articles if a.status == "published"),
|
"published": sum(1 for a in articles if a.status == "published"),
|
||||||
|
# Not a queue — the trash is not work to do — but a number an
|
||||||
|
# editor wants to see before they wonder where an article went.
|
||||||
|
"trashed": binned,
|
||||||
},
|
},
|
||||||
# Ordered by what blocks a learner soonest.
|
# Ordered by what blocks a learner soonest.
|
||||||
"awaiting_review": awaiting[:100],
|
"awaiting_review": awaiting[:100],
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,58 @@ class ArticlesCardsTests(unittest.TestCase):
|
||||||
self.assertEqual(self.client.get('/articles/').json(), [])
|
self.assertEqual(self.client.get('/articles/').json(), [])
|
||||||
self.assertEqual(self.client.get(f"/articles/{article['id']}").status_code, 404)
|
self.assertEqual(self.client.get(f"/articles/{article['id']}").status_code, 404)
|
||||||
|
|
||||||
|
def test_a_whole_topic_can_be_linked_at_once(self):
|
||||||
|
"""One question at a time is right for a cross-reference and wrong for
|
||||||
|
"every Cardiology question belongs to the Cardiology article"."""
|
||||||
|
self.bank.user = self.bank.mod
|
||||||
|
article = self.create().json()
|
||||||
|
article_id = article['id']
|
||||||
|
|
||||||
|
# The count comes first, so the button can carry the number rather than
|
||||||
|
# the educator guessing at what they are about to do.
|
||||||
|
preview = self.client.get(f"/articles/{article_id}/links/from-category",
|
||||||
|
params={'category_id': 2}).json()
|
||||||
|
self.assertEqual(preview['would_link'], preview['total'])
|
||||||
|
self.assertEqual(preview['already_linked'], 0)
|
||||||
|
self.assertGreater(preview['total'], 1)
|
||||||
|
|
||||||
|
done = self.client.post(f"/articles/{article_id}/links/from-category",
|
||||||
|
json={'category_id': 2}).json()
|
||||||
|
self.assertEqual(done['linked'], preview['total'])
|
||||||
|
linked = self.client.get(f"/articles/{article_id}/questions").json()
|
||||||
|
self.assertEqual(len(linked), preview['total'])
|
||||||
|
|
||||||
|
# Twice is not twice as many links. Every one is already here.
|
||||||
|
again = self.client.post(f"/articles/{article_id}/links/from-category",
|
||||||
|
json={'category_id': 2}).json()
|
||||||
|
self.assertEqual(again['linked'], 0)
|
||||||
|
self.assertEqual(again['skipped'], preview['total'])
|
||||||
|
self.assertEqual(len(self.client.get(f"/articles/{article_id}/questions").json()),
|
||||||
|
preview['total'])
|
||||||
|
|
||||||
|
# A parent takes its subtopics with it, which is what an educator means
|
||||||
|
# by the name of a discipline — and does not without the flag.
|
||||||
|
root = self.client.get(f"/articles/{article_id}/links/from-category",
|
||||||
|
params={'category_id': 1}).json()
|
||||||
|
alone = self.client.get(f"/articles/{article_id}/links/from-category",
|
||||||
|
params={'category_id': 1, 'include_subtopics': False}).json()
|
||||||
|
self.assertGreater(root['total'], alone['total'])
|
||||||
|
|
||||||
|
# The same questions may be linked again against a section: a link to
|
||||||
|
# the whole article and a link to one section are different links.
|
||||||
|
section_id = article['sections'][0]['id']
|
||||||
|
deeper = self.client.post(f"/articles/{article_id}/links/from-category",
|
||||||
|
json={'category_id': 2, 'section_id': section_id}).json()
|
||||||
|
self.assertEqual(deeper['linked'], preview['total'])
|
||||||
|
|
||||||
|
self.assertEqual(self.client.post(f"/articles/{article_id}/links/from-category",
|
||||||
|
json={'category_id': 999}).status_code, 404)
|
||||||
|
self.assertEqual(self.client.post(f"/articles/{article_id}/links/from-category",
|
||||||
|
json={'category_id': 2, 'section_id': 'f' * 32}).status_code, 400)
|
||||||
|
self.bank.user = self.bank.owner
|
||||||
|
self.assertEqual(self.client.post(f"/articles/{article_id}/links/from-category",
|
||||||
|
json={'category_id': 2}).status_code, 403)
|
||||||
|
|
||||||
def test_section_stability_remediation_and_question_links(self):
|
def test_section_stability_remediation_and_question_links(self):
|
||||||
self.bank.user = self.bank.mod
|
self.bank.user = self.bank.mod
|
||||||
article = self.create().json()
|
article = self.create().json()
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,15 @@ export default function ArticleQuestions({ articleId, sections }) {
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
|
// Linking a whole topic at once. One question at a time is right for the
|
||||||
|
// odd cross-reference and wrong for "every Cardiology question belongs to
|
||||||
|
// the Cardiology article", which is most of what an educator is doing here.
|
||||||
|
const [categories, setCategories] = useState([])
|
||||||
|
const [categoryId, setCategoryId] = useState('')
|
||||||
|
const [subtopics, setSubtopics] = useState(true)
|
||||||
|
//: What linking it would actually do, asked before it is done — so the
|
||||||
|
//: button carries the number rather than the educator guessing at it.
|
||||||
|
const [preview, setPreview] = useState(null)
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
if (!articleId) return
|
if (!articleId) return
|
||||||
|
|
@ -38,6 +47,25 @@ export default function ArticleQuestions({ articleId, sections }) {
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/question-categories/').then(res => setCategories(res.data || [])).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// The count follows the three things that change it. Cleared first, so a
|
||||||
|
// stale number is never shown against a new choice.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!articleId || !categoryId) { setPreview(null); return undefined }
|
||||||
|
let live = true
|
||||||
|
setPreview(null)
|
||||||
|
api.get(`/articles/${articleId}/links/from-category`, {
|
||||||
|
params: { category_id: categoryId, section_id: sectionId || undefined,
|
||||||
|
include_subtopics: subtopics },
|
||||||
|
})
|
||||||
|
.then(res => { if (live) setPreview(res.data) })
|
||||||
|
.catch(() => { if (live) setPreview(null) })
|
||||||
|
return () => { live = false }
|
||||||
|
}, [articleId, categoryId, sectionId, subtopics])
|
||||||
|
|
||||||
const search = useCallback(async (needle) => {
|
const search = useCallback(async (needle) => {
|
||||||
const res = await api.get('/questions/bank', { params: { q: needle, limit: 8 } })
|
const res = await api.get('/questions/bank', { params: { q: needle, limit: 8 } })
|
||||||
return (res.data?.questions || []).map(question => ({
|
return (res.data?.questions || []).map(question => ({
|
||||||
|
|
@ -59,6 +87,24 @@ export default function ArticleQuestions({ articleId, sections }) {
|
||||||
finally { setBusy(false) }
|
finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkCategory = async () => {
|
||||||
|
if (!categoryId) return
|
||||||
|
setBusy(true); setError(''); setNote('')
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/articles/${articleId}/links/from-category`, {
|
||||||
|
category_id: Number(categoryId), section_id: sectionId || null,
|
||||||
|
include_subtopics: subtopics,
|
||||||
|
})
|
||||||
|
const { linked, skipped } = res.data
|
||||||
|
setNote(linked
|
||||||
|
? `Linked ${linked} question${linked === 1 ? '' : 's'}${skipped ? `, ${skipped} already here` : ''}.`
|
||||||
|
: 'Every question in that topic is already linked here.')
|
||||||
|
setCategoryId(''); setPreview(null)
|
||||||
|
load()
|
||||||
|
} catch (err) { setError(apiError(err, 'Could not link that topic')) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
const unlink = async (row) => {
|
const unlink = async (row) => {
|
||||||
setBusy(true); setError(''); setNote('')
|
setBusy(true); setError(''); setNote('')
|
||||||
try {
|
try {
|
||||||
|
|
@ -102,6 +148,63 @@ export default function ArticleQuestions({ articleId, sections }) {
|
||||||
disabled={busy} search={search} onPick={row => { setError(''); setNote(''); setSectionId(''); setChosen(row) }} />
|
disabled={busy} search={search} onPick={row => { setError(''); setNote(''); setSectionId(''); setChosen(row) }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!chosen && (
|
||||||
|
<div className="rl-bulk">
|
||||||
|
<div className="rl-bulk-row">
|
||||||
|
<label className="rl-field">
|
||||||
|
<span>Or link a whole topic</span>
|
||||||
|
<select value={categoryId} aria-label="Topic to link"
|
||||||
|
onChange={event => { setError(''); setNote(''); setCategoryId(event.target.value) }}>
|
||||||
|
<option value="">Choose a topic…</option>
|
||||||
|
{categories.map(category => (
|
||||||
|
<option key={category.id} value={category.id}>{category.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{categoryId && (sections || []).length > 0 && (
|
||||||
|
<label className="rl-field">
|
||||||
|
<span>Links to</span>
|
||||||
|
<select value={sectionId} aria-label="Section for the topic's questions"
|
||||||
|
onChange={event => setSectionId(event.target.value)}>
|
||||||
|
<option value="">Whole article</option>
|
||||||
|
{sections.map(section => (
|
||||||
|
<option key={section.id} value={section.id}>{section.title || section.slug}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{categoryId && (
|
||||||
|
<div className="rl-bulk-row">
|
||||||
|
<label className="rl-check">
|
||||||
|
<input type="checkbox" checked={subtopics} disabled={busy}
|
||||||
|
onChange={event => setSubtopics(event.target.checked)} />
|
||||||
|
<span>Include subtopics</span>
|
||||||
|
</label>
|
||||||
|
{/* The number is the decision. "Link this topic" is a guess, and
|
||||||
|
the difference matters when the topic turns out to be all of
|
||||||
|
Cardiology. */}
|
||||||
|
<button type="button" className="btn btn-primary btn-sm"
|
||||||
|
disabled={busy || !preview || !preview.would_link}
|
||||||
|
onClick={linkCategory}>
|
||||||
|
{preview
|
||||||
|
? (preview.would_link
|
||||||
|
? `Link ${preview.would_link} question${preview.would_link === 1 ? '' : 's'}`
|
||||||
|
: 'All already linked')
|
||||||
|
: 'Counting…'}
|
||||||
|
</button>
|
||||||
|
{preview && preview.already_linked > 0 && preview.would_link > 0 && (
|
||||||
|
<span className="rl-bulk-note">{preview.already_linked} already here</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="rl-bulk-hint">
|
||||||
|
Adds the questions in that topic as ordinary links, which you can remove one
|
||||||
|
at a time. Questions filed there later are not added.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{chosen && (
|
{chosen && (
|
||||||
<div className="rl-chosen">
|
<div className="rl-chosen">
|
||||||
<p className="rl-chosen-title">{chosen.primary} <code className="rl-link-id">#{chosen.id}</code></p>
|
<p className="rl-chosen-title">{chosen.primary} <code className="rl-link-id">#{chosen.id}</code></p>
|
||||||
|
|
|
||||||
|
|
@ -57,3 +57,18 @@
|
||||||
|
|
||||||
.rl-empty, .rl-note { margin: 0; font-size: .78rem; color: var(--text-muted); line-height: 1.45; }
|
.rl-empty, .rl-note { margin: 0; font-size: .78rem; color: var(--text-muted); line-height: 1.45; }
|
||||||
.rl-error { margin: 0; font-size: .78rem; color: var(--danger, #dc2626); }
|
.rl-error { margin: 0; font-size: .78rem; color: var(--danger, #dc2626); }
|
||||||
|
|
||||||
|
/* Linking a whole topic. Below the stem search, and quieter than it: one
|
||||||
|
question at a time is the precise thing, this is the broad stroke. */
|
||||||
|
.rl-bulk { margin-top: 14px; padding-top: 14px; border-top: 1px dashed var(--border); }
|
||||||
|
.rl-bulk-row { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.rl-bulk-row + .rl-bulk-row { margin-top: 10px; align-items: center; }
|
||||||
|
.rl-bulk-note { font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
.rl-bulk-hint { margin: 10px 0 0; font-size: 0.78rem; line-height: 1.5; color: var(--text-subtle); }
|
||||||
|
.rl-check { display: inline-flex; align-items: center; gap: 7px; font-size: 0.82rem; color: var(--text-muted); }
|
||||||
|
.rl-check input { width: 15px; height: 15px; }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.rl-bulk-row { flex-direction: column; align-items: stretch; }
|
||||||
|
.rl-bulk-row .btn { width: 100%; }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue