feat: Implement front-end assessment interaction AJAX flow

- Add AssessmentAjaxHandler class for AJAX requests.
- Add assessment.js for front-end logic.
- Implement pre-screening form submission via AJAX.
- Implement answer auto-save via AJAX.
- Implement final assessment submission via AJAX.
- Update assessment-shell.php template for dynamic rendering and JS hooks.
- Enqueue and localize assessment.js conditionally.

Refs: assessment_interaction_plan.md
Note: Includes TODOs for evaluation CPT handling, status updates, and sanitization.
This commit is contained in:
Ruben Ramirez 2025-04-03 16:02:16 -05:00
parent d63aa8d409
commit 130b9eefb9
5 changed files with 394 additions and 9 deletions

163
public/js/assessment.js Normal file
View file

@ -0,0 +1,163 @@
/**
* Quiztech Assessment Interaction Script
*
* Handles AJAX submissions for pre-screening, answer auto-save,
* and final assessment submission.
*/
(function($) {
'use strict';
$(function() {
console.log('Assessment script loaded.');
// Check if localized data is available
if (typeof quiztech_assessment_vars === 'undefined') {
console.error('Quiztech Assessment Error: Localized variables not found.');
return;
}
var $prescreeningForm = $('#quiztech-prescreening-form');
var $prescreeningSection = $('#quiztech-prescreening-section');
var $assessmentSection = $('#quiztech-assessment-section');
var $submitButton = $prescreeningForm.find('button[type="submit"]');
var $formMessages = $('<div class="form-messages"></div>').insertBefore($submitButton); // Area for messages
// --- Pre-Screening Form Handling ---
$prescreeningForm.on('submit', function(event) {
event.preventDefault(); // Stop traditional form submission
$formMessages.empty().removeClass('error success'); // Clear previous messages
$submitButton.prop('disabled', true).text('Submitting...'); // Disable button
var formData = $(this).serialize(); // Get form data
// Add required AJAX parameters
formData += '&action=quiztech_submit_prescreening';
formData += '&nonce=' + quiztech_assessment_vars.prescreening_nonce;
formData += '&invitation_id=' + quiztech_assessment_vars.invitation_id;
$.ajax({
type: 'POST',
url: quiztech_assessment_vars.ajax_url,
data: formData,
dataType: 'json', // Expect JSON response from server
success: function(response) {
if (response.success) {
// Success! Hide pre-screening, show assessment
$formMessages.addClass('success').text(response.data.message || 'Success!'); // Show success message briefly
$prescreeningSection.slideUp();
$assessmentSection.slideDown();
// No need to re-enable button as the form is gone
} else {
// Handle WP JSON error
$formMessages.addClass('error').text(response.data.message || 'An error occurred.');
$submitButton.prop('disabled', false).text('Submit Pre-Screening & Start Assessment'); // Re-enable button
}
},
error: function(jqXHR, textStatus, errorThrown) {
// Handle general AJAX error
console.error("AJAX Error:", textStatus, errorThrown);
$formMessages.addClass('error').text('A network error occurred. Please try again.');
$submitButton.prop('disabled', false).text('Submit Pre-Screening & Start Assessment'); // Re-enable button
}
});
});
// --- Assessment Answer Auto-Save ---
var $assessmentForm = $('#quiztech-assessment-form');
var autoSaveTimeout; // To debounce requests
// Target input/textarea/select elements within the assessment form for auto-save
$assessmentForm.on('change blur', 'input, textarea, select', function() {
clearTimeout(autoSaveTimeout); // Clear previous timeout if exists
var $input = $(this);
var $questionGroup = $input.closest('.question-group');
var questionId = $questionGroup.data('question-id');
var answer = $input.val();
// Add a small visual indicator within the question group
var $indicator = $questionGroup.find('.save-indicator');
if ($indicator.length === 0) {
$indicator = $('<span class="save-indicator" style="margin-left: 10px; font-size: 0.8em; color: grey;"></span>').appendTo($questionGroup.find('label:first'));
}
$indicator.text('Saving...');
// Debounce the AJAX request slightly
autoSaveTimeout = setTimeout(function() {
$.ajax({
type: 'POST',
url: quiztech_assessment_vars.ajax_url,
data: {
action: 'quiztech_save_answer',
nonce: quiztech_assessment_vars.assessment_nonce, // Use the correct nonce
invitation_id: quiztech_assessment_vars.invitation_id,
question_id: questionId,
answer: answer
},
dataType: 'json',
success: function(response) {
if (response.success) {
$indicator.text('Saved ✓').css('color', 'green');
// Optionally fade out the indicator after a delay
setTimeout(function() { $indicator.fadeOut().remove(); }, 2000);
} else {
$indicator.text('Error!').css('color', 'red');
console.error("Auto-save error:", response.data.message);
// Consider more prominent error display if needed
}
},
error: function(jqXHR, textStatus, errorThrown) {
$indicator.text('Network Error!').css('color', 'red');
console.error("AJAX Error:", textStatus, errorThrown);
}
});
}, 500); // Wait 500ms after the last change/blur before sending
});
// --- Final Assessment Submission ---
var $submitAssessmentButton = $('#quiztech-submit-assessment');
var $assessmentFormMessages = $('<div class="form-messages"></div>').insertAfter($submitAssessmentButton); // Area for messages
$submitAssessmentButton.on('click', function(event) {
event.preventDefault(); // Stop traditional form submission (though AJAX auto-save handles data)
if (!confirm('Are you sure you want to submit your assessment?')) {
return; // User cancelled
}
$assessmentFormMessages.empty().removeClass('error success');
$submitAssessmentButton.prop('disabled', true).text('Submitting...');
$.ajax({
type: 'POST',
url: quiztech_assessment_vars.ajax_url,
data: {
action: 'quiztech_submit_assessment',
nonce: quiztech_assessment_vars.assessment_nonce, // Reuse assessment nonce
invitation_id: quiztech_assessment_vars.invitation_id
},
dataType: 'json',
success: function(response) {
if (response.success) {
// Success! Display message and potentially hide the form/button
$assessmentFormMessages.addClass('success').text(response.data.message || 'Assessment Submitted Successfully!');
$assessmentForm.hide(); // Hide the form after successful submission
// Optionally redirect: window.location.href = response.data.redirect_url;
} else {
$assessmentFormMessages.addClass('error').text(response.data.message || 'An error occurred during submission.');
$submitAssessmentButton.prop('disabled', false).text('Submit Assessment'); // Re-enable button
}
},
error: function(jqXHR, textStatus, errorThrown) {
console.error("AJAX Error:", textStatus, errorThrown);
$assessmentFormMessages.addClass('error').text('A network error occurred. Please try again.');
$submitAssessmentButton.prop('disabled', false).text('Submit Assessment'); // Re-enable button
}
});
});
}); // End document ready
})(jQuery);

View file

@ -56,15 +56,17 @@ if ( ! $invitation_data || ! $current_step ) {
<h1><?php esc_html_e( 'Assessment Invitation', 'quiztech' ); ?></h1>
<?php if ( 'pre_screening' === $current_step ) : ?>
<div id="quiztech-prescreening-section"> <?php // Added ID for JS targeting ?>
<div class="step-info">
<h2><?php esc_html_e( 'Step 1: Pre-Screening Questions', 'quiztech' ); ?></h2>
<p><?php esc_html_e( 'Please answer the following questions before starting the assessment.', 'quiztech' ); ?></p>
</div>
<form method="post" action=""> <?php // TODO: Add action URL and nonce for processing submission ?>
<?php wp_nonce_field( 'quiztech_submit_prescreening_' . $invitation_data->token, 'quiztech_prescreening_nonce' ); ?>
<input type="hidden" name="quiztech_invitation_token" value="<?php echo esc_attr( $invitation_data->token ); ?>">
<form id="quiztech-prescreening-form" method="post" action=""> <?php // Action handled by AJAX ?>
<?php // Nonce is checked via AJAX, but good to have in form too for non-JS fallback (if implemented) ?>
<?php // wp_nonce_field( 'quiztech_submit_prescreening_' . $invitation_data->token, 'quiztech_prescreening_nonce' ); ?>
<?php // Token is passed via localized script vars ?>
<?php // <input type="hidden" name="quiztech_invitation_token" value="<?php echo esc_attr( $invitation_data->token ); ?>"> ?>
<input type="hidden" name="action" value="quiztech_submit_prescreening">
<?php if ( is_array( $pre_screening_questions ) && ! empty( $pre_screening_questions ) ) : ?>
@ -90,9 +92,10 @@ if ( ! $invitation_data || ! $current_step ) {
<?php // Render questions here - Removed, handled above ?>
<button type="submit"><?php esc_html_e( 'Submit Pre-Screening & Start Assessment', 'quiztech' ); ?></button>
</form>
</div> <!-- #quiztech-prescreening-section -->
<?php elseif ( 'assessment' === $current_step ) : ?>
<div id="quiztech-assessment-section" style="display: none;"> <?php // Added ID and initially hidden ?>
<div class="step-info">
<h2><?php esc_html_e( 'Step 2: Assessment', 'quiztech' ); ?></h2>
<p><?php printf( esc_html__( 'You are about to begin Assessment ID: %d', 'quiztech' ), absint( $invitation_data->assessment_id ) ); ?></p>
@ -132,10 +135,25 @@ if ( ! $invitation_data || ! $current_step ) {
<?php break; ?>
<?php case 'multiple-choice': ?>
<?php // TODO: Fetch actual choices from post meta ?>
<label><input type="radio" name="assessment_answer[<?php echo esc_attr( $question_id ); ?>]" value="opt1" required> Option 1</label><br>
<label><input type="radio" name="assessment_answer[<?php echo esc_attr( $question_id ); ?>]" value="opt2"> Option 2</label><br>
<label><input type="radio" name="assessment_answer[<?php echo esc_attr( $question_id ); ?>]" value="opt3"> Option 3</label>
<?php
$choices = get_post_meta( $question_id, 'question_choices', true );
if ( is_array( $choices ) && ! empty( $choices ) ) :
foreach ( $choices as $choice_index => $choice_text ) :
$choice_value = esc_attr( $choice_text ); // Use text as value for simplicity
$choice_id = 'choice_' . esc_attr( $question_id ) . '_' . esc_attr( $choice_index );
?>
<label for="<?php echo $choice_id; ?>">
<input type="radio"
id="<?php echo $choice_id; ?>"
name="assessment_answer[<?php echo esc_attr( $question_id ); ?>]"
value="<?php echo $choice_value; ?>"
required>
<?php echo esc_html( $choice_text ); ?>
</label><br>
<?php endforeach; ?>
<?php else : ?>
<p class="error"><?php esc_html_e( 'Error: Choices not found for this question.', 'quiztech' ); ?></p>
<?php endif; ?>
<?php break; ?>
<?php case 'true-false': ?>
@ -160,6 +178,7 @@ if ( ! $invitation_data || ! $current_step ) {
endif;
?>
<?php // Removed the simple "Start Assessment" button, replaced by question rendering logic ?>
</div> <!-- #quiztech-assessment-section -->
<?php else : ?>
<p class="error"><?php esc_html_e( 'An unexpected error occurred. Invalid assessment step.', 'quiztech' ); ?></p>

View file

@ -116,6 +116,10 @@ function quiztech_init() {
$frontend_handler = new \Quiztech\AssessmentPlatform\Includes\FrontendHandler();
$frontend_handler->init_hooks();
// Initialize AJAX handler for assessment interactions
\Quiztech\AssessmentPlatform\Includes\Ajax\AssessmentAjaxHandler::init();
// TODO: Instantiate other core classes and call their init methods here
// e.g., Admin menu handler, AJAX handlers, Shortcode handlers etc.
}

View file

@ -0,0 +1,177 @@
<?php
namespace Quiztech\\AssessmentPlatform\\Includes\\Ajax;
/**
* Handles AJAX requests related to the front-end assessment process.
*/
class AssessmentAjaxHandler {
/**
* Constructor. Registers AJAX hooks.
*/
public function __construct() {
add_action('wp_ajax_quiztech_submit_prescreening', [$this, 'handle_submit_prescreening']);
add_action('wp_ajax_quiztech_save_answer', [$this, 'handle_save_answer']);
add_action('wp_ajax_quiztech_submit_assessment', [$this, 'handle_submit_assessment']);
}
/**
* Initialize the handler.
* Static method to instantiate the class and register hooks.
*/
public static function init() {
new self();
}
// AJAX handler methods will be added below:
// - handle_submit_prescreening()
// - handle_save_answer()
// - handle_submit_assessment()
/**
* Handles the AJAX submission of the pre-screening form.
* Expects 'nonce', 'invitation_id', and 'pre_screen_answer' array in $_POST.
*/
public function handle_submit_prescreening() {
// 1. Verify Nonce
check_ajax_referer('quiztech_prescreening_nonce', 'nonce');
// 2. Get and Sanitize Core Data
$invitation_id = isset($_POST['invitation_id']) ? absint($_POST['invitation_id']) : 0;
if ( ! $invitation_id ) {
wp_send_json_error(['message' => __('Missing invitation ID.', 'quiztech')], 400);
}
// --- TODO: Ensure user_evaluation CPT is created earlier (e.g., in FrontendHandler) ---
// For now, query for it based on invitation ID (assuming meta field 'quiztech_invitation_id' exists on evaluation)
$args = [
'post_type' => 'user_evaluation',
'post_status' => 'any', // Find it regardless of status
'meta_query' => [
[
'key' => 'quiztech_invitation_id', // Assumed meta key linking evaluation to invitation
'value' => $invitation_id,
'compare' => '=',
]
],
'posts_per_page' => 1,
'fields' => 'ids', // Only get the ID
];
$evaluation_posts = get_posts($args);
$evaluation_id = !empty($evaluation_posts) ? $evaluation_posts[0] : 0;
if ( ! $evaluation_id ) {
// If not found, something is wrong with the flow (should have been created earlier)
error_log("Quiztech AJAX Error: Could not find user_evaluation CPT for invitation ID: " . $invitation_id);
wp_send_json_error(['message' => __('Could not find associated evaluation record.', 'quiztech')], 404);
}
// --- End TODO section ---
// 3. Sanitize Submitted Answers
$submitted_answers = isset($_POST['pre_screen_answer']) && is_array($_POST['pre_screen_answer']) ? $_POST['pre_screen_answer'] : [];
$sanitized_answers = [];
foreach ($submitted_answers as $index => $answer) {
// Use sanitize_textarea_field as pre-screening questions are currently textareas
$sanitized_answers[sanitize_key($index)] = sanitize_textarea_field(wp_unslash($answer));
}
// 4. Save Answers (as user_evaluation CPT meta)
if (!empty($sanitized_answers)) {
update_post_meta($evaluation_id, 'quiztech_prescreening_answers', $sanitized_answers);
} else {
// Handle case where no answers were submitted? Or rely on form 'required' attribute?
// For now, proceed even if empty.
}
// 5. Update Invitation Status
try {
$invitations = new \Quiztech\AssessmentPlatform\Includes\Invitations();
// TODO: Create the update_status method in Invitations class
$updated = $invitations->update_status($invitation_id, 'pre-screening-complete');
if (!$updated) {
error_log("Quiztech AJAX Error: Failed to update invitation status for ID: " . $invitation_id);
// Decide if this should be a user-facing error or just logged
}
} catch (\Exception $e) {
error_log("Quiztech AJAX Error: Exception updating invitation status: " . $e->getMessage());
// Decide if this should be a user-facing error
}
// 6. Send Response
wp_send_json_success(['message' => __('Pre-screening submitted successfully. Starting assessment...', 'quiztech')]);
// Ensure script execution stops
wp_die();
}
/**
* Handles the AJAX auto-save of a single assessment answer.
* Expects 'nonce', 'invitation_id', 'question_id', and 'answer' in $_POST.
*/
public function handle_save_answer() {
// 1. Verify Nonce
check_ajax_referer('quiztech_assessment_nonce', 'nonce');
// 2. Get and Sanitize Data
$invitation_id = isset($_POST['invitation_id']) ? absint($_POST['invitation_id']) : 0;
$question_id = isset($_POST['question_id']) ? absint($_POST['question_id']) : 0;
$answer = isset($_POST['answer']) ? wp_unslash($_POST['answer']) : ''; // Sanitize based on question type later
// TODO: Get the associated user_evaluation CPT ID based on invitation_id (similar to pre-screening handler)
$evaluation_id = 0; // Placeholder
if ( ! $invitation_id || ! $question_id || ! $evaluation_id ) {
wp_send_json_error(['message' => __('Invalid request data for saving answer.', 'quiztech')], 400);
}
// TODO: Fetch question type for $question_id to apply appropriate sanitization to $answer
// Example: if type is 'text', use sanitize_textarea_field; if 'multiple-choice', check against allowed values.
$sanitized_answer = sanitize_text_field($answer); // Basic sanitization for now
// 3. Save Answer (as user_evaluation CPT meta)
// Use a meta key structure like 'quiztech_answer_{question_id}' or store in a single array meta field.
// Using individual meta keys might be simpler for querying later if needed.
$meta_key = 'quiztech_answer_' . $question_id;
update_post_meta($evaluation_id, $meta_key, $sanitized_answer);
// 4. Send Response
wp_send_json_success(['message' => __('Answer saved.', 'quiztech')]);
// Ensure script execution stops
wp_die();
}
/**
* Handles the final AJAX submission of the assessment.
* Expects 'nonce' and 'invitation_id' in $_POST.
*/
public function handle_submit_assessment() {
// 1. Verify Nonce
check_ajax_referer('quiztech_assessment_nonce', 'nonce'); // Reuse assessment nonce
// 2. Get Data
$invitation_id = isset($_POST['invitation_id']) ? absint($_POST['invitation_id']) : 0;
// TODO: Get the associated user_evaluation CPT ID based on invitation_id (similar to other handlers)
$evaluation_id = 0; // Placeholder
if ( ! $invitation_id || ! $evaluation_id ) {
wp_send_json_error(['message' => __('Invalid request data for final submission.', 'quiztech')], 400);
}
// 3. Update Invitation Status
// TODO: Call Invitations->update_status($invitation_id, 'assessment-complete');
// 4. Update User Evaluation CPT Status
// TODO: Update post status of $evaluation_id to 'completed' using wp_update_post()
// 5. Send Response
// TODO: Consider adding a redirect URL or specific completion message to the response data
wp_send_json_success(['message' => __('Assessment submitted successfully!', 'quiztech')]);
// Ensure script execution stops
wp_die();
}
}

View file

@ -65,6 +65,28 @@ class FrontendHandler {
// This template should be located within the plugin or theme.
$template_path = QUIZTECH_PLUGIN_DIR . 'public/templates/assessment-shell.php'; // Example path
// Enqueue and localize the assessment script
wp_enqueue_script(
'quiztech-assessment-script',
QUIZTECH_PLUGIN_URL . 'public/js/assessment.js',
['jquery'],
QUIZTECH_VERSION,
true // Load in footer
);
wp_localize_script(
'quiztech-assessment-script',
'quiztech_assessment_vars',
[
'ajax_url' => admin_url('admin-ajax.php'),
'prescreening_nonce' => wp_create_nonce('quiztech_prescreening_nonce'),
'assessment_nonce' => wp_create_nonce('quiztech_assessment_nonce'),
'invitation_id' => $invitation_data->id, // Pass invitation ID for context
// Add other necessary vars here, e.g., evaluation ID if available
]
);
if ( file_exists( $template_path ) ) {
include( $template_path );
exit; // Important: Stop WordPress from loading the default theme template