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:
parent
38822cb3e9
commit
1337a6d36e
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './product-tour-container';
|
|
@ -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 } />;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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 |
|
@ -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, you’ll 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 doesn’t yet have all the feautures you need—it’s still in development—you can switch to the classic editor anytime.',
|
||||
'woocommerce'
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
closeHandler: onClose,
|
||||
};
|
||||
|
||||
return <TourKit config={ tourConfig }></TourKit>;
|
||||
};
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -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',
|
||||
} );
|
||||
} );
|
||||
} );
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add product tour to new product management experience
|
Loading…
Reference in New Issue