DEV Community

Murari Kumar
Murari Kumar

Posted on

part 2

9. MockExamsController

File: app/controllers/mock_exams_controller.rb

show action — return can_attempt (lines 25-38)

def show
  @stats = @template.mock_exam_template_stat

  respond_to do |format|
    format.html
    format.json do
      render json: template_json(@template).merge(
        stats: @stats ? stats_json(@stats) : nil,
        pool_ready: @template.pool_ready?,
        can_attempt: can_attempt?,
        )
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

sets action — published sets with user attempt status (lines 45-59)

def sets
  sets_data = @template.published_sets.map do |set_number, count|
    attempts_for_set = @template.mock_exam_attempts.where(pool_set: set_number).count
    user_attempted = current_user ? current_user.mock_exam_attempts
                                                .for_template(@template)
                                                .where(pool_set: set_number).exists? : false
    {
      set_number: set_number,
      question_count: count,
      attempts_count: attempts_for_set,
      user_attempted: user_attempted,
    }
  end
  render json: { sets: sets_data }
end
Enter fullscreen mode Exit fullscreen mode

build_leaderboard_entries — per-set filtering, user names (lines 143-174)

def build_leaderboard_entries
  scope = @template.mock_exam_attempts.submitted_or_timed_out

  case params[:filter]
  when "week"
    scope = scope.where("submitted_at >= ?", 1.week.ago)
  when "month"
    scope = scope.where("submitted_at >= ?", 1.month.ago)
  end

  scope = scope.where(pool_set: params[:set]) if params[:set].present?

  scope
    .order(total_score: :desc, submitted_at: :asc)
    .limit(20)
    .includes(:user)
    .map do |attempt|
    {
      attempt_id: attempt.id,
      user_id: attempt.user_id,
      username: attempt.user.username,
      name: attempt.user.name.presence || attempt.user.username,
      profile_image: attempt.user.profile_image_90,
      total_score: attempt.total_score,
      max_possible_score: attempt.max_possible_score,
      accuracy_percent: attempt.accuracy_percent,
      pool_set: attempt.pool_set,
      time_taken_seconds: attempt.submitted_at && attempt.started_at ?
                            (attempt.submitted_at - attempt.started_at).to_i : nil,
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

can_attempt? — no daily limit (lines 222-224)

def can_attempt?
  current_user.present?
end
Enter fullscreen mode Exit fullscreen mode

10. MockExamAttemptsController: Set-Based Attempts

File: app/controllers/mock_exam_attempts_controller.rb

create action (lines 6-48)

def create
  authorize MockExamAttempt

  selected_set = params[:pool_set].present? ? params[:pool_set].to_i : nil
  service = MockExams::AssembleExamService.new(@template, current_user, pool_set: selected_set)
  questions = service.call

  unless questions
    msg = "No published questions available. Please try a different set or try later."
    respond_to do |format|
      format.html { redirect_to mock_exam_path(slug: @template.slug), alert: msg }
      format.json { render json: { errors: [msg] }, status: :unprocessable_entity }
    end
    return
  end

  @attempt = current_user.mock_exam_attempts.build(
    mock_exam_template: @template,
    started_at: Time.current,
    expires_at: Time.current + @template.duration_minutes.minutes,
    pool_set: selected_set,
    )
  authorize @attempt

  unless @attempt.save
    respond_to do |format|
      format.html { redirect_to mock_exam_path(slug: @template.slug), alert: @attempt.errors.full_messages.first }
      format.json { render json: { errors: @attempt.errors.full_messages }, status: :unprocessable_entity }
    end
    return
  end

  questions.each do |q|
    q.mock_exam_attempt = @attempt
    q.save!
    MockExamResponse.create!(mock_exam_attempt: @attempt, mock_exam_question: q)
  end

  respond_to do |format|
    format.html { redirect_to mock_exam_attempt_path(slug: @template.slug, id: @attempt.id) }
    format.json do
      render json: { id: @attempt.id,
                     redirect_to: "/mock_exams/#{@template.slug}/attempts/#{@attempt.id}" }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Line 9: Reads pool_set from params (sent by frontend).
Line 26: Stores pool_set on the attempt.


11. Admin Controller

File: app/controllers/admin/mock_exam_templates_controller.rb

review_set, publish_set, unpublish_set (lines 20-41)

def review_set
  @template = MockExamTemplate.find(params[:id])
  @set_number = params[:set].to_i
  @questions = @template.set_questions(@set_number)
  @is_published = @template.set_published?(@set_number)
end

def publish_set
  @template = MockExamTemplate.find(params[:id])
  set_number = params[:set].to_i
  @template.set_questions(set_number).update_all(set_published: true)
  redirect_to review_set_admin_mock_exam_template_path(@template, set: set_number),
              notice: "Set #{set_number} published successfully."
end

def unpublish_set
  @template = MockExamTemplate.find(params[:id])
  set_number = params[:set].to_i
  @template.set_questions(set_number).update_all(set_published: false)
  redirect_to review_set_admin_mock_exam_template_path(@template, set: set_number),
              notice: "Set #{set_number} unpublished."
end
Enter fullscreen mode Exit fullscreen mode

edit_question, update_question, destroy_question (lines 43-66)

def edit_question
  @template = MockExamTemplate.find(params[:id])
  @question = @template.pool_questions.find(params[:question_id])
end

def update_question
  @template = MockExamTemplate.find(params[:id])
  @question = @template.pool_questions.find(params[:question_id])
  if @question.update(question_params)
    redirect_to review_set_admin_mock_exam_template_path(@template, set: @question.pool_set),
                notice: "Question updated."
  else
    render :edit_question
  end
end

def destroy_question
  @template = MockExamTemplate.find(params[:id])
  @question = @template.pool_questions.find(params[:question_id])
  set_number = @question.pool_set
  @question.destroy!
  redirect_to review_set_admin_mock_exam_template_path(@template, set: set_number),
              notice: "Question deleted."
end
Enter fullscreen mode Exit fullscreen mode

refresh_pool — only destroy unpublished (lines 108-114)

def refresh_pool
  @template = MockExamTemplate.find(params[:id])
  @template.pool_questions.where(set_published: false).destroy_all
  MockExams::GeneratePoolWorker.perform_async(@template.id)
  redirect_to admin_mock_exam_template_path(@template),
              notice: "Unpublished questions cleared and new sets being generated. Published sets are preserved."
end
Enter fullscreen mode Exit fullscreen mode

question_params — handle array of hashes (lines 118-129)

def question_params
  permitted = params.require(:mock_exam_question).permit(
    :question_text, :correct_option_key, :explanation,
    :solution_steps, :difficulty, :section_name, :question_type,
    )
  if params[:mock_exam_question][:options].present?
    permitted[:options] = params[:mock_exam_question][:options].map do |opt|
      { "key" => opt[:key], "text" => opt[:text] }
    end
  end
  permitted
end
Enter fullscreen mode Exit fullscreen mode

12. Admin Views

Admin Show — app/views/admin/mock_exam_templates/show.html.erb

The "Question Sets" table (lines 69-100):

<% if @available_sets.any? %>
  <h3 class="crayons-subtitle-2 mt-4 mb-3">Question Sets</h3>
  <table class="crayons-table mb-4">
    <thead>
    <tr>
      <th>Set</th>
      <th>Questions</th>
      <th>Status</th>
      <th>Actions</th>
    </tr>
    </thead>
    <tbody>
    <% @available_sets.each do |set_number, count| %>
      <% published = @template.set_published?(set_number) %>
      <tr>
        <td class="fw-bold">Set <%= set_number %></td>
        <td><%= count %> questions</td>
        <td>
          <% if published %>
            <span class="c-indicator c-indicator--success">Published</span>
          <% else %>
            <span class="c-indicator c-indicator--warning">Draft</span>
          <% end %>
        </td>
        <td>
          <%= link_to "Review",
                      review_set_admin_mock_exam_template_path(@template, set: set_number),
                      class: "c-btn c-btn--secondary c-btn--s" %>
        </td>
      </tr>
    <% end %>
    </tbody>
  </table>
<% end %>
Enter fullscreen mode Exit fullscreen mode

The recent attempts table now shows Set instead of Source (lines 164-182):

<tr>
  <th>User</th>
  <th>Status</th>
  <th>Set</th>
  <th>Score</th>
  <th>Accuracy</th>
  <th>Started</th>
</tr>
...
<td><%= attempt.pool_set ? "Set #{attempt.pool_set}" : "Random" %></td>
Enter fullscreen mode Exit fullscreen mode

Admin Review Set — app/views/admin/mock_exam_templates/review_set.html.erb

Full file:

<div class="crayons-card p-6">
  <div class="flex items-center justify-between mb-4">
    <h1 class="crayons-title"><%= @template.title %> — Set <%= @set_number %></h1>
    <div class="flex gap-2">
      <% if @is_published %>
        <%= button_to "Unpublish Set",
                      unpublish_set_admin_mock_exam_template_path(@template, set: @set_number),
                      method: :post, class: "c-btn c-btn--destructive",
                      data: { confirm: "This will hide Set #{@set_number} from users. Continue?" } %>
      <% else %>
        <%= button_to "Publish Set",
                      publish_set_admin_mock_exam_template_path(@template, set: @set_number),
                      method: :post, class: "c-btn c-btn--primary",
                      data: { confirm: "This will make Set #{@set_number} available to users. Continue?" } %>
      <% end %>
      <%= link_to "Back", admin_mock_exam_template_path(@template), class: "c-btn c-btn--secondary" %>
    </div>
  </div>

  <div class="mb-3">
    <% if @is_published %>
      <span class="c-indicator c-indicator--success">Published</span>
    <% else %>
      <span class="c-indicator c-indicator--warning">Draft — not visible to users</span>
    <% end %>
    <span class="color-secondary ml-2"><%= @questions.count %> questions</span>
  </div>

  <% if @questions.any? %>
    <% @questions.each_with_index do |q, idx| %>
      <div class="crayons-card crayons-card--secondary p-4 mb-3">
        <div class="flex items-start justify-between mb-2">
          <div>
            <span class="fw-bold">Q<%= idx + 1 %>.</span>
            <span class="crayons-tag crayons-tag--monochrome fs-xs"><%= q.section_name %></span>
            <span class="crayons-tag crayons-tag--monochrome fs-xs"><%= q.difficulty %></span>
            <span class="crayons-tag crayons-tag--monochrome fs-xs"><%= q.question_type %></span>
          </div>
          <div class="flex gap-1">
            <%= link_to "Edit",
                        edit_question_admin_mock_exam_template_path(@template, question_id: q.id),
                        class: "c-btn c-btn--secondary c-btn--s" %>
            <%= button_to "Delete",
                          destroy_question_admin_mock_exam_template_path(@template, question_id: q.id),
                          method: :delete, class: "c-btn c-btn--destructive c-btn--s",
                          data: { confirm: "Delete this question permanently?" } %>
          </div>
        </div>

        <div class="mb-2">
          <% if q.question_html.present? %>
            <div class="spec__body"><%= q.question_html.html_safe %></div>
          <% else %>
            <p><%= q.question_text %></p>
          <% end %>
        </div>

        <div class="mb-2">
          <% q.options.each do |opt| %>
            <div class="p-2 mb-1 radius-default flex items-center gap-2"
                 style="background: <%= opt['key'] == q.correct_option_key ?
                        'var(--accent-success-a10)' : 'var(--card-secondary-bg)' %>;">
              <span class="fw-bold"
                    style="<%= opt['key'] == q.correct_option_key ? 'color: var(--accent-success)' : '' %>">
                <%= opt['key'] %>.
              </span>
              <span><%= opt['text'] %></span>
              <% if opt['key'] == q.correct_option_key %>
                <span class="fs-xs fw-bold" style="color: var(--accent-success);">✓ Correct</span>
              <% end %>
            </div>
          <% end %>
        </div>

        <% if q.explanation.present? %>
          <details class="mt-2">
            <summary class="fw-bold fs-s cursor-pointer">Explanation</summary>
            <div class="p-2 mt-1" style="background: var(--card-secondary-bg); border-radius: 6px;">
              <% if q.explanation_html.present? %>
                <div class="spec__body"><%= q.explanation_html.html_safe %></div>
              <% else %>
                <p><%= q.explanation %></p>
              <% end %>
            </div>
          </details>
        <% end %>
      </div>
    <% end %>
  <% else %>
    <div class="text-center color-secondary p-6">No questions in this set.</div>
  <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode

Admin Edit Question — app/views/admin/mock_exam_templates/edit_question.html.erb

<div class="crayons-card p-6">
  <div class="flex items-center justify-between mb-4">
    <h1 class="crayons-title">Edit Question</h1>
    <%= link_to "Back to Set",
                review_set_admin_mock_exam_template_path(@template, set: @question.pool_set),
                class: "c-btn c-btn--secondary" %>
  </div>

  <%= form_with model: @question,
                url: update_question_admin_mock_exam_template_path(@template, question_id: @question.id),
                method: :patch, local: true do |f| %>
    <% if @question.errors.any? %>
      <div class="crayons-notice crayons-notice--danger mb-4">
        <ul>
          <% @question.errors.full_messages.each do |msg| %>
            <li><%= msg %></li>
          <% end %>
        </ul>
      </div>
    <% end %>

    <div class="crayons-field mb-4">
      <%= f.label :section_name, class: "crayons-field__label" %>
      <%= f.text_field :section_name, class: "crayons-textfield" %>
    </div>

    <div class="grid grid-cols-2 gap-4 mb-4">
      <div class="crayons-field">
        <%= f.label :question_type, class: "crayons-field__label" %>
        <%= f.select :question_type,
                     MockExamQuestion.question_types.keys.map { |k| [k.titleize, k] },
                     {}, class: "crayons-select" %>
      </div>
      <div class="crayons-field">
        <%= f.label :difficulty, class: "crayons-field__label" %>
        <%= f.select :difficulty,
                     MockExamQuestion.difficulties.keys.map { |k| [k.titleize, k] },
                     {}, class: "crayons-select" %>
      </div>
    </div>

    <div class="crayons-field mb-4">
      <%= f.label :question_text, class: "crayons-field__label" %>
      <%= f.text_area :question_text, class: "crayons-textfield", rows: 5 %>
    </div>

    <div class="crayons-field mb-4">
      <label class="crayons-field__label">Options</label>
      <% %w[A B C D].each_with_index do |key, i| %>
        <div class="flex items-center gap-2 mb-2">
          <span class="fw-bold" style="min-width: 24px;"><%= key %>.</span>
          <input type="text" name="mock_exam_question[options][][key]"
                 value="<%= key %>" class="hidden">
          <input type="text" name="mock_exam_question[options][][text]"
                 value="<%= @question.options[i]&.dig('text') %>"
                 class="crayons-textfield" style="flex: 1;" />
        </div>
      <% end %>
    </div>

    <div class="crayons-field mb-4">
      <%= f.label :correct_option_key, "Correct Answer", class: "crayons-field__label" %>
      <%= f.select :correct_option_key, %w[A B C D], {}, class: "crayons-select" %>
    </div>

    <div class="crayons-field mb-4">
      <%= f.label :explanation, class: "crayons-field__label" %>
      <%= f.text_area :explanation, class: "crayons-textfield", rows: 4 %>
    </div>

    <div class="crayons-field mb-4">
      <%= f.label :solution_steps, class: "crayons-field__label" %>
      <%= f.text_area :solution_steps, class: "crayons-textfield", rows: 4 %>
    </div>

    <div class="flex gap-2">
      <%= f.submit "Save Changes", class: "c-btn c-btn--primary" %>
      <%= link_to "Cancel",
                  review_set_admin_mock_exam_template_path(@template, set: @question.pool_set),
                  class: "c-btn c-btn--secondary" %>
    </div>
  <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode

13. ERB Layouts: Width Constraints & Full-Screen

Width-constrained pages

All public mock exam pages use crayons-layout--limited-l + crayons-layout__content to avoid full-width.

app/views/mock_exams/show.html.erb:

<%= content_for :page_meta do %>
  <title><%= @template.title %> — Mock Exam — <%= community_name %></title>
  <meta name="description" content="<%= truncate(@template.description || '', length: 160) %>" />
<% end %>

<div class="crayons-layout crayons-layout--limited-l">
  <div class="crayons-layout__content">
    <div id="mock-exam-detail" data-props="<%= { slug: @template.slug }.to_json %>"></div>
  </div>
</div>

<%= javascript_include_tag "mockExams", defer: true %>
Enter fullscreen mode Exit fullscreen mode

Same pattern for index.html.erb, dashboard.html.erb, and results.html.erb.

Full-screen exam page

app/views/mock_exam_attempts/show.html.erb:

<%= content_for :page_meta do %>
  <title>Exam in Progress — <%= @template.title %><%= community_name %></title>
  <meta name="robots" content="noindex" />
  <style>
    .top-bar, .crayons-header, #page-content > footer, footer,
    .side-bar, .crayons-layout__sidebar-left, .stories-sidebar,
    .broadcast-wrapper, #runtime-banner-container { display: none !important; }
    #page-content { padding: 0 !important; margin: 0 !important; max-width: 100% !important; }
    #page-content-inner { padding: 0 !important; }
    body { overflow: hidden; }
  </style>
<% end %>

<div id="mock-exam-interface"
     data-props="<%= { slug: @template.slug, attemptId: @attempt.id }.to_json %>"></div>

<%= javascript_include_tag "mockExams", defer: true %>
Enter fullscreen mode Exit fullscreen mode

Hides all Forem chrome (header, footer, sidebar) and makes the exam take the full viewport.

Top comments (0)