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'
+ ),
+ },
+ ] }
+ />
+