diff --git a/assets/core/ts/components/statics.ts b/assets/core/ts/components/statics.ts index 691b3eae6b..0adfea1100 100644 --- a/assets/core/ts/components/statics.ts +++ b/assets/core/ts/components/statics.ts @@ -9,6 +9,7 @@ export interface StaticsProps { size?: StaticsSize; background?: string; strokeColor?: string; + progressStrokeColor?: string; showLabel?: boolean; label?: string; animated?: boolean; @@ -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, @@ -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, @@ -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}" diff --git a/assets/icons/github.svg b/assets/icons/github.svg index 7bd5e235f7..b897e0c219 100644 --- a/assets/icons/github.svg +++ b/assets/icons/github.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/src/js/frontend/learning-area/quiz/questions/ordering.ts b/assets/src/js/frontend/learning-area/quiz/questions/ordering.ts index efb9f90c36..f6c4b21515 100644 --- a/assets/src/js/frontend/learning-area/quiz/questions/ordering.ts +++ b/assets/src/js/frontend/learning-area/quiz/questions/ordering.ts @@ -118,7 +118,7 @@ const questionOrdering = ( }); }, - getOrder() { + getOrder(): string[] { const container = this.$el; if (!container) { return []; @@ -128,7 +128,13 @@ const questionOrdering = ( container.querySelectorAll(`.${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() { diff --git a/assets/src/js/lib/modules/quiz.js b/assets/src/js/lib/modules/quiz.js index 6722e62dd4..e950ce1278 100644 --- a/assets/src/js/lib/modules/quiz.js +++ b/assets/src/js/lib/modules/quiz.js @@ -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'); @@ -23,6 +24,7 @@ window.jQuery(document).ready(($) => { data: { attempt_id, attempt_answer_id, + question_id, mark_as, context, back_url, diff --git a/assets/src/js/v3/entries/course-builder/components/modals/QuestionPreviewModal.tsx b/assets/src/js/v3/entries/course-builder/components/modals/QuestionPreviewModal.tsx index ffbdd5af0c..8ef21b6187 100644 --- a/assets/src/js/v3/entries/course-builder/components/modals/QuestionPreviewModal.tsx +++ b/assets/src/js/v3/entries/course-builder/components/modals/QuestionPreviewModal.tsx @@ -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, diff --git a/assets/src/js/v3/shared/components/fields/FormSelectUser.tsx b/assets/src/js/v3/shared/components/fields/FormSelectUser.tsx index 354bf76a79..0a6cbb48e2 100644 --- a/assets/src/js/v3/shared/components/fields/FormSelectUser.tsx +++ b/assets/src/js/v3/shared/components/fields/FormSelectUser.tsx @@ -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]}; diff --git a/assets/src/scss/frontend/components/_quiz-attempt-details.scss b/assets/src/scss/frontend/components/_quiz-attempt-details.scss index 125ebdd11a..bbf826569e 100644 --- a/assets/src/scss/frontend/components/_quiz-attempt-details.scss +++ b/assets/src/scss/frontend/components/_quiz-attempt-details.scss @@ -319,6 +319,11 @@ } } + &-status { + @include tutor-flex(row, center, flex-start, wrap); + gap: $tutor-spacing-3; + } + &-divider { width: 1px; align-self: stretch; diff --git a/assets/src/scss/frontend/components/_quiz-attempts.scss b/assets/src/scss/frontend/components/_quiz-attempts.scss index 39c44c375d..65e0a0d636 100644 --- a/assets/src/scss/frontend/components/_quiz-attempts.scss +++ b/assets/src/scss/frontend/components/_quiz-attempts.scss @@ -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: @@ -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'); } } diff --git a/assets/src/scss/frontend/dashboard/settings/_profile.scss b/assets/src/scss/frontend/dashboard/settings/_profile.scss index 82b272778e..a9cdcd83e0 100644 --- a/assets/src/scss/frontend/dashboard/settings/_profile.scss +++ b/assets/src/scss/frontend/dashboard/settings/_profile.scss @@ -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; diff --git a/assets/src/scss/frontend/dashboard/settings/_social-accounts.scss b/assets/src/scss/frontend/dashboard/settings/_social-accounts.scss index 6c4b093c86..7fa6ef399f 100644 --- a/assets/src/scss/frontend/dashboard/settings/_social-accounts.scss +++ b/assets/src/scss/frontend/dashboard/settings/_social-accounts.scss @@ -23,6 +23,7 @@ &-icon { position: relative; + color: $tutor-icon-idle; top: 38px; } } \ No newline at end of file diff --git a/classes/Quiz.php b/classes/Quiz.php index 4bd44ce4f8..077d20c009 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -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 ) { @@ -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 ); @@ -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 ) { + 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. * diff --git a/classes/Quiz_Attempts_List.php b/classes/Quiz_Attempts_List.php index 467f10e528..0c295f48a7 100644 --- a/classes/Quiz_Attempts_List.php +++ b/classes/Quiz_Attempts_List.php @@ -24,6 +24,7 @@ use Tutor\Helpers\UrlHelper; use Tutor\Models\QuizModel; use Tutor\Components\SvgIcon; +use Tutor\Components\Progress; /** * Quiz attempt class @@ -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. * diff --git a/components/Nav.php b/components/Nav.php index 74512ee2a7..52644fa9f2 100644 --- a/components/Nav.php +++ b/components/Nav.php @@ -276,7 +276,7 @@ class="tutor-nav-item %s"> %s %s -
+
%s
', diff --git a/components/Progress.php b/components/Progress.php index 53bb65680a..81f62594a7 100644 --- a/components/Progress.php +++ b/components/Progress.php @@ -103,13 +103,21 @@ class Progress extends BaseComponent { protected $background = 'none'; /** - * Stroke color. + * Stroke color (background circle). * * @since 4.0.0 * @var string */ protected $stroke_color = 'var(--tutor-actions-brand-secondary)'; + /** + * Progress stroke color (progress arc). + * + * @since 4.0.0 + * @var string + */ + protected $progress_stroke_color = 'var(--tutor-actions-brand-primary)'; + /** * Whether to show label. * @@ -255,7 +263,7 @@ public function background( $background ) { } /** - * Set stroke color. + * Set stroke color (background circle). * * @since 4.0.0 * @@ -267,6 +275,19 @@ public function stroke_color( $stroke_color ) { return $this; } + /** + * Set progress stroke color (progress arc). + * + * @since 4.0.0 + * + * @param string $progress_stroke_color Progress stroke color. + * @return $this + */ + public function progress_stroke_color( $progress_stroke_color ) { + $this->progress_stroke_color = $progress_stroke_color; + return $this; + } + /** * Set whether to show label. * @@ -355,13 +376,14 @@ protected function render_circle() { $alpine_data['type'] = $this->state; } - $alpine_data['size'] = $this->size; - $alpine_data['background'] = $this->background; - $alpine_data['strokeColor'] = $this->stroke_color; - $alpine_data['showLabel'] = $this->show_label; - $alpine_data['label'] = $this->label; - $alpine_data['animated'] = $this->animated; - $alpine_data['duration'] = $this->duration; + $alpine_data['size'] = $this->size; + $alpine_data['background'] = $this->background; + $alpine_data['strokeColor'] = $this->stroke_color; + $alpine_data['progressStrokeColor'] = $this->progress_stroke_color; + $alpine_data['showLabel'] = $this->show_label; + $alpine_data['label'] = $this->label; + $alpine_data['animated'] = $this->animated; + $alpine_data['duration'] = $this->duration; // Convert to JSON for x-data. $alpine_json = wp_json_encode( $alpine_data ); @@ -393,5 +415,4 @@ public function get(): string { return $this->component_string; } - } diff --git a/models/QuizModel.php b/models/QuizModel.php index ebe9bff4db..87c8a229ad 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -851,13 +851,48 @@ public static function get_quiz_answers_by_attempt_id( $attempt_id, $add_index = return $results; } + /** + * Check whether an attempt answer should be treated as skipped. + * + * @since 4.0.0 + * + * @param object|null $attempt_answer Attempt answer object. + * + * @return bool + */ + public static function is_attempt_answer_skipped( $attempt_answer ): bool { + if ( ! is_object( $attempt_answer ) ) { + return true; + } + + $given_answer = maybe_unserialize( $attempt_answer->given_answer ?? '' ); + + if ( is_array( $given_answer ) ) { + $given_answer = array_filter( + array_map( + static function ( $item ) { + return trim( wp_strip_all_tags( (string) $item ) ); + }, + $given_answer + ), + static function ( string $item ) { + return '' !== $item; + } + ); + + return empty( $given_answer ); + } + + return '' === trim( wp_strip_all_tags( (string) $given_answer ) ); + } + /** * Get normalized attempt-answer status. * * Status rules follow legacy attempt-details logic: * - correct: is_correct is truthy. * - pending: is_correct is null for manually reviewed question types. - * - wrong: all other cases. + * - incorrect: all other cases. * * @since 4.0.0 * diff --git a/templates/dashboard/instructor/home/top-performing-course-filter.php b/templates/dashboard/instructor/home/top-performing-course-filter.php index 1f40801867..65c1be2ef0 100644 --- a/templates/dashboard/instructor/home/top-performing-course-filter.php +++ b/templates/dashboard/instructor/home/top-performing-course-filter.php @@ -21,10 +21,11 @@ placement: 'bottom-end', offset: 4, })" + x-transition.origin.top.right >