Add product tour to new product management experience (#36428)

* Add product tour container and modal

* Fix modal open class name

* Add product tour

* Add changelog entry

* Move product tour state logic into hook

* Fix tour selectors for pricing and actions

* Add tests around product tour container

* Add tests around useProductTour hook

* Make tour responsive

* Use tabs instead of spaces

* Fix more scss lint errors

* Remove extra whitespace
This commit is contained in:
Joshua T Flowers 2023-01-18 11:11:02 -08:00 committed by GitHub
parent 38822cb3e9
commit 1337a6d36e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 506 additions and 0 deletions

View File

@ -45,6 +45,7 @@ $alert-green: $valid-green;
$adminbar-height: 32px;
$adminbar-height-mobile: 46px;
$admin-menu-width: 160px;
$admin-menu-width-collapsed: 36px;
// wp-admin colors
$wp-admin-background: #f1f1f1;

View File

@ -8,6 +8,7 @@ import { useEffect } from '@wordpress/element';
* Internal dependencies
*/
import { ProductForm } from './product-form';
import { ProductTourContainer } from './tour';
import './product-page.scss';
const AddProductPage: React.FC = () => {
@ -18,6 +19,7 @@ const AddProductPage: React.FC = () => {
return (
<div className="woocommerce-add-product">
<ProductForm />
<ProductTourContainer />
</div>
);
};

View File

@ -0,0 +1 @@
export * from './product-tour-container';

View File

@ -0,0 +1,21 @@
/**
* Internal dependencies
*/
import { ProductTour } from './product-tour';
import { ProductTourModal } from './product-tour-modal';
import { useProductTour } from './use-product-tour';
export const ProductTourContainer: React.FC = () => {
const { dismissModal, endTour, isModalHidden, isTouring, startTour } =
useProductTour();
if ( isTouring ) {
return <ProductTour onClose={ endTour } />;
}
if ( isModalHidden ) {
return null;
}
return <ProductTourModal onClose={ dismissModal } onStart={ startTour } />;
};

View File

@ -0,0 +1,76 @@
.woocommerce-product-tour-modal {
max-width: 400px;
position: fixed;
left: $admin-menu-width + $gap-large;
bottom: $gap-large;
// This puts the modal on top of the RichTextEditor toolbars.
z-index: 31;
@include breakpoint( '<960px' ) {
left: $admin-menu-width-collapsed + $gap-large;
}
@include breakpoint( '<782px' ) {
display: none;
}
.components-modal__content {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
}
.components-modal__header {
position: static;
padding-top: $gap;
padding-left: $gap;
padding-right: $gap;
height: auto;
.components-button {
position: absolute;
top: $gap;
right: $gap;
left: auto;
}
.components-modal__header-heading {
font-size: 16px;
line-height: 24px;
}
}
.woocommerce-product-tour-modal__header-img {
background: #c5d9ed;
order: -1;
padding: 28px 28px 0 28px;
img {
max-width: 286px;
display: block;
margin: 0 auto;
}
}
.woocommerce-product-tour-modal__content {
padding: $gap-smaller $gap $gap $gap;
> p:first-child {
margin-top: 0;
}
}
.woocommerce-product-tour-modal__actions {
text-align: right;
margin-top: 28px;
button {
margin-left: $gap-smaller;
}
}
}
.woocommerce-product-tour-modal__overlay {
position: static;
}

View File

@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Modal } from '@wordpress/components';
/**
* Internal dependencies
*/
import ProductTourImage from './product-tour.png';
import './product-tour-modal.scss';
type ProductTourModalProps = {
onClose: () => void;
onStart: () => void;
};
export const ProductTourModal: React.FC< ProductTourModalProps > = ( {
onClose,
onStart,
} ) => {
return (
<Modal
bodyOpenClassName={ 'woocommerce-product-tour-modal__modal-open' }
className="woocommerce-product-tour-modal"
onRequestClose={ () => onClose() }
overlayClassName="woocommerce-product-tour-modal__overlay"
shouldCloseOnClickOutside={ false }
title={ __( 'Meet the product editing form', 'woocommerce' ) }
>
<div className="woocommerce-product-tour-modal__header-img">
<img
src={ ProductTourImage }
alt={ __( 'Product editing tour', 'woocommerce' ) }
/>
</div>
<div className="woocommerce-product-tour-modal__content">
<p>
{ __(
'Let us show you how to navigate the form and create this product from start to finish in no time.',
'woocommerce'
) }
</p>
<div className="woocommerce-product-tour-modal__actions">
<Button variant="tertiary" onClick={ () => onClose() }>
{ __( "I'll explore on my own", 'woocommerce' ) }
</Button>
<Button
variant="primary"
onClick={ () => {
onStart();
} }
>
{ __( 'Show me around (10s)', 'woocommerce' ) }
</Button>
</div>
</div>
</Modal>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { TourKit, TourKitTypes } from '@woocommerce/components';
type ProductTourProps = {
onClose: () => void;
};
export const ProductTour: React.FC< ProductTourProps > = ( { onClose } ) => {
const tourConfig: TourKitTypes.WooConfig = {
placement: 'auto',
options: {
effects: {
spotlight: {
interactivity: {
enabled: false,
},
},
liveResize: {
mutation: true,
resize: true,
},
},
},
steps: [
{
referenceElements: {
desktop: `.woocommerce-product-form-tab__general .woocommerce-form-section__content`,
},
meta: {
name: 'story',
heading: __(
'📣 Tell a story about your product',
'woocommerce'
),
descriptions: {
desktop: __(
'The product form will help you describe your product field by field—from basic details like name and description to attributes the customers can use to find it on your store.',
'woocommerce'
),
},
},
},
{
referenceElements: {
desktop: `#tab-panel-0-pricing`,
},
meta: {
name: 'tabs',
heading: __( '✍️ Set up pricing & more', 'woocommerce' ),
descriptions: {
desktop: __(
'When done, use the tabs to switch between other details and settings. In the future, youll also find here extensions and plugins.',
'woocommerce'
),
},
},
},
{
referenceElements: {
desktop: `.woocommerce-product-form-actions`,
},
meta: {
name: 'actions',
heading: __( '🔍 Preview and publish', 'woocommerce' ),
descriptions: {
desktop: __(
'With all the details in place, use the buttons at the top to easily preview and publish your product. Click the arrow button for more options.',
'woocommerce'
),
},
},
},
{
referenceElements: {
desktop: `.woocommerce-product-form-more-menu`,
},
meta: {
name: 'more',
heading: __( '⚙️ Looking for more?', 'woocommerce' ),
descriptions: {
desktop: __(
'If the form doesnt yet have all the feautures you need—its still in development—you can switch to the classic editor anytime.',
'woocommerce'
),
},
},
},
],
closeHandler: onClose,
};
return <TourKit config={ tourConfig }></TourKit>;
};

View File

@ -0,0 +1,94 @@
/**
* External dependencies
*/
import userEvent from '@testing-library/user-event';
import { render } from '@testing-library/react';
/**
* Internal dependencies
*/
import { ProductTourContainer } from '../';
import { useProductTour } from '../use-product-tour';
const dismissModal = jest.fn();
const endTour = jest.fn();
const startTour = jest.fn();
const defaultValues = {
dismissModal,
endTour,
isModalHidden: false,
isTouring: false,
startTour,
};
jest.mock( '../use-product-tour', () => {
return {
useProductTour: jest.fn(),
};
} );
const mockedUseProductTour = useProductTour as jest.Mock;
describe( 'ProductTourContainer', () => {
it( 'should render the modal initially if not already hidden', () => {
mockedUseProductTour.mockImplementation( () => defaultValues );
const { getByText } = render( <ProductTourContainer /> );
expect(
getByText( 'Meet the product editing form' )
).toBeInTheDocument();
} );
it( 'should not render the modal when the tour has already started', () => {
mockedUseProductTour.mockImplementation( () => ( {
...defaultValues,
isTouring: true,
} ) );
const { queryByText } = render( <ProductTourContainer /> );
expect(
queryByText( 'Meet the product editing form' )
).not.toBeInTheDocument();
expect(
queryByText( 'Tell a story about your product' )
).not.toBeInTheDocument();
} );
it( 'should call startTour after clicking the button to begin the tour', () => {
mockedUseProductTour.mockImplementation( () => defaultValues );
const { getByText } = render( <ProductTourContainer /> );
userEvent.click( getByText( 'Show me around (10s)' ) );
expect( startTour ).toBeCalled();
} );
it( 'should call dismissModal after closing the modal', () => {
mockedUseProductTour.mockImplementation( () => defaultValues );
const { getByText } = render( <ProductTourContainer /> );
userEvent.click( getByText( "I'll explore on my own" ) );
expect( dismissModal ).toBeCalled();
} );
it( 'should call endTour after closing the tour', () => {
mockedUseProductTour.mockImplementation( () => ( {
...defaultValues,
isTouring: true,
} ) );
const { getByLabelText } = render( <ProductTourContainer /> );
userEvent.click( getByLabelText( 'Close Tour' ) );
expect( endTour ).toBeCalled();
} );
it( 'should not show tour or modal once tour is complete', () => {
mockedUseProductTour.mockImplementation( () => ( {
...defaultValues,
isTouring: false,
isModalHidden: true,
} ) );
const { queryByText } = render( <ProductTourContainer /> );
expect(
queryByText( 'Meet the product editing form' )
).not.toBeInTheDocument();
expect(
queryByText( 'Tell a story about your product' )
).not.toBeInTheDocument();
} );
} );

View File

@ -0,0 +1,101 @@
/**
* External dependencies
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useDispatch, useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { PRODUCT_TOUR_MODAL_HIDDEN, useProductTour } from '../use-product-tour';
jest.mock( '@wordpress/data', () => {
// Require the original module to not be mocked...
const originalModule = jest.requireActual( '@wordpress/data' );
return {
__esModule: true, // Use it when dealing with esModules
...originalModule,
useDispatch: jest.fn().mockReturnValue( {} ),
useSelect: jest.fn().mockReturnValue( {} ),
};
} );
const mockedUseDispatch = useDispatch as jest.Mock;
const mockedUseSelect = useSelect as jest.Mock;
describe( 'useProductTour', () => {
it( 'should initially set the tour to hidden', () => {
const { result } = renderHook( () => useProductTour() );
expect( result.current.isTouring ).toBeFalsy();
} );
it( 'should update the tour state when starting the tour', () => {
const updateOptions = jest.fn();
mockedUseDispatch.mockImplementation( () => ( {
updateOptions,
} ) );
const { result } = renderHook( () => useProductTour() );
act( () => {
result.current.startTour();
} );
expect( result.current.isTouring ).toBeTruthy();
} );
it( 'should dismiss the modal when starting the tour', () => {
const updateOptions = jest.fn();
mockedUseDispatch.mockImplementation( () => ( {
updateOptions,
} ) );
const { result } = renderHook( () => useProductTour() );
act( () => {
result.current.startTour();
} );
expect( updateOptions ).toHaveBeenCalledWith( {
[ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes',
} );
} );
it( 'should update the tour state when ending the tour', () => {
const { result } = renderHook( () => useProductTour() );
act( () => {
result.current.startTour();
result.current.endTour();
} );
expect( result.current.isTouring ).toBeFalsy();
} );
it( 'should return true when the modal is hidden', () => {
mockedUseSelect.mockImplementation( () => ( {
isModalHidden: true,
} ) );
const { result } = renderHook( () => useProductTour() );
expect( result.current.isModalHidden ).toBeTruthy();
} );
it( 'should return false when the modal is hidden', () => {
mockedUseSelect.mockImplementation( () => ( {
isModalHidden: false,
} ) );
const { result } = renderHook( () => useProductTour() );
expect( result.current.isModalHidden ).toBeFalsy();
} );
it( 'should dismiss the modal when manually called', () => {
const updateOptions = jest.fn();
mockedUseDispatch.mockImplementation( () => ( {
updateOptions,
} ) );
const { result } = renderHook( () => useProductTour() );
act( () => {
result.current.dismissModal();
} );
expect( updateOptions ).toHaveBeenCalledWith( {
[ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes',
} );
} );
} );

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { useSelect, useDispatch } from '@wordpress/data';
import { useState } from '@wordpress/element';
export const PRODUCT_TOUR_MODAL_HIDDEN =
'woocommerce_product_tour_modal_hidden';
export const useProductTour = () => {
const [ isTouring, setIsTouring ] = useState( false );
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const { isModalHidden } = useSelect( ( select ) => {
const { getOption, hasFinishedResolution } =
select( OPTIONS_STORE_NAME );
return {
isModalHidden:
getOption( PRODUCT_TOUR_MODAL_HIDDEN ) === 'yes' ||
! hasFinishedResolution( 'getOption', [
PRODUCT_TOUR_MODAL_HIDDEN,
] ),
};
} );
const dismissModal = () => {
updateOptions( {
[ PRODUCT_TOUR_MODAL_HIDDEN ]: 'yes',
} );
};
const endTour = () => {
setIsTouring( false );
};
const startTour = () => {
dismissModal();
setIsTouring( true );
};
return {
dismissModal,
endTour,
isModalHidden,
isTouring,
startTour,
};
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add product tour to new product management experience