import { useEffect, useMemo, useState } from 'react'; import { getWpApi } from '../../api'; import { NavIcon } from '../NavIcon'; import { ApiErrorPanel } from './ApiErrorPanel'; import { ButtonPrimary } from './buttons'; import { HorizontalEditorTabs } from './HorizontalEditorTabs'; import { Modal } from './Modal'; import { WPMediaPickerField } from './WPMediaPickerField'; import type { SikshyaReactConfig } from '../../types'; import { appViewHref } from '../../lib/appUrl'; import { isFeatureEnabled } from '../../lib/licensing'; import { useAddonEnabled } from '../../hooks/useAddons'; import { PRO_QUESTION_DEFAULTS, ProQuestionFields, buildProQuestionMeta, readProQuestionFromMeta, type ProQuestionValues, } from '../../pages/content-editors/ProIntegrationFields'; import { contentFromPost, readMeta, titleFromPost, type WpPostRest, } from '../../pages/content-editors/useWpContentPost'; import { __, sprintf } from '../../lib/i18n'; export type QuestionType = | 'true_false' | 'multiple_choice' | 'multiple_response' | 'short_answer' | 'fill_blank' | 'ordering' | 'matching' | 'essay'; type PickerOpt = { type: QuestionType; label: string; hint: string; icon: 'helpCircle' | 'puzzle' | 'layers' | 'plusDocument'; /** When true, this question type requires the Advanced Quiz addon (Pro). */ requiresAdvancedQuiz?: boolean; }; export const QUESTION_PICKER_TYPES: PickerOpt[] = [ { type: 'true_false', label: __('True / False', 'sikshya'), hint: __('Fast checks with one correct answer.', 'sikshya'), icon: 'puzzle', }, { type: 'multiple_choice', label: __('Multiple choice', 'sikshya'), hint: __('One correct answer from a list of options.', 'sikshya'), icon: 'puzzle', }, { type: 'multiple_response', label: __('Multiple response', 'sikshya'), hint: __('Learner can select more than one correct option.', 'sikshya'), icon: 'layers', requiresAdvancedQuiz: true, }, { type: 'short_answer', label: __('Short answer', 'sikshya'), hint: __('Learner types a short text response.', 'sikshya'), icon: 'plusDocument', }, { type: 'fill_blank', label: __('Fill in the blank', 'sikshya'), hint: __('A short prompt with a missing word or phrase.', 'sikshya'), icon: 'puzzle', requiresAdvancedQuiz: true, }, { type: 'matching', label: __('Matching', 'sikshya'), hint: __('Pair items from two columns.', 'sikshya'), icon: 'layers', requiresAdvancedQuiz: true, }, { type: 'ordering', label: __('Ordering', 'sikshya'), hint: __('Reorder items into the correct sequence.', 'sikshya'), icon: 'layers', requiresAdvancedQuiz: true, }, { type: 'essay', label: __('Essay', 'sikshya'), hint: __('Long-form response, typically manually graded.', 'sikshya'), icon: 'helpCircle', requiresAdvancedQuiz: true, }, ]; const FIELD = 'mt-1.5 w-full rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 focus-visible:ring-brand-500/40 dark:border-slate-600 dark:bg-slate-800 dark:text-white'; const LABEL = 'block text-sm font-medium text-slate-800 dark:text-slate-200'; const HINT = 'mt-1 text-xs text-slate-500 dark:text-slate-400'; const DEFAULT_MCQ_OPTIONS = ['', '', '', '']; function metaStringArray(raw: unknown): string[] { if (!Array.isArray(raw)) { return []; } return raw.map((x) => String(x)); } function parseQuestionCorrectJson(raw: string): unknown { try { return JSON.parse(raw) as unknown; } catch { return null; } } /** * Populate modal form state from a `sik_question` REST record (same rules as full-page QuestionEditor). */ function hydrateAuthoringFormFromQuestionPost( p: WpPostRest, setters: { setTitle: (v: string) => void; setContent: (v: string) => void; setStatus: (v: string) => void; setFeatured: (v: number) => void; setQType: (v: string) => void; setPoints: (v: number) => void; setOptions: (v: string[]) => void; setCorrectAnswer: (v: string) => void; setMultiCorrect: (v: number[]) => void; setMatchLeft: (v: string[]) => void; setMatchRight: (v: string[]) => void; setMatchMap: (v: number[]) => void; setOrderItems: (v: string[]) => void; setOrderPerm: (v: number[]) => void; setProQuestionValues: (v: ProQuestionValues) => void; } ): void { const { setTitle, setContent, setStatus, setFeatured, setQType, setPoints, setOptions, setCorrectAnswer, setMultiCorrect, setMatchLeft, setMatchRight, setMatchMap, setOrderItems, setOrderPerm, setProQuestionValues, } = setters; setTitle(titleFromPost(p)); setContent(contentFromPost(p)); setStatus(p.status || 'draft'); setFeatured(typeof p.featured_media === 'number' ? p.featured_media : 0); const m = p.meta as Record | undefined; const t = String(readMeta(m, '_sikshya_question_type') ?? ''); setQType(t); setPoints(Number(readMeta(m, '_sikshya_question_points') ?? 1)); setProQuestionValues(readProQuestionFromMeta(m)); const loadedOpts = metaStringArray(readMeta(m, '_sikshya_question_options')); const rawCorrect = String(readMeta(m, '_sikshya_question_correct_answer') ?? ''); if (t === 'matching') { setOptions([]); const parsed = parseQuestionCorrectJson(rawCorrect) as { matching?: { left?: string[]; right?: string[]; map?: number[] }; } | null; const mm = parsed && typeof parsed === 'object' && parsed !== null ? parsed.matching : undefined; if (mm && Array.isArray(mm.left) && Array.isArray(mm.right)) { setMatchLeft(mm.left.map(String)); setMatchRight(mm.right.map(String)); const n = mm.left.length; const map = Array.isArray(mm.map) && mm.map.length === n ? mm.map.map((x) => Number(x)) : mm.left.map((_, i) => i); setMatchMap(map); } else { setMatchLeft(['', '']); setMatchRight(['', '']); setMatchMap([0, 0]); } setCorrectAnswer(''); setMultiCorrect([]); return; } if (t === 'ordering') { const items = loadedOpts.length >= 2 ? loadedOpts : ['Item 1', 'Item 2', 'Item 3']; setOrderItems(items); const permRaw = parseQuestionCorrectJson(rawCorrect); if (Array.isArray(permRaw) && permRaw.length === items.length && permRaw.every((x) => typeof x === 'number')) { setOrderPerm(permRaw as number[]); } else { setOrderPerm(items.map((_, i) => i)); } setOptions(items); setCorrectAnswer(''); setMultiCorrect([]); return; } if (t === 'multiple_response') { setOptions(loadedOpts.length >= 2 ? loadedOpts : [...DEFAULT_MCQ_OPTIONS]); const parsed = parseQuestionCorrectJson(rawCorrect); setMultiCorrect( Array.isArray(parsed) ? parsed.map((x) => Number(x)).filter((n) => Number.isInteger(n) && n >= 0) : [], ); setCorrectAnswer(''); setMatchLeft(['', '']); setMatchRight(['', '']); setMatchMap([0, 0]); return; } if (t === 'true_false') { setOptions(['True', 'False']); setCorrectAnswer(rawCorrect === 'false' ? 'false' : 'true'); setMultiCorrect([]); setMatchLeft(['', '']); setMatchRight(['', '']); setMatchMap([0, 0]); return; } setOptions(loadedOpts.length >= 2 ? loadedOpts : [...DEFAULT_MCQ_OPTIONS]); setCorrectAnswer(rawCorrect); setMultiCorrect([]); setMatchLeft(['', '']); setMatchRight(['', '']); setMatchMap([0, 0]); setOrderItems(['Item 1', 'Item 2', 'Item 3']); setOrderPerm([0, 1, 2]); } function useAttachmentPreviewUrl(attachmentId: number): string { const [url, setUrl] = useState(''); useEffect(() => { if (!attachmentId || attachmentId <= 0) { setUrl(''); return; } let cancelled = false; void getWpApi() .get<{ source_url?: string }>(`/media/${attachmentId}`) .then((media) => { if (!cancelled && media?.source_url) { setUrl(media.source_url); } }) .catch(() => { if (!cancelled) { setUrl(''); } }); return () => { cancelled = true; }; }, [attachmentId]); return url; } function ModalFeaturedImageField(props: { fieldId: string; attachmentId: number; onAttachmentIdChange: (id: number) => void; description?: string; }) { const previewUrl = useAttachmentPreviewUrl(props.attachmentId); return (

{props.description ?? 'Optional image shown with the question in supported themes or future quiz layouts.'}

{}} onAttachmentIdChange={props.onAttachmentIdChange} className={FIELD} placeholder={__( 'Opens the media library — upload a new image or choose an existing file.', 'sikshya' )} />
); } function buildCreateBody(params: { title: string; content: string; status: string; featured: number; qType: string; points: number; options: string[]; correctAnswer: string; multiCorrect: number[]; matchLeft: string[]; matchRight: string[]; matchMap: number[]; orderItems: string[]; orderPerm: number[]; proQuestionValues: ProQuestionValues; }): Record { const { title, content, status, featured, qType, points, options, correctAnswer, multiCorrect, matchLeft, matchRight, matchMap, orderItems, orderPerm, proQuestionValues, } = params; const trimmedOptions = options.map((o) => o.trim()).filter(Boolean); let optionsPayload: string[] = []; let correctPayload = ''; if (qType === 'multiple_choice') { optionsPayload = trimmedOptions; correctPayload = correctAnswer; } else if (qType === 'multiple_response') { optionsPayload = trimmedOptions; correctPayload = JSON.stringify([...multiCorrect].sort((a, b) => a - b)); } else if (qType === 'true_false') { optionsPayload = []; correctPayload = correctAnswer === 'false' ? 'false' : 'true'; } else if (qType === 'short_answer' || qType === 'fill_blank') { optionsPayload = []; correctPayload = correctAnswer.trim(); } else if (qType === 'essay') { optionsPayload = []; correctPayload = ''; } else if (qType === 'matching') { optionsPayload = []; correctPayload = JSON.stringify({ matching: { left: matchLeft.map((x) => x.trim()), right: matchRight.map((x) => x.trim()), map: matchMap.map((x) => Number(x)), }, }); } else if (qType === 'ordering') { const items = orderItems.map((x) => x.trim()).filter(Boolean); const useItems = items.length >= 2 ? items : ['Item 1', 'Item 2']; optionsPayload = useItems; const perm = orderPerm.length === useItems.length ? orderPerm : useItems.map((_, i) => i); correctPayload = JSON.stringify(perm); } return { title, content, status, featured_media: featured > 0 ? featured : 0, meta: { _sikshya_question_type: qType, _sikshya_question_points: Math.max(0, points), _sikshya_question_options: optionsPayload, _sikshya_question_correct_answer: correctPayload, // Mirror the explanation textarea to the dedicated meta key so the // learn-page renderer can pick it up without depending on raw // post_content (which doubles as instructor notes in some workflows). _sikshya_question_explanation: content, ...buildProQuestionMeta(proQuestionValues), }, }; } type Props = { config: SikshyaReactConfig; open: boolean; onClose: () => void; onCreated: (questionId: number) => void; /** When set, modal loads this question and saves via REST update instead of create. */ editQuestionId?: number | null; /** Called after a successful save in edit mode (e.g. refresh titles in the parent list). */ onUpdated?: (questionId: number) => void; onPickExisting?: (questionId: number) => void; pickExistingLabel?: string; }; /** * Full “Content library → New question” authoring experience inside a modal (same fields as QuestionEditor). */ export function AddQuestionAuthoringModal(props: Props) { const { config, open, onClose, onCreated, editQuestionId, onUpdated, onPickExisting, pickExistingLabel = 'Add to quiz' } = props; const isEditMode = Boolean(editQuestionId && editQuestionId > 0); const advFeatureOk = isFeatureEnabled(config, 'quiz_advanced'); const advAddon = useAddonEnabled('quiz_advanced'); const canUseAdvancedTypes = useMemo(() => { if (!advFeatureOk) return false; if (advAddon.loading) return false; if (!advAddon.enabled) return false; if (advAddon.licenseOk === false) return false; return true; }, [advAddon.enabled, advAddon.loading, advAddon.licenseOk, advFeatureOk]); const addonsHref = useMemo(() => appViewHref(config, 'addons'), [config]); const isLockedType = useMemo(() => { const locked = new Map(); QUESTION_PICKER_TYPES.forEach((t) => locked.set(t.type, Boolean(t.requiresAdvancedQuiz) && !canUseAdvancedTypes)); return (t: QuestionType) => Boolean(locked.get(t)); }, [canUseAdvancedTypes]); const [busy, setBusy] = useState(false); const [postLoading, setPostLoading] = useState(false); const [loadRetry, setLoadRetry] = useState(0); const [loadError, setLoadError] = useState(null); const [error, setError] = useState(null); const [editorTab, setEditorTab] = useState<'content' | 'settings'>('content'); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [status, setStatus] = useState('draft'); const [qType, setQType] = useState(''); const [points, setPoints] = useState(1); const [options, setOptions] = useState(() => [...DEFAULT_MCQ_OPTIONS]); const [correctAnswer, setCorrectAnswer] = useState(''); const [multiCorrect, setMultiCorrect] = useState([]); const [matchLeft, setMatchLeft] = useState(['', '']); const [matchRight, setMatchRight] = useState(['', '']); const [matchMap, setMatchMap] = useState([0, 0]); const [orderItems, setOrderItems] = useState(['Item 1', 'Item 2', 'Item 3']); const [orderPerm, setOrderPerm] = useState([0, 1, 2]); const [featured, setFeatured] = useState(0); const [proQuestionValues, setProQuestionValues] = useState(PRO_QUESTION_DEFAULTS); const [typeMenuPos, setTypeMenuPos] = useState<{ top: number; left: number } | null>(null); const [librarySearch, setLibrarySearch] = useState(''); const [libraryRows, setLibraryRows] = useState>([]); const [libraryLoading, setLibraryLoading] = useState(false); const [libraryError, setLibraryError] = useState(null); const [librarySelected, setLibrarySelected] = useState([]); useEffect(() => { if (!open) { setPostLoading(false); setLoadError(null); setLoadRetry(0); return; } setError(null); setLoadError(null); setEditorTab('content'); setTypeMenuPos(null); setLibrarySearch(''); setLibraryRows([]); setLibraryError(null); setLibrarySelected([]); setTitle(''); setContent(''); setStatus('draft'); setQType(''); setPoints(1); setOptions([...DEFAULT_MCQ_OPTIONS]); setCorrectAnswer(''); setMultiCorrect([]); setMatchLeft(['', '']); setMatchRight(['', '']); setMatchMap([0, 0]); setOrderItems(['Item 1', 'Item 2', 'Item 3']); setOrderPerm([0, 1, 2]); setFeatured(0); setProQuestionValues(PRO_QUESTION_DEFAULTS); if (isEditMode) { setPostLoading(true); } else { setPostLoading(false); } }, [open, isEditMode]); useEffect(() => { if (!open || !isEditMode || !editQuestionId || editQuestionId <= 0) { return; } let cancelled = false; setPostLoading(true); setLoadError(null); void getWpApi() .get(`/sik_question/${editQuestionId}?context=edit`) .then((p) => { if (cancelled || !p) { return; } hydrateAuthoringFormFromQuestionPost(p, { setTitle, setContent, setStatus, setFeatured, setQType, setPoints, setOptions, setCorrectAnswer, setMultiCorrect, setMatchLeft, setMatchRight, setMatchMap, setOrderItems, setOrderPerm, setProQuestionValues, }); }) .catch((e) => { if (!cancelled) { setLoadError(e); } }) .finally(() => { if (!cancelled) { setPostLoading(false); } }); return () => { cancelled = true; }; }, [open, isEditMode, editQuestionId, loadRetry]); useEffect(() => { if (!open) { return; } let cancelled = false; setLibraryLoading(true); setLibraryError(null); const q = new URLSearchParams({ per_page: '50', status: 'any', context: 'edit' }); if (librarySearch.trim()) q.set('search', librarySearch.trim()); void getWpApi() .get<{ id: number; title?: { raw?: string; rendered?: string } }[]>(`/sik_question?${q.toString()}`) .then((rows) => { if (cancelled || !Array.isArray(rows)) return; setLibraryRows( rows .map((r) => ({ id: Number(r.id) || 0, title: r.title?.raw || r.title?.rendered?.replace(/<[^>]+>/g, '') || `Question #${r.id}`, })) .filter((r) => r.id > 0) ); }) .catch((e) => { if (cancelled) return; setLibraryError(e); setLibraryRows([]); }) .finally(() => { if (!cancelled) setLibraryLoading(false); }); return () => { cancelled = true; }; }, [librarySearch, open]); const onTypeChange = (v: string) => { setError(null); if (isLockedType(v as QuestionType)) { setError(new Error('This question type requires the Advanced Quiz add-on.')); return; } setQType(v); if (v === 'true_false') { setCorrectAnswer('true'); setOptions(['True', 'False']); setMultiCorrect([]); return; } if (v === 'multiple_choice') { setOptions([...DEFAULT_MCQ_OPTIONS]); setCorrectAnswer(''); setMultiCorrect([]); return; } if (v === 'multiple_response') { setOptions([...DEFAULT_MCQ_OPTIONS]); setCorrectAnswer(''); setMultiCorrect([]); return; } if (v === 'matching') { setMatchLeft(['', '']); setMatchRight(['', '']); setMatchMap([0, 0]); setOptions([]); setCorrectAnswer(''); setMultiCorrect([]); return; } if (v === 'ordering') { setOrderItems(['Item 1', 'Item 2', 'Item 3']); setOrderPerm([0, 1, 2]); setOptions(['Item 1', 'Item 2', 'Item 3']); setCorrectAnswer(''); setMultiCorrect([]); return; } setOptions([...DEFAULT_MCQ_OPTIONS]); setCorrectAnswer(''); setMultiCorrect([]); }; const moveOrderSlot = (pos: number, dir: -1 | 1) => { setOrderPerm((prev) => { const j = pos + dir; if (j < 0 || j >= prev.length) { return prev; } const next = [...prev]; const tmp = next[pos]; next[pos] = next[j]; next[j] = tmp; return next; }); }; const openTypeMenu = (el: HTMLElement | null) => { if (!el) { setTypeMenuPos(null); return; } const rect = el.getBoundingClientRect(); setTypeMenuPos({ top: rect.bottom + 8, left: Math.min(rect.left, window.innerWidth - 320) }); }; const submitPrimary = () => { const stem = title.trim(); if (!stem || !qType) { return; } if (isLockedType(qType as QuestionType)) { setError(new Error('This question type requires the Advanced Quiz add-on.')); return; } if (isEditMode && (!editQuestionId || editQuestionId <= 0)) { return; } setBusy(true); setError(null); const body = buildCreateBody({ title: stem, content, status, featured, qType, points, options, correctAnswer, multiCorrect, matchLeft, matchRight, matchMap, orderItems, orderPerm, proQuestionValues, }); if (isEditMode && editQuestionId && editQuestionId > 0) { void getWpApi() .put(`/sik_question/${editQuestionId}?context=edit`, body) .then(() => { onUpdated?.(editQuestionId); onClose(); }) .catch((e) => { setError(e); }) .finally(() => { setBusy(false); }); return; } void getWpApi() .post<{ id: number }>(`/sik_question`, body) .then((created) => { if (!created?.id) { throw new Error('Could not create question.'); } onCreated(created.id); onClose(); }) .catch((e) => { setError(e); }) .finally(() => { setBusy(false); }); }; const canSubmit = Boolean( title.trim() && qType && !busy && !(isEditMode && postLoading) && !(isEditMode && loadError) ); const canBulkAdd = Boolean(!busy && onPickExisting && librarySelected.length > 0); return ( <> { if (!busy) { onClose(); } }} title={isEditMode ? __('Edit question', 'sikshya') : __('New question', 'sikshya')} description={ isEditMode ? __( 'Update this question in your library. Changes apply everywhere this question is used.', 'sikshya' ) : __( 'Same authoring flow as Content library → Questions. Add to this quiz when you are ready.', 'sikshya' ) } size="xl" footer={
{onPickExisting ? ( ) : null} {busy ? isEditMode ? __('Saving…', 'sikshya') : __('Creating…', 'sikshya') : isEditMode ? __('Save changes', 'sikshya') : __('Create and add to quiz', 'sikshya')}
} >
{loadError && isEditMode ? ( { setLoadError(null); setLoadRetry((n) => n + 1); }} /> ) : null} {error ? ( setError(null)} /> ) : null} {postLoading && isEditMode && !loadError ? (

{__('Loading question…', 'sikshya')}

) : null} {!(isEditMode && (postLoading || loadError)) ? ( <> setEditorTab(id as 'content' | 'settings')} />
{editorTab === 'content' ? (

{__( 'Each type shows different fields — multiple choice, matching, essay, and so on.', 'sikshya' )}

{__('What the learner sees (plain text or short HTML).', 'sikshya')}