From d5269d7772af60cafa46ff59f3377a6893a57116 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 16:32:21 +0200 Subject: [PATCH] feat: rename Flashcards to Cards and add card-question linking UI Orthobullets-style Cards naming, per-card link panel to attach/unlink bank questions, article linked-content counts. New FlashcardsPage test suite; 75 frontend tests pass with build. --- frontend/src/components/Navbar.jsx | 2 +- frontend/src/pages/ArticlesPage.jsx | 7 +- frontend/src/pages/DocumentDetailPage.jsx | 8 +-- frontend/src/pages/FlashcardsPage.jsx | 79 ++++++++++++++++++++-- frontend/src/pages/FlashcardsPage.test.jsx | 61 +++++++++++++++++ frontend/src/pages/SettingsPage.jsx | 2 +- 6 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 frontend/src/pages/FlashcardsPage.test.jsx diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 2f71c66..8d883f1 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -93,7 +93,7 @@ export default function Navbar({ onSignIn, onRegister }) { { to: '/quizzes', label: 'Quizzes' }, { to: '/question-bank', label: 'Question Bank' }, { to: '/articles', label: 'Reading' }, - { to: '/flashcards', label: 'Flashcards' }, + { to: '/flashcards', label: 'Cards' }, { to: '/courses', label: 'Courses' }, { to: '/settings', label: '⚙ Settings' }, ] : [] diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index d052f99..9a7bd64 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -195,7 +195,10 @@ export function ArticlePage() { / {article.title}
-

{article.title}

+
+

{article.title}

+

{questions.length} linked questions · {cards.length} linked cards

+
{canEdit && !editing && ( <> @@ -307,7 +310,7 @@ export function ArticlePage() { )} {cards.length > 0 && (
-

Related flashcards

+

Related cards

{cards.map(card => (
{card.front} → {card.back}
diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index 05cb0ba..24d10f0 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -184,7 +184,7 @@ export default function DocumentDetailPage() { setGenerating(sectionId) setError('') try { - const title = quizTitle || `Flashcards: ${sectionName}` + const title = quizTitle || `Cards: ${sectionName}` const res = await api.post('/flashcards/', { section_id: sectionId, title, @@ -197,7 +197,7 @@ export default function DocumentDetailPage() { setActiveJob({ jobId: res.data.job_id, sectionName, type: 'flashcard' }) } } catch (err) { - setError(err.response?.data?.detail || 'Failed to start flashcard generation. Check AI model config.') + setError(err.response?.data?.detail || 'Failed to start card generation. Check AI model config.') } finally { setGenerating(null) } @@ -284,7 +284,7 @@ export default function DocumentDetailPage() { {activeJob && ( { setActiveJob(null); if (activeJob.type === 'flashcard') navigate('/flashcards'); else navigate(`/quizzes/${quizId}`) }} onClose={() => setActiveJob(null)} /> @@ -496,7 +496,7 @@ export default function DocumentDetailPage() { onClick={() => generateFlashcards(section.id, section.name)} disabled={generating === section.id} > - Create Flashcards + Create Cards deleteSection(section.id)} diff --git a/frontend/src/pages/FlashcardsPage.jsx b/frontend/src/pages/FlashcardsPage.jsx index e5b446f..0ca991a 100644 --- a/frontend/src/pages/FlashcardsPage.jsx +++ b/frontend/src/pages/FlashcardsPage.jsx @@ -44,6 +44,10 @@ export default function FlashcardsPage() { const [newFront, setNewFront] = useState('') const [newBack, setNewBack] = useState('') const [cardError, setCardError] = useState('') + const [linkCardId, setLinkCardId] = useState(null) + const [linkQuestionId, setLinkQuestionId] = useState('') + const [linkError, setLinkError] = useState('') + const [cardLinks, setCardLinks] = useState({}) const { user } = useAuth() const navigate = useNavigate() const isAdmin = user?.role === 'admin' @@ -160,6 +164,38 @@ export default function FlashcardsPage() { } catch { } } + const loadCardLinks = async (cardId) => { + setLinkError('') + try { + const res = await api.get(`/flashcards/cards/${cardId}/links`) + setCardLinks(prev => ({ ...prev, [cardId]: res.data })) + } catch (err) { setLinkError(err.response?.data?.detail || 'Could not load links') } + } + + const openLinks = (card) => { + setLinkCardId(linkCardId === card.id ? null : card.id) + if (linkCardId !== card.id) { setCardLinks(prev => ({ ...prev, [card.id]: null })); loadCardLinks(card.id) } + } + + const addQuestionLink = async (cardId) => { + setLinkError('') + const questionId = parseInt(linkQuestionId, 10) + if (!questionId) { setLinkError('Enter a question ID'); return } + try { + await api.put(`/flashcards/cards/${cardId}/links/question`, { question_id: questionId }) + setLinkQuestionId('') + loadCardLinks(cardId) + } catch (err) { setLinkError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not link question') } + } + + const removeQuestionLink = async (cardId, questionId) => { + setLinkError('') + try { + await api.delete(`/flashcards/cards/${cardId}/links/question/${questionId}`) + loadCardLinks(cardId) + } catch (err) { setLinkError(typeof err.response?.data?.detail === 'string' ? err.response.data.detail : 'Could not remove link') } + } + const addCard = async (deckId) => { setCardError('') if (!newFront.trim() || !newBack.trim()) { setCardError('Both sides are required'); return } @@ -176,7 +212,7 @@ export default function FlashcardsPage() {
-

Flashcards

+

Cards

@@ -209,7 +245,7 @@ export default function FlashcardsPage() { {tab === 'decks' && ( <> {decks.length === 0 && !loading && ( -
No flashcard decks yet. Create one from a document.
+
No card decks yet. Create one from a document.
)}
{decks.map(deck => ( @@ -332,7 +368,8 @@ export default function FlashcardsPage() {

{total} cards

{cards.map(card => ( -
+
setDeletingCard(null)}>No
) : ( - + <> + + + )}
+ {linkCardId === card.id && ( +
+ Linked questions + {cardLinks[card.id] === null &&

Loading…

} + {cardLinks[card.id]?.questions?.length > 0 ? ( +
    + {cardLinks[card.id].questions.map(q => ( +
  • + {q.question_text} + +
  • + ))} +
+ ) : cardLinks[card.id] ?

No questions linked yet.

: null} +
+ setLinkQuestionId(e.target.value)} + placeholder="Question ID" aria-label="Question ID to link" /> + +
+ {cardLinks[card.id]?.articles?.length > 0 && ( +

+ Linked articles: {cardLinks[card.id].articles.map(a => a.title).join(', ')} +

+ )} + {linkError &&
{linkError}
} +
+ )} +
))} {!loading && cards.length === 0 && ( diff --git a/frontend/src/pages/FlashcardsPage.test.jsx b/frontend/src/pages/FlashcardsPage.test.jsx new file mode 100644 index 0000000..4eb2624 --- /dev/null +++ b/frontend/src/pages/FlashcardsPage.test.jsx @@ -0,0 +1,61 @@ +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 FlashcardsPage from './FlashcardsPage' +import api from '../api/client' + +vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn() } })) +vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, name: 'Learner', role: 'user' } }) })) + +const card = { id: 7, deck_id: 2, deck_title: 'Neonatology', front: 'Front text', back: 'Back text' } + +beforeEach(() => { + vi.resetAllMocks() + api.get.mockImplementation(url => { + if (url === '/flashcards/') return Promise.resolve({ data: [] }) + if (url === '/flashcards/trash') return Promise.resolve({ data: [] }) + if (url === '/flashcards/shared') return Promise.resolve({ data: { total: 0, decks: [] } }) + if (url === '/flashcards/cards/browse') return Promise.resolve({ data: { total: 1, cards: [card] } }) + if (url === '/flashcards/cards/7/links') return Promise.resolve({ data: { questions: [{ id: 12, question_text: 'Linked question text' }], articles: [] } }) + return Promise.resolve({ data: [] }) + }) + api.put.mockResolvedValue({ data: { linked: true } }) + api.delete.mockResolvedValue({ data: null }) +}) + +function renderPage() { + return render() +} + +describe('cards page', () => { + it('uses the Cards label and honest empty deck state', async () => { + renderPage() + expect(await screen.findByRole('heading', { name: 'Cards' })).toBeInTheDocument() + expect(await screen.findByText('No card decks yet. Create one from a document.')).toBeInTheDocument() + }) + + it('links a card to a question and unlinks it', async () => { + renderPage() + await userEvent.click(await screen.findByRole('button', { name: 'Browse Cards' })) + expect(await screen.findByText('Front text')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Links' })) + expect(await screen.findByText('Linked question text')).toBeInTheDocument() + await userEvent.type(screen.getByLabelText('Question ID to link'), '42') + await userEvent.click(screen.getByRole('button', { name: 'Link question' })) + await waitFor(() => expect(api.put).toHaveBeenCalledWith('/flashcards/cards/7/links/question', { question_id: 42 })) + await userEvent.click(screen.getByRole('button', { name: 'Unlink' })) + await waitFor(() => expect(api.delete).toHaveBeenCalledWith('/flashcards/cards/7/links/question/12')) + }) + + it('shows an empty linked state honestly', async () => { + const original = api.get.getMockImplementation() + api.get.mockImplementation(url => url === '/flashcards/cards/7/links' + ? Promise.resolve({ data: { questions: [], articles: [] } }) + : original(url)) + renderPage() + await userEvent.click(await screen.findByRole('button', { name: 'Browse Cards' })) + await userEvent.click(await screen.findByRole('button', { name: 'Links' })) + expect(await screen.findByText('No questions linked yet.')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 21036fa..9c5511a 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -288,7 +288,7 @@ function DocumentsSection() {

- Your uploaded PDFs for quiz and flashcard generation. + Your uploaded PDFs for quiz and card generation.

Upload PDF