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-standalone-quiz.js

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

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