14. Frontend: MockExamDetail with Set Selection
File: app/javascript/mockExams/MockExamDetail.jsx
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { request } from '@utilities/http';
import { ExamLeaderboard } from './components/ExamLeaderboard';
export function MockExamDetail({ slug }) {
const [template, setTemplate] = useState(null);
const [sets, setSets] = useState([]);
const [loading, setLoading] = useState(true);
const [starting, setStarting] = useState(false);
const [error, setError] = useState(null);
const [selectedSet, setSelectedSet] = useState(null);
useEffect(() => {
Promise.all([
request(`/mock_exams/${slug}.json`).then((r) => r.json()),
request(`/mock_exams/${slug}/sets.json`).then((r) => r.json()).catch(() => ({ sets: [] })),
]).then(([tmpl, setsData]) => {
setTemplate(tmpl);
setSets(setsData.sets || []);
setLoading(false);
}).catch(() => {
setError('Failed to load exam details');
setLoading(false);
});
}, [slug]);
const handleStart = async (poolSet) => {
setStarting(true);
setError(null);
try {
const body = poolSet ? JSON.stringify({ pool_set: poolSet }) : undefined;
const headers = poolSet ? { 'Content-Type': 'application/json' } : {};
const res = await request(`/mock_exams/${slug}/attempts`, {
method: 'POST',
body,
headers,
});
if (res.ok) {
const data = await res.json();
window.location.href = data.redirect_to || `/mock_exams/${slug}/attempts/${data.id}`;
} else {
const errData = await res.json();
setError(errData.errors?.[0] || errData.error || 'Failed to start exam');
setStarting(false);
}
} catch {
setError('Network error. Please try again.');
setStarting(false);
}
};
if (loading) {
return (
<div class="crayons-card p-6 flex justify-center">
<div class="crayons-loading" aria-label="Loading..." />
</div>
);
}
if (!template) {
return (
<div class="crayons-card p-6">
<p class="color-danger">Exam not found.</p>
</div>
);
}
const t = template;
return (
<div>
<div class="crayons-card p-6 mb-4">
<h1 class="crayons-title mb-2">{t.title}</h1>
{t.description && <p class="color-secondary mb-4">{t.description}</p>}
<div class="grid gap-4 mb-6"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
<InfoItem label="Questions" value={t.total_questions} />
<InfoItem label="Duration" value={`${t.duration_minutes} minutes`} />
<InfoItem label="Marks/Correct" value={`+${t.marks_per_correct}`} />
<InfoItem label="Negative Marks" value={`-${t.negative_marks_per_wrong}`} />
<InfoItem label="Category" value={t.exam_category?.replace(/_/g, ' ')} />
<InfoItem label="Difficulty" value={t.difficulty_level} />
</div>
{t.sections_config?.length > 0 && (
<div class="mb-6">
<h3 class="crayons-subtitle-2 mb-2">Sections</h3>
<div class="flex flex-wrap gap-2">
{t.sections_config.map((s, i) => (
<span key={i} class="crayons-tag">
{s.name} ({s.count}q)
</span>
))}
</div>
</div>
)}
{t.has_calculator && (
<p class="fs-s mb-1">On-screen calculator available</p>
)}
{t.has_scratchpad && (
<p class="fs-s mb-1">Scratchpad available</p>
)}
{error && (
<div class="crayons-notice crayons-notice--danger mb-4">
{error}
</div>
)}
</div>
{/* Question Sets — select and attempt */}
{sets.length > 0 && (
<div class="crayons-card p-6 mb-4">
<h3 class="crayons-subtitle-2 mb-1">Choose a Question Set</h3>
<p class="color-secondary fs-s mb-4">Select a set below to start your exam.</p>
<div class="grid gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))' }}>
{sets.map((s) => {
const isSelected = selectedSet === s.set_number;
return (
<div
key={s.set_number}
role="button"
tabIndex={0}
aria-pressed={isSelected}
class="crayons-card crayons-card--secondary p-4"
style={{
cursor: t.can_attempt ? 'pointer' : 'default',
borderColor: isSelected
? 'var(--accent-brand)' : 'var(--card-border)',
borderWidth: isSelected ? '2px' : '1px',
borderStyle: 'solid',
background: isSelected
? 'var(--accent-brand-a10)' : 'var(--card-secondary-bg)',
transition: 'all 0.15s ease',
opacity: t.can_attempt ? 1 : 0.6,
}}
onClick={() =>
t.can_attempt &&
setSelectedSet(isSelected ? null : s.set_number)
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
t.can_attempt &&
setSelectedSet(isSelected ? null : s.set_number);
}
}}
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span
style={{
width: '20px',
height: '20px',
borderRadius: '50%',
border: isSelected
? '2px solid var(--accent-brand)'
: '2px solid var(--card-border)',
background: isSelected
? 'var(--accent-brand)' : 'transparent',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
color: '#fff',
fontSize: '12px',
fontWeight: 'bold',
}}
>
{isSelected ? '✓' : ''}
</span>
<span class="fw-bold">Set {s.set_number}</span>
</div>
<span class="fs-s color-secondary">
{s.question_count} questions
</span>
</div>
<div class="flex items-center justify-between"
style={{ paddingLeft: '28px' }}>
<span class="fs-s color-secondary">
{s.attempts_count} total attempts
</span>
{s.user_attempted && (
<span class="crayons-tag crayons-tag--monochrome fs-xs">
You attempted
</span>
)}
</div>
</div>
);
})}
</div>
<div class="flex items-center gap-3 mt-4">
<button
class="c-btn c-btn--primary"
onClick={() => handleStart(selectedSet)}
disabled={starting || !t.can_attempt || !selectedSet}
>
{starting
? 'Starting...'
: selectedSet
? `Start Set ${selectedSet}`
: 'Select a set to begin'}
</button>
{sets.length > 1 && t.can_attempt && (
<button
class="c-btn c-btn--secondary"
onClick={() => handleStart(null)}
disabled={starting}
>
{starting ? 'Starting...' : 'Random Set'}
</button>
)}
</div>
</div>
)}
{/* Fallback when no sets are published yet */}
{sets.length === 0 && t.can_attempt && (
<div class="crayons-card p-6 mb-4">
<p class="color-secondary fs-s mb-3">
No question sets are published yet. Check back soon.
</p>
</div>
)}
{t.stats && (
<StatsCard stats={t.stats}
maxScore={t.total_questions * t.marks_per_correct} />
)}
{/* Leaderboard */}
<div class="mt-4">
<ExamLeaderboard slug={slug} sets={sets} />
</div>
</div>
);
}
function InfoItem({ label, value }) {
return (
<div class="p-3 radius-default"
style={{ background: 'var(--card-secondary-bg)' }}>
<div class="fs-xs color-secondary uppercase">{label}</div>
<div class="fw-bold fs-l">{value}</div>
</div>
);
}
function StatsCard({ stats, maxScore }) {
return (
<div class="crayons-card p-6">
<h3 class="crayons-subtitle-2 mb-4">Community Statistics</h3>
<div class="grid gap-4"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}>
<InfoItem label="Total Attempts" value={stats.total_attempts} />
<InfoItem label="Unique Users" value={stats.unique_users} />
<InfoItem label="Avg Score"
value={`${stats.average_score} / ${maxScore}`} />
<InfoItem label="Highest Score" value={stats.highest_score} />
<InfoItem label="Avg Accuracy"
value={`${stats.average_accuracy}%`} />
<InfoItem label="Completion Rate"
value={`${stats.completion_rate}%`} />
</div>
</div>
);
}
Key UX details:
- Radio-style circle indicators on each set card (filled blue with ✓ when selected)
- Cards show question count, total attempts, and "You attempted" badge
- Primary "Start Set X" button only activates after selection
- Secondary "Random Set" button picks from all published sets
- Keyboard accessible (Enter / Space to select)
15. Frontend: ExamLeaderboard with Per-Set Filtering
File: app/javascript/mockExams/components/ExamLeaderboard.jsx
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { request } from '@utilities/http';
const TIME_FILTERS = [
{ key: 'all', label: 'All Time' },
{ key: 'month', label: 'This Month' },
{ key: 'week', label: 'This Week' },
];
export function ExamLeaderboard({ slug, currentUserId, currentAttemptId, sets }) {
const [entries, setEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [timeFilter, setTimeFilter] = useState('all');
const [setFilter, setSetFilter] = useState('');
useEffect(() => {
setLoading(true);
let url = `/mock_exams/${slug}/leaderboard.json?filter=${timeFilter}`;
if (setFilter) url += `&set=${setFilter}`;
request(url)
.then((res) => res.json())
.then((data) => {
setEntries(data.entries || []);
setLoading(false);
})
.catch(() => setLoading(false));
}, [slug, timeFilter, setFilter]);
if (loading) {
return (
<div class="crayons-card p-6 flex justify-center">
<div class="crayons-loading" aria-label="Loading leaderboard..." />
</div>
);
}
const availableSets = sets || [];
return (
<div class="crayons-card p-6">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h3 class="crayons-subtitle-2">Leaderboard</h3>
<div class="flex gap-2 flex-wrap">
{availableSets.length > 0 && (
<select
class="crayons-select fs-s"
value={setFilter}
onChange={(e) => setSetFilter(e.target.value)}
style={{ minWidth: '100px' }}
>
<option value="">All Sets</option>
{availableSets.map((s) => (
<option key={s.set_number} value={s.set_number}>
Set {s.set_number}
</option>
))}
</select>
)}
<div class="flex gap-1">
{TIME_FILTERS.map((f) => (
<button
key={f.key}
class={`c-btn c-btn--s ${
timeFilter === f.key ? 'c-btn--primary' : 'c-btn--secondary'
}`}
onClick={() => setTimeFilter(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
</div>
{entries.length === 0 ? (
<p class="color-secondary text-center">
No attempts yet for this period.
</p>
) : (
<table class="crayons-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: '50px' }}>#</th>
<th>Name</th>
<th>Set</th>
<th>Score</th>
<th>Accuracy</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{entries.map((entry, i) => {
const isCurrentUser = entry.user_id === currentUserId;
return (
<tr
key={entry.attempt_id}
style={{
background: isCurrentUser
? 'var(--accent-brand-a10)' : 'transparent',
fontWeight: isCurrentUser ? 'bold' : 'normal',
}}
>
<td><RankBadge rank={i + 1} /></td>
<td>
<div class="flex items-center gap-2">
{entry.profile_image && (
<img
src={entry.profile_image}
alt=""
style={{
width: '24px', height: '24px', borderRadius: '50%',
}}
loading="lazy"
/>
)}
<span>
{entry.name || entry.username}
{isCurrentUser && (
<span class="fs-xs color-secondary"> (you)</span>
)}
</span>
</div>
</td>
<td class="fs-s">
{entry.pool_set ? `Set ${entry.pool_set}` : 'Random'}
</td>
<td>{entry.total_score} / {entry.max_possible_score}</td>
<td>{entry.accuracy_percent}%</td>
<td>{formatTime(entry.time_taken_seconds)}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
);
}
function RankBadge({ rank }) {
const medals = { 1: '🥇', 2: '🥈', 3: '🥉' };
if (medals[rank]) {
return <span style={{ fontSize: '1.2rem' }}>{medals[rank]}</span>;
}
return <span class="color-secondary">{rank}</span>;
}
function formatTime(seconds) {
if (!seconds) return '—';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
Key features:
- Dropdown filter to view leaderboard for a specific set or "All Sets"
- Time filters: All Time, This Month, This Week
- Shows user's display name (falls back to username) + profile image
- Medal emojis for top 3
- Highlights current user's row
16. Full-Screen Exam Interface (JS side)
File: app/javascript/mockExams/MockExamInterface.jsx (lines 205-216)
The useEffect hook that requests full-screen on mount (must come before any early returns to comply with React hooks rules):
// Request full-screen on mount
useEffect(() => {
const el = document.documentElement;
if (el.requestFullscreen && !document.fullscreenElement) {
el.requestFullscreen().catch(() => {});
}
return () => {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
};
}, []);
Combined with the CSS in the ERB view (Section 13), this creates a fully immersive exam experience with no Forem UI chrome visible.
17. Dead Code Cleanup
The following were removed as part of this change:
| What | Where | Why |
|---|---|---|
GenerateQuestionsWorker file |
app/workers/mock_exams/generate_questions_worker.rb |
On-demand generation replaced by admin-publish workflow |
generate_for_attempt method |
app/services/ai/mock_exam_question_generator.rb |
Only called by deleted worker |
questions_source enum |
app/models/mock_exam_attempt.rb |
All questions come from published pool copies |
pool_question? method |
app/models/mock_exam_question.rb |
Unused; pool scope is the standard check |
POOL_EXHAUSTION_THRESHOLD |
app/services/mock_exams/assemble_exam_service.rb |
Unreferenced after removing fallback |
GenerateQuestionsWorker fallback |
app/controllers/mock_exam_attempts_controller.rb |
Returns error instead of silently generating |
MAX_DAILY_ATTEMPTS_PER_TEMPLATE |
app/models/mock_exam_attempt.rb |
No daily limit when sets are published |
daily_attempt_limit validation |
app/models/mock_exam_attempt.rb |
Same as above |
user_attempts_today_count |
app/controllers/mock_exams_controller.rb |
No longer needed |
questions_source in JSON |
app/controllers/mock_exam_attempts_controller.rb |
Removed enum |
questions_source in admin view |
app/views/admin/mock_exam_templates/show.html.erb |
Replaced with pool_set display |
:generated trait in factory |
spec/factories/mock_exam_attempts.rb |
Referenced removed enum |
pool_question? specs |
spec/models/mock_exam_question_spec.rb |
Method removed |
generate_for_attempt specs |
spec/services/ai/mock_exam_question_generator_spec.rb |
Method removed |
18. Factory Updates
File: spec/factories/mock_exam_attempts.rb
FactoryBot.define do
factory :mock_exam_attempt do
mock_exam_template
user
status { :in_progress }
started_at { Time.current }
expires_at { Time.current + 30.minutes }
trait :submitted do
status { :submitted }
submitted_at { Time.current }
total_score { 14.0 }
max_possible_score { 20.0 }
correct_count { 8 }
incorrect_count { 1 }
unanswered_count { 1 }
accuracy_percent { 80.0 }
percentile { 65.0 }
rank { 5 }
end
trait :timed_out do
status { :timed_out }
submitted_at { Time.current }
end
trait :abandoned do
status { :abandoned }
end
end
end
Removed: questions_source { :pool } default and :generated trait.
Summary
This implementation gives you:
-
AI generates question sets → pool questions grouped by
pool_set - Admin reviews & publishes each set via review/publish/unpublish actions
- Users pick a set (or random) from the detail page with a visual radio-select UI
- Questions are copied into each attempt, preserving pool integrity
- Leaderboard filterable by set and time period, showing user names
- Full-screen exam experience with all Forem chrome hidden
- No daily limit — users can attempt any published set freely
-
Width-constrained pages using
crayons-layout--limited-l
All changes follow Forem's existing patterns and use the Crayons design system.
Top comments (0)