PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / trunk
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses vtrunk
4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 All 140 releases
learnpress / assets / src / js / frontend / course-builder / builder-quiz / builder-edit-quiz.js

builder-edit-quiz.js in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses trunk, at assets/src/js/frontend/course-builder/builder-quiz/builder-edit-quiz.js

1,281 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Builder Edit Quiz Handler
3 *
4 * Important separation note:
5 * - This file is the Question-tab engine (question CRUD, sorting, TinyMCE, question bank).
6 * - It is reused in multiple contexts (standalone quiz page and popup quiz editor).
7 * - Keep this file focused on question-tab internals; page-level save/publish flow belongs to BuilderStandaloneQuiz.
8 *
9 * @since 4.3.0
10 * @version 1.0.0
11 */
12
13 import * as lpUtils from 'lpAssetsJsPath/utils.js';
14 import * as lpToastify from 'lpAssetsJsPath/lpToastify.js';
15 import { LpPopupSelectItemToAdd } from 'lpAssetsJsPath/lpPopupSelectItemToAdd.js';
16 import { EditQuestion } from 'lpAssetsJsPath/admin/edit-question.js';
17 import Sortable from 'sortablejs';
18 import SweetAlert from 'sweetalert2';
19
20 export class BuilderEditQuiz {
21 constructor() {
22 this.elEditQuizWrap = null;
23 this.elEditListQuestions = null;
24 this.quizID = null;
25 this.lpPopupSelectItemToAdd = null;
26 this.sortableInstance = null;
27 this.sortableAnswerInstances = [];
28 this.editQuestion = null;
29 this.initPromise = null;
30 this.tinyMCEInitToken = null;
31 this.isInitialized = false;
32 }
33
34 static selectors = {
35 elEditQuizWrap: '.lp-edit-quiz-wrap',
36 elQuestionEditMain: '.lp-question-edit-main',
37 elEditListQuestions: '.lp-edit-list-questions',
38 elQuestionItem: '.lp-question-item',
39 elQuestionToggle: '.lp-question-toggle',
40 elQuestionToggleAll: '.lp-question-toggle-all',
41 elBtnAddQuestion: '.lp-btn-add-question',
42 elBtnRemoveQuestion: '.lp-btn-remove-question',
43 elBtnUpdateQuestionTitle: '.lp-btn-update-question-title',
44 elBtnCancelUpdateQuestionTitle: '.lp-btn-cancel-update-question-title',
45 elQuestionTitleNewInput: '.lp-question-title-new-input',
46 elQuestionTitleInput: '.lp-question-title-input',
47 elQuestionTypeNew: '.lp-question-type-new',
48 elAddNewQuestion: 'add-new-question',
49 LPTarget: '.lp-target',
50 elCollapse: 'lp-collapse',
51 elAnswersConfig: '.lp-answers-config',
52 elQuestionTinymce: '.lp-question-edit-main .lp-editor-tinymce',
53 };
54
55 init( container = null ) {
56 this.initQuizQuestionsTab( container );
57 this.events();
58 }
59
60 /**
61 * Reinitialize for new quiz context
62 */
63 reinit( container = null ) {
64 this.cleanup();
65 this.events();
66
67 // Use async init with proper error handling
68 this.initQuizQuestionsTabAsync( container ).catch( ( error ) => {
69 // Silently handle error - element might not exist yet
70 console.debug( 'BuilderEditQuiz: Quiz questions tab not found', error.message );
71 } );
72 }
73
74 /**
75 * Cleanup all instances and state
76 */
77 cleanup() {
78 // Cancel pending promise if exists
79 if ( this.initPromise && typeof this.initPromise === 'object' ) {
80 this.initPromise.cancelled = true;
81 }
82 this.initPromise = null;
83 this.tinyMCEInitToken = null;
84
85 this.elEditQuizWrap = null;
86 this.elEditListQuestions = null;
87 this.quizID = null;
88 this.isInitialized = false;
89
90 // Destroy sortable instances
91 if ( this.sortableInstance?.destroy ) {
92 try {
93 this.sortableInstance.destroy();
94 } catch ( e ) {
95 console.warn( 'Error destroying sortable instance:', e );
96 }
97 this.sortableInstance = null;
98 }
99
100 // Destroy answer sortable instances
101 this.sortableAnswerInstances.forEach( ( instance ) => {
102 if ( instance?.destroy ) {
103 try {
104 instance.destroy();
105 } catch ( e ) {
106 console.warn( 'Error destroying answer sortable:', e );
107 }
108 }
109 } );
110 this.sortableAnswerInstances = [];
111 }
112
113 /**
114 * Initialize Quiz Questions Tab
115 */
116 initQuizQuestionsTab( container = null ) {
117 const searchContainer = container || document;
118 const elEditQuizWrap = searchContainer.querySelector( BuilderEditQuiz.selectors.elEditQuizWrap );
119
120 if ( elEditQuizWrap ) {
121 this._initQuizQuestionsTabElement( elEditQuizWrap );
122 }
123 }
124
125 /**
126 * Initialize Quiz Questions Tab asynchronously
127 */
128 initQuizQuestionsTabAsync( container = null, maxAttempts = 50, interval = 200 ) {
129 // Cancel previous promise if exists
130 if ( this.initPromise && typeof this.initPromise === 'object' ) {
131 this.initPromise.cancelled = true;
132 }
133
134 // Create new promise
135 this.initPromise = new Promise( ( resolve, reject ) => {
136 let attempts = 0;
137 const searchContainer = container || document;
138
139 const checkElement = () => {
140 // Check if cancelled
141 if ( this.initPromise && this.initPromise.cancelled ) {
142 reject( new Error( 'Init cancelled' ) );
143 return;
144 }
145
146 attempts++;
147 const elEditQuizWrap = searchContainer.querySelector( BuilderEditQuiz.selectors.elEditQuizWrap );
148
149 if ( elEditQuizWrap ) {
150 this._initQuizQuestionsTabElement( elEditQuizWrap );
151 resolve( elEditQuizWrap );
152 } else if ( attempts >= maxAttempts ) {
153 reject( new Error( `Quiz questions tab not found after ${ maxAttempts } attempts` ) );
154 } else {
155 setTimeout( checkElement, interval );
156 }
157 };
158
159 checkElement();
160 } );
161
162 // Add cancelled flag to promise object
163 this.initPromise.cancelled = false;
164
165 return this.initPromise;
166 }
167
168 /**
169 * Initialize quiz questions tab element
170 */
171 _initQuizQuestionsTabElement( elEditQuizWrap ) {
172 if ( ! elEditQuizWrap ) {
173 return;
174 }
175
176 // Prevent double initialization
177 if ( this.isInitialized && this.elEditQuizWrap === elEditQuizWrap ) {
178 return;
179 }
180
181 this.elEditQuizWrap = elEditQuizWrap;
182 this.elEditListQuestions = elEditQuizWrap.querySelector( BuilderEditQuiz.selectors.elEditListQuestions );
183
184 this._getQuizID( elEditQuizWrap );
185
186 // Initialize popup select items
187 if ( ! this.lpPopupSelectItemToAdd ) {
188 this.lpPopupSelectItemToAdd = new LpPopupSelectItemToAdd();
189 this.lpPopupSelectItemToAdd.init();
190 }
191
192 // Init sortables
193 this.sortAbleQuestion();
194 this._initAnswerSortables( elEditQuizWrap );
195
196 // Init EditQuestion
197 this._initEditQuestion( elEditQuizWrap );
198
199 // Init TinyMCE asynchronously after EditQuestion events are registered.
200 this._initTinyMCEAsync( elEditQuizWrap );
201
202 this.isInitialized = true;
203 }
204
205 /**
206 * Get Quiz ID from various sources
207 */
208 _getQuizID( elEditQuizWrap ) {
209 // Try from lp-target
210 const elLPTarget = elEditQuizWrap.closest( BuilderEditQuiz.selectors.LPTarget );
211 if ( elLPTarget && window.lpAJAXG ) {
212 try {
213 const dataSend = window.lpAJAXG.getDataSetCurrent( elLPTarget );
214 this.quizID = dataSend?.args?.quiz_id || 0;
215 } catch ( e ) {
216 console.warn( 'Error getting quiz ID from lpAJAXG:', e );
217 }
218 }
219
220 // Try from popup
221 if ( ! this.quizID ) {
222 const popup = elEditQuizWrap.closest( '.lp-builder-popup' );
223 this.quizID = popup?.dataset.quizId || 0;
224 }
225
226 // Try from wrapper
227 if ( ! this.quizID ) {
228 const wrapper = elEditQuizWrap.closest( '[data-quiz-id]' );
229 this.quizID = wrapper?.dataset.quizId || 0;
230 }
231 }
232
233 /**
234 * Initialize EditQuestion for answer management
235 */
236 _initEditQuestion( elEditQuizWrap ) {
237 if ( ! elEditQuizWrap ) {
238 return;
239 }
240
241 // Create EditQuestion instance if not exists
242 if ( ! this.editQuestion ) {
243 this.editQuestion = new EditQuestion();
244 }
245
246 // Initialize sortable for answers
247 const elQuestionEditMains = elEditQuizWrap.querySelectorAll( BuilderEditQuiz.selectors.elQuestionEditMain );
248 elQuestionEditMains.forEach( ( elQuestionEditMain ) => {
249 if ( elQuestionEditMain && this.editQuestion?.sortAbleQuestionAnswer ) {
250 try {
251 this.editQuestion.sortAbleQuestionAnswer( elQuestionEditMain );
252 } catch ( e ) {
253 console.warn( 'Error initializing answer sortable:', e );
254 }
255 }
256 } );
257
258 // Register events if not loaded
259 if ( ! EditQuestion._loadedEvents && this.editQuestion?.events ) {
260 try {
261 this.editQuestion.events();
262 } catch ( e ) {
263 console.warn( 'Error registering EditQuestion events:', e );
264 }
265 }
266 }
267
268 /**
269 * Initialize answer sortables
270 */
271 _initAnswerSortables( elEditQuizWrap ) {
272 if ( ! elEditQuizWrap ) {
273 return;
274 }
275
276 const elQuestionEditMains = elEditQuizWrap.querySelectorAll( BuilderEditQuiz.selectors.elQuestionEditMain );
277 elQuestionEditMains.forEach( ( elQuestionEditMain ) => {
278 if ( elQuestionEditMain ) {
279 this._sortAbleQuestionAnswer( elQuestionEditMain );
280 }
281 } );
282 }
283
284 /**
285 * Make question answers sortable
286 */
287 _sortAbleQuestionAnswer( elQuestionEditMain ) {
288 if ( ! elQuestionEditMain ) {
289 return;
290 }
291
292 const elAnswersConfig = elQuestionEditMain.querySelector( BuilderEditQuiz.selectors.elAnswersConfig );
293 if ( ! elAnswersConfig ) {
294 return;
295 }
296
297 try {
298 const instance = new Sortable( elAnswersConfig, {
299 handle: '.drag',
300 animation: 150,
301 onEnd: ( evt ) => {
302 if ( ! evt?.item ) {
303 return;
304 }
305 const elAutoSaveAnswer = evt.item.querySelector( '.lp-auto-save-question-answer' );
306 if ( elAutoSaveAnswer ) {
307 elAutoSaveAnswer.dispatchEvent( new Event( 'change', { bubbles: true } ) );
308 }
309 },
310 } );
311 this.sortableAnswerInstances.push( instance );
312 } catch ( e ) {
313 console.warn( 'Error creating answer sortable:', e );
314 }
315 }
316
317 /**
318 * Initialize TinyMCE asynchronously
319 */
320 _initTinyMCEAsync( elEditQuizWrap ) {
321 if ( ! elEditQuizWrap ) {
322 return;
323 }
324
325 const elTextareas = Array.from(
326 elEditQuizWrap.querySelectorAll(
327 `${ BuilderEditQuiz.selectors.elQuestionItem }:not(.${ BuilderEditQuiz.selectors.elCollapse }) ${ BuilderEditQuiz.selectors.elQuestionTinymce }`
328 )
329 );
330 if ( ! elTextareas.length ) {
331 return;
332 }
333
334 const chunkSize = 2;
335 let index = 0;
336 const initToken = {};
337 this.tinyMCEInitToken = initToken;
338
339 const queueInit = ( callback ) => {
340 if ( window.requestIdleCallback ) {
341 window.requestIdleCallback( () => callback(), { timeout: 100 } );
342 } else {
343 setTimeout( () => callback(), 50 );
344 }
345 };
346
347 const processChunk = ( attempts = 0 ) => {
348 if ( this.tinyMCEInitToken !== initToken ) {
349 return;
350 }
351
352 if ( typeof window.tinymce === 'undefined' ) {
353 if ( attempts < 20 ) {
354 setTimeout( () => processChunk( attempts + 1 ), 100 );
355 }
356 return;
357 }
358
359 const chunk = elTextareas.slice( index, index + chunkSize );
360 chunk.forEach( ( elTextarea ) => {
361 if ( elTextarea?.id ) {
362 this._reInitTinymce( elTextarea.id );
363 }
364 } );
365
366 index += chunkSize;
367 if ( index < elTextareas.length ) {
368 queueInit( processChunk );
369 }
370 };
371
372 queueInit( processChunk );
373 }
374
375 /**
376 * Reinitialize single TinyMCE editor
377 */
378 _reInitTinymce( id ) {
379 if ( ! window.tinymce || ! id ) {
380 return;
381 }
382
383 const elTextarea = document.getElementById( id );
384 if ( ! elTextarea?.closest( '.lp-question-edit-main' ) ) {
385 return;
386 }
387
388 // Use EditQuestion.reInitTinymce to properly register events
389 if ( this.editQuestion?.reInitTinymce ) {
390 try {
391 this.editQuestion.reInitTinymce( id );
392 } catch ( e ) {
393 console.warn( 'TinyMCE reinit via EditQuestion error:', e );
394 // Fallback to manual reinit without events
395 this._manualReInitTinymce( id );
396 }
397 } else {
398 // Fallback if editQuestion not available
399 this._manualReInitTinymce( id );
400 }
401 }
402
403 /**
404 * Manual TinyMCE reinit (fallback without events)
405 */
406 _manualReInitTinymce( id ) {
407 try {
408 window.tinymce.execCommand( 'mceRemoveEditor', true, id );
409 window.tinymce.execCommand( 'mceAddEditor', true, id );
410 this.editQuestion?.setDefaultEditorTab?.( id );
411 } catch ( e ) {
412 console.warn( 'Manual TinyMCE init error:', e );
413 }
414 }
415
416 /**
417 * Reinitialize handlers for new question
418 */
419 reinitQuestionHandlers( elQuestionEditMain ) {
420 if ( ! elQuestionEditMain ) {
421 return;
422 }
423
424 // Init answer sortable
425 this._sortAbleQuestionAnswer( elQuestionEditMain );
426
427 this._initQuestionTinymce(
428 elQuestionEditMain.closest( BuilderEditQuiz.selectors.elQuestionItem )
429 );
430
431 // Init answer sortable via EditQuestion
432 if ( this.editQuestion?.sortAbleQuestionAnswer ) {
433 try {
434 this.editQuestion.sortAbleQuestionAnswer( elQuestionEditMain );
435 } catch ( e ) {
436 console.warn( 'Error initializing answer sortable:', e );
437 }
438 }
439 }
440
441 /**
442 * Initialize TinyMCE only after a question item is expanded.
443 */
444 _initQuestionTinymce( elQuestionItem ) {
445 if (
446 ! elQuestionItem ||
447 elQuestionItem.classList.contains( BuilderEditQuiz.selectors.elCollapse )
448 ) {
449 return;
450 }
451
452 const elTextareas = elQuestionItem.querySelectorAll(
453 BuilderEditQuiz.selectors.elQuestionTinymce
454 );
455 elTextareas.forEach( ( elTextarea ) => {
456 if ( elTextarea?.id ) {
457 this._reInitTinymce( elTextarea.id );
458 }
459 } );
460 }
461
462 events() {
463 if ( BuilderEditQuiz._loadedEvents ) {
464 return;
465 }
466 BuilderEditQuiz._loadedEvents = true;
467
468 // Click events
469 lpUtils.eventHandlers( 'click', [
470 {
471 selector: BuilderEditQuiz.selectors.elQuestionToggleAll,
472 class: this,
473 callBack: this.toggleQuestionAll.name,
474 },
475 {
476 selector: BuilderEditQuiz.selectors.elBtnAddQuestion,
477 class: this,
478 callBack: this.addQuestion.name,
479 },
480 {
481 selector: BuilderEditQuiz.selectors.elBtnRemoveQuestion,
482 class: this,
483 callBack: this.removeQuestion.name,
484 },
485 {
486 selector: BuilderEditQuiz.selectors.elBtnUpdateQuestionTitle,
487 class: this,
488 callBack: this.updateQuestionTitle.name,
489 },
490 {
491 selector: BuilderEditQuiz.selectors.elBtnCancelUpdateQuestionTitle,
492 class: this,
493 callBack: this.cancelChangeTitleQuestion.name,
494 },
495 {
496 selector: LpPopupSelectItemToAdd.selectors.elBtnShowPopupItemsToSelect,
497 class: this,
498 callBack: this.handleShowPopupQuestionBank.name,
499 },
500 {
501 selector: LpPopupSelectItemToAdd.selectors.elBtnAddItemsSelected,
502 class: this,
503 callBack: this.handleAddItemsSelected.name,
504 },
505 ] );
506
507 // Keydown events
508 lpUtils.eventHandlers( 'keydown', [
509 {
510 selector: BuilderEditQuiz.selectors.elQuestionTitleInput,
511 class: this,
512 callBack: this.updateQuestionTitle.name,
513 checkIsEventEnter: true,
514 },
515 {
516 selector: BuilderEditQuiz.selectors.elQuestionTitleNewInput,
517 class: this,
518 callBack: this.addQuestion.name,
519 checkIsEventEnter: true,
520 },
521 ] );
522
523 // Keyup events
524 lpUtils.eventHandlers( 'keyup', [
525 {
526 selector: BuilderEditQuiz.selectors.elQuestionTitleInput,
527 class: this,
528 callBack: this.changeTitleQuestion.name,
529 },
530 {
531 selector: `${ BuilderEditQuiz.selectors.elQuestionTitleNewInput }, ${ BuilderEditQuiz.selectors.elQuestionTypeNew }`,
532 class: this,
533 callBack: this.checkCanAddQuestion.name,
534 },
535 ] );
536
537 // Change events
538 lpUtils.eventHandlers( 'change', [
539 {
540 selector: BuilderEditQuiz.selectors.elQuestionTypeNew,
541 class: this,
542 callBack: this.checkCanAddQuestion.name,
543 },
544 ] );
545
546 // Toggle collapse
547 document.addEventListener( 'click', ( e ) => {
548 const target = e.target;
549 lpUtils.toggleCollapse( e, target, BuilderEditQuiz.selectors.elQuestionToggle, [], ( elQuestionItem ) => {
550 this._initQuestionTinymce( elQuestionItem );
551 this.checkAllQuestionsCollapsed();
552 } );
553 } );
554 }
555
556 /**
557 * Handle show popup question bank - track quiz context
558 */
559 handleShowPopupQuestionBank( args ) {
560 const { target } = args;
561 // Only handle if button is inside Quiz popup context
562 const elQuizWrap = target.closest( BuilderEditQuiz.selectors.elEditQuizWrap );
563 const elBuilderPopup = target.closest( '.lp-builder-popup' );
564
565 if ( elQuizWrap || elBuilderPopup ) {
566 // Store reference that we're in quiz context
567 BuilderEditQuiz._isQuizPopupContext = true;
568
569 // Store quiz wrap reference for later use
570 if ( elQuizWrap ) {
571 this.elEditQuizWrap = elQuizWrap;
572 this._getQuizID( elQuizWrap );
573 this.elEditListQuestions = elQuizWrap.querySelector( BuilderEditQuiz.selectors.elEditListQuestions );
574 }
575 } else {
576 BuilderEditQuiz._isQuizPopupContext = false;
577 }
578 }
579
580 /**
581 * Handle add items selected from Question Bank popup
582 */
583 handleAddItemsSelected( args ) {
584 const { target } = args;
585
586 // Only handle if we're in quiz context
587 if ( ! BuilderEditQuiz._isQuizPopupContext ) {
588 return;
589 }
590
591 // Get items selected from popup
592 const elPopup = SweetAlert.getPopup();
593 if ( ! elPopup ) {
594 return;
595 }
596
597 // Get selected items from checkboxes
598 const itemsSelected = [];
599 const elListItems = elPopup.querySelector( '.list-items' );
600 if ( elListItems ) {
601 const elCheckedInputs = elListItems.querySelectorAll( 'input[type="checkbox"]:checked' );
602 elCheckedInputs.forEach( ( elInput ) => {
603 itemsSelected.push( { ...elInput.dataset } );
604 } );
605 }
606
607 // Also check from list-items-selected (if user is viewing selected items)
608 const elListItemsSelected = elPopup.querySelector( '.list-items-selected' );
609 if ( elListItemsSelected ) {
610 const elSelectedItems = elListItemsSelected.querySelectorAll( '.li-item-selected:not(.clone)' );
611 elSelectedItems.forEach( ( elItem ) => {
612 const itemData = { ...elItem.dataset };
613 // Avoid duplicates
614 if ( ! itemsSelected.some( ( item ) => item.id === itemData.id ) ) {
615 itemsSelected.push( itemData );
616 }
617 } );
618 }
619
620 // If still no items, try to get from LpPopupSelectItemToAdd internal state
621 if ( itemsSelected.length === 0 ) {
622 // Get from data attribute on lp-target
623 const elLPTarget = elPopup.querySelector( '.lp-target' );
624 if ( elLPTarget && window.lpAJAXG ) {
625 try {
626 const dataSend = window.lpAJAXG.getDataSetCurrent( elLPTarget );
627 if ( dataSend?.args?.item_selecting && Array.isArray( dataSend.args.item_selecting ) ) {
628 itemsSelected.push( ...dataSend.args.item_selecting );
629 }
630 } catch ( e ) {
631 console.warn( 'Error getting item_selecting:', e );
632 }
633 }
634 }
635
636 if ( itemsSelected.length === 0 ) {
637 console.warn( 'BuilderEditQuiz: No items selected' );
638 return;
639 }
640
641 // Close popup and add questions
642 SweetAlert.close();
643
644 // Reset context flag
645 BuilderEditQuiz._isQuizPopupContext = false;
646
647 // Add questions to quiz
648 this.addQuestionsSelectedToQuiz( itemsSelected );
649 }
650
651 /**
652 * Add questions selected from Question Bank popup to quiz
653 */
654 addQuestionsSelectedToQuiz( itemsSelected ) {
655 if ( ! itemsSelected || itemsSelected.length === 0 ) {
656 console.warn( 'BuilderEditQuiz: No items to add' );
657 return;
658 }
659
660 // Ensure elEditQuizWrap is available - try to find it
661 if ( ! this.elEditQuizWrap ) {
662 // Try to find from builder popup first
663 const builderPopup = document.querySelector( '.lp-builder-popup' );
664 if ( builderPopup ) {
665 this.elEditQuizWrap = builderPopup.querySelector( BuilderEditQuiz.selectors.elEditQuizWrap );
666 }
667
668 // Fallback to document
669 if ( ! this.elEditQuizWrap ) {
670 this.elEditQuizWrap = document.querySelector( BuilderEditQuiz.selectors.elEditQuizWrap );
671 }
672 }
673
674 if ( ! this.elEditQuizWrap ) {
675 console.error( 'BuilderEditQuiz: elEditQuizWrap not found' );
676 return;
677 }
678
679 // Ensure quizID is available
680 if ( ! this.quizID ) {
681 this._getQuizID( this.elEditQuizWrap );
682 }
683
684 if ( ! this.quizID ) {
685 console.error( 'BuilderEditQuiz: quizID not found' );
686 return;
687 }
688
689 // Ensure elEditListQuestions is available
690 if ( ! this.elEditListQuestions ) {
691 this.elEditListQuestions = this.elEditQuizWrap.querySelector( BuilderEditQuiz.selectors.elEditListQuestions );
692 }
693
694 if ( ! this.elEditListQuestions ) {
695 console.error( 'BuilderEditQuiz: elEditListQuestions not found' );
696 return;
697 }
698
699 const questionIds = [];
700 const placeholderItems = [];
701
702 // Create placeholder items
703 itemsSelected.forEach( ( item ) => {
704 const elQuestionItemClone = this.elEditQuizWrap.querySelector(
705 `${ BuilderEditQuiz.selectors.elQuestionItem }.clone`
706 );
707
708 if ( ! elQuestionItemClone ) {
709 console.error( 'BuilderEditQuiz: Question clone element not found' );
710 return;
711 }
712
713 questionIds.push( item.id );
714 const elQuestionItemNew = elQuestionItemClone.cloneNode( true );
715 const elQuestionItemTitleInput = elQuestionItemNew.querySelector(
716 BuilderEditQuiz.selectors.elQuestionTitleInput
717 );
718
719 elQuestionItemNew.classList.remove( 'clone' );
720 elQuestionItemNew.dataset.questionId = item.id;
721
722 // Use title from dataset
723 const questionTitle = item.title || '';
724 if ( elQuestionItemTitleInput ) {
725 elQuestionItemTitleInput.value = questionTitle;
726 }
727
728 lpUtils.lpSetLoadingEl( elQuestionItemNew, 1 );
729 lpUtils.lpShowHideEl( elQuestionItemNew, 1 );
730 elQuestionItemClone.insertAdjacentElement( 'beforebegin', elQuestionItemNew );
731
732 placeholderItems.push( elQuestionItemNew );
733 } );
734
735 if ( questionIds.length === 0 ) {
736 console.warn( 'BuilderEditQuiz: No questions to add' );
737 return;
738 }
739
740 const callBack = {
741 success: ( response ) => {
742 const { message, status, data } = response;
743
744 if ( status !== 'success' ) {
745 throw new Error( message || 'Failed to add questions' );
746 }
747
748 lpToastify.show( message, status );
749
750 const { html_edit_question } = data;
751
752 if ( ! html_edit_question || typeof html_edit_question !== 'object' ) {
753 throw new Error( 'Invalid response: missing html_edit_question' );
754 }
755
756 // Replace placeholder items with actual HTML
757 Object.entries( html_edit_question ).forEach( ( [ question_id, item_html ] ) => {
758 if ( ! item_html ) {
759 console.warn( `Empty HTML for question ${ question_id }` );
760 return;
761 }
762
763 const elQuestionItemPlaceholder = this.elEditQuizWrap.querySelector(
764 `${ BuilderEditQuiz.selectors.elQuestionItem }[data-question-id="${ question_id }"]`
765 );
766
767 if ( ! elQuestionItemPlaceholder ) {
768 console.warn( `Placeholder not found for question ${ question_id }` );
769 return;
770 }
771
772 // Replace with actual HTML
773 elQuestionItemPlaceholder.outerHTML = item_html;
774
775 // Get the newly created element after outerHTML replacement
776 const elQuestionItemCreated = this.elEditQuizWrap.querySelector(
777 `${ BuilderEditQuiz.selectors.elQuestionItem }[data-question-id="${ question_id }"]`
778 );
779
780 // Initialize handlers for new question
781 if ( elQuestionItemCreated ) {
782 const elQuestionEditMain = elQuestionItemCreated.querySelector(
783 BuilderEditQuiz.selectors.elQuestionEditMain
784 );
785 this.reinitQuestionHandlers( elQuestionEditMain );
786 }
787 } );
788
789 this.updateCountItems();
790 },
791 error: ( error ) => {
792 console.error( 'Error adding questions:', error );
793
794 // Remove placeholder items on error
795 placeholderItems.forEach( ( elPlaceholder ) => {
796 if ( elPlaceholder && elPlaceholder.parentNode ) {
797 elPlaceholder.remove();
798 }
799 } );
800
801 lpToastify.show( error?.message || error || 'Failed to add questions', 'error' );
802 },
803 completed: () => {
804 // Remove loading state from all items (if still exist)
805 questionIds.forEach( ( question_id ) => {
806 const elQuestionItem = this.elEditQuizWrap.querySelector(
807 `${ BuilderEditQuiz.selectors.elQuestionItem }[data-question-id="${ question_id }"]`
808 );
809 if ( elQuestionItem ) {
810 lpUtils.lpSetLoadingEl( elQuestionItem, 0 );
811 }
812 } );
813 },
814 };
815
816 const dataSend = {
817 action: 'add_questions_to_quiz',
818 quiz_id: this.quizID,
819 question_ids: questionIds,
820 args: { id_url: 'edit-quiz-questions' },
821 };
822
823 window.lpAJAXG.fetchAJAX( dataSend, callBack );
824 }
825
826 /**
827 * Toggle all questions
828 */
829 toggleQuestionAll( args ) {
830 const { target } = args;
831 const elQuestionToggleAll = target.closest( BuilderEditQuiz.selectors.elQuestionToggleAll );
832 if ( ! elQuestionToggleAll || ! this.elEditQuizWrap ) {
833 return;
834 }
835
836 const elQuestionItems = this.elEditQuizWrap.querySelectorAll( `${ BuilderEditQuiz.selectors.elQuestionItem }:not(.clone)` );
837 elQuestionToggleAll.classList.toggle( BuilderEditQuiz.selectors.elCollapse );
838
839 const shouldCollapse = elQuestionToggleAll.classList.contains( BuilderEditQuiz.selectors.elCollapse );
840 elQuestionItems.forEach( ( el ) => {
841 if ( el ) {
842 el.classList.toggle( BuilderEditQuiz.selectors.elCollapse, shouldCollapse );
843 if ( ! shouldCollapse ) {
844 this._initQuestionTinymce( el );
845 }
846 }
847 } );
848 }
849
850 /**
851 * Check if all questions are collapsed
852 */
853 checkAllQuestionsCollapsed() {
854 if ( ! this.elEditQuizWrap ) {
855 return;
856 }
857
858 const elQuestionItems = this.elEditQuizWrap.querySelectorAll( `${ BuilderEditQuiz.selectors.elQuestionItem }:not(.clone)` );
859 const elQuestionToggleAll = this.elEditQuizWrap.querySelector( BuilderEditQuiz.selectors.elQuestionToggleAll );
860
861 if ( ! elQuestionToggleAll ) {
862 return;
863 }
864
865 const isAllExpand = Array.from( elQuestionItems ).every( ( el ) => el && ! el.classList.contains( BuilderEditQuiz.selectors.elCollapse ) );
866
867 elQuestionToggleAll.classList.toggle( BuilderEditQuiz.selectors.elCollapse, ! isAllExpand );
868 }
869
870 /**
871 * Update question count
872 */
873 updateCountItems() {
874 if ( ! this.elEditQuizWrap ) {
875 return;
876 }
877
878 const elCountItemsAll = this.elEditQuizWrap.querySelector( '.total-items' );
879 const elItemsAll = this.elEditQuizWrap.querySelectorAll( `${ BuilderEditQuiz.selectors.elQuestionItem }:not(.clone)` );
880 const itemsAllCount = elItemsAll.length;
881
882 if ( elCountItemsAll ) {
883 elCountItemsAll.dataset.count = itemsAllCount;
884 const countEl = elCountItemsAll.querySelector( '.count' );
885 if ( countEl ) {
886 countEl.textContent = itemsAllCount;
887 }
888 }
889 }
890
891 /**
892 * Add question to quiz
893 */
894 addQuestion( args ) {
895 const { e, target, callBackNest } = args;
896 e.preventDefault();
897
898 const elAddNewQuestion = target.closest( `.${ BuilderEditQuiz.selectors.elAddNewQuestion }` );
899 if ( ! elAddNewQuestion || ! this.elEditListQuestions ) {
900 return;
901 }
902
903 const elQuestionTitleNewInput = elAddNewQuestion.querySelector( BuilderEditQuiz.selectors.elQuestionTitleNewInput );
904 const questionTitle = elQuestionTitleNewInput?.value?.trim();
905 if ( ! questionTitle ) {
906 lpToastify.show( elQuestionTitleNewInput?.dataset?.messEmptyTitle || 'Title is required', 'error' );
907 return;
908 }
909
910 const elQuestionType = elAddNewQuestion.querySelector( BuilderEditQuiz.selectors.elQuestionTypeNew );
911 const questionType = elQuestionType?.value;
912 if ( ! questionType ) {
913 lpToastify.show( elQuestionType?.dataset?.messEmptyType || 'Type is required', 'error' );
914 return;
915 }
916
917 const elQuestionClone = this.elEditListQuestions.querySelector( `${ BuilderEditQuiz.selectors.elQuestionItem }.clone` );
918 if ( ! elQuestionClone ) {
919 lpToastify.show( 'Question template not found', 'error' );
920 return;
921 }
922
923 const newQuestionItem = elQuestionClone.cloneNode( true );
924 const elQuestionTitleInput = newQuestionItem.querySelector( BuilderEditQuiz.selectors.elQuestionTitleInput );
925
926 if ( elQuestionTitleInput ) {
927 elQuestionTitleInput.value = questionTitle;
928 }
929 elQuestionTitleNewInput.value = '';
930 newQuestionItem.classList.remove( 'clone' );
931 lpUtils.lpShowHideEl( newQuestionItem, 1 );
932 elQuestionClone.insertAdjacentElement( 'beforebegin', newQuestionItem );
933 lpUtils.lpSetLoadingEl( newQuestionItem, 1 );
934
935 const callBack = {
936 success: ( response ) => {
937 const { message, status, data } = response;
938
939 if ( status === 'error' ) {
940 throw new Error( message );
941 }
942
943 if ( status === 'success' && data?.question ) {
944 const { question, html_edit_question } = data;
945 newQuestionItem.dataset.questionId = question.ID;
946 newQuestionItem.dataset.questionType = question.meta_data?._lp_type || '';
947 newQuestionItem.outerHTML = html_edit_question;
948
949 const elQuestionItemCreated = this.elEditListQuestions.querySelector(
950 `${ BuilderEditQuiz.selectors.elQuestionItem }[data-question-id="${ question.ID }"]`
951 );
952
953 if ( elQuestionItemCreated ) {
954 elQuestionItemCreated.classList.remove( BuilderEditQuiz.selectors.elCollapse );
955 this.updateCountItems();
956
957 const elQuestionEditMain = elQuestionItemCreated.querySelector( BuilderEditQuiz.selectors.elQuestionEditMain );
958 this.reinitQuestionHandlers( elQuestionEditMain );
959
960 if ( callBackNest?.success ) {
961 callBackNest.success( { response, elQuestionItemCreated } );
962 }
963 }
964 }
965
966 lpToastify.show( message, status );
967 },
968 error: ( error ) => {
969 newQuestionItem.remove();
970 lpToastify.show( error?.message || error || 'Failed to add question', 'error' );
971
972 if ( callBackNest?.error ) {
973 callBackNest.error( { error, newQuestionItem } );
974 }
975 },
976 completed: () => {
977 lpUtils.lpSetLoadingEl( newQuestionItem, 0 );
978 this.checkCanAddQuestion( { e, target: elQuestionTitleNewInput } );
979
980 if ( callBackNest?.completed ) {
981 callBackNest.completed( { newQuestionItem } );
982 }
983 },
984 };
985
986 try {
987 let dataSend = JSON.parse( elQuestionTitleNewInput.dataset.send || '{}' );
988 dataSend = { ...dataSend, question_title: questionTitle, question_type: questionType };
989 window.lpAJAXG.fetchAJAX( dataSend, callBack );
990 } catch ( e ) {
991 console.error( 'Error adding question:', e );
992 newQuestionItem.remove();
993 lpToastify.show( 'Failed to add question', 'error' );
994 }
995 }
996
997 /**
998 * Check if can add question
999 */
1000 checkCanAddQuestion( args ) {
1001 const { target } = args;
1002 const elTrigger = target?.closest( BuilderEditQuiz.selectors.elQuestionTitleNewInput ) ||
1003 target?.closest( BuilderEditQuiz.selectors.elQuestionTypeNew );
1004 if ( ! elTrigger ) {
1005 return;
1006 }
1007
1008 const elAddNewQuestion = elTrigger.closest( `.${ BuilderEditQuiz.selectors.elAddNewQuestion }` );
1009 const elBtnAddQuestion = elAddNewQuestion?.querySelector( BuilderEditQuiz.selectors.elBtnAddQuestion );
1010 if ( ! elBtnAddQuestion ) {
1011 return;
1012 }
1013
1014 const elQuestionTitleInput = elAddNewQuestion.querySelector( BuilderEditQuiz.selectors.elQuestionTitleNewInput );
1015 const elQuestionTypeNew = elAddNewQuestion.querySelector( BuilderEditQuiz.selectors.elQuestionTypeNew );
1016
1017 const questionTitle = elQuestionTitleInput?.value?.trim();
1018 const questionType = elQuestionTypeNew?.value;
1019
1020 elBtnAddQuestion.classList.toggle( 'active', !! ( questionTitle && questionType ) );
1021 }
1022
1023 /**
1024 * Remove question from quiz
1025 */
1026 removeQuestion( args ) {
1027 const { target } = args;
1028 const elBtnRemoveQuestion = target.closest( BuilderEditQuiz.selectors.elBtnRemoveQuestion );
1029 if ( ! elBtnRemoveQuestion ) {
1030 return;
1031 }
1032
1033 const elQuestionItem = elBtnRemoveQuestion.closest( BuilderEditQuiz.selectors.elQuestionItem );
1034 if ( ! elQuestionItem ) {
1035 return;
1036 }
1037
1038 const questionId = elQuestionItem.dataset.questionId;
1039 if ( ! questionId ) {
1040 return;
1041 }
1042
1043 const i18n = window.lpDataAdmin?.i18n || window.lpData?.i18n || { cancel: 'Cancel', yes: 'Yes' };
1044
1045 SweetAlert.fire( {
1046 title: elBtnRemoveQuestion.dataset.title || 'Are you sure?',
1047 text: elBtnRemoveQuestion.dataset.content || 'Do you want to remove this question?',
1048 icon: 'warning',
1049 showCloseButton: true,
1050 showCancelButton: true,
1051 cancelButtonText: i18n.cancel,
1052 confirmButtonText: i18n.yes,
1053 reverseButtons: true,
1054 } ).then( ( result ) => {
1055 if ( result.isConfirmed ) {
1056 lpUtils.lpSetLoadingEl( elQuestionItem, 1 );
1057
1058 const callBack = {
1059 success: ( response ) => {
1060 const { message, status } = response;
1061 lpToastify.show( message, status );
1062
1063 if ( status === 'success' ) {
1064 elQuestionItem.remove();
1065 this.updateCountItems();
1066 }
1067 },
1068 error: ( error ) => {
1069 lpToastify.show( error?.message || error || 'Failed to remove question', 'error' );
1070 },
1071 completed: () => {
1072 lpUtils.lpSetLoadingEl( elQuestionItem, 0 );
1073 },
1074 };
1075
1076 const dataSend = {
1077 quiz_id: this.quizID,
1078 action: 'remove_question_from_quiz',
1079 question_id: questionId,
1080 args: { id_url: 'edit-quiz-questions' },
1081 };
1082 window.lpAJAXG.fetchAJAX( dataSend, callBack );
1083 }
1084 } );
1085 }
1086
1087 /**
1088 * Update question title
1089 */
1090 updateQuestionTitle( args ) {
1091 const { e, target } = args;
1092 const canHandle = target.closest( BuilderEditQuiz.selectors.elBtnUpdateQuestionTitle ) ||
1093 ( target.closest( BuilderEditQuiz.selectors.elQuestionTitleInput ) && e.key === 'Enter' );
1094
1095 if ( ! canHandle ) {
1096 return;
1097 }
1098
1099 e.preventDefault();
1100
1101 const elQuestionItem = target.closest( BuilderEditQuiz.selectors.elQuestionItem );
1102 const elQuestionTitleInput = elQuestionItem?.querySelector( BuilderEditQuiz.selectors.elQuestionTitleInput );
1103 if ( ! elQuestionTitleInput ) {
1104 return;
1105 }
1106
1107 const questionId = elQuestionItem.dataset.questionId;
1108 const questionTitleValue = elQuestionTitleInput.value.trim();
1109 const titleOld = elQuestionTitleInput.dataset.old;
1110
1111 if ( ! questionTitleValue ) {
1112 lpToastify.show( elQuestionTitleInput.dataset.messEmptyTitle || 'Title is required', 'error' );
1113 return;
1114 }
1115
1116 if ( questionTitleValue === titleOld ) {
1117 return;
1118 }
1119
1120 elQuestionTitleInput.blur();
1121 lpUtils.lpSetLoadingEl( elQuestionItem, 1 );
1122
1123 const callBack = {
1124 success: ( response ) => {
1125 const { message, status } = response;
1126
1127 if ( status === 'success' ) {
1128 elQuestionTitleInput.dataset.old = questionTitleValue;
1129 } else {
1130 elQuestionTitleInput.value = titleOld;
1131 }
1132
1133 lpToastify.show( message, status );
1134 },
1135 error: ( error ) => {
1136 lpToastify.show( error?.message || error || 'Failed to update title', 'error' );
1137 },
1138 completed: () => {
1139 lpUtils.lpSetLoadingEl( elQuestionItem, 0 );
1140 elQuestionItem.classList.remove( 'editing' );
1141 },
1142 };
1143
1144 const dataSend = {
1145 quiz_id: this.quizID,
1146 action: 'update_question',
1147 question_id: questionId,
1148 question_title: questionTitleValue,
1149 args: { id_url: 'edit-quiz-questions' },
1150 };
1151 window.lpAJAXG.fetchAJAX( dataSend, callBack );
1152 }
1153
1154 /**
1155 * Handle title change
1156 */
1157 changeTitleQuestion( args ) {
1158 const { target } = args;
1159 const elQuestionTitleInput = target.closest( BuilderEditQuiz.selectors.elQuestionTitleInput );
1160 if ( ! elQuestionTitleInput ) {
1161 return;
1162 }
1163
1164 const elQuestionItem = elQuestionTitleInput.closest( BuilderEditQuiz.selectors.elQuestionItem );
1165 if ( ! elQuestionItem ) {
1166 return;
1167 }
1168
1169 const titleValue = elQuestionTitleInput.value.trim();
1170 const titleValueOld = elQuestionTitleInput.dataset.old || '';
1171
1172 elQuestionItem.classList.toggle( 'editing', titleValue !== titleValueOld );
1173 }
1174
1175 /**
1176 * Cancel title change
1177 */
1178 cancelChangeTitleQuestion( args ) {
1179 const { target } = args;
1180 const elBtnCancel = target.closest( BuilderEditQuiz.selectors.elBtnCancelUpdateQuestionTitle );
1181 if ( ! elBtnCancel ) {
1182 return;
1183 }
1184
1185 const elQuestionItem = elBtnCancel.closest( BuilderEditQuiz.selectors.elQuestionItem );
1186 if ( ! elQuestionItem ) {
1187 return;
1188 }
1189
1190 const elQuestionTitleInput = elQuestionItem.querySelector( BuilderEditQuiz.selectors.elQuestionTitleInput );
1191 if ( elQuestionTitleInput ) {
1192 elQuestionTitleInput.value = elQuestionTitleInput.dataset.old || '';
1193 }
1194 elQuestionItem.classList.remove( 'editing' );
1195 }
1196
1197 /**
1198 * Make questions sortable
1199 */
1200 sortAbleQuestion() {
1201 if ( ! this.elEditListQuestions ) {
1202 return;
1203 }
1204
1205 // Destroy existing instance first
1206 if ( this.sortableInstance?.destroy ) {
1207 try {
1208 this.sortableInstance.destroy();
1209 } catch ( e ) {
1210 console.warn( 'Error destroying sortable:', e );
1211 }
1212 this.sortableInstance = null;
1213 }
1214
1215 let isUpdateSectionPosition = 0;
1216 let timeout;
1217
1218 try {
1219 this.sortableInstance = new Sortable( this.elEditListQuestions, {
1220 handle: '.drag',
1221 animation: 150,
1222 onEnd: ( evt ) => {
1223 const elQuestionItem = evt.item;
1224 if ( ! isUpdateSectionPosition ) {
1225 return;
1226 }
1227
1228 clearTimeout( timeout );
1229 timeout = setTimeout( () => {
1230 lpUtils.lpSetLoadingEl( elQuestionItem, 1 );
1231
1232 const questionIds = [];
1233 const elQuestionItems = this.elEditListQuestions.querySelectorAll( `${ BuilderEditQuiz.selectors.elQuestionItem }:not(.clone)` );
1234 elQuestionItems.forEach( ( elItem ) => {
1235 const questionId = elItem?.dataset?.questionId;
1236 if ( questionId ) {
1237 questionIds.push( questionId );
1238 }
1239 } );
1240
1241 const callBack = {
1242 success: ( response ) => {
1243 const { message, status } = response;
1244
1245 if ( status === 'success' ) {
1246 lpToastify.show( message, status );
1247 } else {
1248 throw new Error( message );
1249 }
1250 },
1251 error: ( error ) => {
1252 lpToastify.show( error?.message || error || 'Failed to update order', 'error' );
1253 },
1254 completed: () => {
1255 lpUtils.lpSetLoadingEl( elQuestionItem, 0 );
1256 isUpdateSectionPosition = 0;
1257 },
1258 };
1259
1260 const dataSend = {
1261 quiz_id: this.quizID,
1262 action: 'update_questions_position',
1263 question_ids: questionIds,
1264 args: { id_url: 'edit-quiz-questions' },
1265 };
1266 window.lpAJAXG.fetchAJAX( dataSend, callBack );
1267 }, 1000 );
1268 },
1269 onMove: () => {
1270 clearTimeout( timeout );
1271 },
1272 onUpdate: () => {
1273 isUpdateSectionPosition = 1;
1274 },
1275 } );
1276 } catch ( e ) {
1277 console.error( 'Error creating sortable:', e );
1278 }
1279 }
1280 }
1281