diff --git a/packages/js/internal-style-build/abstracts/_variables.scss b/packages/js/internal-style-build/abstracts/_variables.scss index 73155c89baf..812b09a7464 100644 --- a/packages/js/internal-style-build/abstracts/_variables.scss +++ b/packages/js/internal-style-build/abstracts/_variables.scss @@ -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; diff --git a/plugins/woocommerce-admin/client/products/add-product-page.tsx b/plugins/woocommerce-admin/client/products/add-product-page.tsx index 6b0ab353f11..7702b9e5ac8 100644 --- a/plugins/woocommerce-admin/client/products/add-product-page.tsx +++ b/plugins/woocommerce-admin/client/products/add-product-page.tsx @@ -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 (
+
); }; diff --git a/plugins/woocommerce-admin/client/products/tour/index.tsx b/plugins/woocommerce-admin/client/products/tour/index.tsx new file mode 100644 index 00000000000..4aaf84f546c --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/index.tsx @@ -0,0 +1 @@ +export * from './product-tour-container'; diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx new file mode 100644 index 00000000000..86bec8ccbc8 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour-container.tsx @@ -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 ; + } + + if ( isModalHidden ) { + return null; + } + + return ; +}; diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss new file mode 100644 index 00000000000..c515f50e2f4 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.scss @@ -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; +} diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx new file mode 100644 index 00000000000..95a60b2997a --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour-modal.tsx @@ -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 ( + onClose() } + overlayClassName="woocommerce-product-tour-modal__overlay" + shouldCloseOnClickOutside={ false } + title={ __( 'Meet the product editing form', 'woocommerce' ) } + > +
+ { +
+
+

+ { __( + 'Let us show you how to navigate the form and create this product from start to finish in no time.', + 'woocommerce' + ) } +

+
+ + +
+
+
+ ); +}; diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour.png b/plugins/woocommerce-admin/client/products/tour/product-tour.png new file mode 100644 index 00000000000..858df4b9322 Binary files /dev/null and b/plugins/woocommerce-admin/client/products/tour/product-tour.png differ diff --git a/plugins/woocommerce-admin/client/products/tour/product-tour.tsx b/plugins/woocommerce-admin/client/products/tour/product-tour.tsx new file mode 100644 index 00000000000..3d6d272acc6 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/product-tour.tsx @@ -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 ; +}; diff --git a/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx b/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx new file mode 100644 index 00000000000..17d236ae1fe --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/test/product-tour-container.spec.tsx @@ -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( ); + 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( ); + 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( ); + userEvent.click( getByText( 'Show me around (10s)' ) ); + expect( startTour ).toBeCalled(); + } ); + + it( 'should call dismissModal after closing the modal', () => { + mockedUseProductTour.mockImplementation( () => defaultValues ); + const { getByText } = render( ); + 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( ); + 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( ); + expect( + queryByText( 'Meet the product editing form' ) + ).not.toBeInTheDocument(); + expect( + queryByText( 'Tell a story about your product' ) + ).not.toBeInTheDocument(); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx b/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx new file mode 100644 index 00000000000..6fc011e3f01 --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/test/use-product-tour.spec.tsx @@ -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', + } ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts b/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts new file mode 100644 index 00000000000..c8bd7dbec3b --- /dev/null +++ b/plugins/woocommerce-admin/client/products/tour/use-product-tour.ts @@ -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, + }; +}; diff --git a/plugins/woocommerce/changelog/add-36322 b/plugins/woocommerce/changelog/add-36322 new file mode 100644 index 00000000000..0f45f7d8cb3 --- /dev/null +++ b/plugins/woocommerce/changelog/add-36322 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add product tour to new product management experience