DEV Community

Murari Kumar
Murari Kumar

Posted on

part 3

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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`;
}
Enter fullscreen mode Exit fullscreen mode

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(() => {});
    }
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Removed: questions_source { :pool } default and :generated trait.


Summary

This implementation gives you:

  1. AI generates question sets → pool questions grouped by pool_set
  2. Admin reviews & publishes each set via review/publish/unpublish actions
  3. Users pick a set (or random) from the detail page with a visual radio-select UI
  4. Questions are copied into each attempt, preserving pool integrity
  5. Leaderboard filterable by set and time period, showing user names
  6. Full-screen exam experience with all Forem chrome hidden
  7. No daily limit — users can attempt any published set freely
  8. Width-constrained pages using crayons-layout--limited-l

All changes follow Forem's existing patterns and use the Crayons design system.

Top comments (0)