Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e3d3cb7
Add progressStrokeColor support to statics component and fix calendar…
b-l-i-n-d Apr 13, 2026
ccb43cc
Add secondary icon color tokens for success and warning
b-l-i-n-d Apr 13, 2026
23d4d08
Add quiz attempts list functionality and update quiz attempt templates
b-l-i-n-d Apr 13, 2026
8fcc658
fix: Use current color instead of hard coded colors
b-l-i-n-d Apr 15, 2026
8c45201
fix: Use icon color tokens
b-l-i-n-d Apr 15, 2026
c0abf9b
feat: Add transition to nav
b-l-i-n-d Apr 15, 2026
af0c68b
fix(quiz): add is_attempt_answer_skipped() and fix docblock
b-l-i-n-d Apr 15, 2026
c8b65dc
feat(quiz): refactor review_quiz_answer to handle skipped questions
b-l-i-n-d Apr 15, 2026
1015d7f
refactor(quiz): use status_badges array for multiple badges
b-l-i-n-d Apr 15, 2026
41ab92e
fix(quiz): correct status fallback values
b-l-i-n-d Apr 15, 2026
2a97e85
feat(quiz): add question_id data attribute for manual review
b-l-i-n-d Apr 15, 2026
1e3c07d
fix(quiz): show skipped questions in attempt details
b-l-i-n-d Apr 15, 2026
ebfcf38
fix(preview): disable pointer cursor for non-interactive question opt…
b-l-i-n-d Apr 15, 2026
c34ceb6
fix: Remove default padding in FormSelectUser
b-l-i-n-d Apr 15, 2026
677e606
Merge branch '4.0.0-dev' into v4-blind
b-l-i-n-d Apr 16, 2026
bedcac2
Merge branch '4.0.0-dev' into v4-blind
b-l-i-n-d Apr 21, 2026
b5474f8
fix: Resolve id duplication on getOrder return value
b-l-i-n-d Apr 21, 2026
627647e
Merge branch '4.0.0-dev' into v4-blind
b-l-i-n-d Apr 22, 2026
b594309
fix: Text color in attempt list
b-l-i-n-d Apr 22, 2026
cf76cab
feat: Use Progress component for quiz attempt
b-l-i-n-d Apr 22, 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
5 changes: 4 additions & 1 deletion assets/core/ts/components/statics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface StaticsProps {
size?: StaticsSize;
background?: string;
strokeColor?: string;
progressStrokeColor?: string;
showLabel?: boolean;
label?: string;
animated?: boolean;
Expand All @@ -28,6 +29,7 @@ const DEFAULT_CONFIG = {
size: 'small' as StaticsSize,
background: 'none',
strokeColor: 'var(--tutor-actions-brand-secondary)',
progressStrokeColor: 'var(--tutor-actions-brand-primary)',
showLabel: true,
label: '',
animated: false,
Expand All @@ -44,6 +46,7 @@ export const statics = (config: StaticsProps) => ({
type: config.type ?? DEFAULT_CONFIG.type,
background: config.background ?? DEFAULT_CONFIG.background,
strokeColor: config.strokeColor ?? DEFAULT_CONFIG.strokeColor,
progressStrokeColor: config.progressStrokeColor ?? DEFAULT_CONFIG.progressStrokeColor,
showLabel: config.showLabel ?? DEFAULT_CONFIG.showLabel,
label: config.label ?? DEFAULT_CONFIG.label,
animated: config.animated ?? DEFAULT_CONFIG.animated,
Expand Down Expand Up @@ -170,7 +173,7 @@ export const statics = (config: StaticsProps) => ({
cy="${this.center}"
r="${this.radius}"
fill="none"
stroke="var(--tutor-actions-brand-primary)"
stroke="${this.progressStrokeColor}"
stroke-width="${this.strokeWidth}"
stroke-linecap="round"
stroke-dasharray="${this.circumference}"
Expand Down
2 changes: 1 addition & 1 deletion assets/icons/github.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions assets/src/js/frontend/learning-area/quiz/questions/ordering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const questionOrdering = (
});
},

getOrder() {
getOrder(): string[] {
const container = this.$el;
if (!container) {
return [];
Expand All @@ -128,7 +128,13 @@ const questionOrdering = (
container.querySelectorAll<HTMLElement>(`.${QUESTION_ORDERING_CONSTANTS.CLASSES.QUESTION_OPTION}`),
);

return options.map((option) => option.dataset[QUESTION_ORDERING_CONSTANTS.DATASET.ID] ?? '').filter(Boolean);
return [
...new Set(
options
.map((option) => option.dataset[QUESTION_ORDERING_CONSTANTS.DATASET.ID])
.filter((id): id is string => typeof id === 'string'),
),
];
},

destroy() {
Expand Down
2 changes: 2 additions & 0 deletions assets/src/js/lib/modules/quiz.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ window.jQuery(document).ready(($) => {
var $that = $(this);
var attempt_id = $that.attr('data-attempt-id');
var attempt_answer_id = $that.attr('data-attempt-answer-id');
var question_id = $that.attr('data-question-id');
var mark_as = $that.attr('data-mark-as');
var context = $that.attr('data-context');
var back_url = $that.attr('data-back-url');
Expand All @@ -23,6 +24,7 @@ window.jQuery(document).ready(($) => {
data: {
attempt_id,
attempt_answer_id,
question_id,
mark_as,
context,
back_url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,10 @@ const getPreviewFrameStyles = () => `
box-shadow: none;
}

.tutor-quiz-question-option {
cursor: default;
}

body[data-preview-device='mobile'] .tutor-draw-image-question .tutor-draw-image-wrapper,
body[data-preview-device='mobile'] .tutor-draw-image-question .tutor-draw-image-reference-inner,
body[data-preview-device='mobile'] .tutor-pin-image-question .tutor-pin-image-wrapper,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -516,12 +516,12 @@ const styles = {
}
`,
options: css`
${styleUtils.ulReset};
z-index: ${zIndex.dropdown};
background-color: ${colorTokens.background.white};
list-style-type: none;
box-shadow: ${shadow.popover};
margin: ${spacing[4]} 0;
margin: 0;
max-height: 400px;
border: 1px solid ${colorTokens.stroke.border};
border-radius: ${borderRadius[6]};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,11 @@
}
}

&-status {
@include tutor-flex(row, center, flex-start, wrap);
gap: $tutor-spacing-3;
}

&-divider {
width: 1px;
align-self: stretch;
Expand Down
6 changes: 5 additions & 1 deletion assets/src/scss/frontend/components/_quiz-attempts.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
gap: $tutor-spacing-4;
align-items: center;

.tutor-quiz-item-info-date a {
color: $tutor-text-subdued;
}

@include tutor-breakpoint-down(sm) {
grid-template-columns: 2fr 1fr 1fr;
grid-template-areas:
Expand All @@ -73,7 +77,7 @@
@include tutor-visually-hidden;
}

.tutor-quiz-item-info-date {
.tutor-quiz-item-info-date a {
@include tutor-typography('small', 'semibold', 'brand');
}
}
Expand Down
4 changes: 4 additions & 0 deletions assets/src/scss/frontend/dashboard/settings/_profile.scss
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ body:has(#wpadminbar) {
border: 1px solid $tutor-border-idle;
overflow: hidden;

svg {
color: $tutor-icon-idle;
}

&:hover {
.tutor-profile-notification-toggle {
visibility: visible;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

&-icon {
position: relative;
color: $tutor-icon-idle;
top: 38px;
}
}
124 changes: 119 additions & 5 deletions classes/Quiz.php
Original file line number Diff line number Diff line change
Expand Up @@ -1090,13 +1090,18 @@ public function review_quiz_answer() {
$attempt_id = Input::post( 'attempt_id', 0, Input::TYPE_INT );
$context = Input::post( 'context' );
$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' );

if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) || ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) {
if ( ! tutor_utils()->can_user_manage( 'attempt', $attempt_id ) ) {
wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
}

$attempt_answer = $this->get_attempt_answer( $attempt_answer_id );
if ( $attempt_answer_id && ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer_id ) ) {
wp_send_json_error( array( 'message' => __( 'Access Denied', 'tutor' ) ) );
}

$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;

if ( ! $review_data ) {
Expand Down Expand Up @@ -1173,11 +1178,11 @@ private function review_quiz_answers_bulk( int $attempt_id, array $review_status
$attempt_answer = $answers_by_question_id[ $question_id ] ?? null;

if ( ! $attempt_answer ) {
continue;
$attempt_answer = $this->resolve_attempt_answer_for_review( $attempt_id, 0, $question_id );
}

if ( ! tutor_utils()->can_user_manage( 'attempt_answer', $attempt_answer->attempt_answer_id ) ) {
$this->response_fail( __( 'Access Denied', 'tutor' ), 403 );
if ( ! $attempt_answer ) {
continue;
}

$this->apply_quiz_answer_review( $attempt_id, $attempt_answer, $mark_as );
Expand Down Expand Up @@ -1210,6 +1215,115 @@ private function get_attempt_answer( int $attempt_answer_id ) {
);
}

/**
* Get attempt answer by attempt and question IDs.
*
* @since 4.0.0
*
* @param int $attempt_id Attempt ID.
* @param int $question_id Question ID.
*
* @return object|null
*/
private function get_attempt_answer_by_attempt_and_question( int $attempt_id, int $question_id ) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already a method for fetching attempt: tutor_utils()->get_attempt( $attempt_id )

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need get_attempt_answer_by_attempt_and_question() because creating a placeholder row is only correct when no answer row already exists for the same attempt_id + question_id. In the skipped legacy case there is no attempt_answer_id, so the review flow first has to look up by attempt_id and question_id: if a row exists we should update it, and if not we create the missing row and then apply the review. Without that lookup the fallback becomes a blind insert, which can create duplicate attempt-answer rows for questions that already have a saved record.

if ( $attempt_id <= 0 || $question_id <= 0 ) {
return null;
}

return QueryHelper::get_row(
'tutor_quiz_attempt_answers',
array(
'quiz_attempt_id' => $attempt_id,
'question_id' => $question_id,
),
'attempt_answer_id',
'ASC'
);
}

/**
* Create a placeholder attempt answer for a skipped question.
*
* @since 4.0.0
*
* @param int $attempt_id Attempt ID.
* @param int $question_id Question ID.
*
* @return object|null
*/
private function create_skipped_attempt_answer( int $attempt_id, int $question_id ) {
$attempt = tutor_utils()->get_attempt( $attempt_id );
if ( ! $attempt ) {
return null;
}

$question = QuizModel::get_quiz_question_by_id( $question_id );
if ( ! $question || (int) $question->quiz_id !== (int) $attempt->quiz_id ) {
return null;
}

$attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id );
if ( $attempt_answer ) {
return $attempt_answer;
}

try {
$inserted_id = QueryHelper::insert(
'tutor_quiz_attempt_answers',
array(
'user_id' => (int) $attempt->user_id,
'quiz_id' => (int) $attempt->quiz_id,
'question_id' => $question_id,
'quiz_attempt_id' => $attempt_id,
'given_answer' => '',
'question_mark' => $question->question_mark,
'achieved_mark' => 0,
'minus_mark' => 0,
'is_correct' => 0,
)
);
} catch ( \Exception $exception ) {
return null;
}

if ( $inserted_id <= 0 ) {
return null;
}

return $this->get_attempt_answer( $inserted_id );
}

/**
* Resolve the attempt answer used for instructor review.
*
* @since 4.0.0
*
* @param int $attempt_id Attempt ID.
* @param int $attempt_answer_id Attempt answer ID.
* @param int $question_id Question ID.
*
* @return object|null
*/
private function resolve_attempt_answer_for_review( int $attempt_id, int $attempt_answer_id = 0, int $question_id = 0 ) {
$attempt_answer = $attempt_answer_id ? $this->get_attempt_answer( $attempt_answer_id ) : null;

if ( $attempt_answer ) {
return $attempt_answer;
}

if ( $question_id <= 0 ) {
return null;
}

$attempt_answer = $this->get_attempt_answer_by_attempt_and_question( $attempt_id, $question_id );

if ( $attempt_answer ) {
return $attempt_answer;
}

return $this->create_skipped_attempt_answer( $attempt_id, $question_id );
}

/**
* Apply quiz answer review update.
*
Expand Down
37 changes: 37 additions & 0 deletions classes/Quiz_Attempts_List.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use Tutor\Helpers\UrlHelper;
use Tutor\Models\QuizModel;
use Tutor\Components\SvgIcon;
use Tutor\Components\Progress;

/**
* Quiz attempt class
Expand Down Expand Up @@ -555,6 +556,42 @@ public function render_student_attempt_popover( $attempt = array(), $attempts_co
}
}

/**
* Render quiz attempt marks percentage.
*
* @since 4.0.0
*
* @param string $attempt_result the quiz attempt `QuizModel::RESULT_PASS | QuizModel::RESULT_PENDING | QuizModel::RESULT_FAIL`.
* @param int $earned_percentage the earned percentage.
* @param string $size the size of the component.
* @param string $wrapper_class the wrapper class of the component.
*
* @return void
*/
public static function render_quiz_attempt_marks_percentage( $attempt_result = '', $earned_percentage = 0, $size = 'small', $wrapper_class = '' ) {
$statics_stroke_color = 'var(--tutor-icon-critical)';

if ( QuizModel::RESULT_PASS === $attempt_result ) {
$statics_stroke_color = 'var(--tutor-icon-success-secondary)';

if ( 100 === (int) $earned_percentage ) {
$statics_stroke_color = 'var(--tutor-icon-success-primary)';
}
} elseif ( QuizModel::RESULT_PENDING === $attempt_result ) {
$statics_stroke_color = 'var(--tutor-icon-warning-secondary)';
}

Progress::make()
->type( 'circle' )
->value( $earned_percentage )
->size( $size )
->stroke_color( 'var(--tutor-border-idle)' )
->progress_stroke_color( $statics_stroke_color )
->animated()
->attr( 'class', $wrapper_class )
->render();
}

/**
* Render List Badge for quiz attempts.
*
Expand Down
2 changes: 1 addition & 1 deletion components/Nav.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ class="tutor-nav-item %s">
%s
%s
</button>
<div x-ref="content" x-show="open" x-cloak @click.outside="handleClickOutside()" class="tutor-popover tutor-nav-dropdown">
<div x-ref="content" x-show="open" x-cloak @click.outside="handleClickOutside()" class="tutor-popover tutor-nav-dropdown" x-transition.origin.top.left>
%s
</div>
</div>',
Expand Down
Loading
Loading