PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.3.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.3.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.3.8, at assets/src/js/frontend/course-builder/builder-question/builder-edit-question.js

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