diff --git a/assets/core/ts/components/form.ts b/assets/core/ts/components/form.ts index 987747aa2b..17ff826594 100644 --- a/assets/core/ts/components/form.ts +++ b/assets/core/ts/components/form.ts @@ -227,12 +227,12 @@ async function validateFieldValue(name: string, value: unknown, rules?: Validati } // Numeric validations - if (rules.min && !isNaN(numericValue)) { + if (typeof rules.min !== 'undefined' && !isNaN(numericValue)) { const error = ValidationHelpers.validateMin(numericValue, rules.min); if (error) return error; } - if (rules.max && !isNaN(numericValue)) { + if (typeof rules.max !== 'undefined' && !isNaN(numericValue)) { const error = ValidationHelpers.validateMax(numericValue, rules.max); if (error) return error; } diff --git a/assets/core/ts/utils/endpoints.ts b/assets/core/ts/utils/endpoints.ts index 78bfd475f4..43e7372525 100644 --- a/assets/core/ts/utils/endpoints.ts +++ b/assets/core/ts/utils/endpoints.ts @@ -67,6 +67,8 @@ const endpoints = { QUIZ_ATTEMPT_SUBMIT: 'tutor_answering_quiz_question', REVIEW_QUIZ_ANSWERS: 'tutor_review_quiz_answers', INSTRUCTOR_FEEDBACK: 'tutor_instructor_feedback', + SAVE_QUESTION_FEEDBACK: 'tutor_save_question_feedback', + DELETE_QUESTION_FEEDBACK: 'tutor_delete_question_feedback', // ZOOM GET_ZOOM_MEETING_DETAILS: 'tutor_zoom_meeting_details', diff --git a/assets/icons/partial.svg b/assets/icons/partial.svg new file mode 100644 index 0000000000..3cb58067b4 --- /dev/null +++ b/assets/icons/partial.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/src/js/admin-dashboard/segments/options.js b/assets/src/js/admin-dashboard/segments/options.js index e361832531..0e0c329586 100644 --- a/assets/src/js/admin-dashboard/segments/options.js +++ b/assets/src/js/admin-dashboard/segments/options.js @@ -713,4 +713,156 @@ document.addEventListener('DOMContentLoaded', function () { bankTransferInstruction.previousElementSibling?.classList.toggle('tutor-option-no-bottom-border', !e.target.checked); }); } + + /** + * Toggle turn-off confirmation modals. + * + * Intercepts the change event on tutor-form-toggle-input elements that are + * configured in the localized `tutorTurnoffConfirm` map (keyed by field key). + * When such a toggle is turned OFF, the handler reverts the toggle, shows a + * confirm modal, and only proceeds with the turn-off if the user confirms. + * + * The config can include a usage-check AJAX action to decide whether the + * modal needs to be shown at all. + * + * The map is localized by Tutor Pro; Free only provides this generic, + * configuration-driven mechanism. + * + * @since 4.1.0 + */ + const turnOffConfirmations = window.tutorTurnoffConfirm || {}; + Object.entries(turnOffConfirmations).forEach(([fieldKey, config]) => { + document.querySelectorAll(`#field_${fieldKey} .tutor-form-toggle-input`).forEach((checkbox) => { + checkbox.addEventListener('change', function (e) { + if (this.checked) { + return; + } + + const message = config.message; + const title = config.title; + const cancelText = config.cancel; + const confirmText = config.confirm; + const usageAjaxAction = config.usage_check_action; + if (!message) { + return; + } + + const hiddenInput = this.previousElementSibling; + const syncToggleVisibility = () => { + const $toggle = $(this); + if ($toggle.data('toggle-fields')) { + showHideToggleChildren($toggle); + } + if ($toggle.data('toggle-blocks')) { + showHideToggleBlock($toggle); + } + }; + + const revertToggle = () => { + this.checked = true; + if (hiddenInput) { + hiddenInput.value = 'on'; + } + syncToggleVisibility(); + }; + + const proceedWithTurnoff = () => { + this.checked = false; + if (hiddenInput) { + hiddenInput.value = 'off'; + } + syncToggleVisibility(); + }; + + if (!usageAjaxAction) { + revertToggle(); + tutorConfirmTurnoffModal(message, title, cancelText, confirmText).then((confirmed) => { + if (confirmed) { + proceedWithTurnoff(); + } + }); + return; + } + + const formData = new FormData(); + formData.append('action', usageAjaxAction); + formData.append(_tutorobject.nonce_key, _tutorobject._tutor_nonce); + + fetch(_tutorobject.ajaxurl, { method: 'POST', body: formData }) + .then((response) => response.json()) + .then((result) => { + const hasCustomized = result?.data?.has_customized; + if (hasCustomized) { + revertToggle(); + tutorConfirmTurnoffModal(message, title, cancelText, confirmText).then((confirmed) => { + if (confirmed) { + proceedWithTurnoff(); + } + }); + } else { + proceedWithTurnoff(); + } + }) + .catch(() => { + revertToggle(); + }); + }); + }); + }); }); + +/** + * Show a confirmation modal for toggle turn-off. + * + * @since 4.1.0 + * + * @param {string} message The confirmation message. + * @param {string} [title] Optional modal title. + * @param {string} [cancelText] Optional cancel button label. + * @param {string} [confirmText] Optional confirm button label. + * @return {Promise} Resolves true if confirmed, false if cancelled. + */ +function tutorConfirmTurnoffModal(message, title, cancelText, confirmText) { + const { __ } = wp.i18n; + + return new Promise((resolve) => { + let popup; + let resolved = false; + + const finish = (confirmed) => { + if (resolved) { + return; + } + resolved = true; + resolve(confirmed); + popup.find('[data-tutor-modal-close]').click(); + }; + + popup = new window.tutor_popup(window.jQuery, '').popup({ + title: title || __('Turn off setting?', 'tutor'), + description: message, + buttons: { + cancel: { + title: cancelText || __('No, keep it', 'tutor'), + id: 'cancel', + class: 'tutor-btn tutor-btn-outline-primary', + callback: function () { + finish(false); + }, + }, + confirm: { + title: confirmText || __('Yes, turn off', 'tutor'), + id: 'confirm', + class: 'tutor-btn tutor-btn-primary tutor-ml-20', + callback: function () { + finish(true); + }, + }, + }, + }); + + popup.on('click', '[data-tutor-modal-close], .tutor-modal-overlay', function () { + finish(false); + }); + }); +} diff --git a/assets/src/js/frontend/dashboard/pages/quiz-attempt-feedback.ts b/assets/src/js/frontend/dashboard/pages/quiz-attempt-feedback.ts index 752ad556a2..68c9852446 100644 --- a/assets/src/js/frontend/dashboard/pages/quiz-attempt-feedback.ts +++ b/assets/src/js/frontend/dashboard/pages/quiz-attempt-feedback.ts @@ -5,11 +5,19 @@ import { type MutationState } from '@Core/ts/services/Query'; const REVIEW_STATUSES = ['correct', 'incorrect'] as const; const REVIEW_STATUS_FIELD = 'review_statuses' as const; +const MANUAL_MARK_FIELD = 'manual_marks' as const; +const QUESTION_FEEDBACK_FIELD = 'question_feedback' as const; type ReviewStatus = (typeof REVIEW_STATUSES)[number]; type ReviewStatusFieldName = `${typeof REVIEW_STATUS_FIELD}[${string}]`; type ReviewStatusMap = Record; type ReviewStatusesAjaxPayload = Partial>; +type ManualMarkFieldName = `${typeof MANUAL_MARK_FIELD}[${string}]`; +type ManualMarksMap = Record; +type ManualMarksAjaxPayload = Partial>; +type QuestionFeedbackFieldName = `${typeof QUESTION_FEEDBACK_FIELD}[${string}]`; +type QuestionFeedbackMap = Record; +type QuestionFeedbackAjaxPayload = Partial>; interface QuizAttemptFeedbackProps { attemptId: number; @@ -20,6 +28,8 @@ interface QuizAttemptFeedbackPayload { attempt_id: number; feedback: string; review_statuses: ReviewStatusMap; + manual_marks: ManualMarksMap; + question_feedback: QuestionFeedbackMap; } interface QuizAttemptFeedbackResponse { @@ -39,6 +49,8 @@ const quizAttemptFeedback = ({ attemptId, formId }: QuizAttemptFeedbackProps) => const { convertToErrorMessage } = window.TutorCore.error; const reviewStatusFieldPattern = new RegExp(`^${REVIEW_STATUS_FIELD}\\[[^\\]]+\\]$`); + const manualMarkFieldPattern = new RegExp(`^${MANUAL_MARK_FIELD}\\[[^\\]]+\\]$`); + const questionFeedbackFieldPattern = new RegExp(`^${QUESTION_FEEDBACK_FIELD}\\[[^\\]]+\\]$`); let isProgrammaticReload = false; const getReviewStatuses = (data: Record) => { @@ -68,6 +80,42 @@ const quizAttemptFeedback = ({ attemptId, formId }: QuizAttemptFeedbackProps) => }, {}); }; + const getManualMarks = (data: Record) => { + return Object.entries(data).reduce((acc, [key, value]) => { + if (!manualMarkFieldPattern.test(key)) return acc; + if (value === '' || value === null || value === undefined) return acc; + if (typeof value === 'string' && value.trim() === '') return acc; + const num = Number(value); + if (Number.isNaN(num)) return acc; + const questionId = key.slice(`${MANUAL_MARK_FIELD}[`.length, -1); + acc[questionId] = num; + return acc; + }, {}); + }; + + const getManualMarksPayload = (manualMarks: ManualMarksMap) => { + return Object.entries(manualMarks).reduce((acc, [questionId, mark]) => { + acc[`${MANUAL_MARK_FIELD}[${questionId}]` as ManualMarkFieldName] = mark; + return acc; + }, {}); + }; + + const getQuestionFeedback = (data: Record) => { + return Object.entries(data).reduce((acc, [key, value]) => { + if (!questionFeedbackFieldPattern.test(key)) return acc; + const questionId = key.slice(`${QUESTION_FEEDBACK_FIELD}[`.length, -1); + acc[questionId] = typeof value === 'string' ? value : String(value ?? ''); + return acc; + }, {}); + }; + + const getQuestionFeedbackPayload = (questionFeedback: QuestionFeedbackMap) => { + return Object.entries(questionFeedback).reduce((acc, [questionId, feedback]) => { + acc[`${QUESTION_FEEDBACK_FIELD}[${questionId}]` as QuestionFeedbackFieldName] = feedback; + return acc; + }, {}); + }; + return { formId, attemptId, @@ -118,6 +166,8 @@ const quizAttemptFeedback = ({ attemptId, formId }: QuizAttemptFeedbackProps) => async saveFeedback(payload: QuizAttemptFeedbackPayload) { let feedbackDirty = true; let reviewStatusesDirty = true; + let manualMarksDirty = true; + let questionFeedbackDirty = true; if (form.hasForm(formId)) { const formState = form.getFormState(formId); @@ -126,14 +176,30 @@ const quizAttemptFeedback = ({ attemptId, formId }: QuizAttemptFeedbackProps) => reviewStatusesDirty = Object.keys(dirtyFields ?? {}).some( (key) => key.startsWith(`${REVIEW_STATUS_FIELD}[`) && dirtyFields[key], ); + manualMarksDirty = Object.keys(dirtyFields ?? {}).some( + (key) => key.startsWith(`${MANUAL_MARK_FIELD}[`) && dirtyFields[key], + ); + questionFeedbackDirty = Object.keys(dirtyFields ?? {}).some( + (key) => key.startsWith(`${QUESTION_FEEDBACK_FIELD}[`) && dirtyFields[key], + ); } const reviewStatusesPayload = getReviewStatusesPayload(payload.review_statuses); + const manualMarksPayload = getManualMarksPayload(payload.manual_marks); + const questionFeedbackPayload = getQuestionFeedbackPayload(payload.question_feedback); + + const hasReviewPayload = + Object.keys(reviewStatusesPayload).length > 0 || + Object.keys(manualMarksPayload).length > 0 || + Object.keys(questionFeedbackPayload).length > 0; + const reviewRequest = - reviewStatusesDirty && Object.keys(reviewStatusesPayload).length > 0 + (reviewStatusesDirty || manualMarksDirty || questionFeedbackDirty) && hasReviewPayload ? wpPost(endpoints.REVIEW_QUIZ_ANSWERS, { attempt_id: payload.attempt_id, ...reviewStatusesPayload, + ...manualMarksPayload, + ...questionFeedbackPayload, }) : Promise.resolve(null); @@ -153,10 +219,23 @@ const quizAttemptFeedback = ({ attemptId, formId }: QuizAttemptFeedbackProps) => }, async handleSaveFeedback(data: Record) { + const mergedData = { ...data }; + const formEl = document.getElementById(this.formId) as HTMLFormElement | null; + if (formEl) { + const fd = new FormData(formEl); + fd.forEach((value, key) => { + if (!(key in mergedData) || !mergedData[key]) { + mergedData[key] = value; + } + }); + } + await this.feedbackMutation?.mutate({ attempt_id: this.attemptId, feedback: String(data.feedback ?? ''), - review_statuses: getReviewStatuses(data), + review_statuses: getReviewStatuses(mergedData), + manual_marks: getManualMarks(mergedData), + question_feedback: getQuestionFeedback(mergedData), }); }, }; @@ -166,3 +245,60 @@ export const quizAttemptFeedbackMeta = { name: 'quizAttemptFeedback', component: quizAttemptFeedback, }; + +interface QuestionFeedbackProps { + initialFeedback?: string; + fieldName?: string; + formId?: string; +} + +const questionFeedback = ({ + initialFeedback = '', + fieldName = '', + formId = 'quiz-attempt-review-form', +}: QuestionFeedbackProps = {}) => { + const { form } = window.TutorCore; + + return { + expanded: false, + feedback: String(initialFeedback || ''), + fieldName: String(fieldName || ''), + formId: String(formId || 'quiz-attempt-review-form'), + + toggle() { + if (!this.expanded && form.hasForm(this.formId)) { + form.setValue(this.formId, this.fieldName, this.feedback); + } + this.expanded = !this.expanded; + }, + + save() { + if (form.hasForm(this.formId)) { + const val = form.getValue(this.formId, this.fieldName); + this.feedback = typeof val === 'string' ? val : String(val ?? ''); + form.setValue(this.formId, this.fieldName, this.feedback, { shouldDirty: true }); + } + this.expanded = false; + }, + + cancel() { + if (form.hasForm(this.formId)) { + form.setValue(this.formId, this.fieldName, this.feedback); + } + this.expanded = false; + }, + + del() { + this.feedback = ''; + if (form.hasForm(this.formId)) { + form.setValue(this.formId, this.fieldName, '', { shouldDirty: true }); + } + this.expanded = false; + }, + }; +}; + +export const questionFeedbackMeta = { + name: 'questionFeedback', + component: questionFeedback, +}; diff --git a/assets/src/js/frontend/dashboard/pages/quiz-attempts.ts b/assets/src/js/frontend/dashboard/pages/quiz-attempts.ts index f1c7aba0d3..1b6c774bce 100644 --- a/assets/src/js/frontend/dashboard/pages/quiz-attempts.ts +++ b/assets/src/js/frontend/dashboard/pages/quiz-attempts.ts @@ -4,7 +4,7 @@ import { type MutationState } from '@Core/ts/services/Query'; import { quizRetryAttemptMeta } from '@FrontendComponents/quiz/retry-attempt'; import { quizSummarySidebarMeta } from '@FrontendComponents/quiz/summary-sidebar'; -import { quizAttemptFeedbackMeta } from './quiz-attempt-feedback'; +import { questionFeedbackMeta, quizAttemptFeedbackMeta } from './quiz-attempt-feedback'; const quizAttemptsPage = () => { const { query, modal, toast } = window.TutorCore; @@ -48,6 +48,7 @@ export const initializeQuizAttempts = () => { }, quizRetryAttemptMeta, quizAttemptFeedbackMeta, + questionFeedbackMeta, quizSummarySidebarMeta, ], }); diff --git a/assets/src/js/lib/modules/quiz.js b/assets/src/js/lib/modules/quiz.js index e950ce1278..7da200ac2c 100644 --- a/assets/src/js/lib/modules/quiz.js +++ b/assets/src/js/lib/modules/quiz.js @@ -1,7 +1,58 @@ import { get_response_message } from '../../helper/response'; window.jQuery(document).ready(($) => { - const { __ } = wp.i18n; + const { __, sprintf } = wp.i18n; + + /** + * Get the validation message for a manual mark input value. + * + * @param {jQuery} $input The manual mark input element. + * + * @return {string} Empty string when the value is valid, otherwise a message. + */ + function getManualMarkValidationMessage($input) { + var max = $input.attr('max'); + var value = parseFloat($input.val()); + + if (isNaN(value)) { + return __('Mark must be a valid number.', 'tutor'); + } + + if (value < 0) { + return __('Mark cannot be negative.', 'tutor'); + } + + if ('' !== max && value > parseFloat(max)) { + return sprintf(__('Mark cannot exceed %s.', 'tutor'), max); + } + + return ''; + } + + /** + * Refresh the manual mark input visual state based on its value. + * + * @param {jQuery} $wrapper The tutorial manual review wrapper. + */ + function renderManualMarkState($wrapper) { + var $input = $wrapper.find('.quiz-manual-mark-input'); + var $save = $wrapper.find('.quiz-manual-mark-save'); + var $error = $wrapper.find('.quiz-manual-mark-error'); + var value = $.trim($input.val()); + var message = ''; + + if ('' !== value && !$input[0].checkValidity()) { + message = getManualMarkValidationMessage($input); + } + + $input.toggleClass('is-invalid', '' !== message); + $input.attr('aria-invalid', '' !== message ? 'true' : 'false'); + $input.css('border-color', '' !== message ? 'var(--tutor-color-danger)' : ''); + + $error.text(message).toggle('' !== message); + + $save.toggle('' !== value && '' === message); + } /** * Quiz Frontend Review Action @@ -46,4 +97,58 @@ window.jQuery(document).ready(($) => { }, }); }); + + $(document).on('keydown', '.quiz-manual-review-action[role="button"]', function(e) { + if (13 === e.which || 32 === e.which) { + e.preventDefault(); + $(this).trigger('click'); + } + }); + + $(document).on('click', '.quiz-manual-mark-save', function(e) { + e.preventDefault(); + + var $that = $(this); + var $wrapper = $that.closest('.tutor-manual-review-wrapper'); + var $input = $wrapper.find('.quiz-manual-mark-input'); + + if ('' !== $.trim($input.val()) && !$input[0].checkValidity()) { + renderManualMarkState($wrapper); + return; + } + + var manual_mark = $input.val(); + + $.ajax({ + url: _tutorobject.ajaxurl, + type: 'POST', + data: { + attempt_id: $that.attr('data-attempt-id'), + attempt_answer_id: $that.attr('data-attempt-answer-id'), + question_id: $that.attr('data-question-id'), + manual_mark, + context: $that.attr('data-context'), + back_url: $that.attr('data-back-url'), + action: 'review_quiz_answer', + }, + beforeSend: function() { + $that.addClass('is-loading'); + }, + success: function(data) { + if (data.success && (data.data || {}).html) { + $that.closest('.tutor-quiz-attempt-details-wrapper').html(data.data.html); + return; + } + + tutor_toast(__('Error!', 'tutor'), get_response_message(data), 'error'); + }, + complete: function() { + $that.removeClass('is-loading'); + }, + }); + }); + + $(document).on('input', '.quiz-manual-mark-input', function() { + renderManualMarkState($(this).closest('.tutor-manual-review-wrapper')); + }); }); diff --git a/assets/src/js/v2/quiz-attempt.js b/assets/src/js/v2/quiz-attempt.js index d15569af49..1179f2b7d1 100644 --- a/assets/src/js/v2/quiz-attempt.js +++ b/assets/src/js/v2/quiz-attempt.js @@ -14,6 +14,11 @@ window.addEventListener('DOMContentLoaded', function() { let targetRow; const currentPage = _tutorobject.current_page; const modal = document.getElementById('tutor-common-confirmation-modal'); + const defaultErrorMsg = __( 'Something went wrong, please try again', 'tutor' ); + + // Question-level feedback handlers (task 6.5). + initQuestionFeedbackHandlers(__, defaultErrorMsg); + // Check if it is quiz attempt page. if (currentPage === 'quiz-attempts' || currentPage === 'tutor_quiz_attempts' ) { const deleteButtons = document.querySelectorAll('.tutor-quiz-attempt-delete'); @@ -65,4 +70,172 @@ window.addEventListener('DOMContentLoaded', function() { } } } -}); \ No newline at end of file +}); + +/** + * Question-level feedback modal handlers. + * + * @param {Function} __ i18n translate. + * @param {string} defaultErrorMsg Fallback error text. + * + * @return {void} + */ +function initQuestionFeedbackHandlers(__, defaultErrorMsg) { + let activeTriggerLink = null; + + // The attempt-details markup — including these modals — is re-rendered by AJAX + // after a manual review action, so node references are re-queried on every + // interaction and all handlers are bound via event delegation. + const feedbackModalElements = () => ({ + feedbackModal: document.getElementById('tutor-question-feedback-modal'), + modalTitle: document.getElementById('tutor-question-feedback-modal-title'), + attemptInput: document.getElementById('tutor-question-feedback-attempt-id'), + answerInput: document.getElementById('tutor-question-feedback-answer-id'), + textarea: document.getElementById('tutor-question-feedback-content'), + deleteBtn: document.getElementById('tutor-question-feedback-delete'), + deleteConfirmModal: document.getElementById('tutor-question-feedback-delete-modal'), + }); + + // Helper to update the trigger button state. + const updateTriggerLinkState = (link, hasFeedback, feedbackText = '') => { + if (!link) return; + link.dataset.feedback = feedbackText; + const icon = hasFeedback ? 'tutor-icon-eye-line' : 'tutor-icon-comment'; + const label = hasFeedback ? __('Show Feedback', 'tutor') : __('Add Feedback', 'tutor'); + link.innerHTML = `${label}`; + link.setAttribute('title', hasFeedback ? __('Show feedback', 'tutor') : __('Add feedback', 'tutor')); + }; + + const openFeedbackModal = (link) => { + const { feedbackModal, modalTitle, attemptInput, answerInput, textarea, deleteBtn } = feedbackModalElements(); + if (!feedbackModal || !attemptInput || !answerInput || !textarea) return; + + activeTriggerLink = link; + attemptInput.value = link.dataset.attemptId || ''; + answerInput.value = link.dataset.attemptAnswerId || ''; + const currentFeedback = (link.dataset.feedback || '').trim(); + textarea.value = currentFeedback; + if (modalTitle) { + modalTitle.textContent = currentFeedback ? __('Edit feedback', 'tutor') : __('Write feedback', 'tutor'); + } + if (deleteBtn) { + deleteBtn.style.display = currentFeedback ? '' : 'none'; + } + openModal(feedbackModal, link); + }; + + // Save or delete feedback via AJAX with shared loading state and error toast. + const persistFeedback = async (action, button) => { + const { attemptInput, answerInput, textarea } = feedbackModalElements(); + const attemptId = attemptInput.value; + const attemptAnswerId = answerInput.value; + const feedback = textarea.value.trim(); + + if (!attemptId || !attemptAnswerId) return null; + + const formData = new FormData(); + formData.append('action', action); + formData.append('attempt_id', attemptId); + formData.append('attempt_answer_id', attemptAnswerId); + formData.append('feedback', feedback); + formData.append(_tutorobject.nonce_key, _tutorobject._tutor_nonce); + + button.classList.add('is-loading'); + button.setAttribute('disabled', true); + + try { + const response = await fetch(_tutorobject.ajaxurl, { method: 'POST', body: formData }); + const result = await response.json(); + if (result.success) return result; + tutor_toast(__('Error', 'tutor'), result.data || defaultErrorMsg, 'error'); + } catch { + tutor_toast(__('Error', 'tutor'), defaultErrorMsg, 'error'); + } finally { + button.classList.remove('is-loading'); + button.removeAttribute('disabled'); + } + + return null; + }; + + // Open feedback modal when "Add Feedback" or "Show Feedback" is clicked. + document.addEventListener('click', (e) => { + const link = e.target.closest('.quiz-question-feedback-action'); + if (!link) return; + e.preventDefault(); + openFeedbackModal(link); + }); + + // Save feedback via AJAX. + document.addEventListener('click', async (e) => { + const saveBtn = e.target.closest('#tutor-question-feedback-save'); + if (!saveBtn) return; + + const { textarea, feedbackModal } = feedbackModalElements(); + const feedback = textarea.value.trim(); + const result = await persistFeedback('tutor_save_question_feedback', saveBtn); + if (!result) return; + + tutor_toast(__('Saved', 'tutor'), result.data || __('Feedback saved', 'tutor'), 'success'); + updateTriggerLinkState(activeTriggerLink, '' !== feedback, feedback); + closeModal(feedbackModal); + }); + + // Open delete confirmation modal. + document.addEventListener('click', (e) => { + const deleteBtn = e.target.closest('#tutor-question-feedback-delete'); + if (!deleteBtn) return; + + const { feedbackModal, deleteConfirmModal } = feedbackModalElements(); + closeModal(feedbackModal, false); + openModal(deleteConfirmModal, deleteBtn); + }); + + // Confirm delete via AJAX. + document.addEventListener('click', async (e) => { + const deleteConfirmBtn = e.target.closest('#tutor-question-feedback-delete-confirm'); + if (!deleteConfirmBtn) return; + + const { textarea, deleteConfirmModal } = feedbackModalElements(); + const result = await persistFeedback('tutor_delete_question_feedback', deleteConfirmBtn); + if (!result) return; + + tutor_toast(__('Deleted', 'tutor'), result.data || __('Feedback deleted', 'tutor'), 'success'); + updateTriggerLinkState(activeTriggerLink, false, ''); + textarea.value = ''; + closeModal(deleteConfirmModal); + }); +} + +/** + * Open a modal element using the tutor-modal system. + * + * @param {HTMLElement} modal The .tutor-modal element. + * @param {HTMLElement} [trigger] The originating click target for focus restore. + */ +function openModal(modal, trigger) { + if (!modal) return; + modal.classList.add('tutor-is-active'); + modal.setAttribute('aria-hidden', 'false'); + document.body.classList.add('tutor-modal-open'); + const autofocus = modal.querySelector('[autofocus]'); + const firstInput = modal.querySelector('input:not([type="hidden"]), textarea, select'); + const target = autofocus || firstInput; + if (target) { + requestAnimationFrame(() => target.focus()); + } +} + +/** + * Close a modal element. + * + * @param {HTMLElement} modal The .tutor-modal element. + */ +function closeModal(modal) { + if (!modal) return; + modal.classList.remove('tutor-is-active'); + modal.setAttribute('aria-hidden', 'true'); + if (!document.querySelector('.tutor-modal.tutor-is-active')) { + document.body.classList.remove('tutor-modal-open'); + } +} \ No newline at end of file diff --git a/assets/src/js/v3/@types/index.d.ts b/assets/src/js/v3/@types/index.d.ts index 1e25dcf047..c5f1b6c375 100644 --- a/assets/src/js/v3/@types/index.d.ts +++ b/assets/src/js/v3/@types/index.d.ts @@ -191,6 +191,10 @@ declare global { is_tax_included_in_price: boolean; pagination_per_page: string | number; has_active_membership_plans: boolean; + enable_quiz_partial_marking: 'on' | 'off'; + enable_quiz_negative_marking: 'on' | 'off'; + quiz_negative_mark_mode: 'percent' | 'fixed'; + quiz_negative_mark_amount: string | number; }; tutor_currency: { symbol: string; diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx index 49119d298d..7079cab261 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuizSettings.tsx @@ -38,6 +38,7 @@ import QuizSingleLayoutSvg from '@SharedImages/quiz-single-question.svg'; import FormQuizLayoutSelect from './FormQuizLayoutSelect'; const courseId = getCourseId(); +const isTutorPro = !!tutorConfig.tutor_pro_url; interface QuizSettingsProps { contentDripType: ContentDripType; @@ -96,6 +97,11 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => { const { quizId, contentType } = useQuizModalContext(); const form = useFormContext(); const isLegacyLearningMode = tutorConfig.settings?.learning_mode === 'legacy'; + const adminPartialEnabled = tutorConfig.settings?.enable_quiz_partial_marking === 'on'; + const adminNegativeEnabled = tutorConfig.settings?.enable_quiz_negative_marking === 'on'; + const quizOptionPartialAlreadyOn = form.watch('quiz_option.enable_partial_marking'); + const negativeMarkType = form.watch('quiz_option.negative_mark_type'); + const negativeMarkingEnabled = form.watch('quiz_option.enable_negative_marking'); const questions = form.watch('questions'); const questionsCount = questions.length; @@ -173,39 +179,6 @@ const QuizSettings = ({ contentDripType }: QuizSettingsProps) => {
{__('Quiz scope', 'tutor')}
- { - if (value > 100) { - return __('Passing grade cannot be greater than 100', 'tutor'); - } - - if (value < 0) { - return __('Passing grade cannot be less than 0', 'tutor'); - } - - return true; - }, - }} - render={(controllerProps) => ( - - )} - /> - {
+ +
{__('Grading', 'tutor')}
+
+ { + if (value > 100) { + return __('Passing grade cannot be greater than 100', 'tutor'); + } + + if (value < 0) { + return __('Passing grade cannot be less than 0', 'tutor'); + } + + return true; + }, + }} + render={(controllerProps) => ( + + )} + /> + + +
+ ( + + )} + /> +

+ {__('Award credit for correct sub-answers on multi-part questions.', 'tutor')} +

+
+ + +
+
+ + +
+ ( + + )} + /> + + { + const numericValue = Number(value); + if (numericValue < 0) return __('Negative mark value cannot be less than 0', 'tutor'); + if (negativeMarkType === 'percent' && numericValue > 100) + return __('Percentage penalty cannot be greater than 100', 'tutor'); + return true; + }, + }} + render={(controllerProps) => ( + + )} + /> + +
+
+
+
+
{__('Timing', 'tutor')}
@@ -1058,4 +1139,8 @@ const styles = { color: ${colorTokens.color.black[30]}; } `, + infoText: css` + ${typography.small()}; + color: ${colorTokens.text.hints}; + `, }; diff --git a/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx b/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx index 98cfcdd3a6..e0a9d615fe 100644 --- a/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx +++ b/assets/src/js/v3/entries/course-builder/components/modals/QuizModal.tsx @@ -14,6 +14,7 @@ import type { ModalProps } from '@TutorShared/components/modals/Modal'; import ModalWrapper from '@TutorShared/components/modals/ModalWrapper'; import { CURRENT_VIEWPORT, DEFAULT_QUIZ_ATTEMPTS_ALLOWED, modal } from '@TutorShared/config/constants'; +import { tutorConfig } from '@TutorShared/config/config'; import { borderRadius, Breakpoint, colorTokens, spacing } from '@TutorShared/config/styles'; import { typography } from '@TutorShared/config/typography'; import Show from '@TutorShared/controls/Show'; @@ -101,6 +102,10 @@ const QuizModal = ({ hide_question_number_overview: false, short_answer_characters_limit: 200, open_ended_answer_characters_limit: 500, + enable_partial_marking: false, + enable_negative_marking: false, + negative_mark_type: tutorConfig.settings?.quiz_negative_mark_mode === 'fixed' ? 'fixed' : 'percent', + negative_mark_value: Number(tutorConfig.settings?.quiz_negative_mark_amount ?? 0.15), content_drip_settings: { unlock_date: '', after_xdays_of_enroll: 0, diff --git a/assets/src/js/v3/entries/course-builder/services/quiz.ts b/assets/src/js/v3/entries/course-builder/services/quiz.ts index 5c8740e07c..63004a456a 100644 --- a/assets/src/js/v3/entries/course-builder/services/quiz.ts +++ b/assets/src/js/v3/entries/course-builder/services/quiz.ts @@ -102,6 +102,10 @@ export interface QuizDetailsResponse { hide_question_number_overview: '0' | '1'; short_answer_characters_limit: number; open_ended_answer_characters_limit: number; + enable_partial_marking?: '0' | '1'; + enable_negative_marking?: '0' | '1'; + negative_mark_type?: 'percent' | 'fixed'; + negative_mark_value?: number; content_drip_settings: { unlock_date: string; after_xdays_of_enroll: number; @@ -140,6 +144,10 @@ export interface QuizForm { short_answer_characters_limit: number; open_ended_answer_characters_limit: number; pagination_type: QuizPaginationType; + enable_partial_marking: boolean; + enable_negative_marking: boolean; + negative_mark_type: 'percent' | 'fixed'; + negative_mark_value: number; content_drip_settings: { unlock_date: string; after_xdays_of_enroll: number; @@ -210,6 +218,13 @@ export const convertQuizResponseToFormData = (quiz: QuizDetailsResponse, slotFie hide_question_number_overview: quiz.quiz_option.hide_question_number_overview === '1', short_answer_characters_limit: quiz.quiz_option.short_answer_characters_limit ?? 200, open_ended_answer_characters_limit: quiz.quiz_option.open_ended_answer_characters_limit ?? 500, + enable_partial_marking: quiz.quiz_option.enable_partial_marking === '1', + enable_negative_marking: quiz.quiz_option.enable_negative_marking === '1', + negative_mark_type: + quiz.quiz_option.negative_mark_type ?? + (tutorConfig.settings?.quiz_negative_mark_mode === 'fixed' ? 'fixed' : 'percent'), + negative_mark_value: + quiz.quiz_option.negative_mark_value ?? Number(tutorConfig.settings?.quiz_negative_mark_amount ?? 0.15), content_drip_settings: quiz.quiz_option.content_drip_settings || { unlock_date: '', after_xdays_of_enroll: 0, @@ -270,6 +285,10 @@ export const convertQuizFormDataToPayload = ( quiz_auto_start: formData.quiz_option.quiz_auto_start ? '1' : '0', auto_start_delay: Number(formData.quiz_option.auto_start_delay), short_answer_characters_limit: formData.quiz_option.short_answer_characters_limit, + enable_partial_marking: formData.quiz_option.enable_partial_marking ? '1' : '0', + enable_negative_marking: formData.quiz_option.enable_negative_marking ? '1' : '0', + negative_mark_type: formData.quiz_option.negative_mark_type, + negative_mark_value: formData.quiz_option.negative_mark_value, time_limit: { time_type: formData.quiz_option.time_limit.time_type, time_value: formData.quiz_option.enable_time_limit ? formData.quiz_option.time_limit.time_value : 0, diff --git a/assets/src/js/v3/shared/components/fields/FormInputWithContent.tsx b/assets/src/js/v3/shared/components/fields/FormInputWithContent.tsx index d68862ae3e..07ad0fda92 100644 --- a/assets/src/js/v3/shared/components/fields/FormInputWithContent.tsx +++ b/assets/src/js/v3/shared/components/fields/FormInputWithContent.tsx @@ -26,6 +26,7 @@ interface FormInputWithContentProps extends FormControllerProps {(inputProps) => { const { css: inputCss, ...restInputProps } = inputProps; diff --git a/assets/src/js/v3/shared/config/config.ts b/assets/src/js/v3/shared/config/config.ts index 22d54dd255..560f9305aa 100644 --- a/assets/src/js/v3/shared/config/config.ts +++ b/assets/src/js/v3/shared/config/config.ts @@ -97,6 +97,10 @@ const defaultTutorConfig = { is_tax_included_in_price: false, pagination_per_page: 10, has_active_membership_plans: false, + enable_quiz_partial_marking: 'off', + enable_quiz_negative_marking: 'off', + quiz_negative_mark_mode: 'percent', + quiz_negative_mark_amount: 0.15, }, tutor_currency: { symbol: '', diff --git a/assets/src/js/v3/shared/icons/types.ts b/assets/src/js/v3/shared/icons/types.ts index 2d92c90742..9bf9a9f33f 100644 --- a/assets/src/js/v3/shared/icons/types.ts +++ b/assets/src/js/v3/shared/icons/types.ts @@ -285,6 +285,7 @@ export const icons = [ 'notification2', 'open', 'outlineNone', + 'partial', 'passed', 'passedFill', 'passing', diff --git a/assets/src/scss/frontend/components/_quiz-attempt-details.scss b/assets/src/scss/frontend/components/_quiz-attempt-details.scss index 242c72db8a..e5d2f34570 100644 --- a/assets/src/scss/frontend/components/_quiz-attempt-details.scss +++ b/assets/src/scss/frontend/components/_quiz-attempt-details.scss @@ -147,6 +147,14 @@ &.pending .tutor-question-number::before { background-color: $tutor-border-warning-tertiary; } + + &.partial .tutor-question-number::before { + background-color: $tutor-icon-success-primary; + } + + &.graded .tutor-question-number::before { + background-color: $tutor-icon-exception2; + } } } } @@ -400,6 +408,12 @@ width: 1px; align-self: stretch; background-color: $tutor-border-idle; + flex-shrink: 0; + } + + &-score { + @include tutor-typography(small, regular, subdued); + white-space: nowrap; } } @@ -522,3 +536,137 @@ body:has(#wpadminbar) margin-bottom: $tutor-spacing-6; } } + +.tutor-quiz-open-ended-answer { + border: 1px solid $tutor-border-idle; + border-radius: $tutor-radius-2xl; + background-color: $tutor-surface-base; + padding: $tutor-spacing-6; + @include tutor-typography(p2, regular, primary); + line-height: 1.5; + word-break: break-word; + + &.is-empty { + color: $tutor-text-subdued; + } +} + +.tutor-quiz-manual-review-wrap { + margin-top: $tutor-spacing-6; + + .tutor-quiz-obtained-marks-group { + @include tutor-flex(column); + gap: $tutor-spacing-3; + } + + .tutor-quiz-obtained-marks-label { + @include tutor-typography(p2, regular, secondary); + } + + .tutor-quiz-obtained-marks-row { + @include tutor-flex(row, baseline); + gap: $tutor-spacing-3; + + .tutor-input-field { + width: 80px; + margin-bottom: 0; + + .tutor-input-wrapper { + width: 80px; + } + } + } + + .tutor-quiz-obtained-marks-total { + @include tutor-typography(p2, regular, secondary); + } + + .tutor-quiz-manual-review-error { + @include tutor-typography(small, regular); + color: $tutor-text-critical; + margin-top: $tutor-spacing-1; + } + + .tutor-quiz-add-feedback-btn { + @include tutor-flex(row, center); + gap: $tutor-spacing-2; + background: none; + border: none; + padding: 0; + margin-top: $tutor-spacing-4; + cursor: pointer; + color: $tutor-text-brand; + @include tutor-typography(p2, medium); + @include tutor-transition(color); + + &:hover { + color: $tutor-text-brand-hover; + } + + svg { + color: $tutor-text-brand; + flex-shrink: 0; + } + + &:hover svg { + color: $tutor-text-brand-hover; + } + } + + .tutor-quiz-feedback-panel { + background-color: $tutor-surface-brand-tertiary; + border-radius: $tutor-radius-2xl; + padding: $tutor-spacing-6; + margin-top: $tutor-spacing-4; + + &-header { + @include tutor-flex(row, center, space-between); + margin-bottom: $tutor-spacing-3; + } + + &-title { + @include tutor-typography(p2, semibold, primary); + } + + &-status { + @include tutor-typography(small, regular, secondary); + } + + &-content { + @include tutor-typography(p2, regular, primary); + line-height: 1.5; + margin-bottom: $tutor-spacing-4; + } + + .tutor-input-field { + margin-bottom: $tutor-spacing-4; + } + + .tutor-quiz-feedback-actions { + @include tutor-flex(row, center, space-between); + + .tutor-quiz-feedback-actions-main { + @include tutor-flex(row, center); + gap: $tutor-spacing-3; + } + } + } +} + +.tutor-quiz-question-feedback-wrap { + margin-top: $tutor-spacing-6; + + .tutor-quiz-question-feedback-title { + @include tutor-typography(p2, semibold, primary); + margin-bottom: $tutor-spacing-3; + } + + .tutor-quiz-question-feedback-card { + background-color: $tutor-surface-brand-tertiary; + border-radius: $tutor-radius-2xl; + padding: $tutor-spacing-6; + @include tutor-typography(p2, regular, primary); + line-height: 1.5; + word-break: break-word; + } +} diff --git a/assets/src/scss/frontend/components/_quiz-summary.scss b/assets/src/scss/frontend/components/_quiz-summary.scss index 428830b957..dc217510c2 100644 --- a/assets/src/scss/frontend/components/_quiz-summary.scss +++ b/assets/src/scss/frontend/components/_quiz-summary.scss @@ -86,6 +86,10 @@ background-color: $tutor-icon-success-primary; } + &.partial::before { + background-color: $tutor-icon-success-primary; + } + &.incorrect::before { background-color: $tutor-icon-critical; } diff --git a/assets/src/scss/modules/quiz-attempts.scss b/assets/src/scss/modules/quiz-attempts.scss index 8331f9af4b..9de4f4e56f 100644 --- a/assets/src/scss/modules/quiz-attempts.scss +++ b/assets/src/scss/modules/quiz-attempts.scss @@ -99,6 +99,42 @@ } } + td.result { + .tutor-quiz-attempt-result-col { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + } + + .tutor-quiz-result-wrap { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .tutor-quiz-result-delta { + font-size: 11px; + line-height: 1.2; + + .is-earned { + color: #218838; + } + + .is-penalty { + color: #dc3545; + margin-left: 2px; + } + } + + .tutor-quiz-result-score { + font-size: 11px; + color: #757c8e; + line-height: 1.2; + } + } + .explain-toggle{ background:#F4F6F9; text-align:center!important; @@ -109,6 +145,152 @@ } } + .tutor-quiz-feedback-toggle-button { + background-color: transparent; + border: none; + padding: 0; + color: #0049F8; + font-size: 14px; + font-weight: 500; + line-height: 32px; + white-space: nowrap; + display: inline-flex; + align-items: center; + cursor: pointer; + + &:hover, + &:focus, + &:active { + background-color: transparent; + color: #0049F8; + } + } + + .tutor-quiz-question-feedback-row { + .tutor-quiz-question-feedback-card { + background-color: #f4f6f9; + border-radius: 8px; + padding: 16px; + font-size: 14px; + line-height: 1.5; + word-break: break-word; + + .tutor-quiz-question-feedback-title { + font-size: 14px; + font-weight: 500; + color: #1b1d21; + } + + .tutor-quiz-question-feedback-body { + color: #5b616f; + } + } + } + + .tutor-quiz-question-feedback-wrap { + margin-top: 12px; + + .tutor-quiz-question-feedback-title { + font-size: 13px; + font-weight: 500; + color: #1b1d21; + margin-bottom: 6px; + } + + .tutor-quiz-question-feedback-card { + background-color: #f4f6f9; + border-radius: 8px; + padding: 12px; + font-size: 13px; + color: #5b616f; + line-height: 1.5; + word-break: break-word; + } + } + + .tutor-quiz-question-review-actions { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + flex-wrap: nowrap; + gap: 8px; + } + + .tutor-quiz-question-review-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: 6px; + font-weight: 500; + font-family: inherit; + background-color: #ececed; + color: #0c111d; + padding: 8px; + min-width: 32px; + min-height: 32px; + cursor: pointer; + position: relative; + text-decoration: none; + transition: background-color 0.25s ease-in-out, color 0.25s ease-in-out, opacity 0.25s ease-in-out; + + svg:not([class]) { + color: #333741; + flex-shrink: 0; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: #cecfd2; + color: #0c111d; + } + + &:focus-visible:not(:disabled):not(.disabled) { + outline: none; + box-shadow: 0 0 0 2px #90a0f7; + } + + &[data-review-status='correct']:has(input:checked) { + background-color: #24983f; + color: #ffffff; + + svg:not([class]) { + color: #ffffff; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: #1c7731; + color: #ffffff; + } + } + + &[data-review-status='incorrect']:has(input:checked) { + background-color: #d92d20; + color: #ffffff; + + svg:not([class]) { + color: #ffffff; + } + + &:hover:not(:disabled):not(.disabled) { + background-color: #b42318; + color: #ffffff; + } + } + } + + .tutor-quiz-question-review-input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + @include breakpoint-max(mobile) { .tutor-manual-review-wrapper { text-align: left; diff --git a/classes/Icon.php b/classes/Icon.php index 0154b3fba8..89635fb619 100644 --- a/classes/Icon.php +++ b/classes/Icon.php @@ -301,6 +301,7 @@ final class Icon { const NOTIFICATION_2 = 'notification-2'; const OPEN = 'open'; const OUTLINE_NONE = 'outline-none'; + const PARTIAL = 'partial'; const PASSED = 'passed'; const PASSED_FILL = 'passed-fill'; const PASSING = 'passing'; diff --git a/classes/Quiz.php b/classes/Quiz.php index 8ce8e8d9d0..adb92b9f2a 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -135,6 +135,8 @@ public function __construct( $register_hooks = true ) { add_action( 'wp_ajax_review_quiz_answer', array( $this, 'review_quiz_answer' ) ); add_action( 'wp_ajax_tutor_review_quiz_answers', array( $this, 'review_quiz_answers' ) ); add_action( 'wp_ajax_tutor_instructor_feedback', array( $this, 'tutor_instructor_feedback' ) ); + add_action( 'wp_ajax_tutor_save_question_feedback', array( $this, 'save_question_feedback' ) ); + add_action( 'wp_ajax_tutor_delete_question_feedback', array( $this, 'delete_question_feedback' ) ); /** * New quiz builder Ajax API. @@ -522,6 +524,132 @@ public function tutor_instructor_feedback() { wp_send_json_error(); } + /** + * Read the question feedback map from an attempt's serialized info. + * + * @since 4.1.0 + * + * @param int $attempt_id Attempt ID. + * + * @return array + */ + private function get_question_feedback_map( int $attempt_id ): array { + $attempt_row = QueryHelper::get_row( + 'tutor_quiz_attempts', + array( 'attempt_id' => $attempt_id ), + 'attempt_id' + ); + + if ( ! $attempt_row ) { + return array(); + } + + return QuizModel::get_attempt_feedback_map( $attempt_row->attempt_info ); + } + + /** + * Persist the question feedback map into an attempt's serialized info. + * + * @since 4.1.0 + * + * @param int $attempt_id Attempt ID. + * @param array $feedback_map Feedback keyed by attempt answer ID. + * + * @return bool + */ + private function save_question_feedback_map( int $attempt_id, array $feedback_map ): bool { + $attempt_row = QueryHelper::get_row( + 'tutor_quiz_attempts', + array( 'attempt_id' => $attempt_id ), + 'attempt_id' + ); + + $attempt_info = array(); + if ( $attempt_row && ! empty( $attempt_row->attempt_info ) ) { + $attempt_info = maybe_unserialize( $attempt_row->attempt_info ); + $attempt_info = is_array( $attempt_info ) ? $attempt_info : array(); + } + + $attempt_info['question_feedback'] = $feedback_map; + + return QueryHelper::update( + 'tutor_quiz_attempts', + array( + 'attempt_info' => maybe_serialize( $attempt_info ), + ), + array( 'attempt_id' => $attempt_id ) + ); + } + + /** + * Save, update, or delete per-question instructor feedback via AJAX. + * + * @since 4.1.0 + * + * @return void + */ + public function save_question_feedback() { + tutor_utils()->checking_nonce(); + + $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT ); + $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT ); + $feedback = Input::post( 'feedback', '', Input::TYPE_KSES_POST ); + + if ( ! $attempt_id || ! $attempt_answer_id ) { + $this->response_fail( __( 'Invalid request data', 'tutor' ), 400 ); + } + + if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) { + $this->response_fail( __( 'Access Denied', 'tutor' ), 403 ); + } + + $feedback_map = $this->get_question_feedback_map( $attempt_id ); + $feedback_map[ $attempt_answer_id ] = $feedback; + + if ( ! $this->save_question_feedback_map( $attempt_id, $feedback_map ) ) { + $this->response_fail( __( 'Could not save feedback', 'tutor' ), 500 ); + } + + $this->response_success( __( 'Feedback saved successfully', 'tutor' ) ); + } + + /** + * Delete per-question instructor feedback via AJAX. + * + * @since 4.1.0 + * + * @return void + */ + public function delete_question_feedback() { + tutor_utils()->checking_nonce(); + + $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT ); + $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT ); + + if ( ! $attempt_id || ! $attempt_answer_id ) { + $this->response_fail( __( 'Invalid request data', 'tutor' ), 400 ); + } + + if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) { + $this->response_fail( __( 'Access Denied', 'tutor' ), 403 ); + } + + $feedback_map = $this->get_question_feedback_map( $attempt_id ); + + if ( ! isset( $feedback_map[ $attempt_answer_id ] ) ) { + $this->response_success( __( 'Feedback removed', 'tutor' ) ); + return; + } + + unset( $feedback_map[ $attempt_answer_id ] ); + + if ( ! $this->save_question_feedback_map( $attempt_id, $feedback_map ) ) { + $this->response_fail( __( 'Could not delete feedback', 'tutor' ), 500 ); + } + + $this->response_success( __( 'Feedback deleted successfully', 'tutor' ) ); + } + /** * Start Quiz from here... * @@ -964,7 +1092,7 @@ function ( $ans ) { 'question_mark' => $question->question_mark, 'achieved_mark' => $question_mark, 'minus_mark' => 0, - 'is_correct' => $is_answer_was_correct ? 1 : 0, + 'is_correct' => $is_answer_was_correct ? QuizModel::ATTEMPT_ANSWER_CORRECT : QuizModel::ATTEMPT_ANSWER_INCORRECT, ); /** @@ -1158,6 +1286,7 @@ public function review_quiz_answer() { $attempt_answer_id = Input::post( 'attempt_answer_id', 0, Input::TYPE_INT ); $question_id = Input::post( 'question_id', 0, Input::TYPE_INT ); $mark_as = Input::post( 'mark_as' ); + $manual_mark = Input::post( 'manual_mark', null ); if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) { wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) ); @@ -1168,10 +1297,32 @@ public function review_quiz_answer() { } $attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, $attempt_answer_id, $question_id ); - $review_data = $attempt_answer ? $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as ) : null; + $review_data = null; + + if ( null !== $manual_mark && $attempt_answer ) { + $mark_delta = $this->apply_manual_quiz_answer_mark( $attempt_answer, $manual_mark ); + $attempt = tutor_utils()->get_attempt( $attempt_id ); + + if ( null !== $mark_delta && is_object( $attempt ) ) { + QueryHelper::update( + 'tutor_quiz_attempts', + array( + 'earned_marks' => max( 0, (float) $attempt->earned_marks + $mark_delta ), + 'is_manually_reviewed' => 1, + 'manually_reviewed_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ), + 'attempt_status' => QuizModel::ATTEMPT_ENDED, + ), + array( 'attempt_id' => $attempt_id ) + ); + + $review_data = array( 'student_id' => $attempt->user_id ); + } + } elseif ( $attempt_answer ) { + $review_data = $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as ); + } if ( ! $review_data ) { - wp_send_json_error( array( 'message' => __( 'Review update failed', 'tutor' ) ) ); + $this->response_fail( __( 'Review update failed', 'tutor' ) ); } QuizModel::update_attempt_result( $attempt_id ); @@ -1192,30 +1343,34 @@ public function review_quiz_answer() { /** * Review quiz answers in bulk for v4 dashboard flow. * - * @since 4.0.0 + * @since 4.1.0 * * @return void */ public function review_quiz_answers() { tutor_utils()->checking_nonce(); - $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT ); - $review_statuses = Input::post( 'review_statuses', array(), Input::TYPE_ARRAY ); + $attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT ); + $review_statuses = Input::post( 'review_statuses', array(), Input::TYPE_ARRAY ); + $manual_marks = Input::post( 'manual_marks', array(), Input::TYPE_ARRAY ); + $question_feedback = Input::post( 'question_feedback', array(), Input::TYPE_ARRAY ); - $this->review_quiz_answers_bulk( $attempt_id, $review_statuses ); + $this->review_quiz_answers_bulk( $attempt_id, $review_statuses, $manual_marks, $question_feedback ); } /** * Review quiz answers in bulk for v4 dashboard flow. * - * @since 4.0.0 + * @since 4.1.0 * * @param int $attempt_id Attempt ID. * @param array $review_statuses Review statuses keyed by question ID. + * @param array $manual_marks Numeric manual marks keyed by question ID. + * @param array $question_feedback Per-question feedback keyed by attempt answer ID or question ID. * * @return void */ - private function review_quiz_answers_bulk( int $attempt_id, array $review_statuses ) { + private function review_quiz_answers_bulk( int $attempt_id, array $review_statuses, array $manual_marks = array(), array $question_feedback = array() ) { if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) { $this->response_fail( __( 'Access Denied', 'tutor' ), 403 ); } @@ -1251,14 +1406,172 @@ private function review_quiz_answers_bulk( int $attempt_id, array $review_status continue; } + $target_is_correct = ( 'correct' === $mark_as ) ? QuizModel::ATTEMPT_ANSWER_CORRECT : QuizModel::ATTEMPT_ANSWER_INCORRECT; + $prev_is_correct = null !== $attempt_answer->is_correct ? (int) $attempt_answer->is_correct : null; + + if ( $prev_is_correct === $target_is_correct ) { + continue; + } + $this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as ); } + $this->apply_manual_marks_bulk( $attempt_id, $manual_marks, $answers_by_question_id ); + $this->apply_quiz_feedback_bulk( $attempt_id, $question_feedback, $answers_by_question_id ); + QuizModel::update_attempt_result( $attempt_id ); $this->response_success( __( 'Review updated successfully', 'tutor' ) ); } + /** + * Apply a numeric manual mark without assigning an auto-grading status. + * + * @since 4.1.0 + * + * @param object $attempt_answer Attempt answer row. + * @param mixed $mark Requested mark. + * + * @return float|null Delta on success, null otherwise. + */ + private function apply_manual_quiz_answer_mark( $attempt_answer, $mark ) { + if ( ! is_object( $attempt_answer ) || ! is_numeric( $mark ) ) { + return null; + } + + $question_type = $attempt_answer->question_type ?? ''; + if ( empty( $question_type ) && ! empty( $attempt_answer->question_id ) ) { + $question = QuizModel::get_quiz_question_by_id( $attempt_answer->question_id ); + $question_type = $question->question_type ?? ''; + $attempt_answer->question_type = $question_type; + } + + if ( ! in_array( $question_type, QuizModel::get_manual_review_types(), true ) ) { + return null; + } + + $question_mark = isset( $attempt_answer->question_mark ) ? (float) $attempt_answer->question_mark : 0.0; + if ( $question_mark <= 0.0 && ! empty( $attempt_answer->question_id ) ) { + $question = isset( $question ) && is_object( $question ) ? $question : QuizModel::get_quiz_question_by_id( $attempt_answer->question_id ); + $question_mark = (float) ( $question->question_mark ?? 0.0 ); + } + + $new_mark = min( max( 0, (float) $mark ), $question_mark ); + $previous_mark = (float) ( $attempt_answer->achieved_mark ?? 0.0 ); + $answer_updated = QueryHelper::update( + 'tutor_quiz_attempt_answers', + array( + 'achieved_mark' => $new_mark, + 'is_correct' => QuizModel::ATTEMPT_ANSWER_MANUAL_GRADED, + ), + array( 'attempt_answer_id' => (int) $attempt_answer->attempt_answer_id ) + ); + + if ( ! $answer_updated ) { + return null; + } + + return $new_mark - $previous_mark; + } + + /** + * Apply numeric manual marks for multiple questions in one pass. + * + * Marks each manual-review question, accumulates the earned-mark delta, + * and updates the attempt to completed when any mark was applied. + * + * @since 4.1.0 + * + * @param int $attempt_id Attempt ID. + * @param array $manual_marks Numeric manual marks keyed by question ID. + * @param array $answers_by_question_id Attempt answers keyed by question ID. + * + * @return void + */ + private function apply_manual_marks_bulk( int $attempt_id, array $manual_marks, array $answers_by_question_id ): void { + $delta = 0.0; + $applied = false; + + foreach ( $manual_marks as $question_id => $mark ) { + if ( '' === $mark || null === $mark || ! is_numeric( $mark ) ) { + continue; + } + + $question_id = (int) $question_id; + $attempt_answer = $answers_by_question_id[ $question_id ] ?? $this->resolve_attempt_answer_for_review( $attempt_id, 0, $question_id ); + $mark_delta = $this->apply_manual_quiz_answer_mark( $attempt_answer, $mark ); + + if ( null !== $mark_delta ) { + $applied = true; + $delta += $mark_delta; + } + } + + if ( ! $applied ) { + return; + } + + $attempt = tutor_utils()->get_attempt( $attempt_id ); + if ( is_object( $attempt ) ) { + QueryHelper::update( + 'tutor_quiz_attempts', + array( + 'earned_marks' => max( 0, (float) $attempt->earned_marks + $delta ), + 'is_manually_reviewed' => 1, + 'manually_reviewed_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ), + 'attempt_status' => QuizModel::ATTEMPT_ENDED, + ), + array( 'attempt_id' => $attempt_id ) + ); + } + } + + /** + * Apply per-question feedback for multiple questions in one pass. + * + * Builds the merged question feedback map keyed by attempt answer ID + * (with a question ID fallback) and persists it to attempt info. + * + * @since 4.1.0 + * + * @param int $attempt_id Attempt ID. + * @param array $question_feedback Per-question feedback keyed by attempt answer ID or question ID. + * @param array $answers_by_question_id Attempt answers keyed by question ID. + * + * @return void + */ + private function apply_quiz_feedback_bulk( int $attempt_id, array $question_feedback, array $answers_by_question_id ): void { + if ( count( $question_feedback ) === 0 ) { + return; + } + + $feedback_map = $this->get_question_feedback_map( $attempt_id ); + + foreach ( $question_feedback as $key => $feedback_text ) { + $key = (int) $key; + $target_id = $key; + if ( isset( $answers_by_question_id[ $key ]->attempt_answer_id ) ) { + $target_id = (int) $answers_by_question_id[ $key ]->attempt_answer_id; + } + + if ( ! $target_id ) { + continue; + } + + $feedback_text = is_string( $feedback_text ) ? trim( $feedback_text ) : ''; + if ( '' === $feedback_text ) { + unset( $feedback_map[ $target_id ] ); + if ( $target_id !== $key ) { + unset( $feedback_map[ $key ] ); + } + } else { + $feedback_map[ $target_id ] = $feedback_text; + } + } + + $this->save_question_feedback_map( $attempt_id, $feedback_map ); + } + /** * Get attempt answer record by ID. * @@ -1427,54 +1740,62 @@ private function apply_quiz_answer_review( int $attempt_id, $attempt_answer, str $mark_as = apply_filters( 'tutor_quiz_review_mark_as', $mark_as, $attempt_answer_id, $attempt_id, $question ); - if ( 'correct' === $mark_as ) { - $attempt_update_data = array(); - $answer_update_data = array( - 'achieved_mark' => $attempt_answer->question_mark, - 'is_correct' => 1, - ); + $attempt_update_data = array(); + $previous_achieved = (float) ( $attempt_answer->achieved_mark ?? 0.0 ); - $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) ); + $question_mark = (float) ( $attempt_answer->question_mark ?? $question->question_mark ?? 0.0 ); + $default_marks = array( + 'achieved_mark' => 'correct' === $mark_as ? $question_mark : 0.00, + 'minus_mark' => 0, + ); - if ( 0 == $previous_ans || null == $previous_ans ) { - $attempt_update_data = array( - 'earned_marks' => $attempt->earned_marks + $attempt_answer->question_mark, - 'is_manually_reviewed' => 1, - 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore - ); - } + $review_marks = apply_filters( + 'tutor_quiz_review_answer_marks', + $default_marks, + $mark_as, + $attempt_answer, + $question, + $attempt + ); - if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) { - $attempt_update_data['attempt_status'] = QuizModel::ATTEMPT_ENDED; - } + $new_achieved = (float) ( $review_marks['achieved_mark'] ?? $default_marks['achieved_mark'] ); + $new_minus = (float) ( $review_marks['minus_mark'] ?? $default_marks['minus_mark'] ); + $mark_diff = $new_achieved - $previous_achieved; - if ( ! empty( $attempt_update_data ) ) { - $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) ); - } - } elseif ( 'incorrect' === $mark_as ) { - $attempt_update_data = array(); - $answer_update_data = array( - 'achieved_mark' => '0.00', - 'is_correct' => 0, - ); + $answer_update_data = array( + 'achieved_mark' => $new_achieved, + 'minus_mark' => $new_minus, + 'is_correct' => 'correct' === $mark_as ? QuizModel::ATTEMPT_ANSWER_CORRECT : QuizModel::ATTEMPT_ANSWER_INCORRECT, + ); - $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) ); + $wpdb->update( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answer_update_data, array( 'attempt_answer_id' => $attempt_answer_id ) ); - if ( 1 == $previous_ans ) { - $attempt_update_data = array( - 'earned_marks' => $attempt->earned_marks - $attempt_answer->question_mark, - 'is_manually_reviewed' => 1, - 'manually_reviewed_at' => date( 'Y-m-d H:i:s', tutor_time() ), //phpcs:ignore - ); - } + $attempt_update_data = array( + 'earned_marks' => max( 0.0, (float) $attempt->earned_marks + $mark_diff ), + 'is_manually_reviewed' => 1, + 'manually_reviewed_at' => gmdate( 'Y-m-d H:i:s', tutor_time() ), + ); - if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) { - $attempt_update_data['attempt_status'] = QuizModel::ATTEMPT_ENDED; - } + if ( ! in_array( $question->question_type, QuizModel::get_manual_review_types(), true ) ) { + $attempt_row = QueryHelper::get_row( 'tutor_quiz_attempts', array( 'attempt_id' => $attempt_id ), 'attempt_id' ); + $attempt_info = is_object( $attempt_row ) && ! empty( $attempt_row->attempt_info ) ? maybe_unserialize( $attempt_row->attempt_info ) : array(); + $attempt_info = is_array( $attempt_info ) ? $attempt_info : array(); - if ( ! empty( $attempt_update_data ) ) { - $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) ); - } + $overrides_map = QuizModel::get_manual_overrides_map( $attempt_info ); + $overrides_map[ (int) $question->question_id ] = $mark_as; + $attempt_info['manual_overrides'] = $overrides_map; + + $attempt_update_data['attempt_info'] = maybe_serialize( $attempt_info ); + $attempt_update_data['is_manually_reviewed'] = 1; + $attempt_update_data['manually_reviewed_at'] = gmdate( 'Y-m-d H:i:s', tutor_time() ); + } + + if ( 'open_ended' === $question->question_type || 'short_answer' === $question->question_type ) { + $attempt_update_data['attempt_status'] = QuizModel::ATTEMPT_ENDED; + } + + if ( ! empty( $attempt_update_data ) ) { + $wpdb->update( $wpdb->tutor_quiz_attempts, $attempt_update_data, array( 'attempt_id' => $attempt_id ) ); } do_action( 'tutor_quiz_review_answer_after', $attempt_answer_id, $attempt_id, $mark_as ); @@ -1852,10 +2173,11 @@ public function render_single_content( WP_Post $quiz ): void { * @param string $passing_grade Passing grade. * @param string $earned_marks Earned marks. * @param string $attempts_allowed Total Attempts allowed. + * @param int $quiz_id Quiz post ID used for Pro scoring parameters. * * @return void */ - public static function render_quiz_summary( $total_questions, $quiz_item_readable, $total_marks, $passing_grade, $earned_marks, $attempts_allowed ) { + public static function render_quiz_summary( $total_questions, $quiz_item_readable, $total_marks, $passing_grade, $earned_marks, $attempts_allowed, $quiz_id = 0 ) { $quiz_summary = array( array( 'columns' => array( @@ -1895,6 +2217,18 @@ public static function render_quiz_summary( $total_questions, $quiz_item_readabl ); } + /** + * Filter the quiz summary parameter rows. + * + * Allows Pro and add-ons to inject additional parameter rows (e.g. partial/negative marking). + * + * @since 4.1.0 + * + * @param array $quiz_summary Array of table rows for the quiz summary. + * @param int $quiz_id Quiz post ID. + */ + $quiz_summary = apply_filters( 'tutor_quiz_summary_parameters', $quiz_summary, $quiz_id ); + $quiz_summary[] = array( 'columns' => array( array( diff --git a/classes/Quiz_Attempts_List.php b/classes/Quiz_Attempts_List.php index e6d080201e..662289651d 100644 --- a/classes/Quiz_Attempts_List.php +++ b/classes/Quiz_Attempts_List.php @@ -641,6 +641,70 @@ public static function render_quiz_attempt_marks_percentage( $attempt_result = ' ->render(); } + /** + * Get quiz attempt summary statics in render order. + * + * @since 4.1.0 + * + * @param object $attempt_data Quiz attempt object. + * @param array $answers Quiz attempt answers. + * + * @return array + */ + public static function get_quiz_attempt_summary_statics( $attempt_data, $answers ) { + $answer_counts = QuizModel::get_attempt_answer_counts( $answers ); + + $static_items = array( + 'correct' => array( + 'class' => 'correct', + /* translators: %d: number of correct answers. */ + 'label' => __( '%d correct', 'tutor' ), + 'count' => (int) $answer_counts['correct'], + ), + 'incorrect' => array( + 'class' => 'incorrect', + /* translators: %d: number of incorrect answers. */ + 'label' => __( '%d incorrect', 'tutor' ), + 'count' => (int) $answer_counts['incorrect'], + ), + 'total' => array( + 'class' => 'total', + /* translators: %d: number of total questions. */ + 'label' => __( '%d total', 'tutor' ), + 'count' => (int) $attempt_data->total_questions, + ), + ); + + /** + * Filters the attempt summary statics and their render order. + * + * The value is an associative array of `key => item` pairs rendered in + * array order by `templates/shared/components/quiz/attempt-details/summary.php`. + * Each item is an associative array with the following keys: + * + * - `class`: Wrap CSS class. Appended to the + * `.tutor-quiz-result-static-item` wrapper element. + * - `label`: Display label. A translated string with a single `%d` + * placeholder for the count, wrapped in + * `%d`. + * - `count`: Stat value. Substituted for the `%d` placeholder in `label`. + * + * Reorder the pairs to change the statics order, unset a pair to hide an + * item, or extend the array to add a custom stat. Statics counts are + * derived from the `tutor_quiz_attempt_answer_counts` filter; read custom + * counts from that filtered value instead of recomputing them here. + * + * @since 4.1.0 + * + * @param array $items Ordered associative array of `key => item` pairs. + * @param object $attempt_data Quiz attempt object. + * @param array $answers Quiz attempt answers. + * + * @return array Ordered associative array of `key => item` pairs. + */ + return apply_filters( 'tutor_quiz_attempt_summary_statics', $static_items, $attempt_data, $answers ); + } + /** * Render List Badge for quiz attempts. * diff --git a/models/QuizModel.php b/models/QuizModel.php index f8b9687bdd..5aa9eafc66 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -11,6 +11,7 @@ namespace Tutor\Models; use Tutor\Cache\TutorCache; +use Tutor\Components\Badge; use TUTOR\Course_List; use Tutor\Helpers\DateTimeHelper; use Tutor\Helpers\QueryHelper; @@ -34,6 +35,23 @@ class QuizModel { const ATTEMPTS_TABLE = 'tutor_quiz_attempts'; + /** + * Attempt-answer correctness values. + * + * These values are only for tutor_quiz_attempt_answers rows. Question-answer + * option rows remain binary and must continue to use 0 or 1. + */ + const ATTEMPT_ANSWER_INCORRECT = 0; + const ATTEMPT_ANSWER_CORRECT = 1; + const ATTEMPT_ANSWER_PARTIAL = 2; + + /** + * Attempt-answer status for manually graded questions. + * + * @since 4.1.0 + */ + const ATTEMPT_ANSWER_MANUAL_GRADED = 3; + /** * Question type constants * @@ -238,21 +256,10 @@ public static function format_quiz_attempts( array $quiz_attempts, string $resul $earned_percent = self::calculate_attempt_earned_percentage( $quiz_attempt ); - $correct_answers = 0; - $incorrect_answers = 0; - - $answers = self::get_quiz_answers_by_attempt_id( $quiz_attempt->attempt_id ); - - if ( tutor_utils()->count( $answers ) ) { - foreach ( $answers as $answer ) { - $is_correct = (int) $answer->is_correct ?? 0; - if ( $is_correct ) { - ++$correct_answers; - } else { - ++$incorrect_answers; - } - } - } + $answers = self::get_quiz_answers_by_attempt_id( $quiz_attempt->attempt_id ); + $answer_counts = self::get_attempt_answer_counts( $answers ); + $correct_answers = $answer_counts['correct']; + $incorrect_answers = $answer_counts['incorrect']; $formatted_attempt = array( 'attempt_id' => $quiz_attempt->attempt_id ?? 0, @@ -1072,19 +1079,26 @@ static function ( $attempt_answer ) use ( $is_instructor_review ) { /** * Get normalized attempt-answer status. * + * Manually graded questions have a separate lifecycle: pending until reviewed, + * then graded. Auto-graded questions use the attempt-answer correctness constants. + * * Status rules follow legacy attempt-details logic: * - correct: is_correct is truthy. * - pending: is_correct is null for manually reviewed question types. * - incorrect: all other cases. + * - graded: is_correct set after an instructor reviews a manually reviewed question. + * - skipped: question has no given answer. * * @since 4.0.0 + * @since 4.1.0 Added manual graded questions and filter hook. * * @param object $attempt_answer Attempt answer object. * - * @return string One of: correct, pending, wrong. + * @return string One of: pending, correct, incorrect, graded, skipped (or one from the filter). */ public static function get_attempt_answer_status( $attempt_answer ): string { $question_type = (string) ( $attempt_answer->question_type ?? '' ); + $is_correct = $attempt_answer->is_correct ?? null; if ( 'image_matching' === $question_type ) { $question_type = 'matching'; @@ -1094,18 +1108,139 @@ public static function get_attempt_answer_status( $attempt_answer ): string { $question_type = 'multiple_choice'; } - if ( (bool) ( $attempt_answer->is_correct ?? false ) ) { - return 'correct'; + if ( self::is_attempt_answer_skipped( $attempt_answer ) ) { + $status = 'skipped'; + } elseif ( null === $is_correct ) { + $status = 'pending'; + } elseif ( in_array( $question_type, self::get_manual_review_types(), true ) ) { + $status = 'graded'; + } elseif ( self::ATTEMPT_ANSWER_CORRECT === (int) $is_correct ) { + $status = 'correct'; + } else { + $status = 'incorrect'; + } + + return apply_filters( 'tutor_quiz_attempt_answer_status', $status, $attempt_answer ); + } + + /** + * Get attempt answer status badge metadata. + * + * @since 4.1.0 + * + * @param object|null $attempt_answer Attempt answer object. + * + * @return array Associative array with status, label, variant, label_map, and variant_map. + */ + public static function get_attempt_answer_badge( $attempt_answer ): array { + $status = $attempt_answer ? self::get_attempt_answer_status( $attempt_answer ) : 'skipped'; + + $label_map = array( + 'pending' => __( 'Pending', 'tutor' ), + 'correct' => __( 'Correct', 'tutor' ), + 'incorrect' => __( 'Incorrect', 'tutor' ), + 'graded' => __( 'Graded', 'tutor' ), + 'skipped' => __( 'Skipped', 'tutor' ), + ); + + $variant_map = array( + 'pending' => Badge::WARNING, + 'correct' => Badge::SUCCESS, + 'incorrect' => Badge::ERROR, + 'graded' => Badge::HIGHLIGHT, + 'skipped' => Badge::INFO, + ); + + // Legacy class map: old `label-*` CSS classes used by the admin attempt-details + // badge rendering. The new Badge component variant map is used instead where available. + $class_map = array( + 'pending' => 'label-warning', + 'correct' => 'label-success', + 'incorrect' => 'label-danger', + 'graded' => 'label-primary', + 'skipped' => 'label-default', + ); + + $badge = array( + 'status' => $status, + 'label' => $label_map[ $status ] ?? '', + 'variant' => $variant_map[ $status ] ?? Badge::INFO, + 'class' => $class_map[ $status ] ?? 'label-default', + 'label_map' => $label_map, + 'variant_map' => $variant_map, + 'class_map' => $class_map, + ); + + return apply_filters( 'tutor_quiz_attempt_answer_badge', $badge, $attempt_answer ); + } + + /** + * Render attempt answer status badge. + * + * @since 4.1.0 + * + * @param object|null $attempt_answer Attempt answer object. + * @param array $options Optional rendering options (is_instructor_review, review_field_name). + * + * @return void + */ + public static function render_attempt_answer_badge( $attempt_answer, array $options = array() ): void { + $badge = self::get_attempt_answer_badge( $attempt_answer ); + $is_instructor_review = ! empty( $options['is_instructor_review'] ); + $review_field_name = (string) ( $options['review_field_name'] ?? '' ); + $is_skipped = self::is_attempt_answer_skipped( $attempt_answer ); + + if ( $is_instructor_review && ! $is_skipped && $review_field_name ) { + $label_map = wp_json_encode( $badge['label_map'] ); + $variant_map = wp_json_encode( $badge['variant_map'] ); + $field = esc_attr( $review_field_name ); + + Badge::make() + ->rounded() + ->attr( 'x-text', "({$label_map})[watch('{$field}')] ?? ''" ) + ->attr( ':class', "'tutor-badge tutor-badge-rounded tutor-badge-' + (({$variant_map})[watch('{$field}')] ?? 'info')" ) + ->render(); + } else { + if ( empty( $badge['label'] ) ) { + return; + } + + Badge::make() + ->label( $badge['label'] ) + ->variant( $badge['variant'] ) + ->rounded() + ->render(); } + } - if ( - null === ( $attempt_answer->is_correct ?? null ) && - in_array( $question_type, array( 'open_ended', 'short_answer', 'image_answering' ), true ) - ) { - return 'pending'; + /** + * Get attempt answer counts categorized by status. + * + * Fully correct answers are counted under 'correct' and incorrect answers under 'incorrect'. + * Pending, graded, and skipped answers are excluded. + * + * @since 4.1.0 + * + * @param array|null $answers List of answer objects. + * + * @return array Associative array with answer status counts. + */ + public static function get_attempt_answer_counts( $answers ): array { + $counts = array( + 'correct' => 0, + 'incorrect' => 0, + ); + + if ( is_array( $answers ) ) { + foreach ( $answers as $answer ) { + $status = self::get_attempt_answer_status( $answer ); + if ( isset( $counts[ $status ] ) ) { + ++$counts[ $status ]; + } + } } - return 'incorrect'; + return apply_filters( 'tutor_quiz_attempt_answer_counts', $counts, $answers ); } /** @@ -1762,4 +1897,52 @@ public static function has_quiz_access( $quiz_id, $course_id = 0, $wp_die = true return $has_access; } + + /** + * Get the question feedback map from attempt info. + * + * Consistent extraction for the question_feedback map stored inside + * attempt_info, keyed by attempt answer ID with a question ID fallback. + * + * @since 4.1.0 + * + * @param array|string $attempt_info Attempt info snapshot array or serialized string. + * + * @return array + */ + public static function get_attempt_feedback_map( $attempt_info ): array { + $attempt_info = is_array( $attempt_info ) ? $attempt_info : ( is_string( $attempt_info ) ? maybe_unserialize( $attempt_info ) : array() ); + + if ( ! is_array( $attempt_info ) ) { + return array(); + } + + $feedback_map = $attempt_info['question_feedback'] ?? array(); + + return is_array( $feedback_map ) ? $feedback_map : array(); + } + + /** + * Get the manual overrides map from attempt info. + * + * Consistent extraction for the manual_overrides map stored inside + * attempt_info, keyed by question ID. + * + * @since 4.1.0 + * + * @param array|string $attempt_info Attempt info snapshot array or serialized string. + * + * @return array + */ + public static function get_manual_overrides_map( $attempt_info ): array { + $attempt_info = is_array( $attempt_info ) ? $attempt_info : ( is_string( $attempt_info ) ? maybe_unserialize( $attempt_info ) : array() ); + + if ( ! is_array( $attempt_info ) ) { + return array(); + } + + $overrides_map = $attempt_info['manual_overrides'] ?? array(); + + return is_array( $overrides_map ) ? $overrides_map : array(); + } } diff --git a/templates/dashboard/quiz-attempts/quiz-reviews.php b/templates/dashboard/quiz-attempts/quiz-reviews.php index 2d50e5a356..270f0aa42d 100644 --- a/templates/dashboard/quiz-attempts/quiz-reviews.php +++ b/templates/dashboard/quiz-attempts/quiz-reviews.php @@ -31,8 +31,9 @@ return; } -$form_id = 'quiz-attempt-review-form'; -$form_default_values = array( +$question_feedback_map = QuizModel::get_attempt_feedback_map( $attempt_info ); +$form_id = 'quiz-attempt-review-form'; +$form_default_values = array( 'feedback' => tutor_utils()->count( $attempt_info ) && isset( $attempt_info['instructor_feedback'] ) ? $attempt_info['instructor_feedback'] : '', ); @@ -47,6 +48,13 @@ $attempt_answers_map[ $question_id ] = $question; $answer_status = QuizModel::get_attempt_answer_status( $question ); $form_default_values[ "review_statuses[{$question_id}]" ] = $answer_status; + if ( in_array( $question->question_type, QuizModel::get_manual_review_types(), true ) ) { + $is_unscored = 'pending' === $answer_status && ( empty( $question->achieved_mark ) || 0.0 === (float) $question->achieved_mark ); + $form_default_values[ "manual_marks[{$question_id}]" ] = $is_unscored ? '' : (float) ( $question->achieved_mark ?? 0 ); + + $answer_id = (int) ( $question->attempt_answer_id ?? $question_id ); + $form_default_values[ "question_feedback[{$answer_id}]" ] = (string) ( $question_feedback_map[ $answer_id ] ?? ( $question_feedback_map[ $question_id ] ?? '' ) ); + } } } } @@ -60,7 +68,7 @@ x-data='(() => { const form = tutorForm({ id: "", - mode: "onSubmit", + mode: "onChange", defaultValues: }); const feedback = tutorQuizAttemptFeedback({ diff --git a/templates/learning-area/quiz/content.php b/templates/learning-area/quiz/content.php index 7bdb09e835..7776fb5951 100644 --- a/templates/learning-area/quiz/content.php +++ b/templates/learning-area/quiz/content.php @@ -65,7 +65,7 @@
diff --git a/templates/shared/components/quiz/attempt-details/question-header.php b/templates/shared/components/quiz/attempt-details/question-header.php index f9040d0e30..6306e4a1e3 100644 --- a/templates/shared/components/quiz/attempt-details/question-header.php +++ b/templates/shared/components/quiz/attempt-details/question-header.php @@ -11,51 +11,20 @@ use TUTOR\Quiz; use TUTOR\Icon; -use Tutor\Components\Badge; use Tutor\Components\SvgIcon; - -/** - * Build Alpine.js attribute expressions for a reactive review-status badge. - * - * @param string $review_field_name The form field name, e.g. "review_statuses[42]". - * - * @return array{ x_text: string, class_expr: string } - */ -$build_badge_attrs = function ( string $review_field_name ): array { - $label_map = wp_json_encode( - array( - 'pending' => __( 'Pending', 'tutor' ), - 'correct' => __( 'Correct', 'tutor' ), - 'incorrect' => __( 'Incorrect', 'tutor' ), - ) - ); - - $variant_map = wp_json_encode( - array( - 'pending' => Badge::WARNING, - 'correct' => Badge::SUCCESS, - 'incorrect' => Badge::ERROR, - ) - ); - - $field = esc_attr( $review_field_name ); - - return array( - 'x_text' => "({$label_map})[watch('{$field}')] ?? ''", - 'class_expr' => "'tutor-badge tutor-badge-rounded tutor-badge-' + (({$variant_map})[watch('{$field}')] ?? 'info')", - ); -}; +use Tutor\Models\QuizModel; $index = (int) ( $index ?? 1 ); $question_title = (string) ( $question_title ?? '' ); $question_description = (string) ( $question_description ?? '' ); -$status_badges = isset( $status_badges ) && is_array( $status_badges ) ? $status_badges : array(); $question = isset( $question ) && is_object( $question ) ? $question : null; $answer_status = (string) ( $answer_status ?? '' ); $attempt_id = (int) ( $attempt_id ?? 0 ); $attempt_answer_id = (int) ( $attempt_answer_id ?? 0 ); $is_instructor_review = ! empty( $is_instructor_review ); +$is_skipped = ! empty( $is_skipped ); $review_field_name = (string) ( $review_field_name ?? '' ); +$is_manual_question = $question && in_array( (string) ( $question->question_type ?? '' ), QuizModel::get_manual_review_types(), true ); ?>
@@ -81,87 +50,89 @@
- +
- -
- - + $is_instructor_review, + 'review_field_name' => $review_field_name, + ) + ); + ?> +
- Badge::make() - ->rounded() - ->attr( 'x-text', $badge_attrs['x_text'] ) - ->attr( ':class', $badge_attrs['class_expr'] ) - ->render(); - else : - $badge_label = (string) ( $badge['label'] ?? '' ); - $badge_variant = (string) ( $badge['variant'] ?? '' ); - - if ( '' === $badge_label || '' === $badge_variant ) { - continue; - } - - Badge::make() - ->label( $badge_label ) - ->variant( $badge_variant ) - ->rounded() - ->render(); - endif; - ?> - -
+ question_mark ); + if ( $show_header_score ) : + $achieved_formatted = (string) round( (float) ( $question->achieved_mark ?? 0 ), 2 ); + $total_formatted = (string) round( (float) ( $question->question_mark ?? 0 ), 2 ); + ?> + + + + - + -
- - -
diff --git a/templates/shared/components/quiz/attempt-details/question.php b/templates/shared/components/quiz/attempt-details/question.php index e37d2e3ac6..83f9c2f86c 100644 --- a/templates/shared/components/quiz/attempt-details/question.php +++ b/templates/shared/components/quiz/attempt-details/question.php @@ -9,7 +9,6 @@ defined( 'ABSPATH' ) || exit; -use Tutor\Components\Badge; use Tutor\Models\QuizModel; if ( ! isset( $question ) || ! is_object( $question ) || empty( $question_template ) ) { @@ -20,9 +19,10 @@ $attempt_id = (int) ( $attempt_id ?? 0 ); $back_url = (string) ( $back_url ?? '' ); $context = (string) ( $context ?? '' ); -$is_instructor_review = ! empty( $is_instructor_review ); -$review_field_name = (string) ( $review_field_name ?? '' ); -$question_settings = maybe_unserialize( $question->question_settings ); +$is_instructor_review = ! empty( $is_instructor_review ); +$is_overridden = ! empty( $is_overridden ); +$review_field_name = (string) ( $review_field_name ?? '' ); +$question_settings = maybe_unserialize( $question->question_settings ); $question_settings = is_array( $question_settings ) ? $question_settings : array(); $question_type = (string) ( $question->question_type ?? '' ); @@ -37,36 +37,6 @@ $is_skipped = QuizModel::is_attempt_answer_skipped( $question ); $review_status = $question ? QuizModel::get_attempt_answer_status( $question ) : 'skipped'; $answer_status = $review_status; -$status_badges = array(); - -if ( $is_skipped ) { - $status_badges[] = array( - 'label' => __( 'Skipped', 'tutor' ), - 'variant' => Badge::INFO, - ); -} - -if ( $is_instructor_review ) { - $status_badges[] = array( - 'status' => $review_status, - ); -} elseif ( 'correct' === $review_status ) { - $status_badges[] = array( - 'label' => __( 'Correct', 'tutor' ), - 'variant' => Badge::SUCCESS, - ); -} elseif ( 'pending' === $review_status ) { - $status_badges[] = array( - 'label' => __( 'Pending', 'tutor' ), - 'variant' => Badge::WARNING, - ); -} elseif ( 'incorrect' === $review_status ) { - $status_badges[] = array( - 'label' => __( 'Incorrect', 'tutor' ), - 'variant' => Badge::ERROR, - ); -} - $question_wrapper_classes = array( 'tutor-quiz-question' ); if ( 'review-answer-dnd' === $question_template ) { @@ -86,22 +56,30 @@ 'question_description' => (string) ( $question->question_description ?? '' ), 'question_mark' => (string) ( $question->question_mark ?? '' ), 'show_question_mark' => '1' === (string) ( $question_settings['show_question_mark'] ?? '1' ), - 'status_badges' => $status_badges, 'answer_status' => $answer_status, 'attempt_id' => $attempt_id, 'attempt_answer_id' => (int) ( $question->attempt_answer_id ?? 0 ), + 'is_skipped' => $is_skipped, 'back_url' => $back_url, 'context' => $context, 'is_instructor_review' => $is_instructor_review, 'review_field_name' => $review_field_name, + 'is_overridden' => $is_overridden, ) ); tutor_load_template( 'shared.components.quiz.attempt-details.questions.' . $question_template, array( - 'question' => $question, - 'index' => $index, + 'question' => $question, + 'index' => $index, + 'is_instructor_review' => $is_instructor_review, + 'is_skipped' => $is_skipped, + 'review_status' => $review_status, + 'manual_mark_field' => "manual_marks[{$question->question_id}]", + 'question_feedback' => (string) ( $question_feedback ?? '' ), + 'attempt_id' => $attempt_id, + 'attempt_answer_id' => (int) ( $question->attempt_answer_id ?? 0 ), ) ); @@ -109,6 +87,7 @@ if ( is_object( $question ) ) { do_action( 'tutor_quiz_attempt_details_loop_after_row', $question, $answer_status, array() ); + do_action( 'tutor_quiz_attempt_details_mark_breakdown', $question, $answer_status, $is_instructor_review, $is_overridden ); } ?>
diff --git a/templates/shared/components/quiz/attempt-details/questions-sidebar.php b/templates/shared/components/quiz/attempt-details/questions-sidebar.php index ccc1cde59c..2e2d4d3818 100644 --- a/templates/shared/components/quiz/attempt-details/questions-sidebar.php +++ b/templates/shared/components/quiz/attempt-details/questions-sidebar.php @@ -25,10 +25,15 @@ $question_status_map = array(); $default_item_status = ( isset( $attempt_data ) && is_object( $attempt_data ) ) ? 'incorrect' : ''; -$status_priority = array( - 'correct' => 1, - 'incorrect' => 2, - 'pending' => 3, +$status_priority = apply_filters( + 'tutor_quiz_questions_sidebar_status_priority', + array( + 'correct' => 1, + 'incorrect' => 2, + 'pending' => 3, + 'graded' => 4, + 'skipped' => 5, + ) ); if ( isset( $attempt_data ) && is_object( $attempt_data ) && ! empty( $attempt_data->attempt_id ) ) { @@ -41,11 +46,13 @@ continue; } - $answer_status = QuizModel::get_attempt_answer_status( $answer_row ); - $item_status = 'correct' === $answer_status ? 'correct' : ( 'pending' === $answer_status ? 'pending' : 'incorrect' ); - $current = $question_status_map[ $question_id ] ?? ''; + $answer_status = QuizModel::get_attempt_answer_status( $answer_row ); + $item_status = $answer_status; + $current = $question_status_map[ $question_id ] ?? ''; + $item_priority = $status_priority[ $item_status ] ?? 0; + $current_priority = $status_priority[ $current ] ?? 0; - if ( ! $current || $status_priority[ $item_status ] > $status_priority[ $current ] ) { + if ( ! $current || $item_priority > $current_priority ) { $question_status_map[ $question_id ] = $item_status; } } diff --git a/templates/shared/components/quiz/attempt-details/questions/open-ended.php b/templates/shared/components/quiz/attempt-details/questions/open-ended.php index a43beaa654..d85e8941c4 100644 --- a/templates/shared/components/quiz/attempt-details/questions/open-ended.php +++ b/templates/shared/components/quiz/attempt-details/questions/open-ended.php @@ -9,6 +9,14 @@ defined( 'ABSPATH' ) || exit; +use Tutor\Components\Button; +use Tutor\Components\Constants\Size; +use Tutor\Components\Constants\Variant; +use Tutor\Components\InputField; +use Tutor\Components\SvgIcon; +use TUTOR\Icon; +use Tutor\Models\QuizModel; + if ( ! isset( $question ) || ! is_object( $question ) ) { return; } @@ -23,17 +31,193 @@ $given_answer = (string) $given_raw; } } + +$is_instructor_review = ! empty( $is_instructor_review ); +$is_skipped = ! empty( $is_skipped ); +$review_status = (string) ( $review_status ?? '' ); +$manual_mark_field = (string) ( $manual_mark_field ?? '' ); +$question_feedback = (string) ( $question_feedback ?? '' ); +$feedback_attempt_id = (int) ( $attempt_id ?? ( $question->quiz_attempt_id ?? 0 ) ); +$feedback_attempt_answer_id = (int) ( $attempt_answer_id ?? ( $question->attempt_answer_id ?? ( $question->question_id ?? 0 ) ) ); +$question_id = (int) ( $question->question_id ?? 0 ); + +if ( ! $feedback_attempt_answer_id && $question_id > 0 ) { + $feedback_attempt_answer_id = $question_id; +} + +if ( '' === $question_feedback && isset( $attempt_data->attempt_info ) ) { + $question_feedback_map = QuizModel::get_attempt_feedback_map( $attempt_data->attempt_info ); + $question_feedback = (string) ( $question_feedback_map[ $feedback_attempt_answer_id ] ?? ( $question_feedback_map[ $question_id ] ?? '' ) ); +} + +$qmark_formatted = (string) round( (float) ( $question->question_mark ?? 0 ), 2 ); +$achieved_raw = isset( $question->achieved_mark ) && null !== $question->achieved_mark && '' !== $question->achieved_mark ? (float) $question->achieved_mark : null; +$is_unscored = 'pending' === $review_status && ( null === $achieved_raw || 0.0 === (float) $achieved_raw ); +$achieved_val = ( null !== $achieved_raw && ! $is_unscored ) ? (string) round( $achieved_raw, 2 ) : ''; + +$is_graded = 'graded' === $review_status; +$mark_validation_rules = array( + 'min' => array( + 'value' => 0, + 'message' => __( 'Mark cannot be negative', 'tutor' ), + ), + 'max' => array( + 'value' => (float) $qmark_formatted, + 'message' => sprintf( + /* translators: %s: maximum mark */ + __( 'Mark cannot exceed %s', 'tutor' ), + $qmark_formatted + ), + ), +); + +if ( $is_graded ) { + $mark_validation_rules['required'] = __( 'Mark is required', 'tutor' ); +} ?>
-
-
- -
+
+
+ + +
+
+ +
+ type( 'number' ) + ->name( $manual_mark_field ) + ->id( 'tutor-' . $manual_mark_field ) + ->value( $achieved_val ) + ->placeholder( '—' ) + ->attr( 'min', '0' ) + ->attr( 'max', $qmark_formatted ) + ->attr( 'step', 'any' ) + ->attr( 'style', 'width: 80px;' ) + ->attr( + 'x-bind', + 'register(' . wp_json_encode( $manual_mark_field ) . ', ' . wp_json_encode( $mark_validation_rules ) . ')' + ) + ->render(); + ?> + / +
+
+ + 0 ) : ?> +
+ })' + > + label( __( 'Add Feedback', 'tutor' ) ) + ->icon( Icon::COMMENTS ) + ->variant( Variant::LINK ) + ->size( Size::SM ) + ->attr( 'type', 'button' ) + ->attr( 'class', 'tutor-quiz-add-feedback-btn' ) + ->attr( 'x-show', '!expanded && !feedback' ) + ->attr( 'x-cloak', true ) + ->attr( 'x-collapse', true ) + ->attr( '@click', 'toggle()' ) + ->render(); + + Button::make() + ->label( __( 'Show Feedback', 'tutor' ) ) + ->icon( Icon::EYE_LINE ) + ->variant( Variant::LINK ) + ->size( Size::SM ) + ->attr( 'type', 'button' ) + ->attr( 'class', 'tutor-quiz-add-feedback-btn' ) + ->attr( 'x-show', '!expanded && feedback' ) + ->attr( 'x-cloak', true ) + ->attr( 'x-collapse', true ) + ->attr( '@click', 'toggle()' ) + ->render(); + ?> + +
+
+ +
+ + type( 'textarea' ) + ->name( "question_feedback[{$feedback_attempt_answer_id}]" ) + ->placeholder( __( 'Write feedback for the student...', 'tutor' ) ) + ->attr( 'rows', '3' ) + ->attr( + 'x-bind', + 'register(' . wp_json_encode( "question_feedback[{$feedback_attempt_answer_id}]" ) . ')' + ) + ->render(); + ?> + +
+
+ label( __( 'Delete', 'tutor' ) ) + ->variant( Variant::DESTRUCTIVE ) + ->size( Size::SM ) + ->attr( 'type', 'button' ) + ->attr( '@click.prevent', 'del()' ) + ->attr( 'x-show', 'feedback' ) + ->attr( 'x-cloak', true ) + ->render(); + ?> +
+ +
+ label( __( 'Cancel', 'tutor' ) ) + ->variant( Variant::GHOST ) + ->size( Size::SM ) + ->attr( 'type', 'button' ) + ->attr( '@click.prevent', 'cancel()' ) + ->render(); + + Button::make() + ->label( __( 'Save', 'tutor' ) ) + ->variant( Variant::PRIMARY ) + ->size( Size::SM ) + ->attr( 'type', 'button' ) + ->attr( '@click.prevent', 'save()' ) + ->render(); + ?> +
+
+
+
+ +
+ + + +
+
+ +
+
+ +
+
+
diff --git a/templates/shared/components/quiz/attempt-details/review-answers.php b/templates/shared/components/quiz/attempt-details/review-answers.php index 3f767dfbc3..28b09937e1 100644 --- a/templates/shared/components/quiz/attempt-details/review-answers.php +++ b/templates/shared/components/quiz/attempt-details/review-answers.php @@ -9,12 +9,18 @@ defined( 'ABSPATH' ) || exit; -use Tutor\Quiz; +use TUTOR\Quiz; +use Tutor\Models\QuizModel; -$questions = isset( $questions ) && is_array( $questions ) ? $questions : array(); -$attempt_data = isset( $attempt_data ) && is_object( $attempt_data ) ? $attempt_data : null; -$back_url = isset( $back_url ) ? (string) $back_url : ''; -$context = isset( $context ) ? (string) $context : ''; +$questions = isset( $questions ) && is_array( $questions ) ? $questions : array(); +$attempt_data = isset( $attempt_data ) && is_object( $attempt_data ) ? $attempt_data : null; +$back_url = isset( $back_url ) ? (string) $back_url : ''; +$context = isset( $context ) ? (string) $context : ''; +$is_instructor_review = ! empty( $is_instructor_review ); + +$attempt_info = $attempt_data && is_object( $attempt_data ) && isset( $attempt_data->attempt_info ) ? maybe_unserialize( $attempt_data->attempt_info ) : array(); +$question_feedback_map = QuizModel::get_attempt_feedback_map( $attempt_info ); +$manual_overrides_map = QuizModel::get_manual_overrides_map( $attempt_info ); ?>
@@ -73,8 +79,10 @@ 'is_manually_reviewed' => ! empty( $attempt_data->is_manually_reviewed ), 'back_url' => $back_url, 'context' => $context, - 'is_instructor_review' => $is_instructor_review, - 'review_field_name' => "review_statuses[{$question_id}]", + 'is_instructor_review' => $is_instructor_review, + 'review_field_name' => "review_statuses[{$question_id}]", + 'question_feedback' => (string) ( $question_feedback_map[ $question->attempt_answer_id ?? 0 ] ?? '' ), + 'is_overridden' => ! empty( $manual_overrides_map[ $question_id ] ), ) ); } diff --git a/templates/shared/components/quiz/attempt-details/summary.php b/templates/shared/components/quiz/attempt-details/summary.php index 5ff0c25bd4..50dec0539b 100644 --- a/templates/shared/components/quiz/attempt-details/summary.php +++ b/templates/shared/components/quiz/attempt-details/summary.php @@ -52,22 +52,9 @@ $attempt_duration = $timing['attempt_duration'] ?? ''; $attempt_duration_taken = $timing['attempt_duration_taken'] ?? ''; -$answers = isset( $answers ) ? $answers : QuizModel::get_quiz_answers_by_attempt_id( $attempt_id ); -$correct = 0; -$incorrect = 0; +$answers = isset( $answers ) ? $answers : QuizModel::get_quiz_answers_by_attempt_id( $attempt_id ); -if ( is_array( $answers ) ) { - foreach ( $answers as $answer ) { - if ( ! empty( $answer->is_correct ) ) { - ++$correct; - } elseif ( ! in_array( $answer->question_type, array( 'open_ended', 'short_answer' ), true ) ) { - ++$incorrect; - } - } -} - -$total_questions = (int) $attempt_data->total_questions; -$attempts_count = 0; +$attempts_count = 0; $attempts = ( new QuizModel() )->quiz_attempts( $quiz_id, get_current_user_id() ); if ( is_array( $attempts ) ) { @@ -172,56 +159,24 @@
-
- %d correct', 'tutor' ), - array( - 'span' => array( - 'class' => true, - ), - ) - ), - esc_html( $correct ) - ); - ?> -
+ array( + 'class' => true, + ), + ); -
- %d incorrect', 'tutor' ), - array( - 'span' => array( - 'class' => true, - ), - ) - ), - esc_html( $incorrect ) - ); + foreach ( Quiz_Attempts_List::get_quiz_attempt_summary_statics( $attempt_data, $answers ) as $item ) { + $item_class = isset( $item['class'] ) ? $item['class'] : ''; + $item_label = isset( $item['label'] ) ? $item['label'] : ''; + $item_count = isset( $item['count'] ) ? (int) $item['count'] : 0; ?> -
- -
+
+ +
%d total', 'tutor' ), - array( - 'span' => array( - 'class' => true, - ), - ) - ), - esc_html( $total_questions ) - ); - ?> -
+ } + ?>
diff --git a/views/options/field-types/toggle_switch.php b/views/options/field-types/toggle_switch.php index 0e42177d42..786aad0569 100644 --- a/views/options/field-types/toggle_switch.php +++ b/views/options/field-types/toggle_switch.php @@ -28,25 +28,25 @@
', esc_attr( $field['label'] ) ) : null; ?> %s', esc_attr( $field['label_tag'] ) ) : null; ?> + +
+ +
+ %s', wp_kses_post( $field['desc'] ) ) : null; ?> diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php index a90f29f5bd..8813135e60 100644 --- a/views/quiz/attempt-details.php +++ b/views/quiz/attempt-details.php @@ -218,19 +218,10 @@ function tutor_render_question_type_icon( $question_type ) { extract( QuizModel::get_quiz_attempt_timing( $attempt_data ) ); // $attempt_duration, $attempt_duration_taken; // Prepare the correct/incorrect answer count for the first summary table. -$answers = QuizModel::get_quiz_answers_by_attempt_id( $attempt_id ); -$correct = 0; -$incorrect = 0; -if ( is_array( $answers ) && count( $answers ) > 0 ) { - foreach ( $answers as $answer ) { - if ( (bool) isset( $answer->is_correct ) ? $answer->is_correct : '' ) { - $correct++; - } elseif ( 'open_ended' === $answer->question_type || 'short_answer' === $answer->question_type ) { - } else { - $incorrect++; - } - } -} +$answers = QuizModel::get_quiz_answers_by_attempt_id( $attempt_id ); +$answer_counts = QuizModel::get_attempt_answer_counts( $answers ); +$correct = $answer_counts['correct']; +$incorrect = $answer_counts['incorrect']; // Prepare the column list for the first summary table. $page_key = 'attempt-details-summary'; @@ -354,10 +345,18 @@ function tutor_render_question_type_icon( $question_type ) { query_vars; -$page_name = isset( $query_vars['tutor_dashboard_page'] ) ? $query_vars['tutor_dashboard_page'] : ''; -$attempt_info = maybe_unserialize( $attempt_data->attempt_info ); -$feedback = is_array( $attempt_info ) && isset( $attempt_info['instructor_feedback'] ) ? $attempt_info['instructor_feedback'] : ''; +$query_vars = $wp_query->query_vars; +$page_name = isset( $query_vars['tutor_dashboard_page'] ) ? $query_vars['tutor_dashboard_page'] : ''; +$attempt_info = maybe_unserialize( $attempt_data->attempt_info ); +$feedback = is_array( $attempt_info ) && isset( $attempt_info['instructor_feedback'] ) ? $attempt_info['instructor_feedback'] : ''; +$question_feedback_map = is_array( $attempt_info ) && isset( $attempt_info['question_feedback'] ) && is_array( $attempt_info['question_feedback'] ) ? $attempt_info['question_feedback'] : array(); +$manual_overrides_map = is_array( $attempt_info ) && isset( $attempt_info['manual_overrides'] ) && is_array( $attempt_info['manual_overrides'] ) ? $attempt_info['manual_overrides'] : array(); +$is_student_context = in_array( $context, array( 'course-single-previous-attempts', 'frontend-dashboard-my-attempts' ), true ); +$is_instructor_review = ! $is_student_context && ( + 'frontend-dashboard-students-attempts' === $context || + 'backend-dashboard-students-attempts' === $context || + ( is_admin() && empty( $context ) ) +) && tutor_utils()->can_user_manage( 'attempt', $attempt_id ); // don't show on instructor quiz attempt since below already have feedback box area. if ( '' !== $feedback && 'my-quiz-attempts' === $page_name ) { ?> @@ -372,10 +371,12 @@ function tutor_render_question_type_icon( $question_type ) { ' . esc_html__( 'Quiz Overview', 'tutor' ) . '' : ''; ?>
@@ -390,452 +391,515 @@ function tutor_render_question_type_icon( $question_type ) { question_type ); - $question_settings = maybe_unserialize( $answer->question_settings ); - $is_image_matching = isset( $question_settings['is_image_matching'] ) && '1' === $question_settings['is_image_matching']; - $answer_status = 'wrong'; - - // If already correct, then show it. - if ( (bool) $answer->is_correct ) { - $answer_status = 'correct'; - } - - // Image answering also needs review since the answer texts are not meant to match exactly. - elseif ( in_array( $answer->question_type, array( 'open_ended', 'short_answer', 'image_answering' ), true ) ) { - $answer_status = null === $answer->is_correct ? 'pending' : 'wrong'; - } - - // Allow Pro and add-ons to set answer status for custom question types. - /** - * Filter to set answer status for custom question types. - * Pro handles draw_image via this filter. - * - * @param string|null $answer_status Current answer status (null if not set). - * @param object $answer Answer object. - * - * @return string|null Answer status or null to use default. - */ - $custom_status = apply_filters( 'tutor_quiz_answer_status_for_question_type', null, $answer ); - if ( null !== $custom_status ) { - $answer_status = $custom_status; - } + ++$answer_i; + $question_type = QuizModel::get_question_types( $answer->question_type ); + $question_settings = maybe_unserialize( $answer->question_settings ); + $is_image_matching = isset( $question_settings['is_image_matching'] ) && '1' === $question_settings['is_image_matching']; + $answer_status = QuizModel::get_attempt_answer_status( $answer ); + $student_q_feedback = trim( (string) ( $question_feedback_map[ $answer->attempt_answer_id ] ?? ( $question_feedback_map[ $answer->question_id ] ?? '' ) ) ); + $feedback_dom_id = 'tutor-question-feedback-' . ( ! empty( $answer->attempt_answer_id ) ? $answer->attempt_answer_id : $answer->question_id ); ?> - + $column ) : ?> - - - - - - - - -
- question_type ); - if ( ! empty( $question_icon_name ) ) { - SvgIcon::make() - ->name( $question_icon_name ) - ->size( 32 ) - ->render(); - } - ?> - - question_type )['name'] ?? '' ); ?> - -
- - - - - question_title ) ); ?> - - - + + + + + + - -
+ case 'type': + ?> + +
question_type ) { - $get_answers = tutor_utils()->get_answer_by_id( $answer->given_answer ); - tutor_render_answer_list( $get_answers ); - } - - - // True false or single choice. - if ( 'true_false' === $answer->question_type ) { - $get_answers = tutor_utils()->get_answer_by_id( $answer->given_answer ); - $answer_titles = wp_list_pluck( $get_answers, 'answer_title' ); - $answer_titles = array_map( 'stripslashes', $answer_titles ); - - echo '' . - implode( '

', $answer_titles ) . //phpcs:ignore - ''; - } - - // Multiple choice. - elseif ( 'multiple_choice' === $answer->question_type ) { - $get_answers = tutor_utils()->get_answer_by_id( maybe_unserialize( $answer->given_answer ) ); - tutor_render_answer_list( $get_answers ); - } - - // Fill in the blank. - elseif ( 'fill_in_the_blank' === $answer->question_type ) { - $answer_titles = maybe_unserialize( $answer->given_answer ); - $get_db_answers_by_question = QuizModel::get_answers_by_quiz_question( $answer->question_id ); - - echo tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $answer_titles ); //phpcs:ignore --contain safe data - } - - // Open ended or short answer. - elseif ( 'open_ended' === $answer->question_type || 'short_answer' === $answer->question_type ) { - if ( $answer->given_answer ) { - echo wp_kses( - wpautop( stripslashes( $answer->given_answer ) ), - array( - 'p' => true, - 'span' => true, - ) - ); - } + $question_icon_name = tutor_render_question_type_icon( $answer->question_type ); + if ( ! empty( $question_icon_name ) ) { + SvgIcon::make() + ->name( $question_icon_name ) + ->size( 32 ) + ->render(); } + ?> + + question_type )['name'] ?? '' ); ?> + +

+ + question_type ) { - $ordering_ids = maybe_unserialize( $answer->given_answer ); - foreach ( $ordering_ids as $ordering_id ) { - $get_answers = tutor_utils()->get_answer_by_id( $ordering_id ); - tutor_render_answer_list( $get_answers ); - } - } - - // Matching. - elseif ( 'matching' === $answer->question_type ) { - - $ordering_ids = maybe_unserialize( $answer->given_answer ); - $original_saved_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id ); - - $answers = array(); - - foreach ( $original_saved_answers as $key => $original_saved_answer ) { - $provided_answer_order_id = isset( $ordering_ids[ $key ] ) ? $ordering_ids[ $key ] : 0; - $provided_answer_order = tutor_utils()->get_answer_by_id( $provided_answer_order_id ); - if ( tutor_utils()->count( $provided_answer_order ) ) { - foreach ( $provided_answer_order as $provided_answer_order ) { - if ( $is_image_matching ) { - $original_saved_answer->answer_view_format = 'text_image'; - $original_saved_answer->answer_title = $provided_answer_order->answer_title; - $original_saved_answer->answer_two_gap_match = ''; - $answers[] = $original_saved_answer; - } else { - $original_saved_answer->answer_two_gap_match = $provided_answer_order->answer_two_gap_match; - $answers[] = $original_saved_answer; - } - } - } - } - - tutor_render_answer_list( $answers ); - } elseif ( 'image_matching' === $answer->question_type ) { - - $ordering_ids = maybe_unserialize( $answer->given_answer ); - $original_saved_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id ); - - $answers = array(); - - foreach ( $original_saved_answers as $key => $original_saved_answer ) { - $provided_answer_order_id = isset( $ordering_ids[ $key ] ) ? $ordering_ids[ $key ] : 0; - $provided_answer_order = tutor_utils()->get_answer_by_id( $provided_answer_order_id ); - foreach ( $provided_answer_order as $p_answer ) { - if ( $p_answer->answer_title ) { - $original_saved_answer->answer_view_format = 'text_image'; - $original_saved_answer->answer_title = $p_answer->answer_title; - $answers[] = $original_saved_answer; - } - } - } - - tutor_render_answer_list( $answers ); - } elseif ( 'image_answering' === $answer->question_type ) { - - $ordering_ids = maybe_unserialize( $answer->given_answer ); - - $answers = array(); + case 'questions': + ?> + + + question_title ) ); ?> + + + $image_answer ) { - $db_answers = tutor_utils()->get_answer_by_id( $answer_id ); - foreach ( $db_answers as $db_answer ) { + case 'given_answer': + ?> + +
+ question_type ) { + $get_answers = tutor_utils()->get_answer_by_id( $answer->given_answer ); + tutor_render_answer_list( $get_answers ); + } + + + // True false or single choice. + if ( 'true_false' === $answer->question_type ) { + $get_answers = tutor_utils()->get_answer_by_id( $answer->given_answer ); + $answer_titles = wp_list_pluck( $get_answers, 'answer_title' ); + $answer_titles = array_map( 'stripslashes', $answer_titles ); + + echo '' . + implode( '

', $answer_titles ) . //phpcs:ignore + ''; + } + + // Multiple choice. + elseif ( 'multiple_choice' === $answer->question_type ) { + $get_answers = tutor_utils()->get_answer_by_id( maybe_unserialize( $answer->given_answer ) ); + tutor_render_answer_list( $get_answers ); + } + + // Fill in the blank. + elseif ( 'fill_in_the_blank' === $answer->question_type ) { + $answer_titles = maybe_unserialize( $answer->given_answer ); + $get_db_answers_by_question = QuizModel::get_answers_by_quiz_question( $answer->question_id ); + + echo tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $answer_titles ); //phpcs:ignore --contain safe data + } + + // Open ended or short answer. + elseif ( 'open_ended' === $answer->question_type || 'short_answer' === $answer->question_type ) { + if ( $answer->given_answer ) { + echo wp_kses( + wpautop( stripslashes( $answer->given_answer ) ), + array( + 'p' => true, + 'span' => true, + ) + ); + } + } + + // Ordering. + elseif ( 'ordering' === $answer->question_type ) { + $ordering_ids = maybe_unserialize( $answer->given_answer ); + foreach ( $ordering_ids as $ordering_id ) { + $get_answers = tutor_utils()->get_answer_by_id( $ordering_id ); + tutor_render_answer_list( $get_answers ); + } + } + + // Matching. + elseif ( 'matching' === $answer->question_type ) { + + $ordering_ids = maybe_unserialize( $answer->given_answer ); + $original_saved_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id ); + + $answers = array(); + + foreach ( $original_saved_answers as $key => $original_saved_answer ) { + $provided_answer_order_id = isset( $ordering_ids[ $key ] ) ? $ordering_ids[ $key ] : 0; + $provided_answer_order = tutor_utils()->get_answer_by_id( $provided_answer_order_id ); + if ( tutor_utils()->count( $provided_answer_order ) ) { + foreach ( $provided_answer_order as $provided_answer_order ) { + if ( $is_image_matching ) { + $original_saved_answer->answer_view_format = 'text_image'; + $original_saved_answer->answer_title = $provided_answer_order->answer_title; + $original_saved_answer->answer_two_gap_match = ''; + $answers[] = $original_saved_answer; + } else { + $original_saved_answer->answer_two_gap_match = $provided_answer_order->answer_two_gap_match; + $answers[] = $original_saved_answer; } - $db_answer->answer_title = $image_answer; - $db_answer->answer_view_format = 'text_image'; - $answers[] = $db_answer; - } - - tutor_render_answer_list( $answers ); - } else { - /** - * Allow Pro and add-ons to render given answer for custom question types. - * Pro handles draw_image and pin_image via this action. - * - * @param object $answer Answer object. - */ - do_action( 'tutor_quiz_render_given_answer_for_question_type', $answer ); } - ?> -

- - - -
- question_type != 'open_ended' && $answer->question_type != 'short_answer' ) ) { - - global $wpdb; - - // True false. - if ( 'true_false' === $answer->question_type ) { - $correct_answer = $wpdb->get_var( - $wpdb->prepare( - "SELECT answer_title FROM {$wpdb->prefix}tutor_quiz_question_answers - WHERE belongs_question_id = %d - AND belongs_question_type='true_false' - AND is_correct = 1", - $answer->question_id - ) - ); + } - echo '' . - esc_html( $correct_answer ) . - ''; - } + tutor_render_answer_list( $answers ); + } elseif ( 'image_matching' === $answer->question_type ) { - // Single choice. - elseif ( 'single_choice' === $answer->question_type ) { - $correct_answer = $wpdb->get_results( - $wpdb->prepare( - "SELECT answer_title, image_id, answer_view_format - FROM {$wpdb->prefix}tutor_quiz_question_answers - WHERE belongs_question_id = %d - AND belongs_question_type='single_choice' AND - is_correct = 1", - $answer->question_id - ) - ); + $ordering_ids = maybe_unserialize( $answer->given_answer ); + $original_saved_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id ); - tutor_render_answer_list( $correct_answer ); - } + $answers = array(); - // Multiple choice. - elseif ( 'multiple_choice' === $answer->question_type ) { - $correct_answer = $wpdb->get_results( - $wpdb->prepare( - "SELECT answer_title, image_id, answer_view_format - FROM {$wpdb->prefix}tutor_quiz_question_answers - WHERE belongs_question_id = %d - AND belongs_question_type='multiple_choice' - AND is_correct = 1 ;", - $answer->question_id - ) - ); - - tutor_render_answer_list( $correct_answer ); + foreach ( $original_saved_answers as $key => $original_saved_answer ) { + $provided_answer_order_id = isset( $ordering_ids[ $key ] ) ? $ordering_ids[ $key ] : 0; + $provided_answer_order = tutor_utils()->get_answer_by_id( $provided_answer_order_id ); + foreach ( $provided_answer_order as $p_answer ) { + if ( $p_answer->answer_title ) { + $original_saved_answer->answer_view_format = 'text_image'; + $original_saved_answer->answer_title = $p_answer->answer_title; + $answers[] = $original_saved_answer; } + } + } - // Fill in the blanks. - elseif ( 'fill_in_the_blank' === $answer->question_type ) { - $correct_answer = $wpdb->get_var( - $wpdb->prepare( - "SELECT answer_two_gap_match FROM {$wpdb->prefix}tutor_quiz_question_answers - WHERE belongs_question_id = %d - AND belongs_question_type='fill_in_the_blank'", - $answer->question_id - ) - ); + tutor_render_answer_list( $answers ); + } elseif ( 'image_answering' === $answer->question_type ) { - $answer_titles = explode( '|', stripslashes( $correct_answer ) ); - $get_db_answers_by_question = QuizModel::get_answers_by_quiz_question( $answer->question_id ); + $ordering_ids = maybe_unserialize( $answer->given_answer ); - echo tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $answer_titles ); //phpcs:ignore --contain safe data - } + $answers = array(); - // Ordering. - elseif ( 'ordering' === $answer->question_type ) { - $correct_answer = $wpdb->get_results( - $wpdb->prepare( - "SELECT answer_title, image_id, answer_view_format - FROM {$wpdb->prefix}tutor_quiz_question_answers - WHERE belongs_question_id = %d - AND belongs_question_type='ordering' - ORDER BY answer_order ASC;", - $answer->question_id - ) - ); + foreach ( $ordering_ids as $answer_id => $image_answer ) { + $db_answers = tutor_utils()->get_answer_by_id( $answer_id ); + foreach ( $db_answers as $db_answer ) { + } + $db_answer->answer_title = $image_answer; + $db_answer->answer_view_format = 'text_image'; + $answers[] = $db_answer; + + } + + tutor_render_answer_list( $answers ); + } else { + /** + * Allow Pro and add-ons to render given answer for custom question types. + * Pro handles draw_image and pin_image via this action. + * + * @param object $answer Answer object. + */ + do_action( 'tutor_quiz_render_given_answer_for_question_type', $answer ); + } + ?> +
+ + + +
+ question_type != 'open_ended' && $answer->question_type != 'short_answer' ) ) { + + global $wpdb; + + // True false. + if ( 'true_false' === $answer->question_type ) { + $correct_answer = $wpdb->get_var( + $wpdb->prepare( + "SELECT answer_title FROM {$wpdb->prefix}tutor_quiz_question_answers + WHERE belongs_question_id = %d + AND belongs_question_type='true_false' + AND is_correct = 1", + $answer->question_id + ) + ); + + echo '' . + esc_html( $correct_answer ) . + ''; + } + + // Single choice. + elseif ( 'single_choice' === $answer->question_type ) { + $correct_answer = $wpdb->get_results( + $wpdb->prepare( + "SELECT answer_title, image_id, answer_view_format + FROM {$wpdb->prefix}tutor_quiz_question_answers + WHERE belongs_question_id = %d + AND belongs_question_type='single_choice' AND + is_correct = 1", + $answer->question_id + ) + ); + + tutor_render_answer_list( $correct_answer ); + } + + // Multiple choice. + elseif ( 'multiple_choice' === $answer->question_type ) { + $correct_answer = $wpdb->get_results( + $wpdb->prepare( + "SELECT answer_title, image_id, answer_view_format + FROM {$wpdb->prefix}tutor_quiz_question_answers + WHERE belongs_question_id = %d + AND belongs_question_type='multiple_choice' + AND is_correct = 1 ;", + $answer->question_id + ) + ); + + tutor_render_answer_list( $correct_answer ); + } + + // Fill in the blanks. + elseif ( 'fill_in_the_blank' === $answer->question_type ) { + $correct_answer = $wpdb->get_var( + $wpdb->prepare( + "SELECT answer_two_gap_match FROM {$wpdb->prefix}tutor_quiz_question_answers + WHERE belongs_question_id = %d + AND belongs_question_type='fill_in_the_blank'", + $answer->question_id + ) + ); + + $answer_titles = explode( '|', stripslashes( $correct_answer ) ); + $get_db_answers_by_question = QuizModel::get_answers_by_quiz_question( $answer->question_id ); + + echo tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $answer_titles ); //phpcs:ignore --contain safe data + } + + // Ordering. + elseif ( 'ordering' === $answer->question_type ) { + $correct_answer = $wpdb->get_results( + $wpdb->prepare( + "SELECT answer_title, image_id, answer_view_format + FROM {$wpdb->prefix}tutor_quiz_question_answers + WHERE belongs_question_id = %d + AND belongs_question_type='ordering' + ORDER BY answer_order ASC;", + $answer->question_id + ) + ); + + foreach ( $correct_answer as $ans ) { + tutor_render_answer_list( array( $ans ) ); + } + } + + // Matching. + elseif ( 'matching' === $answer->question_type ) { + $correct_answer = $wpdb->get_results( + $wpdb->prepare( + "SELECT answer_title, image_id, answer_two_gap_match, answer_view_format + FROM {$wpdb->prefix}tutor_quiz_question_answers + WHERE belongs_question_id = %d + AND belongs_question_type='matching' + ORDER BY answer_order ASC;", + $answer->question_id + ) + ); + + if ( $is_image_matching ) { + array_map( + function ( $ans ) { + $ans->answer_view_format = 'text_image'; + $ans->answer_two_gap_match = ''; + }, + $correct_answer + ); + } - // Matching. - elseif ( 'matching' === $answer->question_type ) { - $correct_answer = $wpdb->get_results( - $wpdb->prepare( - "SELECT answer_title, image_id, answer_two_gap_match, answer_view_format - FROM {$wpdb->prefix}tutor_quiz_question_answers - WHERE belongs_question_id = %d - AND belongs_question_type='matching' - ORDER BY answer_order ASC;", - $answer->question_id - ) - ); + tutor_render_answer_list( $correct_answer ); + } + + // Image matching. + elseif ( 'image_matching' === $answer->question_type ) { + $correct_answer = $wpdb->get_results( + $wpdb->prepare( + "SELECT answer_title, image_id, answer_two_gap_match + FROM {$wpdb->prefix}tutor_quiz_question_answers + WHERE belongs_question_id = %d + AND belongs_question_type='image_matching' + ORDER BY answer_order ASC;", + $answer->question_id + ) + ); + + tutor_render_answer_list( $correct_answer, true ); + } + + // Image Answering. + elseif ( 'image_answering' === $answer->question_type ) { + + $correct_answer = $wpdb->get_results( + $wpdb->prepare( + "SELECT answer_title, image_id, answer_two_gap_match + FROM {$wpdb->prefix}tutor_quiz_question_answers + WHERE belongs_question_id = %d + AND belongs_question_type='image_answering' + ORDER BY answer_order ASC;", + $answer->question_id + ) + ); + + ! is_array( $correct_answer ) ? $correct_answer = array() : 0; + + echo '
'; + foreach ( $correct_answer as $image_answer ) { + ?> +
+

+

answer_title ); ?>

+
+ '; + } else { + /** + * Allow Pro and add-ons to render correct answer for custom question types. + * Pro handles draw_image and pin_image via this action. + * + * @param object $answer Answer object. + */ + do_action( 'tutor_quiz_render_correct_answer_for_question_type', $answer ); + } + } + ?> +
+ + answer_view_format = 'text_image'; - $ans->answer_two_gap_match = ''; - }, - $correct_answer - ); + case 'result': + ?> + +
+
+ + + question_type ) { + $achieved_val = (float) ( $answer->achieved_mark ?? 0 ); + $question_mark = (float) ( $answer->question_mark ?? 0 ); + $qmark_str = ( floor( $question_mark ) === $question_mark ) ? (string) (int) $question_mark : (string) round( $question_mark, 2 ); + $achieved_str = (string) round( $achieved_val, 2 ); + if ( floor( $achieved_val ) === $achieved_val ) { + $achieved_str = number_format( $achieved_val, 1, '.', '' ); } - - tutor_render_answer_list( $correct_answer ); - } - - // Image matching. - elseif ( 'image_matching' === $answer->question_type ) { - $correct_answer = $wpdb->get_results( - $wpdb->prepare( - "SELECT answer_title, image_id, answer_two_gap_match - FROM {$wpdb->prefix}tutor_quiz_question_answers - WHERE belongs_question_id = %d - AND belongs_question_type='image_matching' - ORDER BY answer_order ASC;", - $answer->question_id - ) + $score_label = sprintf( + /* translators: 1: achieved marks, 2: total marks. */ + esc_html__( 'Score: %1$s/%2$s', 'tutor' ), + $achieved_str, + $qmark_str ); - tutor_render_answer_list( $correct_answer, true ); - } + $badge_info = QuizModel::get_attempt_answer_badge( $answer ); + $badge_class = $badge_info['class'] ?? 'label-default'; - // Image Answering. - elseif ( 'image_answering' === $answer->question_type ) { - - $correct_answer = $wpdb->get_results( - $wpdb->prepare( - "SELECT answer_title, image_id, answer_two_gap_match - FROM {$wpdb->prefix}tutor_quiz_question_answers - WHERE belongs_question_id = %d - AND belongs_question_type='image_answering' - ORDER BY answer_order ASC;", - $answer->question_id - ) - ); + echo '' . esc_html( $badge_info['label'] ?? '' ) . ''; - ! is_array( $correct_answer ) ? $correct_answer = array() : 0; + do_action( 'tutor_quiz_attempt_details_result_badge_after', $answer, $answer_status ); - echo '
'; - foreach ( $correct_answer as $image_answer ) { - ?> -
-

-

answer_title ); ?>

-
- ' . esc_html( $score_label ) . '
'; } - echo '
'; - } else { - /** - * Allow Pro and add-ons to render correct answer for custom question types. - * Pro handles draw_image and pin_image via this action. - * - * @param object $answer Answer object. - */ - do_action( 'tutor_quiz_render_correct_answer_for_question_type', $answer ); } - } - ?> + ?>
- - - -
- - - question_type ) { - switch ( $answer_status ) { - case 'correct': - echo '' . esc_html__( 'Correct', 'tutor' ) . ''; - break; - - case 'pending': - echo '' . esc_html__( 'Pending', 'tutor' ) . ''; - break; - - case 'wrong': - echo '' . esc_html__( 'Incorrect', 'tutor' ) . ''; - break; - } - } - ?> - - -
- - + + + +
+ + +
+ +
+
+ + + +
+ question_type, QuizModel::get_manual_review_types(), true ) && 'skipped' !== $answer_status ) : ?> + attempt_answer_id; + $is_manual_graded = null !== ( $answer->is_correct ?? null ); + $manual_mark_value = $is_manual_graded ? (string) ( $answer->achieved_mark ?? '' ) : ''; + $has_manual_mark = '' !== $manual_mark_value; ?> - -
- - - - - - - + + + + attempt_answer_id ] ?? '' ) ); + ?> + - + + - + $answer_is_correct = null !== ( $answer->is_correct ?? null ) && (int) $answer->is_correct === QuizModel::ATTEMPT_ANSWER_CORRECT; + $answer_is_incorrect = null !== ( $answer->is_correct ?? null ) && (int) $answer->is_correct === QuizModel::ATTEMPT_ANSWER_INCORRECT; + ?> +
+ + +
+ question_id ] ) ) : ?> +
+ +
+ + +
+ + + - + + + +
+
+ + + + +
+
+ +
+
+ + + - + + @@ -843,7 +907,75 @@ function( $ans ) {
' . esc_html__( 'Quiz Overview', 'tutor' ) . '
' : ''; + tutor_utils()->tutor_empty_state( __( 'No answered questions to display', 'tutor' ) ); } ?> ' : ''; ?> + + + + + + diff --git a/views/quiz/attempt-table.php b/views/quiz/attempt-table.php index 49efd158e1..7da554af37 100644 --- a/views/quiz/attempt-table.php +++ b/views/quiz/attempt-table.php @@ -49,7 +49,7 @@ @@ -62,19 +62,10 @@ $attempt_result = QuizModel::get_attempt_result( $attempt->attempt_id ); $is_result_pending = QuizModel::RESULT_PENDING === $attempt_result; - $correct = 0; - $incorrect = 0; - $attempt_id = $attempt->attempt_id; - - if ( is_array( $answers ) && count( $answers ) > 0 ) { - foreach ( $answers as $answer ) { - if ( (bool) $answer->is_correct ) { - $correct++; - } elseif ( ! ( null === $answer->is_correct ) ) { - $incorrect++; - } - } - } + $attempt_id = $attempt->attempt_id; + $answer_counts = QuizModel::get_attempt_answer_counts( $answers ); + $correct = $answer_counts['correct']; + $incorrect = $answer_counts['incorrect']; ?> $column ) : ?>