diff --git a/docs/manifest-devhub.json b/docs/manifest-devhub.json index 75573dd5ea1337..e36e868ac92e99 100644 --- a/docs/manifest-devhub.json +++ b/docs/manifest-devhub.json @@ -731,6 +731,12 @@ "markdown_source": "../packages/components/src/form-token-field/README.md", "parent": "components" }, + { + "title": "Guide", + "slug": "guide", + "markdown_source": "../packages/components/src/guide/README.md", + "parent": "components" + }, { "title": "NavigateRegions", "slug": "navigate-regions", diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 00cd959951484e..28dd4971244937 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -1,3 +1,9 @@ +# 8.3.0 (Unreleased) + +### New Features + +- Added a new `Guide` component which allows developers to easily present a user guide. + ## 8.2.0 (2019-08-29) ### New Features diff --git a/packages/components/src/guide/README.md b/packages/components/src/guide/README.md new file mode 100644 index 00000000000000..0c9fdacaf784d4 --- /dev/null +++ b/packages/components/src/guide/README.md @@ -0,0 +1,56 @@ +Guide +======== + +`Guide` is a React component that renders a _user guide_ in a modal. The guide consists of several `GuidePage` components which the user can step through one by one. The guide is finished when the modal is closed or when the user clicks _Finish_ on the last page of the guide. + +## Usage + +```jsx +function MyTutorial() { + const [ isOpen, setIsOpen ] = useState( true ); + + if ( ! isOpen ) { + return null; + } + + setIsOpen( false ) }> + +

Welcome to the ACME Store! Select a category to begin browsing our wares.

+
+ +

When you find something you love, click Add to Cart to add the product to your shopping cart.

+
+
+} +``` + +## Props + +The component accepts the following props: + +### onFinish + +A function which is called when the guide is finished. The guide is finished when the modal is closed or when the user clicks _Finish_ on the last page of the guide. + +- Type: `function` +- Required: Yes + +### children + +A list of `GuidePage` components. One page is shown at a time. + +- Required: Yes + +### className + +A custom class to add to the modal. + +- Type: `string` +- Required: No + +### finishButtonText + +Use this to customize the label of the _Finish_ button shown at the end of the guide. + +- Type: `string` +- Required: No diff --git a/packages/components/src/guide/finish-button.js b/packages/components/src/guide/finish-button.js new file mode 100644 index 00000000000000..baf1fccaff480a --- /dev/null +++ b/packages/components/src/guide/finish-button.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { useRef, useLayoutEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Button from '../button'; + +export default function FinishButton( { className, onClick, children } ) { + const button = useRef( null ); + + // Focus the button on mount if nothing else is focused. This prevents a + // focus loss when the 'Next' button is swapped out. + useLayoutEffect( () => { + if ( document.activeElement === document.body ) { + button.current.focus(); + } + }, [ button ] ); + + return ( + + ); +} diff --git a/packages/components/src/guide/icons.js b/packages/components/src/guide/icons.js new file mode 100644 index 00000000000000..02638ff58ec331 --- /dev/null +++ b/packages/components/src/guide/icons.js @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ +import { SVG, Path, Circle } from '../primitives/svg'; + +export const BackButtonIcon = () => ( + + + + +); + +export const ForwardButtonIcon = () => ( + + + + +); + +export const PageControlIcon = ( { isSelected } ) => ( + + + +); diff --git a/packages/components/src/guide/index.js b/packages/components/src/guide/index.js new file mode 100644 index 00000000000000..3d0a905741e14b --- /dev/null +++ b/packages/components/src/guide/index.js @@ -0,0 +1,107 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { useState, Children } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Modal from '../modal'; +import KeyboardShortcuts from '../keyboard-shortcuts'; +import IconButton from '../icon-button'; +import PageControl from './page-control'; +import { BackButtonIcon, ForwardButtonIcon } from './icons'; +import FinishButton from './finish-button'; + +export default function Guide( { children, className, finishButtonText, onFinish } ) { + const [ currentPage, setCurrentPage ] = useState( 0 ); + + const numberOfPages = Children.count( children ); + const canGoBack = currentPage > 0; + const canGoForward = currentPage < numberOfPages - 1; + + const goBack = () => { + if ( canGoBack ) { + setCurrentPage( currentPage - 1 ); + } + }; + + const goForward = () => { + if ( canGoForward ) { + setCurrentPage( currentPage + 1 ); + } + }; + + if ( numberOfPages === 0 ) { + return null; + } + + return ( + + + + +
+ + { children[ currentPage ] } + + { ! canGoForward && ( + + { finishButtonText || __( 'Finish' ) } + + ) } + +
+ { canGoBack && ( + } + onClick={ goBack } + > + { __( 'Previous' ) } + + ) } + + { canGoForward && ( + } + onClick={ goForward } + > + { __( 'Next' ) } + + ) } + { ! canGoForward && ( + + { finishButtonText || __( 'Finish' ) } + + ) } +
+ +
+ +
+ ); +} diff --git a/packages/components/src/guide/page-control.js b/packages/components/src/guide/page-control.js new file mode 100644 index 00000000000000..6a5a451efd6b1d --- /dev/null +++ b/packages/components/src/guide/page-control.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { times } from 'lodash'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import IconButton from '../icon-button'; +import { PageControlIcon } from './icons'; + +export default function PageControl( { currentPage, numberOfPages, setCurrentPage } ) { + return ( + + ); +} diff --git a/packages/components/src/guide/page.js b/packages/components/src/guide/page.js new file mode 100644 index 00000000000000..d8f0b582e8f9b7 --- /dev/null +++ b/packages/components/src/guide/page.js @@ -0,0 +1,3 @@ +export default function GuidePage( props ) { + return
; +} diff --git a/packages/components/src/guide/stories/index.js b/packages/components/src/guide/stories/index.js new file mode 100644 index 00000000000000..3a96e1031e3968 --- /dev/null +++ b/packages/components/src/guide/stories/index.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import { times } from 'lodash'; +import { text, number } from '@storybook/addon-knobs'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import Guide from '../'; +import GuidePage from '../page'; + +export default { title: 'Components|Guide', component: Guide }; + +const ModalExample = ( { numberOfPages, ...props } ) => { + const [ isOpen, setOpen ] = useState( false ); + + const openGuide = () => setOpen( true ); + const closeGuide = () => setOpen( false ); + + const loremIpsum = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; + + return ( + <> + + { isOpen && ( + + { times( numberOfPages, ( page ) => ( + +

Page { page + 1 } of { numberOfPages }

+

{ loremIpsum }

+
+ ) ) } +
+ ) } + + ); +}; + +export const _default = () => { + const finishButtonText = text( 'finishButtonText', 'Finish' ); + const numberOfPages = number( 'numberOfPages', 3, { range: true, min: 1, max: 10, step: 1 } ); + + const modalProps = { + finishButtonText, + numberOfPages, + }; + + return ; +}; diff --git a/packages/components/src/guide/style.scss b/packages/components/src/guide/style.scss new file mode 100644 index 00000000000000..92044211ce3cd4 --- /dev/null +++ b/packages/components/src/guide/style.scss @@ -0,0 +1,119 @@ +.components-guide { + .components-modal__header { + background: none; + border-bottom: none; + + .components-icon-button { + align-self: flex-start; + margin-top: $grid-size-xlarge; + position: static; + } + } + + &__container { + align-items: center; + display: flex; + flex-direction: column; + margin-top: -$header-height; + min-height: 100%; + } + + &__footer { + align-content: center; + display: flex; + height: 30px; + justify-content: center; + margin: auto 0 $grid-size-xlarge 0; + position: relative; + width: 100%; + + @include break-small() { + margin: $grid-size-xlarge 0 0; + } + } + + &__page-control { + margin: 0; + + li { + display: inline-block; + margin: 0 2px; + } + + .components-icon-button { + height: 30px; + } + } +} + +.components-modal__frame.components-guide { + @media (max-width: $break-small) { + bottom: 15%; + left: $grid-size-xlarge; + right: $grid-size-xlarge; + top: 15%; + } +} + +.components-button { + &.components-guide__back-button, + &.components-guide__forward-button, + &.components-guide__finish-button { + height: 30px; + position: absolute; + } + + &.components-guide__back-button, + &.components-guide__forward-button { + font-size: 0; + padding: 4px 2px; + + &.has-text svg { + margin: 0; + } + + @include break-small() { + font-size: $default-font-size; + } + } + + &.components-guide__back-button { + left: 0; + + @include break-small() { + padding: 4px 8px 4px 2px; + + &.has-text svg { + margin-right: 4px; + } + } + } + + &.components-guide__forward-button { + right: 0; + + @include break-small() { + padding: 4px 2px 4px 8px; + + &.has-text svg { + margin-left: 4px; + order: 1; + } + } + } + + &.components-guide__finish-button { + display: none; + right: 0; + + @include break-small() { + display: unset; + } + } + + &.components-guide__inline-finish-button { + @include break-small() { + display: none; + } + } +} diff --git a/packages/components/src/guide/test/finish-button.js b/packages/components/src/guide/test/finish-button.js new file mode 100644 index 00000000000000..86e2d659f21b10 --- /dev/null +++ b/packages/components/src/guide/test/finish-button.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { create } from 'react-test-renderer'; + +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import FinishButton from '../finish-button'; + +describe( 'FinishButton', () => { + it( 'renders a button', () => { + const wrapper = shallow( ); + expect( wrapper.find( Button ) ).toHaveLength( 1 ); + } ); + + it( 'calls onClick when the button is clicked', () => { + const onClick = jest.fn(); + const wrapper = shallow( ); + wrapper.find( Button ).prop( 'onClick' )(); + expect( onClick ).toHaveBeenCalled(); + } ); + + it( 'receives focus on mount when nothing is focused', () => { + const focus = jest.fn(); + create( , { + createNodeMock: () => ( { focus } ), + } ); + expect( focus ).toHaveBeenCalled(); + } ); + + it( 'does not receive focus on mount when something is already focused', () => { + const button = document.createElement( 'button' ); + document.body.append( button ); + button.focus(); + + const focus = jest.fn(); + create( , { + createNodeMock: () => ( { focus } ), + } ); + expect( focus ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/packages/components/src/guide/test/index.js b/packages/components/src/guide/test/index.js new file mode 100644 index 00000000000000..bb1fad16aa3a7a --- /dev/null +++ b/packages/components/src/guide/test/index.js @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import Guide from '../'; +import GuidePage from '../page'; +import PageControl from '../page-control'; +import Modal from '../../modal'; + +describe( 'Guide', () => { + it( 'renders nothing when there are no pages', () => { + const wrapper = shallow( ); + expect( wrapper.isEmptyRender() ).toBe( true ); + } ); + + it( 'renders one page at a time', () => { + const wrapper = shallow( + + Page 1 + Page 2 + + ); + expect( wrapper.find( GuidePage ) ).toHaveLength( 1 ); + } ); + + it( 'hides back button and shows forward button on the first page', () => { + const wrapper = shallow( + + Page 1 + Page 2 + + ); + expect( wrapper.find( PageControl ).prop( 'currentPage' ) ).toBe( 0 ); + expect( wrapper.find( '.components-guide__back-button' ) ).toHaveLength( 0 ); + expect( wrapper.find( '.components-guide__forward-button' ) ).toHaveLength( 1 ); + expect( wrapper.find( '.components-guide__finish-button' ) ).toHaveLength( 0 ); + } ); + + it( 'shows back button and shows finish button on the last page', () => { + const wrapper = shallow( + + Page 1 + Page 2 + + ); + wrapper.find( '.components-guide__forward-button' ).simulate( 'click' ); + expect( wrapper.find( PageControl ).prop( 'currentPage' ) ).toBe( 1 ); + expect( wrapper.find( '.components-guide__back-button' ) ).toHaveLength( 1 ); + expect( wrapper.find( '.components-guide__forward-button' ) ).toHaveLength( 0 ); + expect( wrapper.find( '.components-guide__finish-button' ) ).toHaveLength( 1 ); + } ); + + it( 'calls onFinish when the finish button is clicked', () => { + const onFinish = jest.fn(); + const wrapper = shallow( + + Page 1 + + ); + wrapper.find( '.components-guide__finish-button' ).simulate( 'click' ); + expect( onFinish ).toHaveBeenCalled(); + } ); + + it( 'calls onFinish when the modal is closed', () => { + const onFinish = jest.fn(); + const wrapper = shallow( + + Page 1 + + ); + wrapper.find( Modal ).prop( 'onRequestClose' )(); + expect( onFinish ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/components/src/guide/test/page-control.js b/packages/components/src/guide/test/page-control.js new file mode 100644 index 00000000000000..6490dc1cfaa021 --- /dev/null +++ b/packages/components/src/guide/test/page-control.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { IconButton } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import PageControl from '../page-control'; + +describe( 'PageControl', () => { + it( 'renders an empty list when there are no pages', () => { + const wrapper = shallow( ); + expect( wrapper.find( IconButton ) ).toHaveLength( 0 ); + } ); + + it( 'renders a button for each page', () => { + const wrapper = shallow( ); + expect( wrapper.find( IconButton ) ).toHaveLength( 5 ); + } ); + + it( 'sets the current page when a button is clicked', () => { + const setCurrentPage = jest.fn(); + const wrapper = shallow( + + ); + wrapper.find( IconButton ).at( 1 ).simulate( 'click' ); + expect( setCurrentPage ).toHaveBeenCalledWith( 1 ); + } ); +} ); diff --git a/packages/components/src/index.js b/packages/components/src/index.js index ff4e4f65b85dc5..eef936406b81b9 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -34,6 +34,8 @@ export { default as FormFileUpload } from './form-file-upload'; export { default as FormToggle } from './form-toggle'; export { default as FormTokenField } from './form-token-field'; export { default as __experimentalGradientPicker } from './gradient-picker'; +export { default as Guide } from './guide'; +export { default as GuidePage } from './guide/page'; export { default as Icon } from './icon'; export { default as IconButton } from './icon-button'; export { default as KeyboardShortcuts } from './keyboard-shortcuts'; diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss index a21839bd27b3fb..27f6bf2b47a0dc 100644 --- a/packages/components/src/style.scss +++ b/packages/components/src/style.scss @@ -22,6 +22,7 @@ @import "./form-file-upload/style.scss"; @import "./form-toggle/style.scss"; @import "./form-token-field/style.scss"; +@import "./guide/style.scss"; @import "./higher-order/navigate-regions/style.scss"; @import "./icon-button/style.scss"; @import "./menu-group/style.scss"; diff --git a/packages/e2e-test-utils/README.md b/packages/e2e-test-utils/README.md index 37d5f86d06cb95..6b2af5c5c6e1ed 100644 --- a/packages/e2e-test-utils/README.md +++ b/packages/e2e-test-utils/README.md @@ -137,6 +137,10 @@ _Parameters_ - _slug_ `string`: Plugin slug. +# **disableFocusLossObservation** + +Removes the focus loss listener that `enableFocusLossObservation()` adds. + # **disablePrePublishChecks** Disables Pre-publish checks. @@ -156,6 +160,11 @@ _Returns_ - `Promise`: Promise resolving when drag completes. +# **enableFocusLossObservation** + +Adds an event listener to the document which throws an error if there is a +loss of focus. + # **enablePageDialogAccept** Enables even listener which accepts a page dialog which @@ -319,11 +328,6 @@ _Returns_ - `Promise`: Promise that uses `mockCheck` to see if a request should be mocked with `mock`, and optionally transforms the response with `responseObjectTransform`. -# **observeFocusLoss** - -Binds to the document on page load which throws an error if a `focusout` -event occurs without a related target (i.e. focus loss). - # **openAllBlockInserterCategories** Opens all block inserter categories. diff --git a/packages/e2e-test-utils/src/create-new-post.js b/packages/e2e-test-utils/src/create-new-post.js index 91a95bf5216a45..3abf89d0e8e63b 100644 --- a/packages/e2e-test-utils/src/create-new-post.js +++ b/packages/e2e-test-utils/src/create-new-post.js @@ -26,14 +26,17 @@ export async function createNewPost( { content, excerpt, } ).slice( 1 ); + await visitAdminPage( 'post-new.php', query ); - await page.evaluate( ( _enableTips ) => { - const action = _enableTips ? 'enableTips' : 'disableTips'; - wp.data.dispatch( 'core/nux' )[ action ](); - }, enableTips ); + const areTipsEnabled = await page.evaluate( () => wp.data.select( 'core/nux' ).areTipsEnabled() ); + + if ( enableTips !== areTipsEnabled ) { + await page.evaluate( ( _enableTips ) => { + const action = _enableTips ? 'enableTips' : 'disableTips'; + wp.data.dispatch( 'core/nux' )[ action ](); + }, enableTips ); - if ( enableTips ) { await page.reload(); } } diff --git a/packages/e2e-test-utils/src/index.js b/packages/e2e-test-utils/src/index.js index 4d78447dec0872..adb2b2867f737d 100644 --- a/packages/e2e-test-utils/src/index.js +++ b/packages/e2e-test-utils/src/index.js @@ -27,7 +27,7 @@ export { installPlugin } from './install-plugin'; export { isCurrentURL } from './is-current-url'; export { isInDefaultBlock } from './is-in-default-block'; export { loginUser } from './login-user'; -export { observeFocusLoss } from './observe-focus-loss'; +export { enableFocusLossObservation, disableFocusLossObservation } from './observe-focus-loss'; export { openAllBlockInserterCategories } from './open-all-block-inserter-categories'; export { openDocumentSettingsSidebar } from './open-document-settings-sidebar'; export { openGlobalBlockInserter } from './open-global-block-inserter'; diff --git a/packages/e2e-test-utils/src/observe-focus-loss.js b/packages/e2e-test-utils/src/observe-focus-loss.js index 13e541de27a8fe..1266d0997c1f29 100644 --- a/packages/e2e-test-utils/src/observe-focus-loss.js +++ b/packages/e2e-test-utils/src/observe-focus-loss.js @@ -1,15 +1,30 @@ /** - * Binds to the document on page load which throws an error if a `focusout` - * event occurs without a related target (i.e. focus loss). + * Adds an event listener to the document which throws an error if there is a + * loss of focus. */ -export function observeFocusLoss() { - page.on( 'load', () => { - page.evaluate( () => { - document.body.addEventListener( 'focusout', ( event ) => { - if ( ! event.relatedTarget ) { - throw new Error( 'Unexpected focus loss' ); - } - } ); - } ); +export async function enableFocusLossObservation() { + await page.evaluate( () => { + if ( window._detectFocusLoss ) { + document.body.removeEventListener( 'focusout', window._detectFocusLoss ); + } + + window._detectFocusLoss = ( event ) => { + if ( ! event.relatedTarget ) { + throw new Error( 'Unexpected focus loss' ); + } + }; + + document.body.addEventListener( 'focusout', window._detectFocusLoss ); + } ); +} + +/** + * Removes the focus loss listener that `enableFocusLossObservation()` adds. + */ +export async function disableFocusLossObservation() { + await page.evaluate( () => { + if ( window._detectFocusLoss ) { + document.body.removeEventListener( 'focusout', window._detectFocusLoss ); + } } ); } diff --git a/packages/e2e-tests/specs/editor/various/nux.test.js b/packages/e2e-tests/specs/editor/various/nux.test.js index d3ca54af511763..d172f138cb50f2 100644 --- a/packages/e2e-tests/specs/editor/various/nux.test.js +++ b/packages/e2e-tests/specs/editor/various/nux.test.js @@ -1,110 +1,105 @@ /** * WordPress dependencies */ -import { - createNewPost, - toggleScreenOption, -} from '@wordpress/e2e-test-utils'; +import { createNewPost, clickOnMoreMenuItem } from '@wordpress/e2e-test-utils'; describe( 'New User Experience (NUX)', () => { - async function clickAllTips( page ) { - // Click through all available tips. - const tips = await getTips( page ); - const numberOfTips = tips.tipIds.length; + it( 'should show the guide to first-time users', async () => { + let welcomeGuideText, welcomeGuide; - for ( let i = 1; i < numberOfTips; i++ ) { - await page.click( '.nux-dot-tip .components-button.is-link' ); - } - - return { numberOfTips, tips }; - } - - async function getTips( page ) { - return await page.evaluate( () => { - return wp.data.select( 'core/nux' ).getAssociatedGuide( 'core/editor.inserter' ); - } ); - } - - async function getTipsEnabled( page ) { - return await page.evaluate( () => { - return wp.data.select( 'core/nux' ).areTipsEnabled(); - } ); - } - - beforeEach( async () => { + // Create a new post as a first-time user await createNewPost( { enableTips: true } ); - } ); - - it( 'should show tips to a first-time user', async () => { - const firstTipText = await page.$eval( '.nux-dot-tip', ( element ) => element.innerText ); - expect( firstTipText ).toContain( 'Welcome to the wonderful world of blocks!' ); - - const [ nextTipButton ] = await page.$x( "//button[contains(text(), 'See next tip')]" ); - await nextTipButton.click(); - const secondTipText = await page.$eval( '.nux-dot-tip', ( element ) => element.innerText ); - expect( secondTipText ).toContain( 'You’ll find more settings for your page and blocks in the sidebar.' ); - } ); - - it( 'should show "Got it" once all tips have been displayed', async () => { - await clickAllTips( page ); - - // Make sure "Got it" button appears on the last tip. - const gotItButton = await page.$x( "//button[contains(text(), 'Got it')]" ); - expect( gotItButton ).toHaveLength( 1 ); + // Guide should be on page 1 of 3 + welcomeGuideText = await page.$eval( '.edit-post-welcome-guide', ( element ) => element.innerText ); + expect( welcomeGuideText ).toContain( 'Welcome to the Block Editor' ); + + // Click on the 'Next' button + const [ nextButton ] = await page.$x( '//button[contains(text(), "Next")]' ); + await nextButton.click(); + + // Guide should be on page 2 of 3 + welcomeGuideText = await page.$eval( '.edit-post-welcome-guide', ( element ) => element.innerText ); + expect( welcomeGuideText ).toContain( 'Make each block your own' ); + + // Click on the 'Previous' button + const [ previousButton ] = await page.$x( '//button[contains(text(), "Previous")]' ); + await previousButton.click(); + + // Guide should be on page 1 of 3 + welcomeGuideText = await page.$eval( '.edit-post-welcome-guide', ( element ) => element.innerText ); + expect( welcomeGuideText ).toContain( 'Welcome to the Block Editor' ); + + // Press the button for Page 2 + await page.click( 'button[aria-label="Page 2 of 3"]' ); + + // Press the right arrow key + await page.keyboard.press( 'ArrowRight' ); + + // Guide should be on page 3 of 3 + welcomeGuideText = await page.$eval( '.edit-post-welcome-guide', ( element ) => element.innerText ); + expect( welcomeGuideText ).toContain( 'Get to know the Block Library' ); + + // Click on the *visible* 'Get started' button. There are two in the DOM + // but only one is shown depending on viewport size + let getStartedButton; + for ( const buttonHandle of await page.$x( '//button[contains(text(), "Get started")]' ) ) { + if ( + await page.evaluate( ( button ) => button.style.display !== 'none', buttonHandle ) + ) { + getStartedButton = buttonHandle; + } + } + await getStartedButton.click(); - // Click the "Got it button". - await page.click( '.nux-dot-tip .components-button.is-link' ); + // Guide should be closed + welcomeGuide = await page.$( '.edit-post-welcome-guide' ); + expect( welcomeGuide ).toBeNull(); - // Verify no more tips are visible on the page. - const nuxTipElements = await page.$$( '.nux-dot-tip' ); - expect( nuxTipElements ).toHaveLength( 0 ); + // Reload the editor + await page.reload(); - // Tips should not be marked as disabled, but when the user has seen all - // of the available tips, they will not appear. - const areTipsEnabled = await getTipsEnabled( page ); - expect( areTipsEnabled ).toEqual( true ); + // Guide should be closed + welcomeGuide = await page.$( '.edit-post-welcome-guide' ); + expect( welcomeGuide ).toBeNull(); } ); - it( 'should hide and disable tips if "disable tips" button is clicked', async () => { - await page.click( '.nux-dot-tip__disable' ); + it( 'should not show the welcome guide again if it is dismissed', async () => { + let welcomeGuide; + + // Create a new post as a first-time user + await createNewPost( { enableTips: true } ); - // Verify no more tips are visible on the page. - let nuxTipElements = await page.$$( '.nux-dot-tip' ); - expect( nuxTipElements ).toHaveLength( 0 ); + // Guide should be open + welcomeGuide = await page.$( '.edit-post-welcome-guide' ); + expect( welcomeGuide ).not.toBeNull(); - // We should be disabling the tips so they don't appear again. - const areTipsEnabled = await getTipsEnabled( page ); - expect( areTipsEnabled ).toEqual( false ); + // Close the guide + await page.click( 'button[aria-label="Close dialog"]' ); - // Refresh the page; tips should not show because they were disabled. + // Reload the editor await page.reload(); - nuxTipElements = await page.$$( '.nux-dot-tip' ); - expect( nuxTipElements ).toHaveLength( 0 ); + // Guide should be closed + welcomeGuide = await page.$( '.edit-post-welcome-guide' ); + expect( welcomeGuide ).toBeNull(); } ); - it( 'should enable tips when the "Tips" option is toggled on', async () => { - // Start by disabling tips. - await page.click( '.nux-dot-tip__disable' ); - - // Verify no more tips are visible on the page. - let nuxTipElements = await page.$$( '.nux-dot-tip' ); - expect( nuxTipElements ).toHaveLength( 0 ); + it( 'should show the welcome guide if it is manually opened', async () => { + let welcomeGuide; - // Tips should be disabled in localStorage as well. - let areTipsEnabled = await getTipsEnabled( page ); - expect( areTipsEnabled ).toEqual( false ); + // Create a new post as a returning user + await createNewPost(); - // Toggle the 'Tips' option to enable. - await toggleScreenOption( 'Tips' ); + // Guide should be closed + welcomeGuide = await page.$( '.edit-post-welcome-guide' ); + expect( welcomeGuide ).toBeNull(); - // Tips should once again appear. - nuxTipElements = await page.$$( '.nux-dot-tip' ); - expect( nuxTipElements ).toHaveLength( 1 ); + // Manually open the guide + await clickOnMoreMenuItem( 'Welcome Guide' ); - // Tips should be enabled in localStorage as well. - areTipsEnabled = await getTipsEnabled( page ); - expect( areTipsEnabled ).toEqual( true ); + // Guide should be open + welcomeGuide = await page.$( '.edit-post-welcome-guide' ); + expect( welcomeGuide ).not.toBeNull(); } ); } ); diff --git a/packages/e2e-tests/specs/editor/various/sidebar.test.js b/packages/e2e-tests/specs/editor/various/sidebar.test.js index d0768f916f0f63..90dc69789de644 100644 --- a/packages/e2e-tests/specs/editor/various/sidebar.test.js +++ b/packages/e2e-tests/specs/editor/various/sidebar.test.js @@ -5,7 +5,8 @@ import { clearLocalStorage, createNewPost, findSidebarPanelWithTitle, - observeFocusLoss, + enableFocusLossObservation, + disableFocusLossObservation, openDocumentSettingsSidebar, pressKeyWithModifier, setBrowserViewport, @@ -16,14 +17,15 @@ const ACTIVE_SIDEBAR_TAB_SELECTOR = '.edit-post-sidebar__panel-tab.is-active'; const ACTIVE_SIDEBAR_BUTTON_TEXT = 'Document'; describe( 'Sidebar', () => { - beforeAll( () => { - observeFocusLoss(); + afterEach( () => { + disableFocusLossObservation(); } ); it( 'should have sidebar visible at the start with document sidebar active on desktop', async () => { await setBrowserViewport( 'large' ); await clearLocalStorage(); await createNewPost(); + await enableFocusLossObservation(); const { nodesCount, content, height, width } = await page.$$eval( ACTIVE_SIDEBAR_TAB_SELECTOR, ( nodes ) => { const firstNode = nodes[ 0 ]; return { @@ -49,6 +51,7 @@ describe( 'Sidebar', () => { await setBrowserViewport( 'small' ); await clearLocalStorage(); await createNewPost(); + await enableFocusLossObservation(); const sidebar = await page.$( SIDEBAR_SELECTOR ); expect( sidebar ).toBeNull(); } ); @@ -57,6 +60,7 @@ describe( 'Sidebar', () => { await setBrowserViewport( 'large' ); await clearLocalStorage(); await createNewPost(); + await enableFocusLossObservation(); const sidebars = await page.$$( SIDEBAR_SELECTOR ); expect( sidebars ).toHaveLength( 1 ); @@ -72,6 +76,7 @@ describe( 'Sidebar', () => { await setBrowserViewport( 'large' ); await clearLocalStorage(); await createNewPost(); + await enableFocusLossObservation(); await setBrowserViewport( 'small' ); const sidebarsMobile = await page.$$( SIDEBAR_SELECTOR ); @@ -85,6 +90,7 @@ describe( 'Sidebar', () => { it( 'should preserve tab order while changing active tab', async () => { await createNewPost(); + await enableFocusLossObservation(); // Region navigate to Sidebar. await pressKeyWithModifier( 'ctrl', '`' ); @@ -112,6 +118,7 @@ describe( 'Sidebar', () => { it( 'should be possible to programmatically remove Document Settings panels', async () => { await createNewPost(); + await enableFocusLossObservation(); await openDocumentSettingsSidebar(); diff --git a/packages/edit-post/src/components/editor-initialization/index.js b/packages/edit-post/src/components/editor-initialization/index.js index a1327684f06b36..a4695fa01aa3b4 100644 --- a/packages/edit-post/src/components/editor-initialization/index.js +++ b/packages/edit-post/src/components/editor-initialization/index.js @@ -1,9 +1,3 @@ -/** - * WordPress dependencies - */ -import { useEffect } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; - /** * Internal dependencies */ @@ -24,14 +18,5 @@ export default function( { postId } ) { useBlockSelectionListener( postId ); useAdjustSidebarListener( postId ); useUpdatePostLinkListener( postId ); - const { triggerGuide } = useDispatch( 'core/nux' ); - useEffect( () => { - triggerGuide( [ - 'core/editor.inserter', - 'core/editor.settings', - 'core/editor.preview', - 'core/editor.publish', - ] ); - }, [ triggerGuide ] ); return null; } diff --git a/packages/edit-post/src/components/header/header-toolbar/index.js b/packages/edit-post/src/components/header/header-toolbar/index.js index 92b877e7626cfc..ad6612d7a54195 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.js @@ -4,7 +4,6 @@ import { compose } from '@wordpress/compose'; import { withSelect } from '@wordpress/data'; import { withViewportMatch } from '@wordpress/viewport'; -import { DotTip } from '@wordpress/nux'; import { __ } from '@wordpress/i18n'; import { Inserter, @@ -31,12 +30,7 @@ function HeaderToolbar( { hasFixedToolbar, isLargeViewport, showInserter, isText className="edit-post-header-toolbar" aria-label={ toolbarAriaLabel } > -
- - - { __( 'Welcome to the wonderful world of blocks! Click the “+” (“Add block”) button to add a new block. There are blocks available for all kinds of content: you can insert text, headings, images, lists, and lots more!' ) } - -
+ diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index 8515b6ba372a00..52d9245be0a157 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -9,7 +9,6 @@ import { } from '@wordpress/editor'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose } from '@wordpress/compose'; -import { DotTip } from '@wordpress/nux'; /** * Internal dependencies @@ -57,19 +56,14 @@ function Header( { forceIsDirty={ hasActiveMetaboxes } forceIsSaving={ isSaving } /> -
- - - { __( 'You’ll find more settings for your page and blocks in the sidebar. Click the cog icon to toggle the sidebar open and closed.' ) } - -
+
diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js index a8596ca2ce7dd3..db618a86d16d9e 100644 --- a/packages/edit-post/src/components/layout/index.js +++ b/packages/edit-post/src/components/layout/index.js @@ -47,6 +47,7 @@ import Sidebar from '../sidebar'; import MetaBoxes from '../meta-boxes'; import PluginPostPublishPanel from '../sidebar/plugin-post-publish-panel'; import PluginPrePublishPanel from '../sidebar/plugin-pre-publish-panel'; +import WelcomeGuide from '../welcome-guide'; function Layout( { isMobileViewport } ) { const { closePublishSidebar, togglePublishSidebar } = useDispatch( 'core/edit-post' ); @@ -141,6 +142,7 @@ function Layout( { isMobileViewport } ) { + { showPageTemplatePicker && <__experimentalPageTemplatePicker /> } diff --git a/packages/edit-post/src/components/options-modal/index.js b/packages/edit-post/src/components/options-modal/index.js index 792cc348ae1110..58600900bc1d0a 100644 --- a/packages/edit-post/src/components/options-modal/index.js +++ b/packages/edit-post/src/components/options-modal/index.js @@ -25,7 +25,6 @@ import Section from './section'; import { EnablePluginDocumentSettingPanelOption, EnablePublishSidebarOption, - EnableTipsOption, EnablePanelOption, EnableFeature, } from './options'; @@ -47,7 +46,6 @@ export function OptionsModal( { isModalActive, isViewable, closeModal } ) { >
-
diff --git a/packages/edit-post/src/components/options-modal/options/deferred.js b/packages/edit-post/src/components/options-modal/options/deferred.js deleted file mode 100644 index 2a0f84348d23bb..00000000000000 --- a/packages/edit-post/src/components/options-modal/options/deferred.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * WordPress dependencies - */ -import { Component } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import BaseOption from './base'; - -class DeferredOption extends Component { - constructor( { isChecked } ) { - super( ...arguments ); - this.state = { - isChecked, - }; - } - - componentWillUnmount() { - if ( this.state.isChecked !== this.props.isChecked ) { - this.props.onChange( this.state.isChecked ); - } - } - - render() { - return ( - this.setState( { isChecked } ) } - /> - ); - } -} - -export default DeferredOption; diff --git a/packages/edit-post/src/components/options-modal/options/enable-tips.js b/packages/edit-post/src/components/options-modal/options/enable-tips.js deleted file mode 100644 index 8771f8437ba53a..00000000000000 --- a/packages/edit-post/src/components/options-modal/options/enable-tips.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * WordPress dependencies - */ -import { compose } from '@wordpress/compose'; -import { withSelect, withDispatch } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import DeferredOption from './deferred'; - -export default compose( - withSelect( ( select ) => ( { - isChecked: select( 'core/nux' ).areTipsEnabled(), - } ) ), - withDispatch( ( dispatch ) => { - const { enableTips, disableTips } = dispatch( 'core/nux' ); - return { - onChange: ( isEnabled ) => ( isEnabled ? enableTips() : disableTips() ), - }; - } ) -)( - // Using DeferredOption here means enableTips() is called when the Options - // modal is dismissed. This stops the NUX guide from appearing above the - // Options modal, which looks totally weird. - DeferredOption -); diff --git a/packages/edit-post/src/components/options-modal/options/index.js b/packages/edit-post/src/components/options-modal/options/index.js index 8684b680377054..b5cb1c6c4fe1b7 100644 --- a/packages/edit-post/src/components/options-modal/options/index.js +++ b/packages/edit-post/src/components/options-modal/options/index.js @@ -2,5 +2,4 @@ export { default as EnableCustomFieldsOption } from './enable-custom-fields'; export { default as EnablePanelOption } from './enable-panel'; export { default as EnablePluginDocumentSettingPanelOption } from './enable-plugin-document-setting-panel'; export { default as EnablePublishSidebarOption } from './enable-publish-sidebar'; -export { default as EnableTipsOption } from './enable-tips'; export { default as EnableFeature } from './enable-feature'; diff --git a/packages/edit-post/src/components/options-modal/test/__snapshots__/index.js.snap b/packages/edit-post/src/components/options-modal/test/__snapshots__/index.js.snap index 401d2d952dbab3..f418cd5a335268 100644 --- a/packages/edit-post/src/components/options-modal/test/__snapshots__/index.js.snap +++ b/packages/edit-post/src/components/options-modal/test/__snapshots__/index.js.snap @@ -12,9 +12,6 @@ exports[`OptionsModal should match snapshot when the modal is active 1`] = ` - ( + +); + +export const EditorImage = ( props ) => ( + +); + +export const BlockLibraryImage = ( props ) => ( + +); + +export const InserterIconImage = ( props ) => ( + { +); diff --git a/packages/edit-post/src/components/welcome-guide/index.js b/packages/edit-post/src/components/welcome-guide/index.js new file mode 100644 index 00000000000000..756098cfe476c7 --- /dev/null +++ b/packages/edit-post/src/components/welcome-guide/index.js @@ -0,0 +1,71 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { Guide, GuidePage } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { __experimentalCreateInterpolateElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { CanvasImage, EditorImage, BlockLibraryImage, InserterIconImage } from './images'; + +export default function WelcomeGuide() { + const areTipsEnabled = useSelect( ( select ) => select( 'core/nux' ).areTipsEnabled() ); + + const { disableTips } = useDispatch( 'core/nux' ); + + if ( ! areTipsEnabled ) { + return null; + } + + return ( + + + +

+ { __( 'Welcome to the Block Editor' ) } +

+ +

+ { __( 'In the WordPress editor, each paragraph, image, or video is presented as a distinct “block” of content.' ) } +

+
+ + +

+ { __( 'Make each block your own' ) } +

+ +

+ { __( 'Each block comes with its own set of controls for changing things like color, width, and alignment. These will show and hide automatically when you have a block selected.' ) } +

+
+ + +

+ { __( 'Get to know the Block Library' ) } +

+ +

+ { __experimentalCreateInterpolateElement( + __( 'All of the blocks available to you live in the Block Library. You’ll find it wherever you see the icon.' ), + { + InserterIconImage: ( + + ), + } + ) } +

+
+ +
+ ); +} diff --git a/packages/edit-post/src/components/welcome-guide/style.scss b/packages/edit-post/src/components/welcome-guide/style.scss new file mode 100644 index 00000000000000..e73c1dce01ea8b --- /dev/null +++ b/packages/edit-post/src/components/welcome-guide/style.scss @@ -0,0 +1,49 @@ +.edit-post-welcome-guide { + $image-height: 300px; + $image-width: 320px; + + &__page { + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + + @include break-small() { + min-height: $image-height; + padding-left: $image-width + $grid-size-xlarge; + } + } + + &__heading { + font-family: $editor-font; + font-size: 32px; + line-height: 44px; + margin: $grid-size 0; + } + + &__image { + background: #66c6e4; + border-radius: $radius-round-rectangle; + height: 200px; + margin: $grid-size 0; + + @include break-small() { + height: $image-height; + left: 0; + position: absolute; + width: $image-width; + } + } + + &__text { + font-size: $editor-font-size; + line-height: 1.5; + margin: $grid-size 0; + } + + &__inserter-icon { + margin: 0 4px; + position: relative; + top: 4px; + } +} diff --git a/packages/edit-post/src/plugins/index.js b/packages/edit-post/src/plugins/index.js index bae6f081189ee6..669a870a8e53c6 100644 --- a/packages/edit-post/src/plugins/index.js +++ b/packages/edit-post/src/plugins/index.js @@ -13,6 +13,7 @@ import CopyContentMenuItem from './copy-content-menu-item'; import ManageBlocksMenuItem from './manage-blocks-menu-item'; import KeyboardShortcutsHelpMenuItem from './keyboard-shortcuts-help-menu-item'; import ToolsMoreMenuGroup from '../components/header/tools-more-menu-group'; +import WelcomeGuideMenuItem from './welcome-guide-menu-item'; registerPlugin( 'edit-post', { render() { @@ -29,6 +30,7 @@ registerPlugin( 'edit-post', { { __( 'Manage All Reusable Blocks' ) } + ) } diff --git a/packages/edit-post/src/plugins/welcome-guide-menu-item/index.js b/packages/edit-post/src/plugins/welcome-guide-menu-item/index.js new file mode 100644 index 00000000000000..5c4c877c796905 --- /dev/null +++ b/packages/edit-post/src/plugins/welcome-guide-menu-item/index.js @@ -0,0 +1,16 @@ +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { MenuItem } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +export default function WelcomeGuideMenuItem() { + const { enableTips } = useDispatch( 'core/nux' ); + + return ( + + { __( 'Welcome Guide' ) } + + ); +} diff --git a/packages/edit-post/src/style.scss b/packages/edit-post/src/style.scss index 2bd70b382796ac..43b69c59da22cc 100644 --- a/packages/edit-post/src/style.scss +++ b/packages/edit-post/src/style.scss @@ -24,6 +24,7 @@ $footer-height: $icon-button-size-small; @import "./components/text-editor/style.scss"; @import "./components/visual-editor/style.scss"; @import "./components/options-modal/style.scss"; +@import "./components/welcome-guide/style.scss"; /** diff --git a/packages/editor/src/components/post-preview-button/index.js b/packages/editor/src/components/post-preview-button/index.js index a715c0fd3da5aa..415d9da9ba070d 100644 --- a/packages/editor/src/components/post-preview-button/index.js +++ b/packages/editor/src/components/post-preview-button/index.js @@ -10,7 +10,6 @@ import { Component, renderToString } from '@wordpress/element'; import { Button, Path, SVG } from '@wordpress/components'; import { __, _x } from '@wordpress/i18n'; import { withSelect, withDispatch } from '@wordpress/data'; -import { DotTip } from '@wordpress/nux'; import { ifCondition, compose } from '@wordpress/compose'; import { applyFilters } from '@wordpress/hooks'; @@ -191,9 +190,6 @@ export class PostPreviewButton extends Component { __( '(opens in a new tab)' ) } - - { __( 'Click “Preview” to load a preview of this page, so you can make sure you’re happy with your blocks.' ) } - ); } diff --git a/packages/editor/src/components/post-preview-button/test/__snapshots__/index.js.snap b/packages/editor/src/components/post-preview-button/test/__snapshots__/index.js.snap index fc928b5e12cb9e..587f30bf8a91bb 100644 --- a/packages/editor/src/components/post-preview-button/test/__snapshots__/index.js.snap +++ b/packages/editor/src/components/post-preview-button/test/__snapshots__/index.js.snap @@ -15,11 +15,6 @@ exports[`PostPreviewButton render() should render currentPostLink otherwise 1`] > (opens in a new tab) - - Click “Preview” to load a preview of this page, so you can make sure you’re happy with your blocks. - `; @@ -38,10 +33,5 @@ exports[`PostPreviewButton render() should render previewLink if provided 1`] = > (opens in a new tab) - - Click “Preview” to load a preview of this page, so you can make sure you’re happy with your blocks. - `; diff --git a/packages/editor/src/components/post-publish-button/index.js b/packages/editor/src/components/post-publish-button/index.js index ac195d4de86ce6..c564cccfd6cbef 100644 --- a/packages/editor/src/components/post-publish-button/index.js +++ b/packages/editor/src/components/post-publish-button/index.js @@ -11,7 +11,6 @@ import { Component, createRef } from '@wordpress/element'; import { withSelect, withDispatch } from '@wordpress/data'; import { compose } from '@wordpress/compose'; import { __ } from '@wordpress/i18n'; -import { DotTip } from '@wordpress/nux'; /** * Internal dependencies @@ -111,19 +110,13 @@ export class PostPublishButton extends Component { const componentProps = isToggle ? toggleProps : buttonProps; const componentChildren = isToggle ? toggleChildren : buttonChildren; return ( -
- - { /* Todo: Remove the wrapping div when DotTips are removed. */ } - - { __( 'Finished writing? That’s great, let’s get this published right now. Just click “Publish” and you’re good to go.' ) } - -
+ ); } } diff --git a/storybook/test/__snapshots__/index.js.snap b/storybook/test/__snapshots__/index.js.snap index 1c0741a0c3f8b7..b8a1abeec7cfd4 100644 --- a/storybook/test/__snapshots__/index.js.snap +++ b/storybook/test/__snapshots__/index.js.snap @@ -1826,6 +1826,16 @@ exports[`Storyshots Components|FontSizePicker Without Custom Sizes 1`] = ` `; +exports[`Storyshots Components|Guide Default 1`] = ` + +`; + exports[`Storyshots Components|Icon Colors 1`] = ` Array [