diff --git a/assets/icons/hand-swipe-right.svg b/assets/icons/hand-swipe-right.svg new file mode 100644 index 0000000000..ef2b599369 --- /dev/null +++ b/assets/icons/hand-swipe-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/src/js/frontend/dashboard/pages/settings.ts b/assets/src/js/frontend/dashboard/pages/settings.ts index 5b7a2d3ddc..bb1bee9cfe 100644 --- a/assets/src/js/frontend/dashboard/pages/settings.ts +++ b/assets/src/js/frontend/dashboard/pages/settings.ts @@ -83,7 +83,6 @@ interface ResetPasswordResponse { const settings = () => { const query = window.TutorCore.query; const form = window.TutorCore.form; - const modal = window.TutorCore.modal; const toast = window.TutorCore.toast; return { diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx index 44f62af47f..c5835799f1 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx @@ -40,6 +40,7 @@ const questionTypeIconMap: Record { image_answering: , ordering: , draw_image: , + scale: , pin_image: , } as const; diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx index 3f5194fc74..54b27fc292 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx @@ -110,6 +110,12 @@ const questionTypeOptions: { icon: 'quizImageAnswer', isPro: true, }, + { + label: __('Scale', 'tutor'), + value: 'scale', + icon: 'quizImageAnswer', + isPro: true, + }, { label: __('Pin on Image', 'tutor'), value: 'pin_image', @@ -124,7 +130,9 @@ const isTutorPro = !!tutorConfig.tutor_pro_url; const QuestionList = ({ isEditing }: { isEditing: boolean }) => { const questionTypeOptionsForUi = useMemo(() => { if (tutorConfig.is_legacy_learning_mode) { - return questionTypeOptions.filter((option) => option.value !== 'draw_image' && option.value !== 'pin_image'); + return questionTypeOptions.filter( + (option) => option.value !== 'draw_image' && option.value !== 'pin_image' && option.value !== 'scale', + ); } return questionTypeOptions; }, []); diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/Scale.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/Scale.tsx new file mode 100644 index 0000000000..d97d0798ae --- /dev/null +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/Scale.tsx @@ -0,0 +1,93 @@ +import { css } from '@emotion/react'; +import { useEffect } from 'react'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; + +import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; +import type { QuizForm } from '@CourseBuilderServices/quiz'; +import FormScale from '@TutorShared/components/fields/quiz/questions/FormScale'; +import { spacing } from '@TutorShared/config/styles'; +import { styleUtils } from '@TutorShared/utils/style-utils'; +import { QuizDataStatus, type QuizQuestionOption } from '@TutorShared/utils/types'; +import { nanoid } from '@TutorShared/utils/util'; + +const Scale = () => { + const form = useFormContext(); + const { activeQuestionId, activeQuestionIndex, validationError, setValidationError } = useQuizModalContext(); + + const answersPath = `questions.${activeQuestionIndex}.question_answers` as 'questions.0.question_answers'; + + const { fields: optionsFields } = useFieldArray({ + control: form.control, + name: answersPath, + }); + + useEffect(() => { + if (!activeQuestionId) { + return; + } + if (optionsFields.length > 0) { + return; + } + const baseAnswer: QuizQuestionOption = { + _data_status: QuizDataStatus.NEW, + // Treat the initial default configuration as already saved so that + // validation doesn’t block adding another question when the instructor + // hasn’t interacted with the scale form yet. + is_saved: true, + answer_id: nanoid(), + belongs_question_id: activeQuestionId, + belongs_question_type: 'scale' as QuizQuestionOption['belongs_question_type'], + answer_title: '', + is_correct: '1', + image_id: undefined, + image_url: '', + answer_two_gap_match: JSON.stringify({ + value: 50, + config: { + min: 0, + max: 100, + step: 1, + defaultValue: 50, + pxPerUnit: 10, + labelEvery: 10, + minorTickEvery: 5, + precision: 0, + }, + }), + answer_view_format: 'scale', + answer_order: 0, + }; + form.setValue(answersPath, [baseAnswer]); + }, [activeQuestionId, optionsFields.length, answersPath, form]); + + if (optionsFields.length === 0) { + return null; + } + + return ( +
+ ( + + )} + /> +
+ ); +}; + +export default Scale; + +const styles = { + optionWrapper: css` + ${styleUtils.display.flex('column')}; + padding-left: ${spacing[40]}; + `, +}; diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormScale.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormScale.tsx new file mode 100644 index 0000000000..fad8a17091 --- /dev/null +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormScale.tsx @@ -0,0 +1,317 @@ +/** + * Form field for Scale quiz question type (instructor sets target value on scale). + * + * @package Tutor + * @since 4.0.0 + */ + +import { css } from '@emotion/react'; +import { __ } from '@wordpress/i18n'; +import { useCallback, useEffect, useState } from 'react'; + +import { + borderRadius, + Breakpoint, + colorTokens, + fontFamily, + fontSize, + fontWeight, + letterSpacing, + lineHeight, + spacing, +} from '@TutorShared/config/styles'; +import type { FormControllerProps } from '@TutorShared/utils/form'; +import { calculateQuizDataStatus } from '@TutorShared/utils/quiz'; +import { styleUtils } from '@TutorShared/utils/style-utils'; +import { + type ID, + QuizDataStatus, + type QuizQuestionOption, + type QuizValidationErrorType, +} from '@TutorShared/utils/types'; + +interface FormScaleProps extends FormControllerProps { + questionId: ID; + validationError?: { + message: string; + type: QuizValidationErrorType; + } | null; + setValidationError?: React.Dispatch< + React.SetStateAction<{ + message: string; + type: QuizValidationErrorType; + } | null> + >; +} + +interface ScaleConfig { + min: number; + max: number; + step: number; + defaultValue: number; + pxPerUnit: number; + labelEvery: number; + minorTickEvery: number; + precision: number; +} + +interface ScaleData { + value: number; + config: ScaleConfig; +} + +function parseStoredScaleData(value: string): ScaleData | null { + if (!value || typeof value !== 'string') return null; + try { + const data = JSON.parse(value) as Partial; + if (typeof data.value === 'number' && data.config) { + return { + value: data.value, + config: { + min: data.config.min ?? 0, + max: data.config.max ?? 100, + step: data.config.step ?? 1, + defaultValue: data.config.defaultValue ?? 50, + pxPerUnit: data.config.pxPerUnit ?? 10, + labelEvery: data.config.labelEvery ?? 10, + minorTickEvery: data.config.minorTickEvery ?? 5, + precision: data.config.precision ?? 0, + }, + }; + } + } catch { + // ignore + } + return null; +} + +const FormScale = ({ field }: FormScaleProps) => { + const option = field.value; + const [scaleData, setScaleData] = useState(() => { + const parsed = parseStoredScaleData(option?.answer_two_gap_match ?? ''); + return ( + parsed || { + value: 50, + config: { + min: 0, + max: 100, + step: 1, + defaultValue: 50, + pxPerUnit: 10, + labelEvery: 10, + minorTickEvery: 5, + precision: 0, + }, + } + ); + }); + + const [config, setConfig] = useState(scaleData.config); + + useEffect(() => { + const parsed = parseStoredScaleData(option?.answer_two_gap_match ?? ''); + if (parsed) { + setScaleData(parsed); + setConfig(parsed.config); + } + }, [option?.answer_two_gap_match]); + + const updateOption = useCallback( + (updated: QuizQuestionOption) => { + field.onChange(updated); + }, + [field], + ); + + const saveScaleData = useCallback( + (data: ScaleData) => { + if (!option) return; + + const json = JSON.stringify(data); + updateOption({ + ...option, + ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + answer_two_gap_match: json, + is_saved: true, + }); + }, + [option, updateOption], + ); + + const handleConfigChange = useCallback( + (field: keyof ScaleConfig, value: number) => { + const newConfig = { ...config, [field]: value }; + setConfig(newConfig); + + // Update scale data with new config + const newScaleData = { + ...scaleData, + config: newConfig, + // Ensure value is within new range + value: Math.max(newConfig.min, Math.min(newConfig.max, scaleData.value)), + }; + setScaleData(newScaleData); + saveScaleData(newScaleData); + }, + [config, scaleData, saveScaleData], + ); + + const handleValueChange = useCallback( + (value: number) => { + const newScaleData = { ...scaleData, value }; + setScaleData(newScaleData); + saveScaleData(newScaleData); + }, + [scaleData, saveScaleData], + ); + + if (!option) { + return null; + } + + return ( +
+
+
+ {__('Scale range', __TUTOR_TEXT_DOMAIN__)} +
+ {/* Scale Configuration */} +
+
+
+ + handleConfigChange('min', parseFloat(e.target.value) || 0)} + className="tutor-scale-config-input" + /> +
+
+ + handleConfigChange('max', parseFloat(e.target.value) || 100)} + className="tutor-scale-config-input" + /> +
+
+ + handleConfigChange('step', parseFloat(e.target.value) || 1)} + className="tutor-scale-config-input" + /> +
+
+ + handleConfigChange('labelEvery', parseFloat(e.target.value) || 10)} + className="tutor-scale-config-input" + /> +
+
+
+
+ +
+
+ {/* Answer Configuration */} +
+ + handleValueChange(parseFloat(e.target.value) || config.min)} + className="tutor-scale-config-input" + /> +
+
+
+
+ ); +}; + +export default FormScale; + +const styles = { + wrapper: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[24]}; + + ${Breakpoint.smallMobile} { + padding-left: ${spacing[8]}; + } + `, + card: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[8]}; + padding: ${spacing[16]}; + background: ${colorTokens.surface.tutor}; + border-radius: ${borderRadius.input}; + `, + answerHeader: css` + ${styleUtils.display.flex('row')}; + align-items: center; + justify-content: space-between; + gap: ${spacing[12]}; + `, + answerHeaderTitle: css` + font-family: ${fontFamily.sfProDisplay}; + font-weight: ${fontWeight.medium}; + font-size: ${fontSize[15]}; + line-height: ${lineHeight[24]}; + letter-spacing: ${letterSpacing.normal}; + color: ${colorTokens.text.title}; + `, + configSection: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[12]}; + `, + answerSection: css` + ${styleUtils.display.flex('column')}; + `, + configGrid: css` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: ${spacing[8]}; + + ${Breakpoint.smallTablet} { + grid-template-columns: 1fr; + } + `, + configField: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[4]}; + + & .tutor-scale-config-input { + padding: ${spacing[8]}; + border: 1px solid ${colorTokens.stroke.default}; + border-radius: ${borderRadius.input}; + font-family: ${fontFamily.sfProDisplay}; + font-weight: ${fontWeight.regular}; + font-size: ${fontSize[16]}; + line-height: ${lineHeight[24]}; + letter-spacing: ${letterSpacing.normal}; + color: ${colorTokens.text.subdued}; + } + `, + configLabel: css` + font-family: ${fontFamily.sfProDisplay}; + font-weight: ${fontWeight.regular}; + font-size: ${fontSize[15]}; + line-height: ${lineHeight[24]}; + letter-spacing: ${letterSpacing.normal}; + color: ${colorTokens.text.title}; + `, +}; diff --git a/assets/src/js/v3/shared/utils/types.ts b/assets/src/js/v3/shared/utils/types.ts index 2b84dc0b0a..fc9594c438 100644 --- a/assets/src/js/v3/shared/utils/types.ts +++ b/assets/src/js/v3/shared/utils/types.ts @@ -296,6 +296,7 @@ export type QuizQuestionType = | 'image_answering' | 'ordering' | 'draw_image' + | 'scale' | 'pin_image' | 'h5p'; diff --git a/classes/Icon.php b/classes/Icon.php index f7564a33ef..045d902708 100644 --- a/classes/Icon.php +++ b/classes/Icon.php @@ -198,6 +198,7 @@ final class Icon { const GRAB_HANDLE = 'grab-handle'; const GUTENBERG_COLORIZED = 'gutenberg-colorized'; const HAND_COIN = 'hand-coin'; + const HAND_SWIPE_RIGHT = 'hand-swipe-right'; const HAPPY = 'happy'; const HEART = 'heart'; const HIGHLIGHTER = 'highlighter'; diff --git a/classes/Quiz.php b/classes/Quiz.php index 89047cd57f..ce90c1eac3 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -196,8 +196,10 @@ public function skip_addon_only_question_partials( $load, $template, $variables 'tutor_quiz_templates_not_in_core', array( 'learning-area.quiz.questions.draw-image', + 'learning-area.quiz.questions.scale', 'learning-area.quiz.questions.pin-image', 'shared.components.quiz.attempt-details.questions.draw-image', + 'shared.components.quiz.attempt-details.questions.scale', 'shared.components.quiz.attempt-details.questions.pin-image', ), $template, diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index aa808b5feb..dda436f0c4 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -188,12 +188,13 @@ public function save_questions( $quiz_id, $questions ) { $question_type = Input::sanitize( $question['question_type'] ); if ( 'draw_image' === $question_type && tutor_utils()->is_legacy_learning_mode() ) { - $legacy_draw_image_message = __( 'Draw on Image questions are not available when Legacy learning mode is enabled.', 'tutor' ); - throw new \Exception( $legacy_draw_image_message ); + throw new \Exception( esc_html__( 'Draw on Image questions are not available when Legacy learning mode is enabled.', 'tutor' ) ); } if ( 'pin_image' === $question_type && tutor_utils()->is_legacy_learning_mode() ) { - $legacy_pin_image_message = __( 'Pin on Image questions are not available when Legacy learning mode is enabled.', 'tutor' ); - throw new \Exception( $legacy_pin_image_message ); + throw new \Exception( esc_html__( 'Pin on Image questions are not available when Legacy learning mode is enabled.', 'tutor' ) ); + } + if ( 'scale' === $question_type && tutor_utils()->is_legacy_learning_mode() ) { + throw new \Exception( esc_html__( 'Scale questions are not available when Legacy learning mode is enabled.', 'tutor' ) ); } $question_data = $this->prepare_question_data( $quiz_id, $question ); $question_answers = isset( $question['question_answers'] ) ? $question['question_answers'] : array(); diff --git a/classes/Utils.php b/classes/Utils.php index f1850b0e46..05af4fe1c1 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -5297,6 +5297,11 @@ public function get_question_types( $type = null ) { 'icon' => '', 'is_pro' => true, ), + 'scale' => array( + 'name' => __( 'Scale', 'tutor' ), + 'icon' => '', + 'is_pro' => true, + ), 'pin_image' => array( 'name' => __( 'Pin on Image', 'tutor' ), 'icon' => '', diff --git a/templates/shared/components/quiz/attempt-details/question.php b/templates/shared/components/quiz/attempt-details/question.php index 101633f815..bf33068ac7 100644 --- a/templates/shared/components/quiz/attempt-details/question.php +++ b/templates/shared/components/quiz/attempt-details/question.php @@ -90,5 +90,8 @@ if ( is_object( $attempt_answer ) ) { do_action( 'tutor_quiz_attempt_details_loop_after_row', $attempt_answer, $answer_status, array() ); } + + // Add-ons may output Pro-only attempt-details partials (scale, draw-image, pin-image) before the wrapper closes. + do_action( 'tutor_quiz_attempt_details_before_question_wrapper_close', $question, $question_template, $attempt_answer, $index ); ?> diff --git a/templates/shared/components/quiz/attempt-details/review-answers.php b/templates/shared/components/quiz/attempt-details/review-answers.php index 5523d42526..f7368b44ed 100644 --- a/templates/shared/components/quiz/attempt-details/review-answers.php +++ b/templates/shared/components/quiz/attempt-details/review-answers.php @@ -52,6 +52,8 @@ $is_pin_review = 'pin_image' === $question_type; + $is_scale_review = 'scale' === $question_type; + $attempt_answer = $attempt_answers_map[ $question_id ] ?? null; $question_template = ''; @@ -66,6 +68,8 @@ $question_template = 'open-ended'; } elseif ( $is_fib_review ) { $question_template = 'fill-in-the-blank'; + } elseif ( $is_scale_review ) { + $question_template = 'scale'; } elseif ( $is_pin_review ) { $question_template = 'pin-image'; } elseif ( $is_draw_image_review ) {