diff --git a/assets/admin/tour/components/sensei-tour-kit/index.js b/assets/admin/tour/components/sensei-tour-kit/index.js index 12862ec9a5..109e84ffc4 100644 --- a/assets/admin/tour/components/sensei-tour-kit/index.js +++ b/assets/admin/tour/components/sensei-tour-kit/index.js @@ -24,9 +24,16 @@ import { WpcomTourKit } from '@automattic/tour-kit'; * @param {string} props.tourName The unique name of the tour. * @param {string} props.trackId ID of tracking event (optional). Tracking will be enabled only when provided. * @param {TourStep[]} props.steps An array of steps to include in the tour. + * @param {Function} [props.beforeEach] A function to run before each step. * @param {Object} [props.extraConfig={}] Additional configuration options for the tour kit. */ -function SenseiTourKit( { tourName, trackId, steps, extraConfig = {} } ) { +function SenseiTourKit( { + tourName, + trackId, + steps, + beforeEach = () => {}, + extraConfig = {}, +} ) { const { showTour } = useSelect( ( select ) => { const { shouldShowTour } = select( SENSEI_TOUR_STORE ); return { @@ -52,6 +59,11 @@ function SenseiTourKit( { tourName, trackId, steps, extraConfig = {} } ) { [ trackId, steps ] ); + const runAction = ( index ) => { + beforeEach( steps[ index ] ); + performStepAction( index, steps ); + }; + const config = { steps, closeHandler: () => setTourShowStatus( false, true, tourName ), @@ -71,27 +83,27 @@ function SenseiTourKit( { tourName, trackId, steps, extraConfig = {} } ) { }, callbacks: { onNextStep: ( index ) => { - performStepAction( index + 1, steps ); + runAction( index + 1 ); }, onPreviousStep: ( index ) => { - performStepAction( index - 1, steps ); + runAction( index - 1 ); }, onGoToStep: ( index ) => { if ( index === steps.length - 1 ) { - performStepAction( 0, steps ); + runAction( 0 ); } else { removeHighlightClasses(); } }, onMaximize: ( index ) => { - performStepAction( index, steps ); + runAction( index ); }, onMinimize: () => { removeHighlightClasses(); }, onStepViewOnce: ( index ) => { if ( index === 0 ) { - performStepAction( index, steps ); + runAction( index ); } trackTourStepView( index ); }, diff --git a/assets/admin/tour/components/sensei-tour-kit/index.test.js b/assets/admin/tour/components/sensei-tour-kit/index.test.js index c0817ad20f..b1b6c8c026 100644 --- a/assets/admin/tour/components/sensei-tour-kit/index.test.js +++ b/assets/admin/tour/components/sensei-tour-kit/index.test.js @@ -42,11 +42,17 @@ describe( 'SenseiTourKit', () => { Close +

WpcomTourKit output

); @@ -139,7 +145,7 @@ describe( 'SenseiTourKit', () => { /> ); - fireEvent.click( getByTestId( 'nextButton' ) ); + fireEvent.click( getByTestId( 'stepViewOnceButton' ) ); expect( window.sensei_log_event @@ -169,8 +175,39 @@ describe( 'SenseiTourKit', () => { /> ); - fireEvent.click( getByTestId( 'nextButton' ) ); + fireEvent.click( getByTestId( 'stepViewOnceButton' ) ); expect( window.sensei_log_event ).not.toHaveBeenCalled(); } ); + + test( 'should call the beforeEach for every step', () => { + useSelect.mockImplementation( () => ( { + showTour: true, + } ) ); + const beforeEachMock = jest.fn(); + + const nextStep = { + slug: 'step-2', + }; + + const { getByTestId } = render( + + ); + + fireEvent.click( getByTestId( 'nextButton' ) ); + + expect( beforeEachMock ).toHaveBeenCalledWith( nextStep ); + } ); } ); diff --git a/assets/admin/tour/course-tour/steps.js b/assets/admin/tour/course-tour/steps.js index 95eb78cb2c..4ba3f3e109 100644 --- a/assets/admin/tour/course-tour/steps.js +++ b/assets/admin/tour/course-tour/steps.js @@ -6,6 +6,9 @@ import { __ } from '@wordpress/i18n'; import { createInterpolateElement } from '@wordpress/element'; import { ExternalLink } from '@wordpress/components'; import { select, dispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as editorStore } from '@wordpress/editor'; + /** * Internal dependencies */ @@ -18,16 +21,16 @@ import { waitForElement, } from '../helper'; -const getCourseOutlineBlock = () => +export const getCourseOutlineBlock = () => getFirstBlockByName( 'sensei-lms/course-outline', - select( 'core/block-editor' ).getBlocks() + select( blockEditorStore ).getBlocks() ); function insertLessonBlock( lessonTitle ) { const courseOutlineBlock = getCourseOutlineBlock(); if ( courseOutlineBlock ) { - const { insertBlock } = dispatch( 'core/block-editor' ); + const { insertBlock } = dispatch( blockEditorStore ); insertBlock( createBlock( 'sensei-lms/course-outline-lesson', { @@ -44,7 +47,7 @@ function focusOnCourseOutlineBlock() { if ( ! courseOutlineBlock ) { return; } - dispatch( 'core/editor' ).selectBlock( courseOutlineBlock.clientId ); + dispatch( editorStore ).selectBlock( courseOutlineBlock.clientId ); } async function ensureLessonBlocksIsInEditor() { @@ -486,7 +489,7 @@ function getTourSteps() { ); if ( ! savedLesson ) { - const { savePost } = dispatch( 'core/editor' ); + const { savePost } = dispatch( editorStore ); savePost(); await waitForElement( savedlessonSelector, 15 ); } diff --git a/assets/admin/tour/helper.js b/assets/admin/tour/helper.js index aa463c7175..9d588c55c5 100644 --- a/assets/admin/tour/helper.js +++ b/assets/admin/tour/helper.js @@ -23,13 +23,18 @@ export function performStepAction( index, steps ) { /** * Highlights the elements with a border. * - * @param {Array} selectors An array of selectors to highlight. + * @param {Array} selectors An array of selectors to highlight. + * @param {string} modifier A modifier to add to the highlight class. */ -export function highlightElementsWithBorders( selectors ) { +export function highlightElementsWithBorders( selectors, modifier = '' ) { selectors.forEach( function ( selector ) { const element = document.querySelector( selector ); if ( element ) { element.classList.add( HIGHLIGHT_CLASS ); + + if ( modifier ) { + element.classList.add( HIGHLIGHT_CLASS + '--' + modifier ); + } } } ); } @@ -42,10 +47,18 @@ export function removeHighlightClasses() { '.sensei-tour-highlight' ); highlightedElements.forEach( function ( element ) { - element.classList.remove( HIGHLIGHT_CLASS ); + // Remove class and modifiers. + [ ...element.classList ].forEach( ( className ) => { + if ( className.startsWith( HIGHLIGHT_CLASS ) ) { + element.classList.remove( className ); + } + } ); } ); } +let stepActionTimeout = null; +let rejectLastPromise = null; + /** * Performs step actions one after another. * @@ -54,15 +67,26 @@ export function removeHighlightClasses() { export async function performStepActionsAsync( stepActions ) { removeHighlightClasses(); - for ( const stepAction of stepActions ) { - if ( stepAction ) { - await new Promise( ( resolve ) => - setTimeout( () => { - stepAction.action(); - resolve(); - }, stepAction.delay ?? 0 ) - ); + // Clear the timeout and reject the last promise if it exists, so it stops the step if actions from another step started. + clearTimeout( stepActionTimeout ); + if ( rejectLastPromise ) { + rejectLastPromise(); + } + + try { + for ( const stepAction of stepActions ) { + if ( stepAction ) { + await new Promise( ( resolve, reject ) => { + rejectLastPromise = reject; + stepActionTimeout = setTimeout( () => { + stepAction.action(); + resolve(); + }, stepAction.delay ?? 0 ); + } ); + } } + } catch ( e ) { + // Do nothing. } } diff --git a/assets/admin/tour/helper.test.js b/assets/admin/tour/helper.test.js index 88aa7cbdb4..a58162226a 100644 --- a/assets/admin/tour/helper.test.js +++ b/assets/admin/tour/helper.test.js @@ -83,6 +83,20 @@ describe( 'highlightElementsWithBorders', () => { expect( document.querySelector( selector ) ).toBeNull(); } ); } ); + + it( 'should add highlight class to the element with a modifier', () => { + const element = document.createElement( 'div' ); + + mockQuerySelector.mockImplementation( () => { + return element; + } ); + + highlightElementsWithBorders( [ 'div' ], 'modifier' ); + + expect( element.className ).toBe( + `${ HIGHLIGHT_CLASS } ${ HIGHLIGHT_CLASS }--modifier` + ); + } ); } ); describe( 'removeHighlightClasses', () => { @@ -137,6 +151,30 @@ describe( 'removeHighlightClasses', () => { ); } ); } ); + + it( 'should add remove modifier classNames', () => { + const mockedElement = document.createElement( 'div' ); + + mockedElement.classList.add( + 'any-other-class', + HIGHLIGHT_CLASS, + HIGHLIGHT_CLASS + '--modifier' + ); + + mockQuerySelectorAll.mockReturnValue( [ mockedElement ] ); + + removeHighlightClasses(); + + expect( mockedElement.classList.contains( HIGHLIGHT_CLASS ) ).toBe( + false + ); + expect( + mockedElement.classList.contains( HIGHLIGHT_CLASS + '--modifier' ) + ).toBe( false ); + expect( mockedElement.classList.contains( 'any-other-class' ) ).toBe( + true + ); + } ); } ); describe( 'performStepActionsAsync', () => { @@ -199,6 +237,23 @@ describe( 'performStepActionsAsync', () => { expect( stepActions[ 2 ].action ).toHaveBeenCalledTimes( 1 ); expect( setTimeout ).toHaveBeenCalledTimes( 3 ); } ); + + it( 'should stop previous step actions if starting a new one', async () => { + jest.useFakeTimers(); + jest.spyOn( global, 'setTimeout' ); + const stepActions = [ + { action: jest.fn(), delay: 100 }, + { action: jest.fn(), delay: 200 }, + ]; + + performStepActionsAsync( stepActions ); + + await jest.runAllTimers(); + expect( stepActions[ 0 ].action ).toHaveBeenCalledTimes( 1 ); + performStepActionsAsync( [] ); + await jest.runAllTimers(); + expect( stepActions[ 1 ].action ).not.toHaveBeenCalled(); + } ); } ); describe( 'waitForElement', () => { diff --git a/assets/admin/tour/lesson-tour/index.js b/assets/admin/tour/lesson-tour/index.js index 542c984416..653104e0d1 100644 --- a/assets/admin/tour/lesson-tour/index.js +++ b/assets/admin/tour/lesson-tour/index.js @@ -9,7 +9,7 @@ import { registerPlugin } from '@wordpress/plugins'; */ import { getFirstBlockByName } from '../../../blocks/course-outline/data'; import SenseiTourKit from '../components/sensei-tour-kit'; -import getTourSteps from './steps'; +import getTourSteps, { beforeEach } from './steps'; import { useState } from '@wordpress/element'; const tourName = 'sensei-lesson-tour'; @@ -33,6 +33,7 @@ export default function LessonTour() { trackId="lesson_quiz_onboarding_step_complete" tourName={ tourName } steps={ tourSteps } + beforeEach={ beforeEach } /> ); } diff --git a/assets/admin/tour/lesson-tour/steps.js b/assets/admin/tour/lesson-tour/steps.js index d25061c607..dfe826e15e 100644 --- a/assets/admin/tour/lesson-tour/steps.js +++ b/assets/admin/tour/lesson-tour/steps.js @@ -2,16 +2,134 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; import { createInterpolateElement } from '@wordpress/element'; import { ExternalLink } from '@wordpress/components'; +import { select, dispatch } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as editPostStore } from '@wordpress/edit-post'; +import { store as editorStore } from '@wordpress/editor'; /** * Internal dependencies */ import { TourStep } from '../types'; +import { getFirstBlockByName } from '../../../blocks/course-outline/data'; +import { + highlightElementsWithBorders, + performStepActionsAsync, +} from '../helper'; + +export const getQuizBlock = () => + getFirstBlockByName( + 'sensei-lms/quiz', + select( blockEditorStore ).getBlocks() + ); + +export const getFirstQuestionBlock = () => + getFirstBlockByName( + 'sensei-lms/quiz-question', + select( blockEditorStore ).getBlocks() + ); + +export const getFirstBooleanQuestionBlock = () => { + const quizBlock = getQuizBlock(); + if ( ! quizBlock ) { + return null; + } + + const questionBlocks = select( blockEditorStore ).getBlocks( + quizBlock.clientId + ); + + const booleanQuestionBlock = questionBlocks.find( + ( block ) => 'boolean' === block.attributes.type + ); + + if ( ! booleanQuestionBlock ) { + return null; + } + + return booleanQuestionBlock; +}; + +export const focusOnQuizBlock = () => { + const quizBlock = getQuizBlock(); + if ( ! quizBlock ) { + return; + } + dispatch( editorStore ).selectBlock( quizBlock.clientId ); +}; + +export const focusOnQuestionBlock = () => { + const questionBlock = getFirstQuestionBlock(); + if ( ! questionBlock ) { + return; + } + dispatch( editorStore ).selectBlock( questionBlock.clientId ); +}; + +export const focusOnBooleanQuestionBlock = () => { + const questionBlock = getFirstBooleanQuestionBlock(); + if ( ! questionBlock ) { + return; + } + dispatch( editorStore ).selectBlock( questionBlock.clientId ); +}; + +export const ensureBooleanQuestionIsInEditor = () => { + const questionBlock = getFirstBooleanQuestionBlock(); + + if ( null === questionBlock ) { + insertBooleanQuestion(); + } +}; + +const insertBooleanQuestion = () => { + const quizBlock = getQuizBlock(); + if ( quizBlock ) { + const { insertBlock } = dispatch( blockEditorStore ); + + insertBlock( + createBlock( 'sensei-lms/quiz-question', { + type: 'boolean', + } ), + 0, + quizBlock.clientId + ); + } +}; + +export const beforeEach = ( step ) => { + // Close answer feedback as the happy path next step. + if ( 'adding-answer-feedback' !== step.slug ) { + const answerFeedbackButton = document.querySelector( + '.sensei-lms-question-block__answer-feedback-toggle__header' + ); + + // Click to close only when it's open. + if ( + answerFeedbackButton && + document.querySelector( + '.wp-block-sensei-lms-quiz-question.show-answer-feedback' + ) + ) { + answerFeedbackButton.click(); + } + } + + // Close sidebar if's a mobile viewport. + const viewportWidth = + window.innerWidth || document.documentElement.clientWidth; + + if ( viewportWidth < 782 ) { + const { closeGeneralSidebar } = dispatch( editPostStore ); + closeGeneralSidebar(); + } +}; /** - * Returns the tour steps for the Course Outline block. + * Returns the tour steps for the Quiz block. * * @return {Array.} An array containing the tour steps. */ @@ -41,6 +159,28 @@ export default function getTourSteps() { referenceElements: { desktop: '', }, + action: () => { + performStepActionsAsync( [ + // Focus on the Quiz block. + { + action: () => { + focusOnQuizBlock(); + }, + }, + // Highlight quiz block. + { + action: () => { + const quizBlockSelector = + '[data-type="sensei-lms/quiz"]'; + + highlightElementsWithBorders( [ + quizBlockSelector, + ] ); + }, + delay: 400, + }, + ] ); + }, }, { slug: 'change-question-type', @@ -63,6 +203,54 @@ export default function getTourSteps() { referenceElements: { desktop: '', }, + action: () => { + performStepActionsAsync( [ + // Focus on question block. + { + action: () => { + focusOnQuestionBlock(); + }, + }, + // Click on type selector. + { + action: () => { + const typeSelectorSelector = + '.sensei-lms-question-block__type-selector button'; + + const typeSelectorButton = document.querySelector( + typeSelectorSelector + ); + + highlightElementsWithBorders( [ + typeSelectorSelector, + ] ); + + if ( typeSelectorButton ) { + typeSelectorButton.click(); + } + }, + delay: 400, + }, + // Highlight options and select true/false type. + { + action: () => { + highlightElementsWithBorders( [ + '.sensei-lms-question-block__type-selector__popover', + ] ); + + const questionBlock = getFirstQuestionBlock(); + + dispatch( blockEditorStore ).updateBlockAttributes( + questionBlock.clientId, + { + type: 'boolean', + } + ); + }, + delay: 400, + }, + ] ); + }, }, { slug: 'adding-a-question', @@ -82,6 +270,36 @@ export default function getTourSteps() { mobile: '', }, }, + action: () => { + performStepActionsAsync( [ + // Focus on question block. + { + action: () => { + focusOnQuestionBlock(); + }, + }, + // Focus on title field. + { + action: () => { + const titleFieldSelector = + '.sensei-lms-question-block__title .sensei-lms-single-line-input'; + + const titleField = document.querySelector( + titleFieldSelector + ); + + highlightElementsWithBorders( [ + titleFieldSelector, + ] ); + + if ( titleField ) { + titleField.focus(); + } + }, + delay: 400, + }, + ] ); + }, }, { slug: 'adding-question-description', @@ -101,6 +319,41 @@ export default function getTourSteps() { mobile: '', }, }, + action: () => { + const descriptionFieldSelector = + '.wp-block-sensei-lms-question-description .rich-text'; + + performStepActionsAsync( [ + // Focus on question block. + { + action: () => { + focusOnQuestionBlock(); + }, + }, + // Focus on description field. + { + action: () => { + const descriptionField = document.querySelector( + descriptionFieldSelector + ); + + if ( descriptionField ) { + descriptionField.focus(); + } + }, + delay: 400, + }, + // Highlight description field. + { + action: () => { + highlightElementsWithBorders( [ + descriptionFieldSelector, + ] ); + }, + delay: 400, + }, + ] ); + }, }, { slug: 'setting-correct-answer', @@ -120,6 +373,34 @@ export default function getTourSteps() { mobile: '', }, }, + action: () => { + performStepActionsAsync( [ + // Focus on question block. + { + action: () => { + ensureBooleanQuestionIsInEditor(); + focusOnBooleanQuestionBlock(); + }, + }, + // Highlight and focus correct answer toggle. + { + action: () => { + highlightElementsWithBorders( [ + '.sensei-lms-question-block__answer--true-false__option:nth-child(1) .sensei-lms-question-block__answer--true-false__toggle', + '.sensei-lms-question-block__answer--true-false__option:nth-child(2) .sensei-lms-question-block__answer--true-false__toggle', + ] ); + + const toggleButton = document.querySelector( + '.sensei-lms-question-block__answer--true-false__toggle' + ); + if ( toggleButton ) { + toggleButton.focus(); + } + }, + delay: 400, + }, + ] ); + }, }, { slug: 'adding-answer-feedback', @@ -139,6 +420,66 @@ export default function getTourSteps() { mobile: '', }, }, + action: () => { + const answerFeedbackButtonSelector = + '.sensei-lms-question-block__answer-feedback-toggle__header'; + + performStepActionsAsync( [ + // Focus on question block. + { + action: () => { + ensureBooleanQuestionIsInEditor(); + focusOnBooleanQuestionBlock(); + }, + }, + // Highlight answer feedback. + { + action: () => { + highlightElementsWithBorders( [ + answerFeedbackButtonSelector, + ] ); + }, + delay: 400, + }, + // Open answer feedback. + { + action: () => { + const answerFeedbackButton = document.querySelector( + answerFeedbackButtonSelector + ); + + // Open answer feedback if it's not already open. + if ( + null === + document.querySelector( + '.wp-block-sensei-lms-quiz-question.is-selected.show-answer-feedback' + ) && + answerFeedbackButton + ) { + answerFeedbackButton.focus(); + answerFeedbackButton.click(); + } + }, + delay: 400, + }, + // Focus on answer feedback field and highlight answer feedback areas. + { + action: () => { + document + .querySelector( + '.sensei-lms-question__answer-feedback__content .block-editor-rich-text__editable' + ) + .focus(); + + highlightElementsWithBorders( [ + '.sensei-lms-question__answer-feedback--correct', + '.sensei-lms-question__answer-feedback--incorrect', + ] ); + }, + delay: 400, + }, + ] ); + }, }, { slug: 'adding-a-new-or-existing-question', @@ -161,6 +502,47 @@ export default function getTourSteps() { mobile: '', }, }, + action: () => { + const inserterSelector = + '.wp-block-sensei-lms-quiz .block-editor-inserter__toggle'; + const popoverSelector = '.components-dropdown-menu__popover'; + + performStepActionsAsync( [ + // Focus on quiz block. + { + action: () => { + focusOnQuizBlock(); + }, + }, + // Click on the inserter. + { + action: () => { + const inserter = document.querySelector( + inserterSelector + ); + if ( inserter ) { + inserter.click(); + } + }, + delay: 400, + }, + // Highlight inserter button. + { + action: () => { + highlightElementsWithBorders( [ + inserterSelector, + ] ); + }, + }, + // Highlight options. + { + action: () => { + highlightElementsWithBorders( [ popoverSelector ] ); + }, + delay: 400, + }, + ] ); + }, }, { slug: 'configure-question-settings', @@ -193,6 +575,43 @@ export default function getTourSteps() { mobile: '', }, }, + action: () => { + performStepActionsAsync( [ + // Focus on question block. + { + action: () => { + focusOnQuestionBlock(); + }, + }, + // Highlight question block and open sidebar settings. + { + action: () => { + highlightElementsWithBorders( [ + '.wp-block-sensei-lms-quiz-question', + ] ); + + const { openGeneralSidebar } = dispatch( + editPostStore + ); + + openGeneralSidebar( 'edit-post/block' ); + }, + delay: 400, + }, + // Highlight sidebar. + { + action: () => { + const sidebarSelector = + '.block-editor-block-inspector .components-panel__body'; + highlightElementsWithBorders( + [ sidebarSelector ], + 'inset' + ); + }, + delay: 400, + }, + ] ); + }, }, { slug: 'configure-quiz-settings', @@ -222,6 +641,52 @@ export default function getTourSteps() { mobile: '', }, }, + action: () => { + const settingsButtonSelector = + '.sensei-lms-quiz-block__settings-quick-nav button'; + + performStepActionsAsync( [ + // Focus on quiz block. + { + action: () => { + focusOnQuizBlock(); + }, + }, + // Click on settings to open. + { + action: () => { + const settingsButton = document.querySelector( + settingsButtonSelector + ); + if ( settingsButton ) { + settingsButton.focus(); + settingsButton.click(); + } + }, + delay: 400, + }, + // Highlight settings button. + { + action: () => { + highlightElementsWithBorders( [ + settingsButtonSelector, + ] ); + }, + }, + // Highlight sidebar. + { + action: () => { + const sidebarSelector = + '.sensei-lms-quiz-block__settings-wrapper'; + highlightElementsWithBorders( + [ sidebarSelector ], + 'inset' + ); + }, + delay: 400, + }, + ] ); + }, }, { slug: 'congratulations', @@ -251,6 +716,10 @@ export default function getTourSteps() { mobile: '', }, }, + action: () => { + // Run the clean up. + performStepActionsAsync( [] ); + }, }, ]; } diff --git a/assets/admin/tour/lesson-tour/steps.test.js b/assets/admin/tour/lesson-tour/steps.test.js new file mode 100644 index 0000000000..1698498a2b --- /dev/null +++ b/assets/admin/tour/lesson-tour/steps.test.js @@ -0,0 +1,446 @@ +/** + * WordPress dependencies + */ +import { select, dispatch } from '@wordpress/data'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { + getQuizBlock, + getFirstQuestionBlock, + getFirstBooleanQuestionBlock, + focusOnQuizBlock, + focusOnQuestionBlock, + focusOnBooleanQuestionBlock, + ensureBooleanQuestionIsInEditor, + beforeEach, +} from './steps'; + +jest.mock( '@wordpress/data' ); +jest.mock( '@wordpress/blocks' ); + +describe( 'getQuizBlock', () => { + test( 'should return first quiz block when block exists', () => { + const blocks = [ + { name: 'sensei-lms/quiz' }, + { name: 'sensei-lms/quiz' }, + ]; + select.mockReturnValue( { + getBlocks: () => blocks, + } ); + + expect( getQuizBlock() ).toBe( blocks[ 0 ] ); + } ); + + test( 'should return false when quiz block does not exist', () => { + select.mockReturnValue( { + getBlocks: () => [ { name: 'sensei-lms/any' } ], + } ); + + expect( getQuizBlock() ).toBeFalsy(); + } ); +} ); + +describe( 'getFirstQuestionBlock', () => { + test( 'should return first question block when block exists', () => { + const blocks = [ + { name: 'sensei-lms/quiz-question' }, + { name: 'sensei-lms/quiz-question' }, + ]; + select.mockReturnValue( { + getBlocks: () => blocks, + } ); + + expect( getFirstQuestionBlock() ).toBe( blocks[ 0 ] ); + } ); + + test( 'should return false when question block does not exist', () => { + select.mockReturnValue( { + getBlocks: () => [ { name: 'sensei-lms/any' } ], + } ); + + expect( getFirstQuestionBlock() ).toBeFalsy(); + } ); +} ); + +describe( 'getFirstBooleanQuestionBlock', () => { + test( 'should return first boolean question block', () => { + select.mockReturnValueOnce( { + getBlocks: () => [ { name: 'sensei-lms/quiz' } ], + } ); + + const questionBlocks = [ + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'any' }, + }, + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'boolean' }, + }, + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'boolean' }, + }, + ]; + + select.mockReturnValueOnce( { + getBlocks: () => questionBlocks, + } ); + + expect( getFirstBooleanQuestionBlock() ).toBe( questionBlocks[ 1 ] ); + } ); + + test( 'should return null when quiz block does not exist', () => { + select.mockReturnValueOnce( { + getBlocks: () => [ { name: 'sensei-lms/any' } ], + } ); + + expect( getFirstBooleanQuestionBlock() ).toBeNull(); + } ); + + test( 'should return null when boolean question block does not exist', () => { + select.mockReturnValueOnce( { + getBlocks: () => [ { name: 'sensei-lms/quiz' } ], + } ); + + select.mockReturnValueOnce( { + getBlocks: () => [ + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'any' }, + }, + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'any' }, + }, + ], + } ); + + expect( getFirstBooleanQuestionBlock() ).toBeNull(); + } ); +} ); + +describe( 'focusOnQuizBlock', () => { + test( 'should call the selectBlock for the quiz block', () => { + const blocks = [ + { name: 'sensei-lms/any', clientId: '999' }, + { name: 'sensei-lms/quiz', clientId: '123' }, + ]; + select.mockReturnValue( { + getBlocks: () => blocks, + } ); + + const selectBlockMock = jest.fn(); + + dispatch.mockReturnValue( { + selectBlock: selectBlockMock, + } ); + + focusOnQuizBlock(); + + expect( selectBlockMock ).toHaveBeenCalledWith( '123' ); + } ); + + test( 'should not call selectBlock if quiz block does not exist', () => { + const blocks = [ + { name: 'sensei-lms/any', clientId: '999' }, + { name: 'sensei-lms/any', clientId: '888' }, + ]; + select.mockReturnValue( { + getBlocks: () => blocks, + } ); + + const selectBlockMock = jest.fn(); + + dispatch.mockReturnValue( { + selectBlock: selectBlockMock, + } ); + + focusOnQuizBlock(); + + expect( selectBlockMock ).not.toHaveBeenCalled(); + } ); +} ); + +describe( 'focusOnQuestionBlock', () => { + test( 'should call the selectBlock for the first question block', () => { + const blocks = [ + { name: 'sensei-lms/any', clientId: '999' }, + { name: 'sensei-lms/quiz-question', clientId: '123' }, + { name: 'sensei-lms/quiz-question', clientId: '456' }, + ]; + select.mockReturnValue( { + getBlocks: () => blocks, + } ); + + const selectBlockMock = jest.fn(); + + dispatch.mockReturnValue( { + selectBlock: selectBlockMock, + } ); + + focusOnQuestionBlock(); + + expect( selectBlockMock ).toHaveBeenCalledWith( '123' ); + } ); + + test( 'should not call selectBlock if question block does not exist', () => { + const blocks = [ + { name: 'sensei-lms/quiz', clientId: '999' }, + { name: 'sensei-lms/any', clientId: '888' }, + ]; + select.mockReturnValue( { + getBlocks: () => blocks, + } ); + + const selectBlockMock = jest.fn(); + + dispatch.mockReturnValue( { + selectBlock: selectBlockMock, + } ); + + focusOnQuestionBlock(); + + expect( selectBlockMock ).not.toHaveBeenCalled(); + } ); +} ); + +describe( 'focusOnBooleanQuestionBlock', () => { + test( 'should call the selectBlock for the first boolean question block', () => { + select.mockReturnValueOnce( { + getBlocks: () => [ { name: 'sensei-lms/quiz' } ], + } ); + const blocks = [ + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'any' }, + clientId: '999', + }, + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'boolean' }, + clientId: '123', + }, + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'boolean' }, + clientId: '456', + }, + ]; + select.mockReturnValue( { + getBlocks: () => blocks, + } ); + + const selectBlockMock = jest.fn(); + + dispatch.mockReturnValue( { + selectBlock: selectBlockMock, + } ); + + focusOnBooleanQuestionBlock(); + + expect( selectBlockMock ).toHaveBeenCalledWith( '123' ); + } ); + + test( 'should not call selectBlock if boolean question block does not exist', () => { + select.mockReturnValueOnce( { + getBlocks: () => [ { name: 'sensei-lms/quiz' } ], + } ); + const blocks = [ + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'any' }, + clientId: '999', + }, + ]; + select.mockReturnValue( { + getBlocks: () => blocks, + } ); + + const selectBlockMock = jest.fn(); + + dispatch.mockReturnValue( { + selectBlock: selectBlockMock, + } ); + + focusOnBooleanQuestionBlock(); + + expect( selectBlockMock ).not.toHaveBeenCalled(); + } ); +} ); + +describe( 'ensureBooleanQuestionIsInEditor', () => { + test( 'should insert a boolean question block when it is not in the editor', () => { + select.mockReturnValueOnce( { + getBlocks: () => [ { name: 'sensei-lms/quiz' } ], + } ); + const blocks = [ + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'any' }, + clientId: '999', + }, + ]; + select.mockReturnValueOnce( { + getBlocks: () => blocks, + } ); + select.mockReturnValueOnce( { + getBlocks: () => [ { name: 'sensei-lms/quiz' } ], + } ); + + const insertBlockMock = jest.fn(); + + dispatch.mockReturnValue( { + insertBlock: insertBlockMock, + } ); + + createBlock.mockReturnValue( {} ); + + ensureBooleanQuestionIsInEditor(); + + expect( insertBlockMock ).toHaveBeenCalled(); + } ); + + test( 'should not insert a boolean question block when it is already in the editor', () => { + select.mockReturnValueOnce( { + getBlocks: () => [ { name: 'sensei-lms/quiz' } ], + } ); + const blocks = [ + { + name: 'sensei-lms/quiz-question', + attributes: { type: 'boolean' }, + clientId: '999', + }, + ]; + select.mockReturnValueOnce( { + getBlocks: () => blocks, + } ); + select.mockReturnValueOnce( { + getBlocks: () => [ { name: 'sensei-lms/quiz' } ], + } ); + + const insertBlockMock = jest.fn(); + + dispatch.mockReturnValue( { + insertBlock: insertBlockMock, + } ); + + createBlock.mockReturnValue( {} ); + + ensureBooleanQuestionIsInEditor(); + + expect( insertBlockMock ).not.toHaveBeenCalled(); + } ); +} ); + +describe( 'beforeEach', () => { + const querySelectorMock = jest.spyOn( document, 'querySelector' ); + + beforeEach( () => { + querySelectorMock.mockClear(); + } ); + + test( 'should close the feedback if it is open and it is not the feedback step', () => { + const element1 = document.createElement( 'div' ); + const element2 = document.createElement( 'div' ); + + const clickMock = jest.spyOn( element1, 'click' ); + + querySelectorMock.mockImplementation( ( selector ) => { + if ( + '.sensei-lms-question-block__answer-feedback-toggle__header' === + selector + ) { + return element1; + } else if ( + '.wp-block-sensei-lms-quiz-question.show-answer-feedback' === + selector + ) { + return element2; + } + } ); + + beforeEach( { slug: 'any' } ); + + expect( clickMock ).toBeCalled(); + } ); + + test( 'should not close the feedback if it is open but it is the feedback step', () => { + const element1 = document.createElement( 'div' ); + const element2 = document.createElement( 'div' ); + + const clickMock = jest.spyOn( element1, 'click' ); + + querySelectorMock.mockImplementation( ( selector ) => { + if ( + '.sensei-lms-question-block__answer-feedback-toggle__header' === + selector + ) { + return element1; + } else if ( + '.wp-block-sensei-lms-quiz-question.show-answer-feedback' === + selector + ) { + return element2; + } + } ); + + beforeEach( { slug: 'adding-answer-feedback' } ); + + expect( clickMock ).not.toBeCalled(); + } ); + + test( 'should not close the feedback if it is already closed', () => { + const element1 = document.createElement( 'div' ); + const element2 = null; + + const clickMock = jest.spyOn( element1, 'click' ); + + querySelectorMock.mockImplementation( ( selector ) => { + if ( + '.sensei-lms-question-block__answer-feedback-toggle__header' === + selector + ) { + return element1; + } else if ( + '.wp-block-sensei-lms-quiz-question.show-answer-feedback' === + selector + ) { + return element2; + } + } ); + + beforeEach( { slug: 'any' } ); + + expect( clickMock ).not.toBeCalled(); + } ); + + test( 'should close the sidebar if it is a mobile viewport', () => { + global.innerWidth = 500; + const closeGeneralSidebarMock = jest.fn(); + + dispatch.mockReturnValue( { + closeGeneralSidebar: closeGeneralSidebarMock, + } ); + + beforeEach( { slug: 'any' } ); + + expect( closeGeneralSidebarMock ).toBeCalled(); + } ); + + test( 'should not close the sidebar if it is not a mobile viewport', () => { + global.innerWidth = 1024; + const closeGeneralSidebarMock = jest.fn(); + + dispatch.mockReturnValue( { + closeGeneralSidebar: closeGeneralSidebarMock, + } ); + + beforeEach( { slug: 'any' } ); + + expect( closeGeneralSidebarMock ).not.toBeCalled(); + } ); +} ); diff --git a/assets/admin/tour/style.scss b/assets/admin/tour/style.scss index 47f3e30e7f..72b8c4a9fe 100644 --- a/assets/admin/tour/style.scss +++ b/assets/admin/tour/style.scss @@ -17,6 +17,11 @@ $border-color: var(--wp--preset--color--primary, currentcolor); .sensei-tour-highlight { box-shadow: 0 0 $shadow-width $border-color !important; + + &.sensei-tour-highlight--inset { + outline: none !important; + box-shadow: inset 0 0 $shadow-width $border-color !important;; + } } // Just keeping it here in case we want to add a pulsing effect. diff --git a/assets/blocks/quiz/quiz-block/quiz-settings.js b/assets/blocks/quiz/quiz-block/quiz-settings.js index 26cc1381b6..05773f02d2 100644 --- a/assets/blocks/quiz/quiz-block/quiz-settings.js +++ b/assets/blocks/quiz/quiz-block/quiz-settings.js @@ -123,193 +123,208 @@ const QuizSettings = ( { - - - - - { passRequired && ( - <> - - - - -
- + + + + + { passRequired && ( + <> + + -

- { __( - 'If student does not pass quiz', - 'sensei-lms' - ) } -

-
- + + +
+ +

+ { __( + 'If student does not pass quiz', + 'sensei-lms' + ) } +

+
+ + + +
+
+
+ + ) } + + + + + + + + + + { randomQuestionOrder && ( + + + - - -
-
-
- - ) } - - - - - - - - - - { randomQuestionOrder && ( - + + + ) } + + { ! hideQuizTimer && ( - + - - ) } - - { ! hideQuizTimer && ( - - - - ) } -
- - - updatePagination( { progressBarColor: value } ), - label: __( 'Progress bar color', 'sensei-lms' ), - }, - { - value: - pagination?.progressBarBackground || undefined, - onChange: ( value ) => - updatePagination( { - progressBarBackground: value, - } ), - label: __( - 'Progress bar background color', - 'sensei-lms' - ), - }, - ] } - /> + ) } + + + + updatePagination( { + progressBarColor: value, + } ), + label: __( 'Progress bar color', 'sensei-lms' ), + }, + { + value: + pagination?.progressBarBackground || + undefined, + onChange: ( value ) => + updatePagination( { + progressBarBackground: value, + } ), + label: __( + 'Progress bar background color', + 'sensei-lms' + ), + }, + ] } + /> +