From e8095055d5323e07affc3c7de1b11f639bb0eafd Mon Sep 17 00:00:00 2001 From: Robert Anderson Date: Thu, 7 Jun 2018 13:13:37 +1000 Subject: [PATCH] New User Experience (NUX) (#6631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix appearance of DotTip on mobile Force DotTip content to appear underneath the indicator dot when on mobile viewports. This requires overriding a lot of .component-popover styles which is quite gross. * Remove unnecessary * Position NUX dots right next to the edge of the button Adjust popover's layout algorithm so that if yAxis === 'middle', it will set popoverLeft such that the popover points to the edge of the anchor node. This aligns with what e.g. position="bottom center" does. * Increase NUX tip padding * Add margin to sides of NUX tip on mobile * Don't encourage using domReady in the NUX README * Add wp-nux as a dependency of wp-edit-post * Suggest that NUX tip identifiers are prefixed * Don't use partial(), which is slated for removal * tipID → tipId * Improve NUX JSDoc comments * Memoize the getAssociatedGuide() selector * NUX: Dismiss the tip when one clicks away from it * Position tooltips above NUX tips * Remove aria-modal property from DotTip Safari 11 has a weird bug when this property is used. * Make order of the markup in DotTip match how they appear visually * Improve a11y of DotTip labels * Implement New User Guide Adds a series of floating modal 'tips' which introduces a new user to the editor. These tips can be advanced one by one or dismissed alltogether. If dismissed, they will never show again. * Dismiss tips instead of disabling when the X is clicked * Fix DotTip focus issue The first DotTip should receive focus when the page loads, and focus should not be on the X button. Also improve the note explaining our temporary position workaround. * NUX: Only show inserter tip in DefaultBlockAppender Only display the first DotTip in the new user guide in the DefaultBlockAppender that appears in a new post. This prevents the guide from appearing in an existing post that contains empty blocks. --- .eslintrc.js | 4 + components/popover/utils.js | 29 +++-- components/tooltip/style.scss | 1 + edit-post/assets/stylesheets/_z-index.scss | 6 + edit-post/components/header/index.js | 7 +- edit-post/index.js | 8 ++ .../default-block-appender/index.js | 31 ++++-- .../default-block-appender/style.scss | 14 +-- .../test/__snapshots__/index.js.snap | 24 +++- .../components/post-preview-button/index.js | 6 +- .../components/post-publish-panel/toggle.js | 4 + lib/client-assets.php | 20 +++- nux/README.md | 84 ++++++++++++++ nux/components/dot-tip/README.md | 31 ++++++ nux/components/dot-tip/index.js | 95 ++++++++++++++++ nux/components/dot-tip/style.scss | 96 ++++++++++++++++ .../dot-tip/test/__snapshots__/index.js.snap | 29 +++++ nux/components/dot-tip/test/index.js | 51 +++++++++ nux/index.js | 6 + nux/store/actions.js | 41 +++++++ nux/store/index.js | 24 ++++ nux/store/reducer.js | 67 +++++++++++ nux/store/selectors.js | 68 ++++++++++++ nux/store/test/actions.js | 32 ++++++ nux/store/test/reducer.js | 51 +++++++++ nux/store/test/selectors.js | 104 ++++++++++++++++++ test/e2e/support/utils.js | 5 + test/unit/jest.config.json | 4 +- webpack.config.js | 1 + 29 files changed, 912 insertions(+), 31 deletions(-) create mode 100644 nux/README.md create mode 100644 nux/components/dot-tip/README.md create mode 100644 nux/components/dot-tip/index.js create mode 100644 nux/components/dot-tip/style.scss create mode 100644 nux/components/dot-tip/test/__snapshots__/index.js.snap create mode 100644 nux/components/dot-tip/test/index.js create mode 100644 nux/index.js create mode 100644 nux/store/actions.js create mode 100644 nux/store/index.js create mode 100644 nux/store/reducer.js create mode 100644 nux/store/selectors.js create mode 100644 nux/store/test/actions.js create mode 100644 nux/store/test/reducer.js create mode 100644 nux/store/test/selectors.js diff --git a/.eslintrc.js b/.eslintrc.js index 198d6dbf0b06ad..ce9869d8d79109 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -98,6 +98,10 @@ module.exports = { "selector": "ImportDeclaration[source.value=/^core-blocks$/]", "message": "Use @wordpress/core-blocks as import path instead." }, + { + "selector": "ImportDeclaration[source.value=/^nux$/]", + "message": "Use @wordpress/nux as import path instead." + }, { selector: 'CallExpression[callee.name="deprecated"] Property[key.name="version"][value.value=/' + majorMinorRegExp + '/]', message: 'Deprecated functions must be removed before releasing this version.', diff --git a/components/popover/utils.js b/components/popover/utils.js index 849f4202e68afa..bfaf441305fe23 100644 --- a/components/popover/utils.js +++ b/components/popover/utils.js @@ -11,26 +11,32 @@ const isMobileViewport = () => window.innerWidth < 782; * @param {Object} anchorRect Anchor Rect. * @param {Object} contentSize Content Size. * @param {string} xAxis Desired xAxis. + * @param {string} chosenYAxis yAxis to be used. * @param {boolean} expandOnMobile Whether to expand the popover on mobile or not. * * @return {Object} Popover xAxis position and constraints. */ -export function computePopoverXAxisPosition( anchorRect, contentSize, xAxis ) { +export function computePopoverXAxisPosition( anchorRect, contentSize, xAxis, chosenYAxis ) { const { width } = contentSize; - const popoverLeft = Math.round( anchorRect.left + ( anchorRect.width / 2 ) ); // x axis alignment choices + const anchorMidPoint = Math.round( anchorRect.left + ( anchorRect.width / 2 ) ); const centerAlignment = { + popoverLeft: anchorMidPoint, contentWidth: ( - ( popoverLeft - ( width / 2 ) > 0 ? ( width / 2 ) : popoverLeft ) + - ( popoverLeft + ( width / 2 ) > window.innerWidth ? window.innerWidth - popoverLeft : ( width / 2 ) ) + ( anchorMidPoint - ( width / 2 ) > 0 ? ( width / 2 ) : anchorMidPoint ) + + ( anchorMidPoint + ( width / 2 ) > window.innerWidth ? window.innerWidth - anchorMidPoint : ( width / 2 ) ) ), }; + const leftAlignmentX = chosenYAxis === 'middle' ? anchorRect.left : anchorMidPoint; const leftAlignment = { - contentWidth: popoverLeft - width > 0 ? width : popoverLeft, + popoverLeft: leftAlignmentX, + contentWidth: leftAlignmentX - width > 0 ? width : leftAlignmentX, }; + const rightAlignmentX = chosenYAxis === 'middle' ? anchorRect.right : anchorMidPoint; const rightAlignment = { - contentWidth: popoverLeft + width > window.innerWidth ? window.innerWidth - popoverLeft : width, + popoverLeft: rightAlignmentX, + contentWidth: rightAlignmentX + width > window.innerWidth ? window.innerWidth - rightAlignmentX : width, }; // Choosing the x axis @@ -48,6 +54,15 @@ export function computePopoverXAxisPosition( anchorRect, contentSize, xAxis ) { contentWidth = chosenWidth !== width ? chosenWidth : null; } + let popoverLeft; + if ( chosenXAxis === 'center' ) { + popoverLeft = centerAlignment.popoverLeft; + } else if ( chosenXAxis === 'left' ) { + popoverLeft = leftAlignment.popoverLeft; + } else { + popoverLeft = rightAlignment.popoverLeft; + } + return { xAxis: chosenXAxis, popoverLeft, @@ -131,8 +146,8 @@ export function computePopoverYAxisPosition( anchorRect, contentSize, yAxis ) { export function computePopoverPosition( anchorRect, contentSize, position = 'top', expandOnMobile = false ) { const [ yAxis, xAxis = 'center' ] = position.split( ' ' ); - const xAxisPosition = computePopoverXAxisPosition( anchorRect, contentSize, xAxis ); const yAxisPosition = computePopoverYAxisPosition( anchorRect, contentSize, yAxis ); + const xAxisPosition = computePopoverXAxisPosition( anchorRect, contentSize, xAxis, yAxisPosition.yAxis ); return { isMobile: isMobileViewport() && expandOnMobile, diff --git a/components/tooltip/style.scss b/components/tooltip/style.scss index cef27ed6e96de3..9f792b7f2fdc9d 100644 --- a/components/tooltip/style.scss +++ b/components/tooltip/style.scss @@ -1,5 +1,6 @@ .components-tooltip.components-popover { pointer-events: none; + z-index: z-index( '.components-tooltip' ); &:before { border-color: transparent; diff --git a/edit-post/assets/stylesheets/_z-index.scss b/edit-post/assets/stylesheets/_z-index.scss index 180c5a94774104..a4c704297de979 100644 --- a/edit-post/assets/stylesheets/_z-index.scss +++ b/edit-post/assets/stylesheets/_z-index.scss @@ -77,6 +77,12 @@ $z-layers: ( '.components-autocomplete__results': 1000000, '.skip-to-selected-block': 100000, + + // Show NUX tips above popovers, wp-admin menus, submenus, and sidebar: + '.nux-dot-tip': 1000001, + + // Show tooltips above NUX tips, wp-admin menus, submenus, and sidebar: + '.components-tooltip': 1000002 ); @function z-index( $key ) { diff --git a/edit-post/components/header/index.js b/edit-post/components/header/index.js index fdaa63dc777a67..72066bfdfc3fa4 100644 --- a/edit-post/components/header/index.js +++ b/edit-post/components/header/index.js @@ -10,6 +10,7 @@ import { } from '@wordpress/editor'; import { withDispatch, withSelect } from '@wordpress/data'; import { compose } from '@wordpress/element'; +import { DotTip } from '@wordpress/nux'; /** * Internal dependencies @@ -57,7 +58,11 @@ function Header( { onClick={ toggleGeneralSidebar } isToggled={ isEditorSidebarOpened } aria-expanded={ isEditorSidebarOpened } - /> + > + + { __( 'You’ll find more settings for your page and blocks in the sidebar. Click ‘Settings’ to open it.' ) } + + diff --git a/edit-post/index.js b/edit-post/index.js index fcdd285684dbd2..b77b0aa2a9a4bd 100644 --- a/edit-post/index.js +++ b/edit-post/index.js @@ -3,6 +3,7 @@ */ import { registerCoreBlocks } from '@wordpress/core-blocks'; import { render, unmountComponentAtNode } from '@wordpress/element'; +import { dispatch } from '@wordpress/data'; /** * Internal dependencies @@ -73,6 +74,13 @@ export function initializeEditor( id, postType, postId, settings, overridePost ) registerCoreBlocks(); + dispatch( 'core/nux' ).triggerGuide( [ + 'core/editor.inserter', + 'core/editor.settings', + 'core/editor.preview', + 'core/editor.publish', + ] ); + render( , target diff --git a/editor/components/default-block-appender/index.js b/editor/components/default-block-appender/index.js index d2de14c4876b9b..5a844824ba62b4 100644 --- a/editor/components/default-block-appender/index.js +++ b/editor/components/default-block-appender/index.js @@ -1,6 +1,7 @@ /** * External dependencies */ +import classnames from 'classnames'; import { get } from 'lodash'; /** @@ -11,6 +12,7 @@ import { compose } from '@wordpress/element'; import { getDefaultBlockName } from '@wordpress/blocks'; import { decodeEntities } from '@wordpress/utils'; import { withSelect, withDispatch } from '@wordpress/data'; +import { DotTip } from '@wordpress/nux'; /** * Internal dependencies @@ -28,6 +30,7 @@ export function DefaultBlockAppender( { placeholder, layout, rootUID, + hasTip, } ) { if ( isLocked || ! isVisible ) { return null; @@ -38,7 +41,9 @@ export function DefaultBlockAppender( { return (
+ className={ classnames( 'editor-default-block-appender', { + 'has-tip': hasTip, + } ) }> - + + + { __( 'Welcome to the wonderful world of blocks! Click ‘Add block’ to insert different kinds of content—text, images, quotes, video, lists, and much more.' ) } + +
); } export default compose( withSelect( ( select, ownProps ) => { - const { - getBlockCount, - getBlock, - getEditorSettings, - } = select( 'core/editor' ); + const { getBlockCount, getBlock, getEditorSettings } = select( 'core/editor' ); + const { isTipVisible } = select( 'core/nux' ); + const isEmpty = ! getBlockCount( ownProps.rootUID ); const lastBlock = getBlock( ownProps.lastBlockUID ); const isLastBlockDefault = get( lastBlock, [ 'name' ] ) === getDefaultBlockName(); @@ -73,6 +80,7 @@ export default compose( showPrompt: isEmpty, isLocked: !! templateLock, placeholder: bodyPlaceholder, + hasTip: isTipVisible( 'core/editor.inserter' ), }; } ), withDispatch( ( dispatch, ownProps ) => { @@ -80,9 +88,12 @@ export default compose( insertDefaultBlock, startTyping, } = dispatch( 'core/editor' ); + + const { dismissTip } = dispatch( 'core/nux' ); + return { onAppend() { - const { layout, rootUID } = ownProps; + const { layout, rootUID, hasTip } = ownProps; let attributes; if ( layout ) { @@ -91,6 +102,10 @@ export default compose( insertDefaultBlock( attributes, rootUID ); startTyping(); + + if ( hasTip ) { + dismissTip( 'core/editor.inserter' ); + } }, }; } ), diff --git a/editor/components/default-block-appender/style.scss b/editor/components/default-block-appender/style.scss index 8a20f01ec63c03..68c229dfaa9ccb 100644 --- a/editor/components/default-block-appender/style.scss +++ b/editor/components/default-block-appender/style.scss @@ -45,17 +45,17 @@ $empty-paragraph-height: $text-editor-font-size * 4; } } - // Don't show inserter until mousing - .editor-inserter__toggle:not( [aria-expanded="true"] ) { - opacity: 0; + &:hover .editor-inserter-with-shortcuts { + opacity: 1; } - &:hover { - .editor-inserter-with-shortcuts { - opacity: 1; + // Show the inserter if mousing over or there is a tip + &:not( .has-tip ) { + .editor-inserter__toggle:not( [aria-expanded="true"] ) { + opacity: 0; } - .editor-inserter__toggle { + &:hover .editor-inserter__toggle { opacity: 1; } } diff --git a/editor/components/default-block-appender/test/__snapshots__/index.js.snap b/editor/components/default-block-appender/test/__snapshots__/index.js.snap index aa218fa220a27a..9fee0c9ce14b73 100644 --- a/editor/components/default-block-appender/test/__snapshots__/index.js.snap +++ b/editor/components/default-block-appender/test/__snapshots__/index.js.snap @@ -38,7 +38,13 @@ exports[`DefaultBlockAppender should append a default block when input focused 1 + > + + Welcome to the wonderful world of blocks! Click ‘Add block’ to insert different kinds of content—text, images, quotes, video, lists, and much more. + + `; @@ -62,7 +68,13 @@ exports[`DefaultBlockAppender should match snapshot 1`] = ` + > + + Welcome to the wonderful world of blocks! Click ‘Add block’ to insert different kinds of content—text, images, quotes, video, lists, and much more. + + `; @@ -86,6 +98,12 @@ exports[`DefaultBlockAppender should optionally show without prompt 1`] = ` + > + + Welcome to the wonderful world of blocks! Click ‘Add block’ to insert different kinds of content—text, images, quotes, video, lists, and much more. + + `; diff --git a/editor/components/post-preview-button/index.js b/editor/components/post-preview-button/index.js index 00244ec04afd16..acb2ae41a3fac2 100644 --- a/editor/components/post-preview-button/index.js +++ b/editor/components/post-preview-button/index.js @@ -8,8 +8,9 @@ import { get } from 'lodash'; */ import { Component, compose } from '@wordpress/element'; import { Button, ifCondition } from '@wordpress/components'; -import { _x } from '@wordpress/i18n'; +import { __, _x } from '@wordpress/i18n'; import { withSelect, withDispatch } from '@wordpress/data'; +import { DotTip } from '@wordpress/nux'; export class PostPreviewButton extends Component { constructor() { @@ -107,6 +108,9 @@ export class PostPreviewButton extends Component { disabled={ ! isSaveable } > { _x( 'Preview', 'imperative verb' ) } + + { __( 'Click ‘Preview’ to load a preview of this page, so you can make sure you’re happy with your blocks.' ) } + ); } diff --git a/editor/components/post-publish-panel/toggle.js b/editor/components/post-publish-panel/toggle.js index 5345cb4759c772..736b08f67a48a3 100644 --- a/editor/components/post-publish-panel/toggle.js +++ b/editor/components/post-publish-panel/toggle.js @@ -10,6 +10,7 @@ import { Button } from '@wordpress/components'; import { compose } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { withSelect } from '@wordpress/data'; +import { DotTip } from '@wordpress/nux'; /** * Internal Dependencies @@ -50,6 +51,9 @@ function PostPublishPanelToggle( { isBusy={ isSaving && isPublished } > { isBeingScheduled ? __( 'Schedule…' ) : __( 'Publish…' ) } + + { __( 'Finished writing? That’s great, let’s get this published right now. Just click ‘Publish’ and you’re good to go.' ) } + ); } diff --git a/lib/client-assets.php b/lib/client-assets.php index 98648c0ce961a3..0cfa47fe9d7a43 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -265,6 +265,13 @@ function gutenberg_register_scripts_and_styles() { filemtime( gutenberg_dir_path() . 'build/core-blocks/index.js' ), true ); + wp_register_script( + 'wp-nux', + gutenberg_url( 'build/nux/index.js' ), + array( 'wp-element', 'wp-components', 'wp-data', 'wp-i18n', 'lodash' ), + filemtime( gutenberg_dir_path() . 'build/nux/index.js' ), + true + ); // Loading the old editor and its config to ensure the classic block works as expected. wp_add_inline_script( 'editor', 'window.wp.oldEditor = window.wp.editor;', 'after' @@ -361,6 +368,7 @@ function gutenberg_register_scripts_and_styles() { 'tinymce-latest-lists', 'tinymce-latest-paste', 'tinymce-latest-table', + 'wp-nux', ), filemtime( gutenberg_dir_path() . 'build/editor/index.js' ) ); @@ -408,7 +416,7 @@ function gutenberg_register_scripts_and_styles() { wp_register_style( 'wp-editor', gutenberg_url( 'build/editor/style.css' ), - array( 'wp-components', 'wp-editor-font' ), + array( 'wp-components', 'wp-editor-font', 'wp-nux' ), filemtime( gutenberg_dir_path() . 'build/editor/style.css' ) ); wp_style_add_data( 'wp-editor', 'rtl', 'replace' ); @@ -416,7 +424,7 @@ function gutenberg_register_scripts_and_styles() { wp_register_style( 'wp-edit-post', gutenberg_url( 'build/edit-post/style.css' ), - array( 'wp-components', 'wp-editor', 'wp-edit-blocks', 'wp-core-blocks' ), + array( 'wp-components', 'wp-editor', 'wp-edit-blocks', 'wp-core-blocks', 'wp-nux' ), filemtime( gutenberg_dir_path() . 'build/edit-post/style.css' ) ); wp_style_add_data( 'wp-edit-post', 'rtl', 'replace' ); @@ -450,6 +458,14 @@ function gutenberg_register_scripts_and_styles() { ); wp_style_add_data( 'wp-edit-blocks', 'rtl', 'replace' ); + wp_register_style( + 'wp-nux', + gutenberg_url( 'build/nux/style.css' ), + array( 'wp-components' ), + filemtime( gutenberg_dir_path() . 'build/nux/style.css' ) + ); + wp_style_add_data( 'wp-nux', 'rtl', 'replace' ); + wp_register_style( 'wp-core-blocks-theme', gutenberg_url( 'build/core-blocks/theme.css' ), diff --git a/nux/README.md b/nux/README.md new file mode 100644 index 00000000000000..d3c9f069a9f886 --- /dev/null +++ b/nux/README.md @@ -0,0 +1,84 @@ +NUX (New User eXperience) +========================= + +The NUX module exposes components, and `wp.data` methods useful for onboarding a new user to the WordPress admin interface. Specifically, it exposes _tips_ and _guides_. + +A _tip_ is a component that points to an element in the UI and contains text that explains the element's functionality. The user can dismiss a tip, in which case it never shows again. The user can also disable tips entirely. Information about tips is persisted between sessions using `localStorage`. + +A _guide_ allows a series of of tips to be presented to the user one by one. When a user dismisses a tip that is in a guide, the next tip in the guide is shown. + +## DotTip + +`DotTip` is a React component that renders a single _tip_ on the screen. The tip will point to the React element that `DotTip` is nested within. Each tip is uniquely identified by a string passed to `id`. + +See [the component's README][dot-tip-readme] for more information. + +[dot-tip-readme]: https://github.com/WordPress/gutenberg/tree/master/nux/components/dot-tip/README.md + +```jsx + +} +``` + +## Determining if a tip is visible + +You can programmatically determine if a tip is visible using the `isTipVisible` select method. + +```jsx +const isVisible = select( 'core/nux' ).isTipVisible( 'acme/add-to-cart' ); +console.log( isVisible ); // true or false +``` + +## Manually dismissing a tip + +`dismissTip` is a dispatch method that allows you to programmatically dismiss a tip. + +```jsx + +``` + +## Manually disabling tips + +`disableTips` is a dispatch method that allows you to programmatically disable all tips. + +```jsx + +``` + +## Triggering a guide + +You can group a series of tips into a guide by calling the `triggerGuide` dispatch method. The given tips will then appear one by one. + +A tip cannot be added to more than one guide. + +```jsx +dispatch( 'core/nux' ).triggerGuide( [ 'acme/product-info', 'acme/add-to-cart', 'acme/checkout' ] ); +``` + +## Getting information about a guide + +`getAssociatedGuide` is a select method that returns useful information about the state of the guide that a tip is associated with. + +```jsx +const guide = select( 'core/nux' ).getAssociatedGuide( 'acme/add-to-cart' ); +console.log( 'Tips in this guide:', guide.tipIds ); +console.log( 'Currently showing:', guide.currentTipId ); +console.log( 'Next to show:', guide.nextTipId ); +``` diff --git a/nux/components/dot-tip/README.md b/nux/components/dot-tip/README.md new file mode 100644 index 00000000000000..caf164760ffeca --- /dev/null +++ b/nux/components/dot-tip/README.md @@ -0,0 +1,31 @@ +DotTip +======== + +`DotTip` is a React component that renders a single _tip_ on the screen. The tip will point to the React element that `DotTip` is nested within. Each tip is uniquely identified by a string passed to `id`. + +## Usage + +```jsx + +} +``` + +## Props + +The component accepts the following props: + +### id + +A string that uniquely identifies the tip. Identifiers should be prefixed with the name of the plugin, followed by a `/`. For example, `acme/add-to-cart`. + +- Type: `string` +- Required: Yes + +### children + +Any React element or elements can be passed as children. They will be rendered within the tip bubble. diff --git a/nux/components/dot-tip/index.js b/nux/components/dot-tip/index.js new file mode 100644 index 00000000000000..8302a326626284 --- /dev/null +++ b/nux/components/dot-tip/index.js @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import { defer } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Component, createRef, compose } from '@wordpress/element'; +import { Popover, Button, IconButton } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { withSelect, withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import './style.scss'; + +export class DotTip extends Component { + constructor() { + super( ...arguments ); + + this.popoverRef = createRef(); + } + + componentDidMount() { + if ( this.props.isVisible ) { + // Force the popover to recalculate its position on the next frame. This is a + // Temporary workaround to fix the tip not appearing next to the inserter + // toggle on page load. This happens because the popover calculates its + // position before is made visible, resulting in the position + // being too high on the page. + defer( () => { + const popover = this.popoverRef.current; + const popoverSize = popover.updatePopoverSize(); + popover.computePopoverPosition( popoverSize ); + popover.focus(); + } ); + } + } + + render() { + const { children, isVisible, hasNextTip, onDismiss } = this.props; + + if ( ! isVisible ) { + return null; + } + + return ( + event.stopPropagation() } + > +

{ children }

+

+ +

+ +
+ ); + } +} + +export default compose( + withSelect( ( select, { id } ) => { + const { isTipVisible, getAssociatedGuide } = select( 'core/nux' ); + const associatedGuide = getAssociatedGuide( id ); + return { + isVisible: isTipVisible( id ), + hasNextTip: !! ( associatedGuide && associatedGuide.nextTipId ), + }; + } ), + withDispatch( ( dispatch, { id } ) => { + const { dismissTip } = dispatch( 'core/nux' ); + return { + onDismiss() { + dismissTip( id ); + }, + }; + } ), +)( DotTip ); diff --git a/nux/components/dot-tip/style.scss b/nux/components/dot-tip/style.scss new file mode 100644 index 00000000000000..7b84c5769a6d7e --- /dev/null +++ b/nux/components/dot-tip/style.scss @@ -0,0 +1,96 @@ +$dot-size: 8px; // Size of the indicator dot +$dot-scale: 3; // How much the pulse animation should scale up by in size + +.nux-dot-tip { + &:before, + &:after { + border-radius: 100%; + content: ' '; + pointer-events: none; + position: absolute; + } + + &:before { + animation: nux-pulse 1.6s infinite cubic-bezier( 0.17, 0.67, 0.92, 0.62 ); + background: rgba( $blue-medium-800, 0.9 ); + height: $dot-size * $dot-scale; + left: -( $dot-size * $dot-scale ) / 2; + top: -( $dot-size * $dot-scale ) / 2; + transform: scale( ( 1 / $dot-scale ) ); + width: $dot-size * $dot-scale + } + + &:after { + background: $blue-medium-800; + height: $dot-size; + left: -$dot-size / 2; + top: -$dot-size / 2; + width: $dot-size; + } + + @keyframes nux-pulse { + 100% { + background: rgba( $blue-medium-800, 0 ); + transform: scale( 1 ); + } + } + + .components-popover__content { + padding: 5px ( 36px + 5px ) 5px 20px; + width: 350px; + + @include break-small { + width: 450px; + } + + .nux-dot-tip__disable { + position: absolute; + right: 0; + top: 0; + } + } + + // Position the dot right next to the edge of the button + &.is-left { + margin-left: -$dot-size / 2; + } + &.is-right { + margin-left: $dot-size / 2; + } + + // Position the tip content away from the dot + &.is-top .components-popover__content { + margin-bottom: 20px; + } + &.is-bottom .components-popover__content { + margin-top: 20px; + } + &.is-middle.is-left .components-popover__content { + margin-right: 20px; + } + &.is-middle.is-right .components-popover__content { + margin-left: 20px; + } + + // Extra specificity so that we can override the styles in .component-popover + &:not( .is-mobile ).is-left, + &:not( .is-mobile ).is-center, + &:not( .is-mobile ).is-right { + + // Position tips above popovers + z-index: z-index( '.nux-dot-tip' ); + + // On mobile, always position the tip below the dot and fill the width of the viewport + @media ( max-width: $break-small ) { + .components-popover__content { + align-self: end; + left: 5px; + margin: 20px 0 0 0; + max-width: none !important; // Override the inline style set by + position: fixed; + right: 5px; + width: auto; + } + } + } +} diff --git a/nux/components/dot-tip/test/__snapshots__/index.js.snap b/nux/components/dot-tip/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000000..a3a6773f5261fe --- /dev/null +++ b/nux/components/dot-tip/test/__snapshots__/index.js.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DotTip should render correctly 1`] = ` + +

+ It looks like you’re writing a letter. Would you like help? +

+

+ +

+ +
+`; diff --git a/nux/components/dot-tip/test/index.js b/nux/components/dot-tip/test/index.js new file mode 100644 index 00000000000000..2f8bf4af2948a0 --- /dev/null +++ b/nux/components/dot-tip/test/index.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { DotTip } from '..'; + +describe( 'DotTip', () => { + it( 'should not render anything if invisible', () => { + const wrapper = shallow( + + It looks like you’re writing a letter. Would you like help? + + ); + expect( wrapper.isEmptyRender() ).toBe( true ); + } ); + + it( 'should render correctly', () => { + const wrapper = shallow( + + It looks like you’re writing a letter. Would you like help? + + ); + expect( wrapper ).toMatchSnapshot(); + } ); + + it( 'should call onDismiss when the dismiss button is clicked', () => { + const onDismiss = jest.fn(); + const wrapper = shallow( + + It looks like you’re writing a letter. Would you like help? + + ); + wrapper.find( 'Button[children="Got it"]' ).first().simulate( 'click' ); + expect( onDismiss ).toHaveBeenCalled(); + } ); + + it( 'should call onDismiss when the X button is clicked', () => { + const onDismiss = jest.fn(); + const wrapper = shallow( + + It looks like you’re writing a letter. Would you like help? + + ); + wrapper.find( 'IconButton[label="Dismiss tip"]' ).first().simulate( 'click' ); + expect( onDismiss ).toHaveBeenCalled(); + } ); +} ); diff --git a/nux/index.js b/nux/index.js new file mode 100644 index 00000000000000..a11d17bc96961a --- /dev/null +++ b/nux/index.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import './store'; + +export { default as DotTip } from './components/dot-tip'; diff --git a/nux/store/actions.js b/nux/store/actions.js new file mode 100644 index 00000000000000..95b14731ab06e0 --- /dev/null +++ b/nux/store/actions.js @@ -0,0 +1,41 @@ +/** + * Returns an action object that, when dispatched, presents a guide that takes + * the user through a series of tips step by step. + * + * @param {string[]} tipIds Which tips to show in the guide. + * + * @return {Object} Action object. + */ +export function triggerGuide( tipIds ) { + return { + type: 'TRIGGER_GUIDE', + tipIds, + }; +} + +/** + * Returns an action object that, when dispatched, dismisses the given tip. A + * dismissed tip will not show again. + * + * @param {string} id The tip to dismiss. + * + * @return {Object} Action object. + */ +export function dismissTip( id ) { + return { + type: 'DISMISS_TIP', + id, + }; +} + +/** + * Returns an action object that, when dispatched, prevents all tips from + * showing again. + * + * @return {Object} Action object. + */ +export function disableTips() { + return { + type: 'DISABLE_TIPS', + }; +} diff --git a/nux/store/index.js b/nux/store/index.js new file mode 100644 index 00000000000000..3d387c76f8f5fc --- /dev/null +++ b/nux/store/index.js @@ -0,0 +1,24 @@ +/** + * WordPress dependencies + */ +import { registerStore, withRehydration, loadAndPersist } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as actions from './actions'; +import * as selectors from './selectors'; + +const REDUCER_KEY = 'preferences'; +const STORAGE_KEY = `GUTENBERG_NUX_${ window.userSettings.uid }`; + +const store = registerStore( 'core/nux', { + reducer: withRehydration( reducer, REDUCER_KEY, STORAGE_KEY ), + actions, + selectors, +} ); + +loadAndPersist( store, reducer, REDUCER_KEY, STORAGE_KEY ); + +export default store; diff --git a/nux/store/reducer.js b/nux/store/reducer.js new file mode 100644 index 00000000000000..e8ba1abb7b0a5a --- /dev/null +++ b/nux/store/reducer.js @@ -0,0 +1,67 @@ +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; + +/** + * Reducer that tracks which tips are in a guide. Each guide is represented by + * an array which contains the tip identifiers contained within that guide. + * + * @param {Array} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Array} Updated state. + */ +export function guides( state = [], action ) { + switch ( action.type ) { + case 'TRIGGER_GUIDE': + return [ + ...state, + action.tipIds, + ]; + } + + return state; +} + +/** + * Reducer that tracks whether or not tips are globally disabled. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function areTipsDisabled( state = false, action ) { + switch ( action.type ) { + case 'DISABLE_TIPS': + return true; + } + + return state; +} + +/** + * Reducer that tracks which tips have been dismissed. If the state object + * contains a tip identifier, then that tip is dismissed. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function dismissedTips( state = {}, action ) { + switch ( action.type ) { + case 'DISMISS_TIP': + return { + ...state, + [ action.id ]: true, + }; + } + + return state; +} + +const preferences = combineReducers( { areTipsDisabled, dismissedTips } ); + +export default combineReducers( { guides, preferences } ); diff --git a/nux/store/selectors.js b/nux/store/selectors.js new file mode 100644 index 00000000000000..bf7365df9f27f2 --- /dev/null +++ b/nux/store/selectors.js @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; +import { includes, difference, keys } from 'lodash'; + +/** + * An object containing information about a guide. + * + * @typedef {Object} NUX.GuideInfo + * @property {string[]} tipIds Which tips the guide contains. + * @property {?string} currentTipId The guide's currently showing tip. + * @property {?string} nextTipId The guide's next tip to show. + */ + +/** + * Returns an object describing the guide, if any, that the given tip is a part + * of. + * + * @param {Object} state Global application state. + * @param {string} tipId The tip to query. + * + * @return {?NUX.GuideInfo} Information about the associated guide. + */ +export const getAssociatedGuide = createSelector( + ( state, tipId ) => { + for ( const tipIds of state.guides ) { + if ( includes( tipIds, tipId ) ) { + const nonDismissedTips = difference( tipIds, keys( state.preferences.dismissedTips ) ); + const [ currentTipId = null, nextTipId = null ] = nonDismissedTips; + return { tipIds, currentTipId, nextTipId }; + } + } + + return null; + }, + ( state ) => [ + state.guides, + state.preferences.dismissedTips, + ], +); + +/** + * Determines whether or not the given tip is showing. Tips are hidden if they + * are disabled, have been dismissed, or are not the current tip in any + * guide that they have been added to. + * + * @param {Object} state Global application state. + * @param {string} id The tip to query. + * + * @return {boolean} Whether or not the given tip is showing. + */ +export function isTipVisible( state, id ) { + if ( state.preferences.areTipsDisabled ) { + return false; + } + + if ( state.preferences.dismissedTips[ id ] ) { + return false; + } + + const associatedGuide = getAssociatedGuide( state, id ); + if ( associatedGuide && associatedGuide.currentTipId !== id ) { + return false; + } + + return true; +} diff --git a/nux/store/test/actions.js b/nux/store/test/actions.js new file mode 100644 index 00000000000000..1aa4d0cf6f2e34 --- /dev/null +++ b/nux/store/test/actions.js @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +import { triggerGuide, dismissTip, disableTips } from '../actions'; + +describe( 'actions', () => { + describe( 'triggerGuide', () => { + it( 'should return a TRIGGER_GUIDE action', () => { + expect( triggerGuide( [ 'test/tip-1', 'test/tip-2' ] ) ).toEqual( { + type: 'TRIGGER_GUIDE', + tipIds: [ 'test/tip-1', 'test/tip-2' ], + } ); + } ); + } ); + + describe( 'dismissTip', () => { + it( 'should return an DISMISS_TIP action', () => { + expect( dismissTip( 'test/tip' ) ).toEqual( { + type: 'DISMISS_TIP', + id: 'test/tip', + } ); + } ); + } ); + + describe( 'disableTips', () => { + it( 'should return an DISABLE_TIPS action', () => { + expect( disableTips() ).toEqual( { + type: 'DISABLE_TIPS', + } ); + } ); + } ); +} ); diff --git a/nux/store/test/reducer.js b/nux/store/test/reducer.js new file mode 100644 index 00000000000000..a40fabdf49f84f --- /dev/null +++ b/nux/store/test/reducer.js @@ -0,0 +1,51 @@ +/** + * Internal dependencies + */ +import { guides, areTipsDisabled, dismissedTips } from '../reducer'; + +describe( 'reducer', () => { + describe( 'guides', () => { + it( 'should start out empty', () => { + expect( guides( undefined, {} ) ).toEqual( [] ); + } ); + + it( 'should add a guide when it is triggered', () => { + const state = guides( [], { + type: 'TRIGGER_GUIDE', + tipIds: [ 'test/tip-1', 'test/tip-2' ], + } ); + expect( state ).toEqual( [ + [ 'test/tip-1', 'test/tip-2' ], + ] ); + } ); + } ); + + describe( 'areTipsDisabled', () => { + it( 'should default to false', () => { + expect( areTipsDisabled( undefined, {} ) ).toBe( false ); + } ); + + it( 'should flip when tips are disabled', () => { + const state = areTipsDisabled( false, { + type: 'DISABLE_TIPS', + } ); + expect( state ).toBe( true ); + } ); + } ); + + describe( 'dismissedTips', () => { + it( 'should start out empty', () => { + expect( dismissedTips( undefined, {} ) ).toEqual( {} ); + } ); + + it( 'should mark tips as dismissed', () => { + const state = dismissedTips( {}, { + type: 'DISMISS_TIP', + id: 'test/tip', + } ); + expect( state ).toEqual( { + 'test/tip': true, + } ); + } ); + } ); +} ); diff --git a/nux/store/test/selectors.js b/nux/store/test/selectors.js new file mode 100644 index 00000000000000..08f6c7eea38f66 --- /dev/null +++ b/nux/store/test/selectors.js @@ -0,0 +1,104 @@ +/** + * Internal dependencies + */ +import { getAssociatedGuide, isTipVisible } from '../selectors'; + +describe( 'selectors', () => { + describe( 'getAssociatedGuide', () => { + const state = { + guides: [ + [ 'test/tip-1', 'test/tip-2', 'test/tip-3' ], + [ 'test/tip-a', 'test/tip-b', 'test/tip-c' ], + [ 'test/tip-α', 'test/tip-β', 'test/tip-γ' ], + ], + preferences: { + dismissedTips: { + 'test/tip-1': true, + 'test/tip-a': true, + 'test/tip-b': true, + 'test/tip-α': true, + 'test/tip-β': true, + 'test/tip-γ': true, + }, + }, + }; + + it( 'should return null when there is no associated guide', () => { + expect( getAssociatedGuide( state, 'test/unknown' ) ).toBeNull(); + } ); + + it( 'should return the associated guide', () => { + expect( getAssociatedGuide( state, 'test/tip-2' ) ).toEqual( { + tipIds: [ 'test/tip-1', 'test/tip-2', 'test/tip-3' ], + currentTipId: 'test/tip-2', + nextTipId: 'test/tip-3', + } ); + } ); + + it( 'should indicate when there is no next tip', () => { + expect( getAssociatedGuide( state, 'test/tip-b' ) ).toEqual( { + tipIds: [ 'test/tip-a', 'test/tip-b', 'test/tip-c' ], + currentTipId: 'test/tip-c', + nextTipId: null, + } ); + } ); + + it( 'should indicate when there is no current or next tip', () => { + expect( getAssociatedGuide( state, 'test/tip-β' ) ).toEqual( { + tipIds: [ 'test/tip-α', 'test/tip-β', 'test/tip-γ' ], + currentTipId: null, + nextTipId: null, + } ); + } ); + } ); + + describe( 'isTipVisible', () => { + it( 'should return true by default', () => { + const state = { + guides: [], + preferences: { + areTipsDisabled: false, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( true ); + } ); + + it( 'should return false if tips are disabled', () => { + const state = { + guides: [], + preferences: { + areTipsDisabled: true, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( false ); + } ); + + it( 'should return false if the tip is dismissed', () => { + const state = { + guides: [], + preferences: { + areTipsDisabled: false, + dismissedTips: { + 'test/tip': true, + }, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( false ); + } ); + + it( 'should return false if the tip is in a guide and it is not the current tip', () => { + const state = { + guides: [ + [ 'test/tip-1', 'test/tip-2', 'test/tip-3' ], + ], + preferences: { + areTipsDisabled: false, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip-2' ) ).toBe( false ); + } ); + } ); +} ); diff --git a/test/e2e/support/utils.js b/test/e2e/support/utils.js index 1d360600ec2250..5f33dfcf6e8543 100644 --- a/test/e2e/support/utils.js +++ b/test/e2e/support/utils.js @@ -68,6 +68,11 @@ export async function visitAdmin( adminPath, query ) { export async function newPost( postType ) { await visitAdmin( 'post-new.php', postType ? 'post_type=' + postType : '' ); + + // Disable new user tips so that their UI doesn't get in the way + await page.evaluate( () => { + wp.data.dispatch( 'core/nux' ).disableTips(); + } ); } export async function newDesktopBrowserPage() { diff --git a/test/unit/jest.config.json b/test/unit/jest.config.json index 1c98a829e06693..d661ae82050172 100644 --- a/test/unit/jest.config.json +++ b/test/unit/jest.config.json @@ -1,11 +1,11 @@ { "rootDir": "../../", "collectCoverageFrom": [ - "(blocks|components|editor|utils|edit-post|viewport|plugins|core-data|core-blocks)/**/*.js", + "(blocks|components|editor|utils|edit-post|viewport|plugins|core-data|core-blocks|nux)/**/*.js", "packages/**/*.js" ], "moduleNameMapper": { - "@wordpress\\/(blocks|components|editor|utils|edit-post|viewport|plugins|core-data|core-blocks)$": "$1", + "@wordpress\\/(blocks|components|editor|utils|edit-post|viewport|plugins|core-data|core-blocks|nux)$": "$1", "@wordpress\\/(blob|data|date|dom|deprecated|element|postcss-themes)$": "packages/$1/src" }, "preset": "@wordpress/jest-preset-default", diff --git a/webpack.config.js b/webpack.config.js index 00b29959d41a39..6e488bc4edafeb 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -146,6 +146,7 @@ const entryPointNames = [ 'plugins', 'edit-post', 'core-blocks', + 'nux', ]; const gutenbergPackages = [