PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.8
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 4.2.0 All 139 releases
learnpress / assets / src / js / frontend / course-builder / builder-question / builder-edit-question.js

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

1,240 lines 35.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import * as lpUtils from 'lpAssetsJsPath/utils.js';
2 import * as lpToastify from 'lpAssetsJsPath/lpToastify.js';
3 import SweetAlert from 'sweetalert2';
4 import { EditQuestion } from 'lpAssetsJsPath/admin/edit-question.js';
5 import { SWAL_ICON_DUPLICATE, SWAL_ICON_TRASH_DRAFT } from '../swal-icons.js';
6 import { getFormState } from '../builder-form-state.js';
7
8 export class BuilderEditQuestion {
9 constructor() {
10 this.editQuestion = null;
11 // Events use document-level event delegation, so always register them
12 // The page context check happens in individual handlers via target.closest()
13 this.init();
14 }
15
16 static selectors = {
17 elDataQuestion: '.cb-section__question-edit',
18 elBtnUpdateQuestion: '.cb-btn-update',
19 elBtnDraftQuestion: '.cb-btn-draft',
20 elBtnDuplicateQuestion: '.cb-btn-duplicate-question',
21 elBtnTrashQuestion: '.cb-btn-trash',
22 elBtnMainAction: '.cb-btn-main-action',
23 elQuestionStatus: '.question-status',
24 idTitle: 'title',
25 idDescEditor: 'question_description_editor',
26 elPublishStatusSelect: '#cb-question-publish-status',
27 elFormSetting: '.lp-form-setting-question',
28 // Permalink component
29 elPermalinkDisplay: '.cb-permalink-display',
30 elPermalinkEditor: '.cb-permalink-editor',
31 elPermalinkEditBtn: '.cb-permalink-edit-btn',
32 elPermalinkOkBtn: '.cb-permalink-ok-btn',
33 elPermalinkCancelBtn: '.cb-permalink-cancel-btn',
34 elPermalinkSlugInput: '.cb-permalink-slug-input',
35 elPermalinkUrl: '.cb-permalink-url',
36 elPermalinkBaseUrl: '#cb-permalink-base-url',
37 // Question edit selectors
38 elEditQuestionWrap: '.lp-edit-question-wrap',
39 elQuestionEditMain: '.lp-question-edit-main',
40 // Tab handling selectors
41 elCBHorizontalTabs: '.lp-cb-tabs__item',
42 elCBTabPanels: '.lp-cb-tab-panel',
43 // Dropdown selectors
44 elDropdownToggle: '.cb-btn-dropdown-toggle',
45 elDropdownMenu: '.cb-dropdown-menu',
46 elDropdownItem: '.cb-dropdown-item',
47 elHeaderActionsDropdown: '.cb-header-actions-dropdown',
48 elQuestionActionExpanded: '.course-action-expanded',
49 };
50
51 init() {
52 this.initQuestionAnswersSettings();
53 this.initTabs();
54 this.initHeaderActionsDropdown();
55 this.syncHeaderActionWithPublishPanel();
56 this.events();
57 }
58
59 /**
60 * Initialize header actions dropdown (toggle behavior)
61 */
62 initHeaderActionsDropdown() {
63 // Close dropdown when clicking outside
64 document.addEventListener( 'click', ( e ) => {
65 const dropdown = document.querySelector(
66 BuilderEditQuestion.selectors.elHeaderActionsDropdown
67 );
68 if ( dropdown && ! dropdown.contains( e.target ) ) {
69 const menu = dropdown.querySelector( BuilderEditQuestion.selectors.elDropdownMenu );
70 const toggle = dropdown.querySelector( BuilderEditQuestion.selectors.elDropdownToggle );
71 if ( menu ) {
72 menu.classList.remove( 'is-open' );
73 }
74 if ( toggle ) {
75 toggle.setAttribute( 'aria-expanded', 'false' );
76 }
77 }
78 } );
79 }
80
81 /**
82 * Handle dropdown toggle click
83 */
84 handleDropdownToggle( args ) {
85 if ( this.hasPublishDrivenSingleAction() ) {
86 return;
87 }
88
89 const { target } = args;
90 const toggleBtn = target.closest( BuilderEditQuestion.selectors.elDropdownToggle );
91
92 if ( ! toggleBtn ) {
93 return;
94 }
95
96 const dropdown = toggleBtn.closest( BuilderEditQuestion.selectors.elHeaderActionsDropdown );
97 if ( ! dropdown ) {
98 return;
99 }
100
101 const menu = dropdown.querySelector( BuilderEditQuestion.selectors.elDropdownMenu );
102 if ( menu ) {
103 menu.classList.toggle( 'is-open' );
104 const isOpen = menu.classList.contains( 'is-open' );
105 toggleBtn.setAttribute( 'aria-expanded', isOpen ? 'true' : 'false' );
106 }
107 }
108
109 /**
110 * Handle dropdown item click (Save Draft, Publish from dropdown menu)
111 */
112 handleDropdownItemClick( args ) {
113 // Context check: only handle if on question edit page
114 if ( ! this.isQuestionContext() ) {
115 return;
116 }
117
118 if ( this.hasPublishDrivenSingleAction() ) {
119 return;
120 }
121
122 const { target } = args;
123 const dropdownItem = target.closest( BuilderEditQuestion.selectors.elDropdownItem );
124
125 if ( ! dropdownItem ) {
126 return;
127 }
128
129 // Skip if this is trash button - it has its own handler
130 if (
131 dropdownItem.classList.contains( 'cb-btn-trash' ) ||
132 dropdownItem.classList.contains( 'cb-btn-duplicate-question' )
133 ) {
134 return;
135 }
136
137 const status = dropdownItem.dataset.status;
138 if ( ! status ) {
139 return;
140 }
141
142 // Close the dropdown menu
143 const menu = dropdownItem.closest( BuilderEditQuestion.selectors.elDropdownMenu );
144 if ( menu ) {
145 menu.classList.remove( 'is-open' );
146 }
147
148 // Save with the specified status
149 this.saveQuestionWithStatus( dropdownItem, status );
150 }
151
152 /**
153 * Save question with specified status (publish/draft)
154 * @param {HTMLElement} btnEl - The button element that was clicked
155 * @param {string} status - The status to save (publish/draft)
156 */
157 async saveQuestionWithStatus( btnEl, status ) {
158 const canContinue = await this.confirmUnpublishIfNeeded( status, btnEl );
159 if ( ! canContinue ) {
160 return;
161 }
162
163 // Validate title before saving
164 if ( ! this.validateTitleBeforeUpdate() ) {
165 return;
166 }
167
168 lpUtils.lpSetLoadingEl( btnEl, 1 );
169
170 const questionData = this.getQuestionDataForUpdate();
171
172 const dataSend = {
173 ...questionData,
174 action: 'builder_update_question',
175 args: {
176 id_url: 'builder-update-question',
177 },
178 question_status: status,
179 };
180
181 if ( typeof lpQuestionBuilder !== 'undefined' && lpQuestionBuilder.nonce ) {
182 dataSend.nonce = lpQuestionBuilder.nonce;
183 }
184
185 const callBack = {
186 success: ( response ) => {
187 const { status: respStatus, message, data } = response;
188 lpToastify.show( message, respStatus );
189
190 if ( respStatus === 'success' ) {
191 // Update action button text
192 this.updateActionButtons( data?.status || status );
193 this.syncPublishPanelStatus( data?.status || status );
194 this.updatePermalinkUIAfterSave( data );
195
196 // Reset form state before potential redirect.
197 document.dispatchEvent( new CustomEvent( 'lp-course-builder-saved' ) );
198
199 if ( data?.redirect_url ) {
200 window.location.href = data.redirect_url;
201 }
202
203 if ( data?.status ) {
204 const elStatus = document.querySelector(
205 BuilderEditQuestion.selectors.elQuestionStatus
206 );
207 if ( elStatus ) {
208 elStatus.className = 'question-status ' + data.status;
209 elStatus.textContent = data.status;
210 }
211
212 if ( data.status === 'trash' || data.status === 'draft' ) {
213 const curriculumItem = document.querySelector(
214 `.question-item[data-question-id="${ questionData.question_id }"]`
215 );
216 if ( curriculumItem ) {
217 const elCurriculumQuestion = curriculumItem.closest( '.question' );
218 if ( elCurriculumQuestion ) {
219 elCurriculumQuestion.remove();
220 }
221 }
222 }
223 }
224 }
225 },
226 error: ( error ) => {
227 lpToastify.show( error.message || error, 'error' );
228 },
229 completed: () => {
230 lpUtils.lpSetLoadingEl( btnEl, 0 );
231 },
232 };
233
234 window.lpAJAXG.fetchAJAX( dataSend, callBack );
235 }
236
237 /**
238 * Initialize horizontal tabs for client-side tab switching
239 */
240 initTabs() {
241 const tabs = document.querySelectorAll( BuilderEditQuestion.selectors.elCBHorizontalTabs );
242 if ( tabs.length === 0 ) {
243 return;
244 }
245
246 // Activate first tab by default if none is active
247 const activeTab = document.querySelector(
248 `${ BuilderEditQuestion.selectors.elCBHorizontalTabs }.is-active`
249 );
250 if ( ! activeTab && tabs.length > 0 ) {
251 tabs[ 0 ].classList.add( 'is-active' );
252 const section = tabs[ 0 ].getAttribute( 'data-tab-section' );
253 if ( section ) {
254 const panel = document.querySelector(
255 `${ BuilderEditQuestion.selectors.elCBTabPanels }[data-section="${ section }"]`
256 );
257 if ( panel ) {
258 panel.classList.remove( 'lp-hidden' );
259 }
260 }
261 }
262 }
263
264 /**
265 * Handle horizontal tab click for client-side tab switching
266 */
267 handleTabClick( args ) {
268 const { e, target } = args;
269 const tabLink = target.closest( BuilderEditQuestion.selectors.elCBHorizontalTabs );
270
271 if ( ! tabLink ) {
272 return;
273 }
274
275 e.preventDefault();
276
277 const section = tabLink.getAttribute( 'data-tab-section' );
278 if ( ! section ) {
279 return;
280 }
281
282 // Update active tab
283 const allTabs = document.querySelectorAll( BuilderEditQuestion.selectors.elCBHorizontalTabs );
284 allTabs.forEach( ( tab ) => tab.classList.remove( 'is-active' ) );
285 tabLink.classList.add( 'is-active' );
286
287 const url = new URL( window.location.href );
288 url.searchParams.set( 'tab', section );
289 window.history.replaceState( {}, '', url );
290
291 // Show/hide panels
292 const allPanels = document.querySelectorAll( BuilderEditQuestion.selectors.elCBTabPanels );
293 allPanels.forEach( ( panel ) => {
294 if ( panel.getAttribute( 'data-section' ) === section ) {
295 panel.classList.remove( 'lp-hidden' );
296 } else {
297 panel.classList.add( 'lp-hidden' );
298 }
299 } );
300 }
301
302 /**
303 * Initialize Question Answers Settings
304 * This will init EditQuestion class for the question answer management
305 */
306 initQuestionAnswersSettings() {
307 lpUtils.lpOnElementReady(
308 BuilderEditQuestion.selectors.elQuestionEditMain,
309 ( elQuestionEditMain ) => {
310 // Initialize EditQuestion for question answer editing
311 if ( ! this.editQuestion ) {
312 this.editQuestion = new EditQuestion();
313 this.editQuestion.init();
314 }
315
316 // Init sortable for question answers
317 if ( this.editQuestion ) {
318 this.editQuestion.sortAbleQuestionAnswer( elQuestionEditMain );
319 }
320 }
321 );
322 }
323
324 /**
325 * Re-initialize when question type changes
326 */
327 reinitQuestionHandlers( elQuestionEditMain ) {
328 if ( this.editQuestion && elQuestionEditMain ) {
329 this.editQuestion.sortAbleQuestionAnswer( elQuestionEditMain );
330 this.editQuestion.initTinyMCE();
331 }
332 }
333
334 /**
335 * Re-initialize for popup context
336 * This is called when popup is opened multiple times to ensure
337 * TinyMCE and other handlers are properly re-initialized
338 *
339 * @param {HTMLElement} container - The popup container element
340 */
341 reinit( container ) {
342 const elQuestionEditMain = container
343 ? container.querySelector( BuilderEditQuestion.selectors.elQuestionEditMain )
344 : document.querySelector( BuilderEditQuestion.selectors.elQuestionEditMain );
345
346 if ( ! elQuestionEditMain ) {
347 return;
348 }
349
350 // Re-create EditQuestion instance to ensure fresh initialization
351 // This is necessary because TinyMCE instances were destroyed when popup closed
352 if ( this.editQuestion ) {
353 // Destroy existing TinyMCE instances in the container first
354 if ( typeof tinymce !== 'undefined' && container ) {
355 const textareas = container.querySelectorAll( 'textarea.lp-meta-box__editor' );
356 textareas.forEach( ( textarea ) => {
357 const editorId = textarea.id;
358 if ( editorId ) {
359 const editor = tinymce.get( editorId );
360 if ( editor ) {
361 editor.remove();
362 }
363 if ( typeof wp !== 'undefined' && wp.editor && wp.editor.remove ) {
364 wp.editor.remove( editorId );
365 }
366 }
367 } );
368 }
369 }
370
371 // Create fresh EditQuestion instance
372 this.editQuestion = new EditQuestion();
373 this.editQuestion.init();
374
375 // Re-init sortable and TinyMCE
376 this.editQuestion.sortAbleQuestionAnswer( elQuestionEditMain );
377
378 // Use setTimeout to ensure DOM is ready for TinyMCE
379 setTimeout( () => {
380 if ( this.editQuestion ) {
381 this.editQuestion.initTinyMCE();
382 }
383 }, 100 );
384 }
385
386 events() {
387 if ( BuilderEditQuestion._loadedEvents ) {
388 return;
389 }
390 BuilderEditQuestion._loadedEvents = true;
391
392 lpUtils.eventHandlers( 'click', [
393 {
394 selector: BuilderEditQuestion.selectors.elBtnMainAction,
395 class: this,
396 callBack: this.updateQuestion.name,
397 },
398 {
399 selector: BuilderEditQuestion.selectors.elBtnDraftQuestion,
400 class: this,
401 callBack: this.saveDraftQuestion.name,
402 },
403 {
404 selector: BuilderEditQuestion.selectors.elBtnDuplicateQuestion,
405 class: this,
406 callBack: this.duplicateQuestion.name,
407 },
408 {
409 selector: BuilderEditQuestion.selectors.elBtnTrashQuestion,
410 class: this,
411 callBack: this.trashQuestion.name,
412 },
413 {
414 selector: BuilderEditQuestion.selectors.elCBHorizontalTabs,
415 class: this,
416 callBack: this.handleTabClick.name,
417 },
418 {
419 selector: BuilderEditQuestion.selectors.elDropdownToggle,
420 class: this,
421 callBack: this.handleDropdownToggle.name,
422 },
423 {
424 selector: BuilderEditQuestion.selectors.elDropdownItem,
425 class: this,
426 callBack: this.handleDropdownItemClick.name,
427 },
428 {
429 selector: BuilderEditQuestion.selectors.elPermalinkEditBtn,
430 class: this,
431 callBack: this.handlePermalinkEdit.name,
432 },
433 {
434 selector: BuilderEditQuestion.selectors.elPermalinkOkBtn,
435 class: this,
436 callBack: this.handlePermalinkOk.name,
437 },
438 {
439 selector: BuilderEditQuestion.selectors.elPermalinkCancelBtn,
440 class: this,
441 callBack: this.handlePermalinkCancel.name,
442 },
443 ] );
444
445 lpUtils.eventHandlers( 'change', [
446 {
447 selector: BuilderEditQuestion.selectors.elPublishStatusSelect,
448 class: this,
449 callBack: this.handlePublishStatusChange.name,
450 },
451 ] );
452 }
453
454 handlePublishStatusChange() {
455 this.syncHeaderActionWithPublishPanel();
456 getFormState().markAsChanged();
457 }
458
459 /**
460 * Validate title is not empty before update
461 * @return {boolean} True if valid, false if invalid
462 */
463 validateTitleBeforeUpdate() {
464 const titleInput = document.getElementById( BuilderEditQuestion.selectors.idTitle );
465 if ( ! titleInput ) {
466 return true;
467 }
468
469 const title = titleInput.value.trim();
470 if ( ! title ) {
471 lpToastify.show( 'Question title is required.', 'error' );
472 titleInput.focus();
473 return false;
474 }
475 return true;
476 }
477
478 /**
479 * Update action buttons after status change, matching course edit logic.
480 * Updates both main button and dropdown items based on new status.
481 * @param {string} newStatus - The new question status
482 */
483 updateActionButtons( newStatus ) {
484 const dropdown = document.querySelector(
485 BuilderEditQuestion.selectors.elHeaderActionsDropdown
486 );
487 if ( ! dropdown ) return;
488
489 const mainBtn = dropdown.querySelector( '.cb-btn-main-action' );
490 const dropdownMenu = dropdown.querySelector( BuilderEditQuestion.selectors.elDropdownMenu );
491 if ( ! mainBtn ) return;
492
493 if ( this.hasPublishDrivenSingleAction() ) {
494 this.syncHeaderActionWithPublishPanel();
495 return;
496 }
497
498 if ( ! dropdownMenu ) return;
499
500 // Status configuration for button labels and classes
501 const statusConfig = {
502 publish: {
503 mainLabel: mainBtn.dataset.titleUpdate || 'Update',
504 mainClass: 'cb-btn-update',
505 mainStatus: 'publish',
506 dropdownLabel: mainBtn.dataset.titleDraft || 'Save Draft',
507 dropdownClass: 'cb-btn-darft',
508 dropdownStatus: 'draft',
509 dropdownIcon: 'dashicons-media-default',
510 },
511 draft: {
512 mainLabel: mainBtn.dataset.titleDraft || 'Save Draft',
513 mainClass: 'cb-btn-darft',
514 mainStatus: 'draft',
515 dropdownLabel: mainBtn.dataset.titlePublish || 'Publish',
516 dropdownClass: 'cb-btn-publish',
517 dropdownStatus: 'publish',
518 dropdownIcon: 'dashicons-visibility',
519 },
520 pending: {
521 mainLabel: 'Submit for Review',
522 mainClass: 'cb-btn-pending',
523 mainStatus: 'pending',
524 dropdownLabel: mainBtn.dataset.titleDraft || 'Save Draft',
525 dropdownClass: 'cb-btn-darft',
526 dropdownStatus: 'draft',
527 dropdownIcon: 'dashicons-media-default',
528 },
529 };
530
531 const config = statusConfig[ newStatus ] || statusConfig.draft;
532
533 // Update main button
534 mainBtn.className = `${ config.mainClass } cb-btn-primary cb-btn-main-action`;
535 mainBtn.dataset.status = config.mainStatus;
536 mainBtn.textContent = config.mainLabel;
537
538 // Update dropdown item (first item, excluding trash)
539 const dropdownItems = dropdownMenu.querySelectorAll( '.cb-dropdown-item:not(.cb-btn-trash)' );
540 if ( dropdownItems.length > 0 ) {
541 const firstItem = dropdownItems[ 0 ];
542 firstItem.className = `cb-dropdown-item ${ config.dropdownClass }`;
543 firstItem.dataset.status = config.dropdownStatus;
544 firstItem.innerHTML = `<span class="dashicons ${ config.dropdownIcon }"></span>${ config.dropdownLabel }`;
545 }
546
547 // Update dropdown data-current-status
548 dropdown.dataset.currentStatus = newStatus;
549 }
550
551 /**
552 * Check if we're in question edit context
553 * @return {boolean}
554 */
555 isQuestionContext() {
556 return !! document.querySelector( BuilderEditQuestion.selectors.elDataQuestion );
557 }
558
559 getQuestionDataForUpdate() {
560 const data = {};
561
562 const wrapperEl = document.querySelector( BuilderEditQuestion.selectors.elDataQuestion );
563
564 data.question_id = wrapperEl ? parseInt( wrapperEl.dataset.questionId ) || 0 : 0;
565
566 const titleInput = document.getElementById( BuilderEditQuestion.selectors.idTitle );
567 data.question_title = titleInput ? titleInput.value : '';
568
569 const descEditor = document.getElementById( BuilderEditQuestion.selectors.idDescEditor );
570 data.question_description = descEditor ? descEditor.value : '';
571
572 if ( typeof tinymce !== 'undefined' ) {
573 const editor = tinymce.get( BuilderEditQuestion.selectors.idDescEditor );
574 if ( editor ) {
575 data.question_description = editor.getContent();
576 }
577 }
578
579 const permalinkInput = document.querySelector(
580 BuilderEditQuestion.selectors.elPermalinkSlugInput
581 );
582 if ( permalinkInput && permalinkInput.value ) {
583 data.question_permalink = permalinkInput.value;
584 }
585
586 const elFormSetting = document.querySelector( BuilderEditQuestion.selectors.elFormSetting );
587
588 if ( elFormSetting ) {
589 data.question_settings = true;
590 const formElements = elFormSetting.querySelectorAll( 'input, select, textarea' );
591
592 formElements.forEach( ( element ) => {
593 const name = element.name || element.id;
594
595 if ( ! name ) {
596 return;
597 }
598
599 if ( name === 'learnpress_meta_box_nonce' || name === '_wp_http_referer' ) {
600 return;
601 }
602
603 if ( element.type === 'checkbox' ) {
604 const fieldName = name.replace( '[]', '' );
605 if ( ! data.hasOwnProperty( fieldName ) ) {
606 data[ fieldName ] = element.checked ? 'yes' : 'no';
607 }
608 } else if ( element.type === 'radio' ) {
609 if ( element.checked ) {
610 const fieldName = name.replace( '[]', '' );
611 data[ fieldName ] = element.value;
612 }
613 } else if ( element.type === 'file' ) {
614 const fieldName = name.replace( '[]', '' );
615 if ( element.files && element.files.length > 0 ) {
616 data[ fieldName ] = element.files;
617 }
618 } else {
619 const fieldName = name.replace( '[]', '' );
620
621 if ( name.endsWith( '[]' ) ) {
622 if ( ! data.hasOwnProperty( fieldName ) ) {
623 data[ fieldName ] = [];
624 }
625
626 if ( Array.isArray( data[ fieldName ] ) ) {
627 data[ fieldName ].push( element.value );
628 }
629 } else {
630 if ( ! data.hasOwnProperty( fieldName ) ) {
631 data[ fieldName ] = element.value;
632 }
633 }
634 }
635 } );
636
637 Object.keys( data ).forEach( ( key ) => {
638 if ( Array.isArray( data[ key ] ) ) {
639 data[ key ] = data[ key ].join( ',' );
640 }
641 } );
642 }
643
644 return data;
645 }
646
647 getStatusFromPublishPanel( fallbackStatus = 'publish' ) {
648 const statusSelect = document.querySelector(
649 BuilderEditQuestion.selectors.elPublishStatusSelect
650 );
651 if ( ! statusSelect ) {
652 return fallbackStatus;
653 }
654
655 const selectedStatus = statusSelect.value;
656 if ( selectedStatus === 'publish' || selectedStatus === 'draft' ) {
657 return selectedStatus;
658 }
659
660 return fallbackStatus;
661 }
662
663 hasPublishDrivenSingleAction() {
664 if ( ! document.querySelector( BuilderEditQuestion.selectors.elPublishStatusSelect ) ) {
665 return false;
666 }
667
668 const wrapperEl = document.querySelector( BuilderEditQuestion.selectors.elDataQuestion );
669 const questionId = wrapperEl ? parseInt( wrapperEl.dataset.questionId ) || 0 : 0;
670
671 return questionId > 0;
672 }
673
674 syncHeaderActionWithPublishPanel() {
675 if ( ! this.hasPublishDrivenSingleAction() ) {
676 return;
677 }
678
679 const dropdown = document.querySelector(
680 BuilderEditQuestion.selectors.elHeaderActionsDropdown
681 );
682 if ( ! dropdown ) {
683 return;
684 }
685
686 const mainBtn = dropdown.querySelector( '.cb-btn-main-action' );
687 if ( ! mainBtn ) {
688 return;
689 }
690
691 const toggleBtn = dropdown.querySelector( BuilderEditQuestion.selectors.elDropdownToggle );
692 const dropdownMenu = dropdown.querySelector( BuilderEditQuestion.selectors.elDropdownMenu );
693 const status = this.getStatusFromPublishPanel( mainBtn.dataset.status || 'publish' );
694
695 mainBtn.classList.remove( 'cb-btn-darft', 'cb-btn-publish', 'cb-btn-pending' );
696 mainBtn.classList.add( 'cb-btn-update', 'cb-btn-primary', 'cb-btn-main-action' );
697 mainBtn.textContent = mainBtn.dataset.titleUpdate || 'Update';
698 mainBtn.dataset.status = status;
699 dropdown.dataset.currentStatus = status;
700 dropdown.classList.add( 'cb-header-actions-dropdown--single' );
701
702 if ( toggleBtn ) {
703 toggleBtn.style.display = 'none';
704 }
705 if ( dropdownMenu ) {
706 dropdownMenu.style.display = 'none';
707 }
708 }
709
710 syncPublishPanelStatus( status ) {
711 const statusSelect = document.querySelector(
712 BuilderEditQuestion.selectors.elPublishStatusSelect
713 );
714 if ( ! statusSelect ) {
715 return;
716 }
717
718 statusSelect.value = status === 'publish' ? 'publish' : 'draft';
719 this.syncHeaderActionWithPublishPanel();
720 }
721
722 updatePermalinkUIAfterSave( data = {} ) {
723 const slugInput = document.querySelector( BuilderEditQuestion.selectors.elPermalinkSlugInput );
724 const urlLink = document.querySelector( BuilderEditQuestion.selectors.elPermalinkUrl );
725 const baseUrlInput = document.querySelector( BuilderEditQuestion.selectors.elPermalinkBaseUrl );
726 const permalinkDisplayUrl = this.buildPermalinkDisplayUrl(
727 baseUrlInput ? baseUrlInput.value : '',
728 data?.question_slug,
729 data?.question_permalink
730 );
731
732 if ( slugInput && data?.question_slug ) {
733 slugInput.value = data.question_slug;
734 slugInput.dataset.originalValue = data.question_slug;
735 }
736
737 if ( urlLink && data?.question_permalink ) {
738 urlLink.href = data.question_permalink;
739 urlLink.textContent = permalinkDisplayUrl || data.question_permalink;
740 } else if ( urlLink && permalinkDisplayUrl ) {
741 urlLink.textContent = permalinkDisplayUrl;
742 }
743 }
744
745 slugify( str ) {
746 return ( str || '' )
747 .toString()
748 .normalize( 'NFD' )
749 .replace( /[\u0300-\u036f]/g, '' )
750 .replace( /đ/g, 'd' )
751 .replace( /Đ/g, 'D' )
752 .toLowerCase()
753 .replace( /\s+/g, '-' )
754 .replace( /[^\w-]+/g, '' )
755 .replace( /--+/g, '-' )
756 .replace( /^-+/, '' )
757 .replace( /-+$/, '' );
758 }
759
760 buildPermalinkDisplayUrl( baseUrl = '', slug = '', fallbackUrl = '' ) {
761 const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl : '';
762 const normalizedSlug = typeof slug === 'string' ? slug.trim() : '';
763
764 if ( normalizedBaseUrl && normalizedSlug ) {
765 return `${ normalizedBaseUrl }${ normalizedSlug }`;
766 }
767
768 return typeof fallbackUrl === 'string' ? fallbackUrl : '';
769 }
770
771 handlePermalinkEdit( args ) {
772 if ( ! this.isQuestionContext() ) {
773 return;
774 }
775
776 const { e } = args;
777 if ( e ) e.preventDefault();
778
779 const display = document.querySelector( BuilderEditQuestion.selectors.elPermalinkDisplay );
780 const editor = document.querySelector( BuilderEditQuestion.selectors.elPermalinkEditor );
781 const input = document.querySelector( BuilderEditQuestion.selectors.elPermalinkSlugInput );
782
783 if ( ! display || ! editor || ! input ) return;
784
785 input.dataset.originalValue = input.value;
786 display.classList.add( 'lp-hidden' );
787 editor.classList.remove( 'lp-hidden' );
788 input.focus();
789 input.select();
790 }
791
792 handlePermalinkOk( args ) {
793 if ( ! this.isQuestionContext() ) {
794 return;
795 }
796
797 const { e } = args;
798 if ( e ) e.preventDefault();
799
800 const display = document.querySelector( BuilderEditQuestion.selectors.elPermalinkDisplay );
801 const editor = document.querySelector( BuilderEditQuestion.selectors.elPermalinkEditor );
802 const input = document.querySelector( BuilderEditQuestion.selectors.elPermalinkSlugInput );
803 const urlLink = document.querySelector( BuilderEditQuestion.selectors.elPermalinkUrl );
804 const baseUrlInput = document.querySelector( BuilderEditQuestion.selectors.elPermalinkBaseUrl );
805
806 if ( ! display || ! editor || ! input || ! urlLink ) return;
807
808 let newSlug = this.slugify( input.value.trim() );
809 if ( ! newSlug ) {
810 newSlug = input.dataset.originalValue || 'question';
811 }
812
813 input.value = newSlug;
814 const baseUrl = baseUrlInput ? baseUrlInput.value : '';
815 const newUrl = this.buildPermalinkDisplayUrl( baseUrl, newSlug, urlLink.textContent || '' );
816
817 // Keep href as the current saved link and only update display text.
818 urlLink.textContent = newUrl;
819 editor.classList.add( 'lp-hidden' );
820 display.classList.remove( 'lp-hidden' );
821
822 if ( newSlug !== input.dataset.originalValue ) {
823 getFormState().markAsChanged();
824 }
825 }
826
827 handlePermalinkCancel( args ) {
828 if ( ! this.isQuestionContext() ) {
829 return;
830 }
831
832 const { e } = args;
833 if ( e ) e.preventDefault();
834
835 const display = document.querySelector( BuilderEditQuestion.selectors.elPermalinkDisplay );
836 const editor = document.querySelector( BuilderEditQuestion.selectors.elPermalinkEditor );
837 const input = document.querySelector( BuilderEditQuestion.selectors.elPermalinkSlugInput );
838
839 if ( ! display || ! editor || ! input ) return;
840
841 input.value = input.dataset.originalValue || '';
842 editor.classList.add( 'lp-hidden' );
843 display.classList.remove( 'lp-hidden' );
844 }
845
846 async updateQuestion( args ) {
847 // Context check: only handle if on question edit page
848 if ( ! this.isQuestionContext() ) {
849 return;
850 }
851
852 const { target } = args;
853 const elBtnMainAction = target.closest( BuilderEditQuestion.selectors.elBtnMainAction );
854
855 if ( ! elBtnMainAction ) {
856 return;
857 }
858
859 // Validate title before update
860 if ( ! this.validateTitleBeforeUpdate() ) {
861 return;
862 }
863
864 // Resolve status from Overview publish panel first, fallback to button data-status.
865 const targetStatus = this.getStatusFromPublishPanel(
866 elBtnMainAction.dataset.status || 'publish'
867 );
868 const canContinue = await this.confirmUnpublishIfNeeded( targetStatus, elBtnMainAction );
869 if ( ! canContinue ) {
870 return;
871 }
872
873 lpUtils.lpSetLoadingEl( elBtnMainAction, 1 );
874
875 const questionData = this.getQuestionDataForUpdate();
876
877 const dataSend = {
878 ...questionData,
879 action: 'builder_update_question',
880 args: {
881 id_url: 'builder-update-question',
882 },
883 question_status: targetStatus,
884 };
885
886 if ( typeof lpQuestionBuilder !== 'undefined' && lpQuestionBuilder.nonce ) {
887 dataSend.nonce = lpQuestionBuilder.nonce;
888 }
889
890 const callBack = {
891 success: ( response ) => {
892 const { status, message, data } = response;
893 lpToastify.show( message, status );
894
895 if ( status === 'success' ) {
896 // Update action button text with actual status from server
897 this.updateActionButtons( data?.status || targetStatus );
898 this.syncPublishPanelStatus( data?.status || targetStatus );
899 this.updatePermalinkUIAfterSave( data );
900
901 // Reset form state before potential redirect.
902 document.dispatchEvent( new CustomEvent( 'lp-course-builder-saved' ) );
903
904 if ( data?.redirect_url ) {
905 window.location.href = data.redirect_url;
906 }
907
908 if ( data?.status ) {
909 const elStatus = document.querySelector(
910 BuilderEditQuestion.selectors.elQuestionStatus
911 );
912 if ( elStatus ) {
913 elStatus.className = 'question-status ' + data.status;
914 elStatus.textContent = data.status;
915 }
916
917 if ( data.status === 'trash' || data.status === 'draft' ) {
918 const curriculumItem = document.querySelector(
919 `.question-item[data-question-id="${ questionData.question_id }"]`
920 );
921 if ( curriculumItem ) {
922 const elCurriculumQuestion = curriculumItem.closest( '.question' );
923 if ( elCurriculumQuestion ) {
924 elCurriculumQuestion.remove();
925 }
926 }
927 }
928 }
929 }
930 },
931 error: ( error ) => {
932 lpToastify.show( error.message || error, 'error' );
933 },
934 completed: () => {
935 lpUtils.lpSetLoadingEl( elBtnMainAction, 0 );
936 },
937 };
938
939 window.lpAJAXG.fetchAJAX( dataSend, callBack );
940 }
941
942 async confirmUnpublishIfNeeded( targetStatus, triggerEl ) {
943 if ( targetStatus !== 'draft' ) {
944 return true;
945 }
946
947 const statusEl = document.querySelector( BuilderEditQuestion.selectors.elQuestionStatus );
948 const isPublished = statusEl && statusEl.classList.contains( 'publish' );
949 if ( ! isPublished ) {
950 return true;
951 }
952
953 const confirmMsg =
954 triggerEl?.dataset?.confirmUnpublish ||
955 'Saving as draft will unpublish this item. Are you sure?';
956 const result = await SweetAlert.fire( {
957 title: confirmMsg,
958 iconHtml: SWAL_ICON_TRASH_DRAFT,
959 customClass: { icon: 'lp-cb-swal-icon-html' },
960 showCloseButton: true,
961 showCancelButton: true,
962 cancelButtonText: lpData.i18n.cancel,
963 confirmButtonText: lpData.i18n.yes,
964 reverseButtons: true,
965 } );
966
967 return !! result.isConfirmed;
968 }
969
970 async saveDraftQuestion( args ) {
971 // Context check: only handle if on question edit page
972 if ( ! this.isQuestionContext() ) {
973 return;
974 }
975
976 const { target } = args;
977 const elBtnDraftQuestion = target.closest( BuilderEditQuestion.selectors.elBtnDraftQuestion );
978
979 if ( ! elBtnDraftQuestion ) {
980 return;
981 }
982
983 // Check if published to show confirm unpublish modal
984 const statusEl = document.querySelector( BuilderEditQuestion.selectors.elQuestionStatus );
985 const isPublished = statusEl && statusEl.classList.contains( 'publish' );
986 if ( isPublished ) {
987 const confirmMsg =
988 elBtnDraftQuestion.dataset.confirmUnpublish ||
989 'Saving as draft will unpublish this item. Are you sure?';
990 const result = await SweetAlert.fire( {
991 title: confirmMsg,
992 iconHtml: SWAL_ICON_TRASH_DRAFT,
993 customClass: { icon: 'lp-cb-swal-icon-html' },
994 showCloseButton: true,
995 showCancelButton: true,
996 cancelButtonText: lpData.i18n.cancel,
997 confirmButtonText: lpData.i18n.yes,
998 reverseButtons: true,
999 } );
1000
1001 if ( ! result.isConfirmed ) {
1002 return;
1003 }
1004 }
1005
1006 // Validate title before saving draft
1007 if ( ! this.validateTitleBeforeUpdate() ) {
1008 return;
1009 }
1010
1011 const elActionExpanded = document.querySelector(
1012 BuilderEditQuestion.selectors.elQuestionActionExpanded
1013 );
1014
1015 lpUtils.lpSetLoadingEl( elActionExpanded, 1 );
1016
1017 const questionData = this.getQuestionDataForUpdate();
1018
1019 const dataSend = {
1020 ...questionData,
1021 action: 'builder_update_question',
1022 args: {
1023 id_url: 'builder-update-question',
1024 },
1025 question_status: 'draft',
1026 };
1027
1028 if ( typeof lpQuestionBuilder !== 'undefined' && lpQuestionBuilder.nonce ) {
1029 dataSend.nonce = lpQuestionBuilder.nonce;
1030 }
1031
1032 const callBack = {
1033 success: ( response ) => {
1034 const { status, message, data } = response;
1035 lpToastify.show( message, status );
1036
1037 if ( status === 'success' ) {
1038 // Update action button text
1039 this.updateActionButtons( 'draft' );
1040 this.syncPublishPanelStatus( data?.status || 'draft' );
1041 this.updatePermalinkUIAfterSave( data );
1042
1043 // Reset form state before potential redirect.
1044 document.dispatchEvent( new CustomEvent( 'lp-course-builder-saved' ) );
1045
1046 if ( data?.redirect_url ) {
1047 window.location.href = data.redirect_url;
1048 }
1049
1050 if ( data?.status ) {
1051 const elStatus = document.querySelector(
1052 BuilderEditQuestion.selectors.elQuestionStatus
1053 );
1054 if ( elStatus ) {
1055 elStatus.className = 'question-status ' + data.status;
1056 elStatus.textContent = data.status;
1057 }
1058
1059 if ( data.status === 'trash' || data.status === 'draft' ) {
1060 const curriculumItem = document.querySelector(
1061 `.question-item[data-question-id="${ questionData.question_id }"]`
1062 );
1063 if ( curriculumItem ) {
1064 const elCurriculumQuestion = curriculumItem.closest( '.question' );
1065 if ( elCurriculumQuestion ) {
1066 elCurriculumQuestion.remove();
1067 }
1068 }
1069 }
1070 }
1071 }
1072 },
1073 error: ( error ) => {
1074 lpToastify.show( error.message || error, 'error' );
1075 },
1076 completed: () => {
1077 lpUtils.lpSetLoadingEl( elActionExpanded, 0 );
1078 },
1079 };
1080
1081 window.lpAJAXG.fetchAJAX( dataSend, callBack );
1082 }
1083
1084 async trashQuestion( args ) {
1085 // Context check: only handle if on question edit page
1086 if ( ! this.isQuestionContext() ) {
1087 return;
1088 }
1089
1090 const { target } = args;
1091 const elBtnTrashQuestion = target.closest( BuilderEditQuestion.selectors.elBtnTrashQuestion );
1092 if ( ! elBtnTrashQuestion ) {
1093 return;
1094 }
1095
1096 const result = await SweetAlert.fire( {
1097 title: 'Are you sure you want to trash this question?',
1098 iconHtml: SWAL_ICON_TRASH_DRAFT,
1099 customClass: { icon: 'lp-cb-swal-icon-html' },
1100 showCloseButton: true,
1101 showCancelButton: true,
1102 cancelButtonText: lpData.i18n.cancel,
1103 confirmButtonText: lpData.i18n.yes,
1104 reverseButtons: true,
1105 } );
1106
1107 if ( ! result.isConfirmed ) {
1108 return;
1109 }
1110
1111 const elActionExpanded = document.querySelector(
1112 BuilderEditQuestion.selectors.elQuestionActionExpanded
1113 );
1114 lpUtils.lpSetLoadingEl( elActionExpanded, 1 );
1115
1116 const questionData = this.getQuestionDataForUpdate();
1117 const dataSend = {
1118 action: 'move_trash_question',
1119 args: {
1120 id_url: 'move-trash-question',
1121 },
1122 question_id: questionData.question_id || 0,
1123 };
1124
1125 const callBack = {
1126 success: ( response ) => {
1127 const { status, message, data } = response;
1128 lpToastify.show( message, status );
1129
1130 if ( status === 'success' ) {
1131 if ( data?.redirect_url ) {
1132 window.location.href = data.redirect_url;
1133 }
1134
1135 if ( data?.status ) {
1136 const elStatus = document.querySelector(
1137 BuilderEditQuestion.selectors.elQuestionStatus
1138 );
1139 if ( elStatus ) {
1140 elStatus.className = 'question-status ' + data.status;
1141 elStatus.textContent = data.status;
1142 }
1143 if ( data.status === 'trash' || data.status === 'draft' ) {
1144 const curriculumItem = document.querySelector(
1145 `.question-item[data-question-id="${ questionData.question_id }"]`
1146 );
1147 if ( curriculumItem ) {
1148 const elCurriculumQuestion = curriculumItem.closest( '.question' );
1149 if ( elCurriculumQuestion ) {
1150 elCurriculumQuestion.remove();
1151 }
1152 }
1153 }
1154 }
1155 }
1156 },
1157 error: ( error ) => {
1158 lpToastify.show( error.message || error, 'error' );
1159 },
1160 completed: () => {
1161 lpUtils.lpSetLoadingEl( elActionExpanded, 0 );
1162 },
1163 };
1164
1165 window.lpAJAXG.fetchAJAX( dataSend, callBack );
1166 }
1167
1168 async duplicateQuestion( args ) {
1169 // Context check: only handle if on question edit page
1170 if ( ! this.isQuestionContext() ) {
1171 return;
1172 }
1173
1174 const { target } = args;
1175 const elBtnDuplicateQuestion = target.closest(
1176 BuilderEditQuestion.selectors.elBtnDuplicateQuestion
1177 );
1178 if ( ! elBtnDuplicateQuestion ) {
1179 return;
1180 }
1181
1182 const questionData = this.getQuestionDataForUpdate();
1183 const questionId = parseInt( questionData?.question_id, 10 ) || 0;
1184 if ( ! questionId ) {
1185 return;
1186 }
1187
1188 const result = await SweetAlert.fire( {
1189 title: elBtnDuplicateQuestion.dataset.title || 'Are you sure?',
1190 text:
1191 elBtnDuplicateQuestion.dataset.content ||
1192 'Are you sure you want to duplicate this question?',
1193 iconHtml: SWAL_ICON_DUPLICATE,
1194 customClass: { icon: 'lp-cb-swal-icon-html' },
1195 showCloseButton: true,
1196 showCancelButton: true,
1197 cancelButtonText: lpData.i18n.cancel,
1198 confirmButtonText: lpData.i18n.yes,
1199 reverseButtons: true,
1200 } );
1201
1202 if ( ! result.isConfirmed ) {
1203 return;
1204 }
1205
1206 const elActionExpanded = document.querySelector(
1207 BuilderEditQuestion.selectors.elQuestionActionExpanded
1208 );
1209
1210 lpUtils.lpSetLoadingEl( elActionExpanded, 1 );
1211
1212 const dataSend = {
1213 action: 'duplicate_question',
1214 args: {
1215 id_url: 'duplicate-question',
1216 },
1217 question_id: questionId,
1218 };
1219
1220 const callBack = {
1221 success: ( response ) => {
1222 const { status, message, data } = response;
1223 lpToastify.show( message || 'Duplicated successfully!', status );
1224
1225 if ( status === 'success' && data?.redirect_url ) {
1226 window.location.href = data.redirect_url;
1227 }
1228 },
1229 error: ( error ) => {
1230 lpToastify.show( error.message || error, 'error' );
1231 },
1232 completed: () => {
1233 lpUtils.lpSetLoadingEl( elActionExpanded, 0 );
1234 },
1235 };
1236
1237 window.lpAJAXG.fetchAJAX( dataSend, callBack );
1238 }
1239 }
1240