diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index 2c3724d65f..4d5eebcf35 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -2,7 +2,7 @@ window.jQuery(document).ready($ => { const { __ } = window.wp.i18n; // Currently only these types of question supports answer reveal mode. - const revealModeSupportedQuestions = ['true_false', 'single_choice', 'multiple_choice', 'draw_image']; + const revealModeSupportedQuestions = ['true_false', 'single_choice', 'multiple_choice', 'draw_image', 'jigsaw']; let quiz_options = _tutorobject.quiz_options let interactions = new Map(); @@ -108,6 +108,11 @@ window.jQuery(document).ready($ => { $question_wrap.find('.tutor-draw-image-reference-wrapper').removeClass('tutor-d-none'); goNext = true; } + // Reveal mode for jigsaw: show explanation. + if (is_reveal_mode() && $question_wrap.data('question-type') === 'jigsaw') { + $question_wrap.find('.tutor-quiz-explanation-wrapper').removeClass('tutor-d-none'); + goNext = true; + } if (validatedTrue) { goNext = true; @@ -172,9 +177,20 @@ window.jQuery(document).ready($ => { var $maskInput = $required_answer_wrap.find('input[name*="[answers][mask]"]'); if ($maskInput.length && !$maskInput.val().trim().length) { $question_wrap.find('.answer-help-block').html(`

${__('Please draw on the image to answer this question.', 'tutor')}

`); - validated = false; + $question_wrap.find('.tutor-quiz-next-btn-all').prop('disabled', true); + return; + } + } + // Jigsaw: require [answers][solved] to have value "solved". + if ($question_wrap.data('question-type') === 'jigsaw') { + var $solvedInput = $required_answer_wrap.find('input[name*="[answers][solved]"]'); + if ($solvedInput.length && $solvedInput.val() !== 'solved') { + $question_wrap.find('.answer-help-block').html(`

${__('Please solve the puzzle to answer this question.', 'tutor')}

`); + $question_wrap.find('.tutor-quiz-next-btn-all').prop('disabled', true); + return; } - } else if ($type === 'radio') { + } + else if ($type === 'radio') { if ($required_answer_wrap.find('input[type="radio"]:checked').length == 0) { $question_wrap.find('.answer-help-block').html(`

${__('Please select an option to answer', 'tutor')}

`); validated = false; @@ -239,6 +255,12 @@ window.jQuery(document).ready($ => { } }); + $(document).on('change', '.quiz-attempt-single-question input[name*="[answers][solved]"]', function () { + if ($('.tutor-quiz-time-expired').length === 0 && $(this).val() === 'solved') { + $('.tutor-quiz-next-btn-all').prop('disabled', false); + } + }); + $(document).on('click', '.tutor-quiz-answer-next-btn, .tutor-quiz-answer-previous-btn', function (e) { e.preventDefault(); 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 b2846d5eae..6ecd615e3b 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: , + jigsaw: , } as const; useEffect(() => { 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 062ddc8877..33289455bc 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: __('Jigsaw', 'tutor'), + value: 'jigsaw', + icon: 'quizImageAnswer', + isPro: true, + }, ]; const isTutorPro = !!tutorConfig.tutor_pro_url; @@ -230,7 +236,26 @@ const QuestionList = ({ isEditing }: { isEditing: boolean }) => { is_correct: '1', }, ] - : [], + : questionType === 'jigsaw' + ? [ + { + _data_status: QuizDataStatus.NEW, + is_saved: false, + answer_id: nanoid(), + answer_title: '', + belongs_question_id: questionId, + belongs_question_type: 'jigsaw', + answer_two_gap_match: JSON.stringify({ + nbPieces: 12, + shape: 0, + rotationAllowed: false, + }), + answer_view_format: 'jigsaw', + answer_order: 0, + is_correct: '1', + }, + ] + : [], answer_explanation: '', question_mark: 1, question_order: questionFields.length + 1, diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/Jigsaw.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/Jigsaw.tsx new file mode 100644 index 0000000000..79b46224d2 --- /dev/null +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/Jigsaw.tsx @@ -0,0 +1,82 @@ +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 FormJigsaw from '@TutorShared/components/fields/quiz/questions/FormJigsaw'; +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 Jigsaw = () => { + 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, + is_saved: false, + answer_id: nanoid(), + belongs_question_id: activeQuestionId, + belongs_question_type: 'jigsaw' as QuizQuestionOption['belongs_question_type'], + answer_title: '', + is_correct: '1', + image_id: undefined, + image_url: '', + answer_two_gap_match: JSON.stringify({ + nbPieces: 12, + shape: 0, + rotationAllowed: false, + }), + answer_view_format: 'jigsaw', + answer_order: 0, + }; + form.setValue(answersPath, [baseAnswer]); + }, [activeQuestionId, optionsFields.length, answersPath, form]); + + if (optionsFields.length === 0) { + return null; + } + + return ( +
+ ( + + )} + /> +
+ ); +}; + +export default Jigsaw; + +const styles = { + optionWrapper: css` + ${styleUtils.display.flex('column')}; + padding-left: ${spacing[40]}; + `, +}; diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormJigsaw.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormJigsaw.tsx new file mode 100644 index 0000000000..753706b2a5 --- /dev/null +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormJigsaw.tsx @@ -0,0 +1,334 @@ +/** + * Jigsaw puzzle question type form (instructor). + * Upload image and set puzzle config: number of pieces, shape, rotation. + * Config is stored in answer_two_gap_match as JSON. + */ + +import { css } from '@emotion/react'; +import { __ } from '@wordpress/i18n'; +import { useCallback, useMemo } from 'react'; + +import ImageInput from '@TutorShared/atoms/ImageInput'; + +import { borderRadius, Breakpoint, colorTokens, spacing } from '@TutorShared/config/styles'; +import { typography } from '@TutorShared/config/typography'; +import Show from '@TutorShared/controls/Show'; +import useWPMedia from '@TutorShared/hooks/useWpMedia'; +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'; + +export const JIGSAW_DEFAULT_NB_PIECES = 9; +export const JIGSAW_GRID_OPTIONS = [ + { value: 4, label: '2×2' }, + { value: 9, label: '3×3' }, + { value: 16, label: '4×4' }, + { value: 25, label: '5×5' }, + { value: 36, label: '6×6' }, + { value: 49, label: '7×7' }, +] as const; +const JIGSAW_ALLOWED_NB_PIECES = [4, 9, 16, 25, 36, 49] as const; +export const JIGSAW_SHAPE_OPTIONS = [ + { value: 0, label: __('Classic', __TUTOR_TEXT_DOMAIN__) }, + { value: 1, label: __('Triangle', __TUTOR_TEXT_DOMAIN__) }, + { value: 2, label: __('Round', __TUTOR_TEXT_DOMAIN__) }, + { value: 3, label: __('Straight', __TUTOR_TEXT_DOMAIN__) }, +] as const; + +export interface JigsawConfig { + nbPieces: number; + shape: number; + rotationAllowed: boolean; +} + +export const defaultJigsawConfig: JigsawConfig = { + nbPieces: JIGSAW_DEFAULT_NB_PIECES, + shape: 0, + rotationAllowed: false, +}; + +export function parseJigsawConfig(raw: string | undefined): JigsawConfig { + if (!raw || typeof raw !== 'string' || raw.trim() === '') { + return { ...defaultJigsawConfig }; + } + try { + const parsed = JSON.parse(raw) as Partial; + return { + nbPieces: + typeof parsed.nbPieces === 'number' && + JIGSAW_ALLOWED_NB_PIECES.includes(parsed.nbPieces as (typeof JIGSAW_ALLOWED_NB_PIECES)[number]) + ? parsed.nbPieces + : defaultJigsawConfig.nbPieces, + shape: + typeof parsed.shape === 'number' && parsed.shape >= 0 && parsed.shape <= 3 + ? parsed.shape + : defaultJigsawConfig.shape, + rotationAllowed: + typeof parsed.rotationAllowed === 'boolean' ? parsed.rotationAllowed : defaultJigsawConfig.rotationAllowed, + }; + } catch { + return { ...defaultJigsawConfig }; + } +} + +interface FormJigsawProps extends FormControllerProps { + questionId: ID; + validationError?: { + message: string; + type: QuizValidationErrorType; + } | null; + setValidationError?: React.Dispatch< + React.SetStateAction<{ + message: string; + type: QuizValidationErrorType; + } | null> + >; +} + +const FormJigsaw = ({ field }: FormJigsawProps) => { + const option = field.value; + const config = useMemo(() => parseJigsawConfig(option?.answer_two_gap_match), [option?.answer_two_gap_match]); + + const updateOption = useCallback( + (updated: Partial, configOverride?: JigsawConfig) => { + if (!option) return; + const nextConfig = configOverride ?? config; + const answerTwoGapMatch = configOverride !== undefined ? JSON.stringify(nextConfig) : option.answer_two_gap_match; + field.onChange({ + ...option, + ...updated, + answer_two_gap_match: + updated.answer_two_gap_match !== undefined ? updated.answer_two_gap_match : answerTwoGapMatch, + }); + }, + [field, option, config], + ); + + const { openMediaLibrary, resetFiles } = useWPMedia({ + options: { + type: 'image', + }, + onChange: (file) => { + if (file && !Array.isArray(file) && option) { + const nextStatus = calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE); + updateOption({ + ...(nextStatus && { + _data_status: nextStatus as QuizDataStatus, + }), + image_id: file.id, + image_url: file.url, + answer_two_gap_match: JSON.stringify({ ...config }), + is_saved: true, + }); + } + }, + initialFiles: option?.image_id + ? { + id: Number(option.image_id), + url: option.image_url || '', + title: option.image_url || '', + } + : null, + }); + + const clearImage = () => { + if (!option) { + return; + } + + const nextStatus = calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE); + + updateOption({ + ...(nextStatus && { + _data_status: nextStatus as QuizDataStatus, + }), + image_id: undefined, + image_url: '', + answer_two_gap_match: JSON.stringify({ ...config }), + }); + resetFiles(); + }; + + const setConfig = useCallback( + (next: Partial) => { + if (!option) { + return; + } + + const nextConfig = { ...config, ...next }; + const nextStatus = calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE); + + updateOption( + { + ...(nextStatus && { + _data_status: nextStatus as QuizDataStatus, + }), + answer_two_gap_match: JSON.stringify(nextConfig), + is_saved: true, + }, + nextConfig, + ); + }, + [config, option, updateOption], + ); + + if (!option) { + return null; + } + + return ( +
+
+
+ +
+
+ + +
+

{__('Puzzle settings', __TUTOR_TEXT_DOMAIN__)}

+ +
+ + +
+ +
+ + +
+
+
+ + +

+ {__( + 'Upload an image to create a jigsaw puzzle. Then set the number of pieces and shape.', + __TUTOR_TEXT_DOMAIN__, + )} +

+
+
+ ); +}; + +export default FormJigsaw; + +const styles = { + wrapper: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[24]}; + padding-left: ${spacing[40]}; + + ${Breakpoint.smallMobile} { + padding-left: ${spacing[8]}; + } + `, + card: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[16]}; + padding: ${spacing[20]}; + background: ${colorTokens.surface.tutor}; + border: 1px solid ${colorTokens.stroke.border}; + border-radius: ${borderRadius.card}; + `, + imageInputWrapper: css` + max-width: 100%; + `, + imageInput: css` + border-radius: ${borderRadius.card}; + `, + sectionTitle: css` + ${typography.body('medium')}; + color: ${colorTokens.text.primary}; + margin: 0; + `, + configRow: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[8]}; + `, + label: css` + ${typography.caption('medium')}; + color: ${colorTokens.text.primary}; + `, + select: css` + ${typography.body()}; + max-width: 200px; + padding: ${spacing[8]} ${spacing[12]}; + border: 1px solid ${colorTokens.stroke.default}; + border-radius: ${borderRadius[6]}; + background: ${colorTokens.background.white}; + color: ${colorTokens.text.primary}; + `, + switchLabel: css` + ${typography.body()}; + ${styleUtils.display.flex('row')}; + align-items: center; + gap: ${spacing[8]}; + cursor: pointer; + color: ${colorTokens.text.primary}; + `, + checkbox: css` + width: 18px; + height: 18px; + `, + hint: css` + ${typography.caption()}; + color: ${colorTokens.text.subdued}; + margin: 0; + `, + placeholder: css` + ${typography.caption()}; + color: ${colorTokens.text.subdued}; + `, +}; diff --git a/assets/src/js/v3/shared/utils/types.ts b/assets/src/js/v3/shared/utils/types.ts index aa117bdad6..6750c8205a 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' + | 'jigsaw' | 'h5p'; export interface QuizQuestionOption { diff --git a/classes/Utils.php b/classes/Utils.php index b3974c9fb5..fd9ed3cb0a 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -5285,6 +5285,11 @@ public function get_question_types( $type = null ) { 'icon' => '', 'is_pro' => true, ), + 'jigsaw' => array( + 'name' => __( 'Jigsaw', 'tutor' ), + 'icon' => '', + 'is_pro' => true, + ), ); if ( isset( $types[ $type ] ) ) { diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php index 0cc4205300..a2453f4c50 100644 --- a/views/quiz/attempt-details.php +++ b/views/quiz/attempt-details.php @@ -561,7 +561,7 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an
question_type != 'open_ended' && $answer->question_type != 'short_answer' ) ) { + if ( ( $answer->question_type != 'open_ended' && $answer->question_type !== 'short_answer' ) ) { global $wpdb;