fix: new question opens the editor page; the bank previews rather than quizzes
Three things asked for. New question opened a cramped modal in both places it was offered — the question bank and the question manager — while Edit opened the full page. Both now go to /questions/new, carrying where they came from so the back link returns there. CreateQuestionModal had no callers left and is gone. The bank's "Study" action is "Preview", and shows the question whole: the correct option, the per-option reasoning, the explanation and the key points, all at once. Making someone answer first is the right shape for practice and the wrong one in the bank, where the question is being inspected rather than sat. Option reasoning and the explanation render as Markdown there now too, instead of raw text. Option explanations get the same formatting toolbar as the stem — an option's reasoning is prose as well, and often carries a list or table. Frontend 309/309. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
2b6af14b84
commit
4a28b5e0a0
6 changed files with 77 additions and 242 deletions
|
|
@ -231,6 +231,13 @@ Captured so nothing is lost while the article writing runs.
|
|||
- [ ] **High-yield / key-exam-info toggles** — mark spans and let the reader show
|
||||
or hide them.
|
||||
|
||||
## Loose ends
|
||||
|
||||
- [ ] **QuestionEditModal is dead code** — nothing has imported it since Edit
|
||||
moved to the full page. Its sibling CreateQuestionModal was removed when
|
||||
its last two callers were replaced; this one was already unreferenced, so
|
||||
it is left for a deliberate decision rather than swept up.
|
||||
|
||||
## Quiz runner
|
||||
|
||||
- [x] **Per-question notes** — done. The notes themselves were already built
|
||||
|
|
|
|||
|
|
@ -200,209 +200,3 @@ export function QuestionEditModal({ question, categories, onSaved, onClose }) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreateQuestionModal({ categories, onCreated, onClose }) {
|
||||
const [form, setForm] = useState({ question_text: '', question_type: 'mcq', options: ['', '', '', ''], correct_answer: '', correctIndex: -1, explanation: '', question_category_id: '', image_path: '' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [imageTab, setImageTab] = useState('upload') // 'upload' | 'bank'
|
||||
const [imageBank, setImageBank] = useState([])
|
||||
const [uploadingImage, setUploadingImage] = useState(false)
|
||||
|
||||
const setOption = (i, val) => {
|
||||
const updated = [...form.options]
|
||||
updated[i] = val
|
||||
setForm(f => ({ ...f, options: updated, correct_answer: f.correctIndex === i ? val : f.correct_answer }))
|
||||
}
|
||||
|
||||
const setCorrectIndex = (i) => {
|
||||
setForm(f => ({ ...f, correctIndex: i, correct_answer: f.options[i] }))
|
||||
}
|
||||
|
||||
const removeOption = (i) => {
|
||||
setForm(f => {
|
||||
const opts = f.options.filter((_, idx) => idx !== i)
|
||||
let ci = f.correctIndex
|
||||
if (ci === i) ci = -1
|
||||
else if (ci > i) ci--
|
||||
return { ...f, options: opts, correctIndex: ci, correct_answer: ci >= 0 ? opts[ci] : '' }
|
||||
})
|
||||
}
|
||||
|
||||
const loadImageBank = async () => {
|
||||
try {
|
||||
const res = await api.get('/questions/images')
|
||||
setImageBank(res.data)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploadingImage(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res = await api.post('/questions/upload-image', fd)
|
||||
setForm(f => ({ ...f, image_path: res.data.image_path }))
|
||||
} catch (err) {
|
||||
setError(apiError(err, 'Image upload failed'))
|
||||
} finally {
|
||||
setUploadingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form.question_text.trim()) return setError('Question text is required')
|
||||
const validOpts = form.options.filter(o => o.trim())
|
||||
if (validOpts.length < 2) return setError('At least 2 options are required')
|
||||
if (form.correctIndex < 0 || !form.correct_answer) return setError('Select the correct answer')
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
const payload = {
|
||||
question_text: form.question_text,
|
||||
question_type: form.question_type,
|
||||
options: validOpts,
|
||||
correct_answer: form.correct_answer,
|
||||
explanation: form.explanation || null,
|
||||
question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null,
|
||||
image_path: form.image_path || null,
|
||||
}
|
||||
const res = await api.post('/questions/create', payload)
|
||||
onCreated(res.data)
|
||||
onClose()
|
||||
} catch (err) { setError(apiError(err, 'Failed to create question')) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
|
||||
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 28, maxWidth: 720, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: '1.15rem', margin: 0 }}>Create Question</h2>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}>✕</button>
|
||||
</div>
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
|
||||
|
||||
<div className="form-group">
|
||||
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Question Text</label>
|
||||
<Suspense fallback={<textarea value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))} rows={4}
|
||||
placeholder="Loading editor..." style={{ width: '100%', padding: 12, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />}>
|
||||
<RichEditor value={form.question_text} onChange={(v) => setForm(f => ({ ...f, question_text: v }))} height={180} placeholder="Write the question..." />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Category</label>
|
||||
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}
|
||||
style={{ width: '100%', padding: '10px 14px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem' }}>
|
||||
<option value="">None</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{(c.breadcrumbs || []).map(b => b.name).join(' › ') || c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="form-group">
|
||||
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Image (optional)</label>
|
||||
{form.image_path ? (
|
||||
<div style={{ position: 'relative', display: 'inline-block', marginBottom: 8 }}>
|
||||
<img src={`/uploads/${form.image_path}`} alt="Question"
|
||||
style={{ maxWidth: '100%', maxHeight: 180, borderRadius: 8, border: '1px solid var(--border)' }} />
|
||||
<button onClick={() => setForm(f => ({ ...f, image_path: '' }))}
|
||||
style={{ position: 'absolute', top: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: '50%', width: 24, height: 24, cursor: 'pointer', fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
<button className={`btn btn-sm ${imageTab === 'upload' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setImageTab('upload')} style={{ fontSize: '0.78rem' }}>Upload New</button>
|
||||
<button className={`btn btn-sm ${imageTab === 'bank' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setImageTab('bank'); loadImageBank() }} style={{ fontSize: '0.78rem' }}>Image Bank</button>
|
||||
</div>
|
||||
{imageTab === 'upload' && (
|
||||
<div>
|
||||
<input type="file" accept="image/*" onChange={handleImageUpload} disabled={uploadingImage}
|
||||
style={{ fontSize: '0.85rem' }} />
|
||||
{uploadingImage && <span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginLeft: 8 }}>Uploading...</span>}
|
||||
</div>
|
||||
)}
|
||||
{imageTab === 'bank' && (
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 8, padding: 4 }}>
|
||||
{imageBank.length === 0 && <div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', gridColumn: '1/-1' }}>No images in bank yet. Upload one first.</div>}
|
||||
{imageBank.map((img, i) => (
|
||||
<div key={i} onClick={() => setForm(f => ({ ...f, image_path: img.image_path }))}
|
||||
style={{ cursor: 'pointer', border: '2px solid var(--border)', borderRadius: 8, overflow: 'hidden', aspectRatio: '1', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--primary)'} onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
|
||||
<img src={img.url} alt="" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'cover' }}
|
||||
onError={e => e.target.style.display = 'none'} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>
|
||||
Options — click the letter to mark as correct
|
||||
</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{form.options.map((opt, i) => {
|
||||
const isCorrect = form.correctIndex === i
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button onClick={() => setCorrectIndex(i)} title={isCorrect ? 'Correct answer' : 'Click to mark as correct'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32,
|
||||
borderRadius: '50%', flexShrink: 0, fontSize: '0.8rem', fontWeight: 700, cursor: 'pointer',
|
||||
border: isCorrect ? '2px solid var(--correct-fg)' : '2px solid var(--border)',
|
||||
background: isCorrect ? 'var(--correct-fg)' : 'transparent',
|
||||
color: isCorrect ? 'white' : 'var(--text-muted)',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{LETTERS[i]}
|
||||
</button>
|
||||
<input value={opt} onChange={e => setOption(i, e.target.value)}
|
||||
placeholder={`Option ${LETTERS[i]}`}
|
||||
style={{
|
||||
flex: 1, padding: '10px 14px',
|
||||
border: `1.5px solid ${isCorrect ? 'var(--correct-bd)' : 'var(--border)'}`,
|
||||
borderRadius: 8, background: isCorrect ? 'var(--correct-bg)' : 'var(--input-bg)',
|
||||
color: 'var(--text)', fontSize: '0.9rem', fontFamily: 'inherit',
|
||||
}} />
|
||||
{form.options.length > 2 && (
|
||||
<button onClick={() => removeOption(i)} title="Remove option"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.1rem', padding: '4px 6px' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{form.options.length < 8 && (
|
||||
<button className="btn btn-sm btn-secondary" style={{ marginTop: 8 }}
|
||||
onClick={() => setForm(f => ({ ...f, options: [...f.options, ''] }))}>+ Add option</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6, display: 'block' }}>Explanation (optional)</label>
|
||||
<Suspense fallback={<textarea value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))} rows={3}
|
||||
placeholder="Loading editor..." style={{ width: '100%', padding: 12, border: '1px solid var(--border)', borderRadius: 8, background: 'var(--input-bg)', color: 'var(--text)', fontSize: '0.9rem', resize: 'vertical', boxSizing: 'border-box' }} />}>
|
||||
<RichEditor value={form.explanation} onChange={(v) => setForm(f => ({ ...f, explanation: v }))} height={150} placeholder="Explain why this is the correct answer..." />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button className="btn btn-primary" onClick={save} disabled={saving}>
|
||||
{saving ? 'Creating...' : 'Create Question'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { useDialog } from '../hooks/useDialog'
|
|||
const TeachChat = lazy(() => import('../components/TeachChat'))
|
||||
const RichEditor = lazy(() => import('../components/RichEditor'))
|
||||
import QuestionReadingLinks from '../components/QuestionReadingLinks'
|
||||
import { CreateQuestionModal } from '../components/QuestionEditors'
|
||||
|
||||
const DIFFICULTY_LABEL = { '': 'Any', easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||
|
||||
|
|
@ -37,8 +36,16 @@ function stripHtml(html) {
|
|||
return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite, collections = [] }) {
|
||||
const [answered, setAnswered] = useState(null)
|
||||
/**
|
||||
* One question, shown whole.
|
||||
*
|
||||
* It used to make you answer before it would reveal anything, which is the
|
||||
* right shape for practice and the wrong one here: in the bank the question is
|
||||
* being inspected, not sat. Everything is shown at once — the correct option,
|
||||
* the per-option reasoning, the explanation and the key points — because that
|
||||
* is what looking at a question means.
|
||||
*/
|
||||
function QuestionPreviewModal({ question, onClose, isFavorited, onToggleFavorite, collections = [] }) {
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
|
|
@ -74,30 +81,27 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite,
|
|||
{question.options && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{question.options.map((opt, i) => {
|
||||
const isSelected = answered === opt
|
||||
const hasAnswered = !!answered
|
||||
const isCorrectOpt = opt === question.correct_answer
|
||||
return (
|
||||
<div key={i}
|
||||
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${hasAnswered && isCorrectOpt ? 'correct' : ''} ${hasAnswered && isSelected && !isCorrectOpt ? 'incorrect' : ''}`}
|
||||
onClick={() => !hasAnswered && setAnswered(opt)}
|
||||
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
|
||||
className={`option ${isCorrectOpt ? 'correct' : ''}`}>
|
||||
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
|
||||
<span style={{ flex: 1 }}>{opt}</span>
|
||||
{hasAnswered && question.option_explanations?.[opt] && (
|
||||
<span style={{ flexBasis: '100%', fontSize: '0.8rem', color: 'var(--text-muted)' }}>{question.option_explanations[opt]}</span>
|
||||
{isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ Correct</span>}
|
||||
{question.option_explanations?.[opt] && (
|
||||
<span style={{ flexBasis: '100%', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
|
||||
<RichText value={question.option_explanations[opt]} />
|
||||
</span>
|
||||
)}
|
||||
{hasAnswered && isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}>✓ {isSelected ? 'Your answer' : 'Correct'}</span>}
|
||||
{hasAnswered && isSelected && !isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}>✗ Wrong</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{answered && (question.explanation || question.explanation_image_path) && (
|
||||
{(question.explanation || question.explanation_image_path) && (
|
||||
<div className="explanation">
|
||||
<strong>Explanation:</strong>
|
||||
{question.explanation && <div style={{ marginTop: 8 }}>{question.explanation}</div>}
|
||||
{question.explanation && <div style={{ marginTop: 8 }}><RichText value={question.explanation} /></div>}
|
||||
{question.explanation_image_path && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<img src={`/uploads/${question.explanation_image_path}`} alt="Explanation illustration"
|
||||
|
|
@ -107,7 +111,7 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite,
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{answered && (question.key_points || []).length > 0 && (
|
||||
{(question.key_points || []).length > 0 && (
|
||||
<div className="explanation" style={{ marginTop: 12 }}>
|
||||
<strong>Key points</strong>
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: 18, fontSize: '0.85rem' }}>
|
||||
|
|
@ -145,12 +149,9 @@ function QuestionStudyModal({ question, onClose, isFavorited, onToggleFavorite,
|
|||
}} style={{ padding: '4px 10px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.8rem' }} />
|
||||
</div>
|
||||
</div>
|
||||
{answered && (
|
||||
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* AI Tutor — z-index above the modal */}
|
||||
|
|
@ -319,7 +320,6 @@ export default function QuestionBankPage() {
|
|||
const [selectedTagIds, setSelectedTagIds] = useState([])
|
||||
const [openFacet, setOpenFacet] = useState(null)
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [showCreateQuestion, setShowCreateQuestion] = useState(false)
|
||||
const [showImport, setShowImport] = useState(false)
|
||||
const [importFile, setImportFile] = useState(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
|
@ -619,10 +619,9 @@ export default function QuestionBankPage() {
|
|||
)
|
||||
})()}
|
||||
|
||||
{studyQuestion && <QuestionStudyModal question={studyQuestion} collections={collections} onClose={() => setStudyQuestion(null)}
|
||||
{studyQuestion && <QuestionPreviewModal question={studyQuestion} collections={collections} onClose={() => setStudyQuestion(null)}
|
||||
isFavorited={favorites.includes(studyQuestion.id)} onToggleFavorite={toggleFavorite} />}
|
||||
{showCreateQuiz && <CreateQuizModal selectedIds={selectedIds} categories={categories} onClose={() => setShowCreateQuiz(false)} onCreated={() => {}} />}
|
||||
{showCreateQuestion && <CreateQuestionModal categories={categories} onCreated={(q) => { setQuestions(prev => [q, ...prev]); setTotal(t => t + 1) }} onClose={() => setShowCreateQuestion(false)} />}
|
||||
|
||||
{/* Import CSV/Excel Modal */}
|
||||
{showImport && (
|
||||
|
|
@ -678,7 +677,10 @@ export default function QuestionBankPage() {
|
|||
Create Quiz ({selectedIds.size} selected)
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button>
|
||||
{/* The full editor, not a cramped modal — the same page Edit
|
||||
opens, so writing a question and fixing one are one screen. */}
|
||||
<Link className="btn btn-secondary btn-sm" to="/questions/new"
|
||||
state={{ from: '/question-bank', label: 'Question bank' }}>+ Question</Link>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleQtiImport} disabled={qtiImporting}>
|
||||
{qtiImporting ? 'Importing QTI...' : 'Import QTI'}
|
||||
|
|
@ -847,7 +849,7 @@ export default function QuestionBankPage() {
|
|||
>
|
||||
{favorites.includes(q.id) ? '⭐' : '☆'}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Study</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Preview</button>
|
||||
{(q.user_id === user?.id || isModerator) && (
|
||||
<button className="btn btn-sm btn-secondary" style={{ fontSize: '0.72rem' }}
|
||||
onClick={async () => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ export default function QuestionEditPage({ mode = 'edit' }) {
|
|||
const [picking, setPicking] = useState(null)
|
||||
const stemRef = useRef(null)
|
||||
const explanationRef = useRef(null)
|
||||
// One ref per option, kept across renders so the toolbar can find the
|
||||
// field it belongs to when options are added or reordered.
|
||||
const optionRefs = useRef([])
|
||||
// Back to wherever you opened this from — the bank with its filters, an
|
||||
// article, the manager — rather than always to the bank you may not have used.
|
||||
const { state } = useLocation()
|
||||
|
|
@ -241,7 +244,16 @@ export default function QuestionEditPage({ mode = 'edit' }) {
|
|||
<div className="qe-option-main">
|
||||
<input value={option} aria-label={`Option ${LETTERS[index]}`}
|
||||
onChange={e => setOption(index, e.target.value)} />
|
||||
<textarea className="qe-option-why" value={form.option_explanations[option] || ''}
|
||||
{/* The same formatting as the stem: an option's reasoning
|
||||
is prose too, and often carries a list or a table. */}
|
||||
<MarkdownToolbar textareaRef={optionRefs.current[index] ||= { current: null }}
|
||||
value={form.option_explanations[option] || ''}
|
||||
label={`Formatting for option ${LETTERS[index]}`}
|
||||
onChange={v => setForm(f => ({
|
||||
...f, option_explanations: { ...f.option_explanations, [option]: v },
|
||||
}))} />
|
||||
<textarea ref={el => { (optionRefs.current[index] ||= { current: null }).current = el }}
|
||||
className="qe-option-why" value={form.option_explanations[option] || ''}
|
||||
placeholder="Why this option is right or wrong — Markdown and $maths$ supported"
|
||||
aria-label={`Explanation for option ${LETTERS[index]}`}
|
||||
onChange={e => setForm(f => ({
|
||||
|
|
|
|||
27
frontend/src/pages/QuestionEntryPoints.test.jsx
Normal file
27
frontend/src/pages/QuestionEntryPoints.test.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import QuestionManagerPage from './QuestionManagerPage'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
|
||||
vi.mock('../context/AuthContext', () => ({ useAuth: () => ({ user: { id: 1, name: 'Mod', role: 'moderator', is_moderator: true } }) }))
|
||||
|
||||
describe('creating a question', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.get.mockImplementation(url => {
|
||||
if (url.includes('my-grants')) return Promise.resolve({ data: { is_moderator: true, can_manage_questions: true, categories: [] } })
|
||||
if (url.includes('question-categories')) return Promise.resolve({ data: [] })
|
||||
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the full editor page, not a modal', async () => {
|
||||
render(<MemoryRouter><QuestionManagerPage /></MemoryRouter>)
|
||||
const link = await screen.findByRole('link', { name: '+ New question' })
|
||||
expect(link).toHaveAttribute('href', '/questions/new')
|
||||
// A modal here meant a cramped form beside the full editor Edit already used.
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -2,7 +2,6 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|||
import { Link, useLocation } from 'react-router-dom'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { CreateQuestionModal } from '../components/QuestionEditors'
|
||||
import GrantsPanel from '../components/GrantsPanel'
|
||||
import './QuestionManagerPage.css'
|
||||
|
||||
|
|
@ -48,7 +47,6 @@ export default function QuestionManagerPage() {
|
|||
const [selected, setSelected] = useState(() => new Set())
|
||||
const [bulkBusy, setBulkBusy] = useState(false)
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState(null)
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
|
|
@ -129,12 +127,6 @@ export default function QuestionManagerPage() {
|
|||
|
||||
return (
|
||||
<div className="qm-page">
|
||||
{creating && (
|
||||
<CreateQuestionModal categories={categories}
|
||||
onCreated={() => { setCreating(false); refresh() }}
|
||||
onClose={() => setCreating(false)} />
|
||||
)}
|
||||
|
||||
<div className="qm-header">
|
||||
<div>
|
||||
<h1>Question manager</h1>
|
||||
|
|
@ -147,7 +139,8 @@ export default function QuestionManagerPage() {
|
|||
<div className="qm-header-actions">
|
||||
<Link className="btn btn-secondary" to="/categories">Taxonomy</Link>
|
||||
<Link className="btn btn-secondary" to="/question-bank">Open question bank</Link>
|
||||
<button className="btn btn-primary" onClick={() => setCreating(true)}>+ New question</button>
|
||||
<Link className="btn btn-primary" to="/questions/new"
|
||||
state={{ from: '/questions/manage', label: 'Question manager' }}>+ New question</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue