Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
532cce6
dev: Add openspec initial schema
b-l-i-n-d Sep 15, 2026
99e4b8f
update(docs): Update spec docs
b-l-i-n-d Sep 15, 2026
05bc804
docs(quiz): Update docs to match the required behavior
b-l-i-n-d Sep 15, 2026
4ce40b9
docs(openspec): update quiz-partial-negative-marking with manual grad…
b-l-i-n-d Sep 15, 2026
3fd209e
docs(openspec): Update requirements
b-l-i-n-d Sep 15, 2026
cf90b5a
feat(quiz): add attempt-answer correctness constants and status resolver
b-l-i-n-d Sep 15, 2026
9e9dcd5
feat(settings): support turn-off confirmation modals for grading sett…
b-l-i-n-d Sep 15, 2026
7548c9e
feat(course-builder): add partial and negative marking controls in qu…
b-l-i-n-d Sep 15, 2026
1c37354
feat(quiz): display partial results and non-binary status across atte…
b-l-i-n-d Sep 15, 2026
56bc220
feat(quiz): implement numeric manual marks and question feedback flows
b-l-i-n-d Sep 15, 2026
ce79263
docs(quiz): update task checklist and add verification report
b-l-i-n-d Sep 15, 2026
9bec03b
refactor(quiz): use QueryHelper for feedback persistence and track ma…
b-l-i-n-d Sep 15, 2026
2cd211e
feat(quiz): filter skipped questions and add feedback and override la…
b-l-i-n-d Sep 15, 2026
1e4f78f
feat(quiz): display auto-grade override notice in v4 question header
b-l-i-n-d Sep 15, 2026
1a05731
docs(quiz): mark all remaining verification tasks complete
b-l-i-n-d Sep 15, 2026
5ea68ad
refactor(quiz): address review feedback, centralize answer counts, an…
b-l-i-n-d Sep 16, 2026
fbc63b9
feat(quiz): specify N/M correct badge and archive legacy workflows
b-l-i-n-d Sep 16, 2026
431bbc1
feat(quiz): display N/M correct badge for partially graded questions …
b-l-i-n-d Sep 16, 2026
40b73b7
refactor(quiz): import QuizModel at top of file instead of inline
b-l-i-n-d Sep 16, 2026
1fe4507
feat(quiz): add tutor_quiz_summary_parameters filter and partial icon
b-l-i-n-d Sep 16, 2026
c7ca89b
docs(openspec): update quiz-partial-negative-marking plan with header…
b-l-i-n-d Sep 16, 2026
5a2c916
feat(quiz): polish open-ended review, form validation, and header sco…
b-l-i-n-d Sep 16, 2026
7197b85
refactor(quiz): centralize attempt answer badge metadata and renderin…
b-l-i-n-d Sep 16, 2026
c5f9bb1
fix(quiz): remove override notice and remove redundant penalty styles…
b-l-i-n-d Sep 16, 2026
aee005a
feat(quiz): streamline v4 attempt question feedback and manual gradin…
b-l-i-n-d Sep 16, 2026
e9b9c9f
refactor(quiz): simplify score formatting and remove redundant floor …
b-l-i-n-d Sep 17, 2026
fdad3a3
refactor: Improve quiz types
b-l-i-n-d Sep 17, 2026
b258f79
feat(quiz): make attempt mark breakdown conditional on partial or neg…
b-l-i-n-d Sep 17, 2026
c06884e
fix(quiz): resolve question type and marks in manual quiz answer review
b-l-i-n-d Sep 17, 2026
6e2126e
refactor: Update admin feedback flow
b-l-i-n-d Sep 17, 2026
2240354
fix: Hide skipped questions from student view
b-l-i-n-d Sep 17, 2026
4686692
style: Update Instructor Feedback style
b-l-i-n-d Sep 17, 2026
611c0cc
refactor(quiz): decouple partial and negative marking to pro via hooks
b-l-i-n-d Sep 17, 2026
9e5aabc
refactor(quiz): consolidate manual quiz review logic
b-l-i-n-d Sep 17, 2026
818d442
refactor(quiz): use QuizModel attempt info maps in templates
b-l-i-n-d Sep 17, 2026
5bb7d76
fix(admin): proceed toggle turn-off when not in use
b-l-i-n-d Sep 17, 2026
85568d5
docs(quiz): add quiz review fix plan
b-l-i-n-d Sep 17, 2026
056a57c
refactor(options): decouple turnoff confirmation from toggle template
b-l-i-n-d Sep 17, 2026
dcb077e
refactor(quiz): drop partial marking from core attempt details
b-l-i-n-d Sep 18, 2026
e4a26ff
chore(openspec): remove openspec planning files and skills
b-l-i-n-d Sep 18, 2026
af5c86a
chore(opsx): remove opsx workflows and commands
b-l-i-n-d Sep 18, 2026
7ec4c0a
docs(quiz): remove quiz review fix plan
b-l-i-n-d Sep 18, 2026
ada51b8
fix(quiz): polish instructor feedback panel in attempt details
b-l-i-n-d Sep 18, 2026
a2da9bb
fix(settings): resync toggle-dependent fields after turn-off confirm …
b-l-i-n-d Sep 18, 2026
a784d61
fix(quiz): inline negative marking penalty and regroup grading settings
b-l-i-n-d Sep 18, 2026
147864a
feat(field): Add tooltip support
b-l-i-n-d Sep 18, 2026
6b0f03f
feat(quiz): Add partial status to attempt details
b-l-i-n-d Sep 18, 2026
31fc106
refactor(quiz): Update admin manual grading flow
b-l-i-n-d Sep 18, 2026
1510167
refactor(quiz): Rework manual review actions and question feedback modal
b-l-i-n-d Sep 20, 2026
9fdb26f
refactor(quiz): Make attempt summary statics filterable and orderable
b-l-i-n-d Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions assets/core/ts/components/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions assets/core/ts/utils/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions assets/icons/partial.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
152 changes: 152 additions & 0 deletions assets/src/js/admin-dashboard/segments/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>} 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);
});
});
}
140 changes: 138 additions & 2 deletions assets/src/js/frontend/dashboard/pages/quiz-attempt-feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ReviewStatus>;
type ReviewStatusesAjaxPayload = Partial<Record<ReviewStatusFieldName, ReviewStatus>>;
type ManualMarkFieldName = `${typeof MANUAL_MARK_FIELD}[${string}]`;
type ManualMarksMap = Record<string, number>;
type ManualMarksAjaxPayload = Partial<Record<ManualMarkFieldName, number>>;
type QuestionFeedbackFieldName = `${typeof QUESTION_FEEDBACK_FIELD}[${string}]`;
type QuestionFeedbackMap = Record<string, string>;
type QuestionFeedbackAjaxPayload = Partial<Record<QuestionFeedbackFieldName, string>>;

interface QuizAttemptFeedbackProps {
attemptId: number;
Expand All @@ -20,6 +28,8 @@ interface QuizAttemptFeedbackPayload {
attempt_id: number;
feedback: string;
review_statuses: ReviewStatusMap;
manual_marks: ManualMarksMap;
question_feedback: QuestionFeedbackMap;
}

interface QuizAttemptFeedbackResponse<TData = unknown> {
Expand All @@ -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<string, unknown>) => {
Expand Down Expand Up @@ -68,6 +80,42 @@ const quizAttemptFeedback = ({ attemptId, formId }: QuizAttemptFeedbackProps) =>
}, {});
};

const getManualMarks = (data: Record<string, unknown>) => {
return Object.entries(data).reduce<ManualMarksMap>((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<ManualMarksAjaxPayload>((acc, [questionId, mark]) => {
acc[`${MANUAL_MARK_FIELD}[${questionId}]` as ManualMarkFieldName] = mark;
return acc;
}, {});
};

const getQuestionFeedback = (data: Record<string, unknown>) => {
return Object.entries(data).reduce<QuestionFeedbackMap>((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<QuestionFeedbackAjaxPayload>((acc, [questionId, feedback]) => {
acc[`${QUESTION_FEEDBACK_FIELD}[${questionId}]` as QuestionFeedbackFieldName] = feedback;
return acc;
}, {});
};

return {
formId,
attemptId,
Expand Down Expand Up @@ -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);
Expand All @@ -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<QuizAttemptFeedbackResponse>(endpoints.REVIEW_QUIZ_ANSWERS, {
attempt_id: payload.attempt_id,
...reviewStatusesPayload,
...manualMarksPayload,
...questionFeedbackPayload,
})
: Promise.resolve(null);

Expand All @@ -153,10 +219,23 @@ const quizAttemptFeedback = ({ attemptId, formId }: QuizAttemptFeedbackProps) =>
},

async handleSaveFeedback(data: Record<string, unknown>) {
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),
});
},
};
Expand All @@ -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,
};
Loading
Loading