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