diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 37d1abb5256f2a..cca62c72dac1df 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -85,6 +85,7 @@ /packages/compose @ajitbohra /packages/element @ajitbohra /packages/notices @ajitbohra +/packages/nux @ajitbohra @peterwilsoncc /packages/viewport @ajitbohra /packages/base-styles /packages/icons diff --git a/docs/contributors/code/scripts.md b/docs/contributors/code/scripts.md index 5cd7efd2fffdad..1483a409a4d08f 100644 --- a/docs/contributors/code/scripts.md +++ b/docs/contributors/code/scripts.md @@ -31,6 +31,7 @@ The editor includes a number of packages to enable various pieces of functionali | [Is Shallow Equal](/packages/is-shallow-equal/README.md) | wp-is-shallow-equal | A function for performing a shallow comparison between two objects or arrays | | [Keycodes](/packages/keycodes/README.md) | wp-keycodes | Keycodes utilities for WordPress, used to check the key pressed in events like `onKeyDown` | | [List Reusable blocks](/packages/list-reusable-blocks/README.md) | wp-list-reusable-blocks | Package used to add import/export links to the listing page of the reusable blocks | +| [NUX](/packages/nux/README.md) | wp-nux | Components, and wp.data methods useful for onboarding a new user to the WordPress admin interface | | [Plugins](/packages/plugins/README.md) | wp-plugins | Plugins module for WordPress | | [Redux Routine](/packages/redux-routine/README.md) | wp-redux-routine | Redux middleware for generator coroutines | | [Rich Text](/packages/rich-text/README.md) | wp-rich-text | Helper functions to convert HTML or a DOM tree into a rich text value and back | diff --git a/docs/manifest.json b/docs/manifest.json index 8a21bc38a54189..6671963966df62 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1745,6 +1745,12 @@ "markdown_source": "../packages/npm-package-json-lint-config/README.md", "parent": "packages" }, + { + "title": "@wordpress/nux", + "slug": "packages-nux", + "markdown_source": "../packages/nux/README.md", + "parent": "packages" + }, { "title": "@wordpress/plugins", "slug": "packages-plugins", @@ -1991,6 +1997,12 @@ "markdown_source": "../docs/reference-guides/data/data-core-notices.md", "parent": "data" }, + { + "title": "The NUX (New User Experience) Data", + "slug": "data-core-nux", + "markdown_source": "../docs/reference-guides/data/data-core-nux.md", + "parent": "data" + }, { "title": "Preferences", "slug": "data-core-preferences", diff --git a/docs/reference-guides/README.md b/docs/reference-guides/README.md index f13c838697f2de..33fdd9aa602414 100644 --- a/docs/reference-guides/README.md +++ b/docs/reference-guides/README.md @@ -63,6 +63,7 @@ - [**core/editor**: The Post Editor’s Data](/docs/reference-guides/data/data-core-editor.md) - [**core/keyboard-shortcuts**: The Keyboard Shortcuts Data](/docs/reference-guides/data/data-core-keyboard-shortcuts.md) - [**core/notices**: Notices Data](/docs/reference-guides/data/data-core-notices.md) + - [**core/nux**: The NUX (New User Experience) Data](/docs/reference-guides/data/data-core-nux.md) - [**core/preferences**: Preferences](/docs/reference-guides/data/data-core-preferences.md) - [**core/reusable-blocks**: Reusable blocks](/docs/reference-guides/data/data-core-reusable-blocks.md) - [**core/rich-text**: Rich Text](/docs/reference-guides/data/data-core-rich-text.md) diff --git a/docs/reference-guides/data/README.md b/docs/reference-guides/data/README.md index 5f4d8d92d4bd49..1134c1d5ddd307 100644 --- a/docs/reference-guides/data/README.md +++ b/docs/reference-guides/data/README.md @@ -12,6 +12,7 @@ - [**core/editor**: The Post Editor’s Data](/docs/reference-guides/data/data-core-editor.md) - [**core/keyboard-shortcuts**: The Keyboard Shortcuts Data](/docs/reference-guides/data/data-core-keyboard-shortcuts.md) - [**core/notices**: Notices Data](/docs/reference-guides/data/data-core-notices.md) +- [**core/nux**: The NUX (New User Experience) Data](/docs/reference-guides/data/data-core-nux.md) - [**core/preferences**: Preferences](/docs/reference-guides/data/data-core-preferences.md) - [**core/reusable-blocks**: Reusable blocks](/docs/reference-guides/data/data-core-reusable-blocks.md) - [**core/rich-text**: Rich Text](/docs/reference-guides/data/data-core-rich-text.md) diff --git a/docs/reference-guides/data/data-core-nux.md b/docs/reference-guides/data/data-core-nux.md new file mode 100644 index 00000000000000..eb6a1c3b5c9a5b --- /dev/null +++ b/docs/reference-guides/data/data-core-nux.md @@ -0,0 +1,93 @@ +# The NUX (New User Experience) Data + +Namespace: `core/nux`. + +## Selectors + + + +### areTipsEnabled + +Returns whether or not tips are globally enabled. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether tips are globally enabled. + +### getAssociatedGuide + +Returns an object describing the guide, if any, that the given tip is a part of. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _tipId_ `string`: The tip to query. + +_Returns_ + +- `?NUXGuideInfo`: Information about the associated guide. + +### isTipVisible + +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. + +_Parameters_ + +- _state_ `Object`: Global application state. +- _tipId_ `string`: The tip to query. + +_Returns_ + +- `boolean`: Whether or not the given tip is showing. + + + +## Actions + + + +### disableTips + +Returns an action object that, when dispatched, prevents all tips from showing again. + +_Returns_ + +- `Object`: Action object. + +### dismissTip + +Returns an action object that, when dispatched, dismisses the given tip. A dismissed tip will not show again. + +_Parameters_ + +- _id_ `string`: The tip to dismiss. + +_Returns_ + +- `Object`: Action object. + +### enableTips + +Returns an action object that, when dispatched, makes all tips show again. + +_Returns_ + +- `Object`: Action object. + +### triggerGuide + +Returns an action object that, when dispatched, presents a guide that takes the user through a series of tips step by step. + +_Parameters_ + +- _tipIds_ `string[]`: Which tips to show in the guide. + +_Returns_ + +- `Object`: Action object. + + diff --git a/docs/toc.json b/docs/toc.json index 085bbb536ece2b..1660afdcc29497 100644 --- a/docs/toc.json +++ b/docs/toc.json @@ -284,6 +284,7 @@ "docs/reference-guides/data/data-core-keyboard-shortcuts.md": [] }, { "docs/reference-guides/data/data-core-notices.md": [] }, + { "docs/reference-guides/data/data-core-nux.md": [] }, { "docs/reference-guides/data/data-core-preferences.md": [] }, diff --git a/lib/client-assets.php b/lib/client-assets.php index 9757e4b7ff24a8..2c1e7b8b33cc42 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -362,6 +362,15 @@ function gutenberg_register_packages_styles( $styles ) { ); $styles->add_data( 'wp-edit-blocks', 'rtl', 'replace' ); + gutenberg_override_style( + $styles, + 'wp-nux', + gutenberg_url( 'build/nux/style.css' ), + array( 'wp-components' ), + $version + ); + $styles->add_data( 'wp-nux', 'rtl', 'replace' ); + gutenberg_override_style( $styles, 'wp-block-library-theme', diff --git a/package-lock.json b/package-lock.json index 59efa6cc364b6f..3c39cac1f8dcee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18248,6 +18248,20 @@ "version": "file:packages/npm-package-json-lint-config", "dev": true }, + "@wordpress/nux": { + "version": "file:packages/nux", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/components": "file:packages/components", + "@wordpress/compose": "file:packages/compose", + "@wordpress/data": "file:packages/data", + "@wordpress/deprecated": "file:packages/deprecated", + "@wordpress/element": "file:packages/element", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/icons": "file:packages/icons", + "rememo": "^4.0.2" + } + }, "@wordpress/plugins": { "version": "file:packages/plugins", "requires": { @@ -30871,7 +30885,7 @@ "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", "dev": true }, "cssesc": { @@ -41428,7 +41442,7 @@ "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", - "integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", "dev": true }, "macos-release": { diff --git a/package.json b/package.json index 8808dcc7370b85..783fa24775544c 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@wordpress/list-reusable-blocks": "file:packages/list-reusable-blocks", "@wordpress/media-utils": "file:packages/media-utils", "@wordpress/notices": "file:packages/notices", + "@wordpress/nux": "file:packages/nux", "@wordpress/plugins": "file:packages/plugins", "@wordpress/preferences": "file:packages/preferences", "@wordpress/preferences-persistence": "file:packages/preferences-persistence", diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index cc4a42df98f0aa..7987e1b4b5b4dc 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -144,7 +144,10 @@ $z-layers: ( // The focus styles of the region navigation containers should be above their content. ".is-focusing-regions {region} :focus::after": 1000000, - // Show tooltips above wp-admin menus, submenus, and sidebar: + // 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, // Keep template popover underneath 'Create custom template' modal overlay. diff --git a/packages/nux/.npmrc b/packages/nux/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/nux/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/nux/CHANGELOG.md b/packages/nux/CHANGELOG.md new file mode 100644 index 00000000000000..edbf4c88a21f0f --- /dev/null +++ b/packages/nux/CHANGELOG.md @@ -0,0 +1,124 @@ + + +## Unreleased + +### Breaking Changes + +- Updated dependencies to require React 18 ([45235](https://github.com/WordPress/gutenberg/pull/45235)) + +## 5.20.0 (2022-11-16) + +## 5.19.0 (2022-11-02) + +## 5.18.0 (2022-10-19) + +## 5.17.0 (2022-10-05) + +## 5.16.0 (2022-09-21) + +## 5.15.0 (2022-09-13) + +## 5.14.0 (2022-08-24) + +## 5.13.0 (2022-08-10) + +## 5.12.0 (2022-07-27) + +## 5.11.0 (2022-07-13) + +## 5.10.0 (2022-06-29) + +## 5.9.0 (2022-06-15) + +## 5.8.0 (2022-06-01) + +## 5.7.0 (2022-05-18) + +## 5.6.0 (2022-05-04) + +## 5.5.0 (2022-04-21) + +## 5.4.0 (2022-04-08) + +## 5.3.0 (2022-03-23) + +## 5.2.0 (2022-03-11) + +## 5.1.0 (2022-01-27) + +## 5.0.0 (2021-07-29) + +### Breaking Change + +- Upgraded React components to work with v17.0 ([#29118](https://github.com/WordPress/gutenberg/pull/29118)). There are no new features in React v17.0 as explained in the [blog post](https://reactjs.org/blog/2020/10/20/react-v17.html). + +## 4.2.0 (2021-07-21) + +## 4.1.0 (2021-05-20) + +## 4.0.0 (2021-05-14) + +### Breaking Changes + +- Drop support for Internet Explorer 11 ([#31110](https://github.com/WordPress/gutenberg/pull/31110)). Learn more at https://make.wordpress.org/core/2021/04/22/ie-11-support-phase-out-plan/. +- Increase the minimum Node.js version to v12 matching Long Term Support releases ([#31270](https://github.com/WordPress/gutenberg/pull/31270)). Learn more at https://nodejs.org/en/about/releases/. + +## 3.25.0 (2021-03-17) + +## 3.24.0 (2020-12-17) + +### New Feature + +- Added a store definition `store` for the core data namespace to use with `@wordpress/data` API ([#26655](https://github.com/WordPress/gutenberg/pull/26655)). + +# 3.1.0 (2019-06-03) + +- The `@wordpress/nux` package has been deprecated. Please use the `Guide` component in `@wordpress/components` to show a user guide. + +## 3.0.6 (2019-01-03) + +## 3.0.5 (2018-12-12) + +## 3.0.4 (2018-11-30) + +## 3.0.3 (2018-11-22) + +## 3.0.2 (2018-11-21) + +## 3.0.1 (2018-11-20) + +## 3.0.0 (2018-11-15) + +### Breaking Changes + +- The id prop of DotTip has been removed. Please use the tipId prop instead. + +## 2.0.13 (2018-11-12) + +## 2.0.12 (2018-11-12) + +## 2.0.11 (2018-11-09) + +## 2.0.10 (2018-11-09) + +## 2.0.9 (2018-11-03) + +## 2.0.8 (2018-10-30) + +## 2.0.7 (2018-10-29) + +### Deprecations + +- The id prop of DotTip has been deprecated. Please use the tipId prop instead. + +## 2.0.6 (2018-10-22) + +## 2.0.5 (2018-10-19) + +## 2.0.4 (2018-10-18) + +## 2.0.0 (2018-09-05) + +### Breaking Change + +- Change how required built-ins are polyfilled with Babel 7 ([#9171](https://github.com/WordPress/gutenberg/pull/9171)). If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. diff --git a/packages/nux/README.md b/packages/nux/README.md new file mode 100644 index 00000000000000..c0941ddd0c5f2a --- /dev/null +++ b/packages/nux/README.md @@ -0,0 +1,114 @@ +# New User eXperience (NUX) + +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 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. + +## Installation + +Install the module + +```bash +npm install @wordpress/nux --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ + +## 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 `tipId`. + +See [the component's README][dot-tip-readme] for more information. + +[dot-tip-readme]: https://github.com/WordPress/gutenberg/tree/HEAD/packages/nux/src/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 + +``` + +## Disabling and enabling tips + +Tips can be programatically disabled or enabled using the `disableTips` and `enableTips` dispatch methods. You can query the current setting by using the `areTipsEnabled` select method. + +Calling `enableTips` will also un-dismiss all previously dismissed tips. + +```jsx +const areTipsEnabled = select( 'core/nux' ).areTipsEnabled(); +return ( + +); +``` + +## 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 ); +``` + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/nux/package.json b/packages/nux/package.json new file mode 100644 index 00000000000000..dbf78ef422a3b9 --- /dev/null +++ b/packages/nux/package.json @@ -0,0 +1,50 @@ +{ + "name": "@wordpress/nux", + "version": "7.0.0", + "description": "NUX (New User eXperience) module for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "nux" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/nux/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/nux" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": [ + "build-style/**", + "src/**/*.scss", + "{src,build,build-module}/{index.js,store/index.js}" + ], + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/components": "file:../components", + "@wordpress/compose": "file:../compose", + "@wordpress/data": "file:../data", + "@wordpress/deprecated": "file:../deprecated", + "@wordpress/element": "file:../element", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "rememo": "^4.0.2" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/nux/src/components/dot-tip/README.md b/packages/nux/src/components/dot-tip/README.md new file mode 100644 index 00000000000000..f143a22a222588 --- /dev/null +++ b/packages/nux/src/components/dot-tip/README.md @@ -0,0 +1,38 @@ +# 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 `tipId`. + +## Usage + +```jsx + +} +``` + +## Props + +The component accepts the following props: + +### tipId + +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 + +### position + +The direction in which the popover should open relative to its parent node. Specify y- and x-axis as a space-separated string. Supports `"top"`, `"middle"`, `"bottom"` y axis, and `"left"`, `"center"`, `"right"` x axis. + +- Type: `String` +- Required: No +- Default: `"middle right"` + +### children + +Any React element or elements can be passed as children. They will be rendered within the tip bubble. diff --git a/packages/nux/src/components/dot-tip/index.js b/packages/nux/src/components/dot-tip/index.js new file mode 100644 index 00000000000000..50de7ddb3be9df --- /dev/null +++ b/packages/nux/src/components/dot-tip/index.js @@ -0,0 +1,93 @@ +/** + * WordPress dependencies + */ +import { compose } from '@wordpress/compose'; +import { Popover, Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { useCallback, useRef } from '@wordpress/element'; +import { close } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { store as nuxStore } from '../../store'; + +function onClick( event ) { + // Tips are often nested within buttons. We stop propagation so that clicking + // on a tip doesn't result in the button being clicked. + event.stopPropagation(); +} + +export function DotTip( { + position = 'middle right', + children, + isVisible, + hasNextTip, + onDismiss, + onDisable, +} ) { + const anchorParent = useRef( null ); + const onFocusOutsideCallback = useCallback( + ( event ) => { + if ( ! anchorParent.current ) { + return; + } + if ( anchorParent.current.contains( event.relatedTarget ) ) { + return; + } + onDisable(); + }, + [ onDisable, anchorParent ] + ); + if ( ! isVisible ) { + return null; + } + + return ( + +

{ children }

+

+ +

+ +

+ + + +`; diff --git a/packages/nux/src/components/dot-tip/test/index.js b/packages/nux/src/components/dot-tip/test/index.js new file mode 100644 index 00000000000000..ff92e1fde04d5e --- /dev/null +++ b/packages/nux/src/components/dot-tip/test/index.js @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import { DotTip } from '..'; + +const noop = () => {}; + +describe( 'DotTip', () => { + beforeEach( () => { + jest.useFakeTimers(); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + it( 'should not render anything if invisible', async () => { + render( + + It looks like you’re writing a letter. Would you like help? + + ); + + await act( () => Promise.resolve() ); + + expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument(); + } ); + + it( 'should render correctly', async () => { + render( + + It looks like you’re writing a letter. Would you like help? + + ); + + await act( () => Promise.resolve() ); + + expect( screen.getByRole( 'dialog' ) ).toMatchSnapshot(); + } ); + + it( 'should call onDismiss when the dismiss button is clicked', async () => { + const user = userEvent.setup( { + advanceTimers: jest.advanceTimersByTime, + } ); + const onDismiss = jest.fn(); + + render( + + It looks like you’re writing a letter. Would you like help? + + ); + + await act( () => Promise.resolve() ); + + await user.click( screen.getByRole( 'button', { name: 'Got it' } ) ); + + expect( onDismiss ).toHaveBeenCalled(); + } ); + + it( 'should call onDisable when the X button is clicked', async () => { + const user = userEvent.setup( { + advanceTimers: jest.advanceTimersByTime, + } ); + const onDisable = jest.fn(); + + render( + + It looks like you’re writing a letter. Would you like help? + + ); + + await act( () => Promise.resolve() ); + + await user.click( + screen.getByRole( 'button', { name: 'Disable tips' } ) + ); + + expect( onDisable ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/nux/src/index.js b/packages/nux/src/index.js new file mode 100644 index 00000000000000..a0b3e073503750 --- /dev/null +++ b/packages/nux/src/index.js @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; + +export { store } from './store'; +export { default as DotTip } from './components/dot-tip'; + +deprecated( 'wp.nux', { + since: '5.4', + hint: 'wp.components.Guide can be used to show a user guide.', + version: '6.2', +} ); diff --git a/packages/nux/src/store/actions.js b/packages/nux/src/store/actions.js new file mode 100644 index 00000000000000..ad8adb79c5530d --- /dev/null +++ b/packages/nux/src/store/actions.js @@ -0,0 +1,52 @@ +/** + * 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', + }; +} + +/** + * Returns an action object that, when dispatched, makes all tips show again. + * + * @return {Object} Action object. + */ +export function enableTips() { + return { + type: 'ENABLE_TIPS', + }; +} diff --git a/packages/nux/src/store/index.js b/packages/nux/src/store/index.js new file mode 100644 index 00000000000000..39fef6c78c7911 --- /dev/null +++ b/packages/nux/src/store/index.js @@ -0,0 +1,36 @@ +/** + * WordPress dependencies + */ +import { createReduxStore, registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as actions from './actions'; +import * as selectors from './selectors'; + +const STORE_NAME = 'core/nux'; + +/** + * Store definition for the nux namespace. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore + * + * @type {Object} + */ +export const store = createReduxStore( STORE_NAME, { + reducer, + actions, + selectors, + persist: [ 'preferences' ], +} ); + +// Once we build a more generic persistence plugin that works across types of stores +// we'd be able to replace this with a register call. +registerStore( STORE_NAME, { + reducer, + actions, + selectors, + persist: [ 'preferences' ], +} ); diff --git a/packages/nux/src/store/reducer.js b/packages/nux/src/store/reducer.js new file mode 100644 index 00000000000000..373e4781f52353 --- /dev/null +++ b/packages/nux/src/store/reducer.js @@ -0,0 +1,70 @@ +/** + * 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 enabled. + * + * @param {boolean} state Current state. + * @param {Object} action Dispatched action. + * + * @return {boolean} Updated state. + */ +export function areTipsEnabled( state = true, action ) { + switch ( action.type ) { + case 'DISABLE_TIPS': + return false; + + case 'ENABLE_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, + }; + + case 'ENABLE_TIPS': + return {}; + } + + return state; +} + +const preferences = combineReducers( { areTipsEnabled, dismissedTips } ); + +export default combineReducers( { guides, preferences } ); diff --git a/packages/nux/src/store/selectors.js b/packages/nux/src/store/selectors.js new file mode 100644 index 00000000000000..e87cf688a1ba32 --- /dev/null +++ b/packages/nux/src/store/selectors.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; + +/** + * An object containing information about a guide. + * + * @typedef {Object} NUXGuideInfo + * @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 {?NUXGuideInfo} Information about the associated guide. + */ +export const getAssociatedGuide = createSelector( + ( state, tipId ) => { + for ( const tipIds of state.guides ) { + if ( tipIds.includes( tipId ) ) { + const nonDismissedTips = tipIds.filter( + ( tId ) => + ! Object.keys( + state.preferences.dismissedTips + ).includes( tId ) + ); + 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} tipId The tip to query. + * + * @return {boolean} Whether or not the given tip is showing. + */ +export function isTipVisible( state, tipId ) { + if ( ! state.preferences.areTipsEnabled ) { + return false; + } + + if ( state.preferences.dismissedTips?.hasOwnProperty( tipId ) ) { + return false; + } + + const associatedGuide = getAssociatedGuide( state, tipId ); + if ( associatedGuide && associatedGuide.currentTipId !== tipId ) { + return false; + } + + return true; +} + +/** + * Returns whether or not tips are globally enabled. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether tips are globally enabled. + */ +export function areTipsEnabled( state ) { + return state.preferences.areTipsEnabled; +} diff --git a/packages/nux/src/store/test/actions.js b/packages/nux/src/store/test/actions.js new file mode 100644 index 00000000000000..4e22afe03c8b82 --- /dev/null +++ b/packages/nux/src/store/test/actions.js @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import { triggerGuide, dismissTip, disableTips, enableTips } 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', + } ); + } ); + } ); + + describe( 'enableTips', () => { + it( 'should return an ENABLE_TIPS action', () => { + expect( enableTips() ).toEqual( { + type: 'ENABLE_TIPS', + } ); + } ); + } ); +} ); diff --git a/packages/nux/src/store/test/reducer.js b/packages/nux/src/store/test/reducer.js new file mode 100644 index 00000000000000..49172442d8f379 --- /dev/null +++ b/packages/nux/src/store/test/reducer.js @@ -0,0 +1,69 @@ +/** + * Internal dependencies + */ +import { guides, areTipsEnabled, 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( 'areTipsEnabled', () => { + it( 'should default to true', () => { + expect( areTipsEnabled( undefined, {} ) ).toBe( true ); + } ); + + it( 'should flip when tips are disabled', () => { + const state = areTipsEnabled( true, { + type: 'DISABLE_TIPS', + } ); + expect( state ).toBe( false ); + } ); + + it( 'should flip when tips are enabled', () => { + const state = areTipsEnabled( false, { + type: 'ENABLE_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, + } ); + } ); + + it( 'should reset if tips are enabled', () => { + const initialState = { + 'test/tip': true, + }; + const state = dismissedTips( initialState, { + type: 'ENABLE_TIPS', + } ); + expect( state ).toEqual( {} ); + } ); + } ); +} ); diff --git a/packages/nux/src/store/test/selectors.js b/packages/nux/src/store/test/selectors.js new file mode 100644 index 00000000000000..e2a06c74e08b68 --- /dev/null +++ b/packages/nux/src/store/test/selectors.js @@ -0,0 +1,146 @@ +/** + * Internal dependencies + */ +import { getAssociatedGuide, isTipVisible, areTipsEnabled } 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( 'is tolerant to individual preferences being undefined', () => { + // See: https://github.com/WordPress/gutenberg/issues/14580 + const state = { + guides: [], + preferences: {}, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( false ); + } ); + + it( 'is tolerant to undefined dismissedTips', () => { + // See: https://github.com/WordPress/gutenberg/issues/14580 + const state = { + guides: [], + preferences: { + areTipsEnabled: true, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( true ); + } ); + + it( 'should return true by default', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: true, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( true ); + } ); + + it( 'should return false if tips are disabled', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: false, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip' ) ).toBe( false ); + } ); + + it( 'should return false if the tip is dismissed', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: true, + 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: { + areTipsEnabled: true, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip-2' ) ).toBe( false ); + } ); + } ); + + describe( 'areTipsEnabled', () => { + it( 'should return true if tips are enabled', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: true, + dismissedTips: {}, + }, + }; + expect( areTipsEnabled( state ) ).toBe( true ); + } ); + + it( 'should return false if tips are disabled', () => { + const state = { + guides: [], + preferences: { + areTipsEnabled: false, + dismissedTips: {}, + }, + }; + expect( areTipsEnabled( state ) ).toBe( false ); + } ); + } ); +} ); diff --git a/packages/nux/src/style.scss b/packages/nux/src/style.scss new file mode 100644 index 00000000000000..0df73ff851e9f9 --- /dev/null +++ b/packages/nux/src/style.scss @@ -0,0 +1 @@ +@import "./components/dot-tip/style.scss"; diff --git a/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json b/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json index 28c0c1b17b4adc..f9cee4142d11ac 100644 --- a/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json +++ b/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json @@ -13,5 +13,11 @@ "hiddenBlockTypes": [], "preferredStyleVariations": {} } + }, + "core/nux": { + "preferences": { + "areTipsEnabled": false, + "dismissedTips": {} + } } }