diff --git a/.gitignore b/.gitignore index 128366ed4c..6021554792 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ assets/css/* !assets/css/.gitkeep assets/js/* +assets/blocks/ languages/* !languages/.gitkeep test/ diff --git a/assets/core/ts/components/calendar.ts b/assets/core/ts/components/calendar.ts index e541a334d2..af1b9602b3 100644 --- a/assets/core/ts/components/calendar.ts +++ b/assets/core/ts/components/calendar.ts @@ -2,6 +2,7 @@ import { __ } from '@wordpress/i18n'; import dayjs from 'dayjs'; import { type Calendar, Calendar as VanillaCalendar, type Options } from 'vanilla-calendar-pro'; +import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; import { DateFormats } from '@Core/ts/date-formats'; import { type AlpineComponentMeta } from '@Core/ts/types'; @@ -481,7 +482,7 @@ export function calendar({ options, hidePopover }: { options: Options; hidePopov }); }, - navigateWithParams(params: Record) { + navigateWithParams(params: Record, presetKey?: string, presetTitle?: string) { const url = new URL(window.location.href); // Always reset pagination when the date filter changes. @@ -502,6 +503,44 @@ export function calendar({ options, hidePopover }: { options: Options; hidePopov } }); + const isAjax = Boolean((options as Record)?.ajaxMode); + if (isAjax) { + window.history.pushState({}, '', url.toString()); + + const startDate = params[TUTOR_CALENDAR_QUERY_PARAMS.startDate] || ''; + const endDate = params[TUTOR_CALENDAR_QUERY_PARAMS.endDate] || ''; + const date = params[TUTOR_CALENDAR_QUERY_PARAMS.date] || ''; + + if (startDate && endDate) { + this.calendar?.set({ selectedDates: [startDate, endDate] }); + } else if (date) { + this.calendar?.set({ selectedDates: [date] }); + } else { + this.calendar?.set({ selectedDates: [] }); + } + + this.updateActivePreset(); + + const formattedLabel = + presetTitle || + (startDate && endDate ? (startDate === endDate ? startDate : `${startDate} - ${endDate}`) : date || ''); + + window.dispatchEvent( + new CustomEvent(TUTOR_CUSTOM_EVENTS.DATE_FILTER_CHANGED, { + detail: { + startDate, + endDate, + date, + label: formattedLabel, + preset: presetKey || '', + presetTitle: presetTitle || '', + url: url.toString(), + }, + }), + ); + return; + } + window.location.href = url.toString(); }, @@ -553,18 +592,29 @@ export function calendar({ options, hidePopover }: { options: Options; hidePopov applyPreset(preset: Preset) { if (!this.calendar) return; + hidePopover?.(); + const dates = this.getPresetDates(preset); + const presetTitle = PRESET_LABELS[preset] || ''; if (dates.length) { - this.navigateWithParams({ - [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: dates[0], - [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: dates[1], - }); + this.navigateWithParams( + { + [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: dates[0], + [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: dates[1], + }, + preset, + presetTitle, + ); } else { - this.navigateWithParams({ - [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: null, - [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: null, - }); + this.navigateWithParams( + { + [TUTOR_CALENDAR_QUERY_PARAMS.startDate]: null, + [TUTOR_CALENDAR_QUERY_PARAMS.endDate]: null, + }, + preset, + presetTitle, + ); } }, 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/components/player.ts b/assets/core/ts/components/player.ts index a7fd67a389..0d1e77a815 100644 --- a/assets/core/ts/components/player.ts +++ b/assets/core/ts/components/player.ts @@ -2,7 +2,7 @@ import { TUTOR_CUSTOM_EVENTS } from '@Core/ts/constant'; import { type AlpineComponentMeta } from '@Core/ts/types'; import { isMobileDevice } from '@Core/ts/utils/util'; -import { isVimeoPlyr } from '@FrontendTypes/index'; +import { isVimeoPlyr, isYouTubePlyr } from '@FrontendTypes/index'; export interface PlayerProps { config?: Plyr.Options; @@ -51,6 +51,13 @@ export const player = (props: PlayerProps = {}): AlpinePlayerData => ({ console.warn('Vimeo mute init failed:', err); } } + + // Tab click reveals youtube controls fixed + if (this.plyr && isYouTubePlyr(this.plyr)) { + const iframe = this.plyr.elements.wrapper?.querySelector('iframe'); + iframe?.setAttribute('tabindex', '-1'); + iframe?.setAttribute('aria-hidden', 'true'); + } }); this.plyr.on('play', () => { @@ -70,6 +77,16 @@ export const player = (props: PlayerProps = {}): AlpinePlayerData => ({ } } }); + + this.plyr.on('enterfullscreen', (event) => { + const instance = event.detail.plyr; + instance?.elements.wrapper?.classList.add('is-fullscreen'); + }); + + this.plyr.on('exitfullscreen', (event) => { + const instance = event.detail.plyr; + instance?.elements.wrapper?.classList.remove('is-fullscreen'); + }); } // Dispatch custom event when player is ready diff --git a/assets/core/ts/constant.ts b/assets/core/ts/constant.ts index faacca10d1..d2f70ddaaf 100644 --- a/assets/core/ts/constant.ts +++ b/assets/core/ts/constant.ts @@ -18,4 +18,6 @@ export const TUTOR_CUSTOM_EVENTS = { QUIZ_ABANDON_REQUESTED: 'tutor-quiz-abandon-requested', QUIZ_ATTEMPT_COMPLETED: 'tutor-quiz-attempt-completed', CONTENT_CHANGED: 'tutor_content_changed_event', + DATE_FILTER_CHANGED: 'tutor:date-filter-changed', + SORT_CHANGED: 'tutor:sort-changed', }; 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/bag.svg b/assets/icons/bag.svg new file mode 100644 index 0000000000..de0f3fddae --- /dev/null +++ b/assets/icons/bag.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/basket.svg b/assets/icons/basket.svg new file mode 100644 index 0000000000..85596fc66f --- /dev/null +++ b/assets/icons/basket.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/comment-outline.svg b/assets/icons/comment-outline.svg new file mode 100644 index 0000000000..821b6acc17 --- /dev/null +++ b/assets/icons/comment-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/kids/minus-circle.svg b/assets/icons/kids/minus-circle.svg new file mode 100644 index 0000000000..7ccea1e8cb --- /dev/null +++ b/assets/icons/kids/minus-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/kids/partial.svg b/assets/icons/kids/partial.svg new file mode 100644 index 0000000000..824d5c5b38 --- /dev/null +++ b/assets/icons/kids/partial.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/minus-circle.svg b/assets/icons/minus-circle.svg new file mode 100644 index 0000000000..f711c9819a --- /dev/null +++ b/assets/icons/minus-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file 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/blocks/cart-button/block.json b/assets/src/blocks/cart-button/block.json new file mode 100644 index 0000000000..94c64002b1 --- /dev/null +++ b/assets/src/blocks/cart-button/block.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "tutor-blocks/cart-button", + "title": "Cart Button", + "category": "tutor", + "icon": "cart", + "description": "Display a cart button with item count for Tutor LMS ecommerce", + "attributes": { + "showCount": { + "type": "string", + "default": "if_has_items" + }, + "customClass": { + "type": "string", + "default": "tutor-cart-button" + }, + "iconColor": { + "type": "string", + "default": "" + }, + "cartIcon": { + "type": "string", + "default": "cart" + }, + "iconSize": { + "type": "number", + "default": 20 + }, + "badgeBgColor": { + "type": "string", + "default": "#0c111d" + }, + "badgeTextColor": { + "type": "string", + "default": "#ffffff" + } + }, + "supports": { + "spacing": { + "margin": true, + "padding": true + } + }, + "editorScript": "file:./index.js", + "style": "tutor-cart-button", + "render": "file:./render.php" +} \ No newline at end of file diff --git a/assets/src/blocks/cart-button/edit.js b/assets/src/blocks/cart-button/edit.js new file mode 100644 index 0000000000..b84cada411 --- /dev/null +++ b/assets/src/blocks/cart-button/edit.js @@ -0,0 +1,176 @@ +import { InspectorControls, useBlockProps } from '@wordpress/block-editor'; +import { + PanelBody, + RadioControl, + TextControl, + RangeControl, + BaseControl, + ColorPalette, + __experimentalToggleGroupControl as ExperimentalToggleGroupControl, + __experimentalToggleGroupControlOption as ExperimentalToggleGroupControlOption, + ToggleGroupControl as WpToggleGroupControl, + ToggleGroupControlOption as WpToggleGroupControlOption, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +import CartIcon from '@Icons/cart.svg'; +import BagIcon from '@Icons/bag.svg'; +import BasketIcon from '@Icons/basket.svg'; + +const ToggleGroupControl = WpToggleGroupControl || ExperimentalToggleGroupControl; +const ToggleGroupControlOption = WpToggleGroupControlOption || ExperimentalToggleGroupControlOption; + +const ICON_COMPONENTS = { + cart: CartIcon, + bag: BagIcon, + basket: BasketIcon, +}; + +export default function Edit( { attributes, setAttributes } ) { + const { + showCount = 'if_has_items', + customClass = 'tutor-cart-button', + cartIcon = 'cart', + iconSize = 20, + iconColor, + badgeBgColor, + badgeTextColor, + } = attributes; + + // Use a sample count for editor preview + const cartCount = 3; + const SelectedIcon = ICON_COMPONENTS[ cartIcon ] || ICON_COMPONENTS.cart; + + return ( + <> + + + { ToggleGroupControl && ( + setAttributes( { cartIcon: value } ) } + > + + + + ) } + aria-label={ __( 'Cart', 'tutor' ) } + /> + + + + ) } + aria-label={ __( 'Bag', 'tutor' ) } + /> + + + + ) } + aria-label={ __( 'Basket', 'tutor' ) } + /> + + ) } + + setAttributes( { iconSize: value } ) } + min={ 16 } + max={ 48 } + step={ 2 } + /> + + setAttributes( { showCount: value } ) } + help={ __( 'The editor does not display the real count value, but a placeholder to indicate how it will look on the front-end.', 'tutor' ) } + /> + setAttributes( { customClass: value } ) } + placeholder="tutor-cart-button" + /> + + + + + + + setAttributes( { iconColor: value } ) } + disableCustomColors={ false } + clearable={ true } + /> + + + setAttributes( { badgeBgColor: value } ) } + disableCustomColors={ false } + clearable={ true } + /> + + + setAttributes( { badgeTextColor: value } ) } + disableCustomColors={ false } + clearable={ true } + /> + + + + +
+
+ + + { ( showCount === 'always' || showCount === 'if_has_items' ) && ( + + { cartCount } + + ) } + +
+
+ + ); +} diff --git a/assets/src/blocks/cart-button/index.js b/assets/src/blocks/cart-button/index.js new file mode 100644 index 0000000000..b156efcd9f --- /dev/null +++ b/assets/src/blocks/cart-button/index.js @@ -0,0 +1,10 @@ +import { registerBlockType } from '@wordpress/blocks'; +import metadata from './block.json'; +import Edit from './edit'; +import save from './save'; + +registerBlockType( metadata.name, { + ...metadata, + edit: Edit, + save, +} ); diff --git a/assets/src/blocks/cart-button/render.php b/assets/src/blocks/cart-button/render.php new file mode 100644 index 0000000000..81a290725c --- /dev/null +++ b/assets/src/blocks/cart-button/render.php @@ -0,0 +1,53 @@ + isset( $attributes['showCount'] ) ? $attributes['showCount'] : 'if_has_items', + 'class' => isset( $attributes['customClass'] ) && ! empty( $attributes['customClass'] ) ? $attributes['customClass'] : 'tutor-cart-button', + 'cart_icon' => isset( $attributes['cartIcon'] ) ? $attributes['cartIcon'] : 'cart', +); + +$shortcode = '[tutor_cart_button'; +foreach ( $atts as $key => $value ) { + $shortcode .= sprintf( ' %s="%s"', $key, esc_attr( $value ) ); +} +$shortcode .= ']'; + +// Build inline CSS variables for custom colors and size set in the block editor. +// The stylesheet consumes these via var(--tutor-cart-*) with hardcoded fallbacks, +// so no +
+ +
+ diff --git a/templates/dashboard/instructor/home/overview-chart.php b/templates/dashboard/instructor/home/overview-chart.php index e614d33e10..e0b4fd3eb0 100644 --- a/templates/dashboard/instructor/home/overview-chart.php +++ b/templates/dashboard/instructor/home/overview-chart.php @@ -9,16 +9,11 @@ */ defined( 'ABSPATH' ) || exit; -?> - +$overview_chart_data = $overview_chart_data ?? array(); +?> -
+
diff --git a/templates/dashboard/instructor/home/top-performing-course-filter.php b/templates/dashboard/instructor/home/top-performing-course-filter.php index 65c1be2ef0..f4430cb16c 100644 --- a/templates/dashboard/instructor/home/top-performing-course-filter.php +++ b/templates/dashboard/instructor/home/top-performing-course-filter.php @@ -14,6 +14,9 @@ use TUTOR\Icon; use Tutor\Components\SvgIcon; use Tutor\Components\Constants\Color; + +$options = $options ?? array(); +$selected = $selected ?? 'revenue'; ?>
$option ) : ?> - + diff --git a/templates/dashboard/my-courses.php b/templates/dashboard/my-courses.php index fdc4f2d65f..d779ca0c27 100644 --- a/templates/dashboard/my-courses.php +++ b/templates/dashboard/my-courses.php @@ -321,6 +321,7 @@ class="tutor-popover" array( 'tutor_action' => 'duplicate_course', 'course_id' => $post->ID, + tutor()->nonce => $tutor_nonce_value, ) ); ?> 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..64a54db9de 100644 --- a/templates/learning-area/quiz/content.php +++ b/templates/learning-area/quiz/content.php @@ -31,7 +31,7 @@ $time_units = Quiz::quiz_time_units(); $quiz_item_readable = $has_time_limit ? $quiz_time['time_value'] . ' ' . $time_units[ $quiz_time['time_type'] ] : null; $quiz_attempt = ( new QuizModel() )->get_quiz_attempt( $quiz_id, $user_id ?? get_current_user_id() ); -$earned_marks = 0; +$earned_marks = null; $total_marks = Quiz::get_quiz_total_marks( $quiz_id ); if ( is_object( $quiz_attempt ) && (float) ( $quiz_attempt->total_marks ?? 0 ) > 0 ) { @@ -65,7 +65,7 @@
diff --git a/templates/shared/components/quiz/attempt-details.php b/templates/shared/components/quiz/attempt-details.php index b241541fda..3cdfdf3d0f 100644 --- a/templates/shared/components/quiz/attempt-details.php +++ b/templates/shared/components/quiz/attempt-details.php @@ -53,12 +53,12 @@ return; } -if ( $is_instructor_review ) { - if ( ! tutor_utils()->can_user_manage( 'attempt', (int) $attempt_data->attempt_id ) ) { - $render_attempt_not_found( __( 'Attempt not found or access permission denied', 'tutor' ) ); - return; - } -} elseif ( $user_id > 0 && (int) $attempt_data->user_id !== $user_id ) { +$is_attempt_owner = $user_id > 0 && (int) $attempt_data->user_id === $user_id; +$can_manage_attempt = tutor_utils()->can_user_manage( 'attempt', (int) $attempt_data->attempt_id ); +// Instructor review: admin/instructor only. Otherwise: attempt owner, admin, or course instructor. +$can_view_attempt = $is_instructor_review ? $can_manage_attempt : ( $is_attempt_owner || $can_manage_attempt ); + +if ( ! $can_view_attempt ) { $render_attempt_not_found( __( 'Attempt not found or access permission denied', 'tutor' ) ); return; } 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..d444f2359e 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 ) ) { @@ -21,6 +20,7 @@ $back_url = (string) ( $back_url ?? '' ); $context = (string) ( $context ?? '' ); $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(); @@ -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..676adf42ac 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( + 'x-bind', + 'register(' . wp_json_encode( $manual_mark_field ) . ', ' . wp_json_encode( $mark_validation_rules ) . ')' + ) + ->render(); + ?> + / +
+
+ + 0 ) : ?> +
+ })' + > + label( __( 'Add Feedback', 'tutor' ) ) + ->icon( Icon::COMMENT_OUTLINE, 'left', 20, 20 ) + ->flip_rtl() + ->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/tsconfig.json b/tsconfig.json index 1d728cf565..4fac3a86e5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ "typeRoots": ["./node_modules/@types/", "./assets/src/js/@types"], "paths": { "@Core/*": ["./assets/core/*"], + "@Icons/*": ["./assets/icons/*"], "@TutorShared/*": ["./assets/src/js/v3/shared/*"], "@SharedImages/*": ["./assets/src/js/v3/public/images/*"], "@CourseBuilderComponents/*": ["./assets/src/js/v3/entries/course-builder/components/*"], diff --git a/tutor.php b/tutor.php index 0c6d87e2e9..789a318450 100644 --- a/tutor.php +++ b/tutor.php @@ -4,7 +4,7 @@ * Plugin URI: https://tutorlms.com * Description: Build and manage professional online courses with unlimited lessons, a flexible quiz engine, and a complete student learning experience. No coding needed. * Author: Themeum - * Version: 4.0.9 + * Version: 4.1.0 * Author URI: https://themeum.com * Requires PHP: 7.4 * Requires at least: 5.3 @@ -26,7 +26,7 @@ * * @since 1.0.0 */ -define( 'TUTOR_VERSION', '4.0.9' ); +define( 'TUTOR_VERSION', '4.1.0' ); define( 'TUTOR_FILE', __FILE__ ); define( 'TUTOR_ENV', 'PROD' ); // DEV || PROD. 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..6fe6b0ddc0 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,512 @@ 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(); + case 'questions': + ?> + + + question_title ) ); ?> + + + $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; - } + 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; } } + } + } - tutor_render_answer_list( $answers ); - } elseif ( 'image_answering' === $answer->question_type ) { - - $ordering_ids = maybe_unserialize( $answer->given_answer ); + tutor_render_answer_list( $answers ); + } elseif ( 'image_matching' === $answer->question_type ) { - $answers = array(); + $ordering_ids = maybe_unserialize( $answer->given_answer ); + $original_saved_answers = QuizModel::get_answers_by_quiz_question( $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; + $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 ); - } 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 - ) - ); + tutor_render_answer_list( $answers ); + } elseif ( 'image_answering' === $answer->question_type ) { - echo '' . - esc_html( $correct_answer ) . - ''; - } + $ordering_ids = maybe_unserialize( $answer->given_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 - ) - ); + $answers = array(); - tutor_render_answer_list( $correct_answer ); - } + 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 ) { - $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 - ) - ); + case 'correct_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 + ); + } - tutor_render_answer_list( $correct_answer ); - } + 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 ); + } + } + ?> +
+ + 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 - ) + 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 = ( floor( $achieved_val ) === $achieved_val ) ? (string) (int) $achieved_val : (string) round( $achieved_val, 2 ); + $score_label = sprintf( + /* translators: 1: achieved marks, 2: total marks. */ + esc_html__( 'Score: %1$s/%2$s', 'tutor' ), + $achieved_str, + $qmark_str ); - $answer_titles = explode( '|', stripslashes( $correct_answer ) ); - $get_db_answers_by_question = QuizModel::get_answers_by_quiz_question( $answer->question_id ); + $badge_info = QuizModel::get_attempt_answer_badge( $answer ); + $badge_class = $badge_info['class'] ?? 'label-default'; - echo tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $answer_titles ); //phpcs:ignore --contain safe data - } + echo '' . esc_html( $badge_info['label'] ?? '' ) . ''; - // 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 - ) - ); + do_action( 'tutor_quiz_attempt_details_result_badge_after', $answer, $answer_status, $is_instructor_review ); - foreach ( $correct_answer as $ans ) { - tutor_render_answer_list( array( $ans ) ); + if ( 'pending' !== $answer_status && 'skipped' !== $answer_status ) { + echo '
' . esc_html( $score_label ) . '
'; } } + ?> +
- // 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 - ); - } - - 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 ); - } - } - ?> +
+ + + +
+ + +
+
- - + + + +
+ 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; ?> - -
- - - 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; - } - } - ?> - - + + + + attempt_answer_id ] ?? '' ) ); + ?> + - + + - -
- - - - - - - + $answer_is_correct = null !== ( $answer->is_correct ?? null ) && (int) QuizModel::ATTEMPT_ANSWER_CORRECT === $answer->is_correct; + $answer_is_incorrect = null !== ( $answer->is_correct ?? null ) && (int) QuizModel::ATTEMPT_ANSWER_INCORRECT === $answer->is_correct; + ?> +
+ + +
+ question_id ] ) ) : ?> +
+
- - - + + +
+ + + - + + + +
+
+ + + + +
+
+ +
+
+ + + - + + @@ -843,7 +904,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..7e4e0deefb 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 ) : ?> @@ -140,7 +131,7 @@ - earned_marks ) ? round( $attempt->earned_marks ) . ' (' . $earned_percentage . '%)' : '0 (0%)' ); ?> + earned_marks ) ? round( (float) $attempt->earned_marks, 2 ) . ' (' . $earned_percentage . '%)' : '0 (0%)' ); ?>
-