diff --git a/packages/editor/src/components/default-block-appender/test/__snapshots__/index.js.snap b/packages/editor/src/components/default-block-appender/test/__snapshots__/index.js.snap index 13ff5d0983c1cf..86ff2fe9f55ef7 100644 --- a/packages/editor/src/components/default-block-appender/test/__snapshots__/index.js.snap +++ b/packages/editor/src/components/default-block-appender/test/__snapshots__/index.js.snap @@ -42,11 +42,11 @@ exports[`DefaultBlockAppender should append a default block when input focused 1 - Welcome to the wonderful world of blocks! Click the “+” (“Add block”) button to add a new block. There are blocks available for all kind of content: you can insert text, headings, images, lists, and lots more! - + `; @@ -72,11 +72,11 @@ exports[`DefaultBlockAppender should match snapshot 1`] = ` - Welcome to the wonderful world of blocks! Click the “+” (“Add block”) button to add a new block. There are blocks available for all kind of content: you can insert text, headings, images, lists, and lots more! - + `; @@ -102,11 +102,11 @@ exports[`DefaultBlockAppender should optionally show without prompt 1`] = ` - Welcome to the wonderful world of blocks! Click the “+” (“Add block”) button to add a new block. There are blocks available for all kind of content: you can insert text, headings, images, lists, and lots more! - + `; 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 fd1cc57238af17..2b142e65c140b5 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 @@ -8,10 +8,10 @@ exports[`PostPreviewButton render() should match the snapshot 1`] = ` onClick={[Function]} > Preview - Click ‘Preview’ to load a preview of this page, so you can make sure you’re happy with your blocks. - + `; diff --git a/packages/nux/src/components/dot-tip/README.md b/packages/nux/src/components/dot-tip/README.md index caf164760ffeca..4fb33bb9ddae39 100644 --- a/packages/nux/src/components/dot-tip/README.md +++ b/packages/nux/src/components/dot-tip/README.md @@ -3,6 +3,8 @@ 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`. +When there are multiple instances of `DotTip` that reference the same tip identifier, only the first instance to have been mounted will be visible. + ## Usage ```jsx @@ -21,7 +23,7 @@ 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`. +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`. Changing the identifier after the component has mounted is not supported. - Type: `string` - Required: Yes diff --git a/packages/nux/src/components/dot-tip/index.js b/packages/nux/src/components/dot-tip/index.js index b41c794e7193bf..80e33285906fbf 100644 --- a/packages/nux/src/components/dot-tip/index.js +++ b/packages/nux/src/components/dot-tip/index.js @@ -1,64 +1,84 @@ /** * WordPress dependencies */ -import { compose } from '@wordpress/compose'; +import { Component } from '@wordpress/element'; +import { compose, withInstanceId } from '@wordpress/compose'; import { Popover, Button, IconButton } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { withSelect, withDispatch } from '@wordpress/data'; -export function DotTip( { - children, - isVisible, - hasNextTip, - onDismiss, - onDisable, -} ) { - if ( ! isVisible ) { - return null; +export class DotTip extends Component { + componentDidMount() { + this.props.onRegister(); } - return ( - { - // 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(); - } } - > -

{ children }

-

- -

- -
- ); + componentWillUnmount() { + this.props.onUnregister(); + } + + render() { + const { children, isVisible, hasNextTip, onDismiss, onDisable } = this.props; + + if ( ! isVisible ) { + return null; + } + + return ( + { + // 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(); + } } + > +

{ children }

+

+ +

+ +
+ ); + } } export default compose( - withSelect( ( select, { id } ) => { + withInstanceId, + withSelect( ( select, { id, instanceId } ) => { const { isTipVisible, getAssociatedGuide } = select( 'core/nux' ); const associatedGuide = getAssociatedGuide( id ); return { - isVisible: isTipVisible( id ), + isVisible: isTipVisible( id, instanceId ), hasNextTip: !! ( associatedGuide && associatedGuide.nextTipId ), }; } ), - withDispatch( ( dispatch, { id } ) => { - const { dismissTip, disableTips } = dispatch( 'core/nux' ); + withDispatch( ( dispatch, { id, instanceId } ) => { + const { + registerTipInstance, + unregisterTipInstance, + dismissTip, + disableTips, + } = dispatch( 'core/nux' ); + return { + onRegister() { + registerTipInstance( id, instanceId ); + }, + onUnregister() { + unregisterTipInstance( id, instanceId ); + }, onDismiss() { dismissTip( id ); }, diff --git a/packages/nux/src/components/dot-tip/test/index.js b/packages/nux/src/components/dot-tip/test/index.js index aa69872d5e6e80..884c9422ecb77c 100644 --- a/packages/nux/src/components/dot-tip/test/index.js +++ b/packages/nux/src/components/dot-tip/test/index.js @@ -14,7 +14,7 @@ jest.mock( '../../../../../components/src/button' ); describe( 'DotTip', () => { it( 'should not render anything if invisible', () => { const wrapper = shallow( - + It looks like you’re writing a letter. Would you like help? ); @@ -23,7 +23,7 @@ describe( 'DotTip', () => { it( 'should render correctly', () => { const wrapper = shallow( - + It looks like you’re writing a letter. Would you like help? ); @@ -33,7 +33,7 @@ describe( 'DotTip', () => { 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? ); @@ -44,7 +44,7 @@ describe( 'DotTip', () => { it( 'should call onDisable when the X button is clicked', () => { const onDisable = jest.fn(); const wrapper = shallow( - + It looks like you’re writing a letter. Would you like help? ); diff --git a/packages/nux/src/store/actions.js b/packages/nux/src/store/actions.js index ad8adb79c5530d..3ee9c8e142a891 100644 --- a/packages/nux/src/store/actions.js +++ b/packages/nux/src/store/actions.js @@ -13,6 +13,43 @@ export function triggerGuide( tipIds ) { }; } +/** + * Returns an action object that, when dispathed, associates an instance of the + * DotTip component with a tip. This is usually done when the component mounts. + * Tracking this lets us only show one DotTip at a time per tip. + * + * @param {string} tipId The tip to associate this instance with. + * @param {number} instanceId A number which uniquely identifies the instance. + * + * @return {Object} Action object. + */ +export function registerTipInstance( tipId, instanceId ) { + return { + type: 'REGISTER_TIP_INSTANCE', + tipId, + instanceId, + }; +} + +/** + * Returns an action object that, when dispatched, removes the association + * between a DotTip component instance and a tip. This is usually done when the + * component unmounts. Tracking this lets us only show one DotTip at a time per + * tip. + * + * @param {string} tipId The tip to disassociate this instance with. + * @param {number} instanceId A number which uniquely identifies the instance. + * + * @return {Object} Action object. + */ +export function unregisterTipInstance( tipId, instanceId ) { + return { + type: 'UNREGISTER_TIP_INSTANCE', + tipId, + instanceId, + }; +} + /** * Returns an action object that, when dispatched, dismisses the given tip. A * dismissed tip will not show again. diff --git a/packages/nux/src/store/reducer.js b/packages/nux/src/store/reducer.js index 5ebf8ea394e70b..2383fee47b4efe 100644 --- a/packages/nux/src/store/reducer.js +++ b/packages/nux/src/store/reducer.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { union, without } from 'lodash'; + /** * WordPress dependencies */ @@ -24,6 +29,34 @@ export function guides( state = [], action ) { return state; } +/** + * Reducer that maps each tip to an array of DotTip component instance IDs that are + * displaying that tip. Tracking this allows us to only show one DotTip at a + * time per tip. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function tipInstanceIds( state = {}, action ) { + switch ( action.type ) { + case 'REGISTER_TIP_INSTANCE': + return { + ...state, + [ action.tipId ]: union( state[ action.tipId ] || [], [ action.instanceId ] ), + }; + + case 'UNREGISTER_TIP_INSTANCE': + return { + ...state, + [ action.tipId ]: without( state[ action.tipId ], action.instanceId ), + }; + } + + return state; +} + /** * Reducer that tracks whether or not tips are globally enabled. * @@ -70,4 +103,4 @@ export function dismissedTips( state = {}, action ) { const preferences = combineReducers( { areTipsEnabled, dismissedTips } ); -export default combineReducers( { guides, preferences } ); +export default combineReducers( { guides, tipInstanceIds, preferences } ); diff --git a/packages/nux/src/store/selectors.js b/packages/nux/src/store/selectors.js index 61f0193d90f46c..149cd30dfb0a3e 100644 --- a/packages/nux/src/store/selectors.js +++ b/packages/nux/src/store/selectors.js @@ -41,29 +41,40 @@ export const getAssociatedGuide = createSelector( ); /** - * 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. + * Determines whether the given tip or DotTip instance should be visible. Checks: * - * @param {Object} state Global application state. - * @param {string} id The tip to query. + * - That all tips are enabled. + * - That the given tip has not been dismissed. + * - If the given tip is part of a guide, that the given tip is the current tip in the guide. + * - If instanceId is provided, that this is the first DotTip instance for the given tip. + * + * @param {Object} state Global application state. + * @param {string} tipId The tip to query. + * @param {?number} instanceId A number which uniquely identifies the DotTip instance. * - * @return {boolean} Whether or not the given tip is showing. + * @return {boolean} Whether the given tip or Dottip instance should be shown. */ -export function isTipVisible( state, id ) { +export function isTipVisible( state, tipId, instanceId ) { if ( ! state.preferences.areTipsEnabled ) { return false; } - if ( state.preferences.dismissedTips[ id ] ) { + if ( state.preferences.dismissedTips[ tipId ] ) { return false; } - const associatedGuide = getAssociatedGuide( state, id ); - if ( associatedGuide && associatedGuide.currentTipId !== id ) { + const associatedGuide = getAssociatedGuide( state, tipId ); + if ( associatedGuide && associatedGuide.currentTipId !== tipId ) { return false; } + if ( instanceId ) { + const [ firstInstanceId ] = state.tipInstanceIds[ tipId ] || []; + if ( instanceId !== firstInstanceId ) { + return false; + } + } + return true; } diff --git a/packages/nux/src/store/test/actions.js b/packages/nux/src/store/test/actions.js index 4e22afe03c8b82..7bdf580fe9eb83 100644 --- a/packages/nux/src/store/test/actions.js +++ b/packages/nux/src/store/test/actions.js @@ -1,7 +1,14 @@ /** * Internal dependencies */ -import { triggerGuide, dismissTip, disableTips, enableTips } from '../actions'; +import { + triggerGuide, + registerTipInstance, + unregisterTipInstance, + dismissTip, + disableTips, + enableTips, +} from '../actions'; describe( 'actions', () => { describe( 'triggerGuide', () => { @@ -13,6 +20,26 @@ describe( 'actions', () => { } ); } ); + describe( 'registerTipInstance', () => { + it( 'should return a REGISTER_TIP_INSTANCE action', () => { + expect( registerTipInstance( 'test/tip-1', 123 ) ).toEqual( { + type: 'REGISTER_TIP_INSTANCE', + tipId: 'test/tip-1', + instanceId: 123, + } ); + } ); + } ); + + describe( 'unregisterTipInstance', () => { + it( 'should return an UNREGISTER_TIP_INSTANCE action', () => { + expect( unregisterTipInstance( 'test/tip-1', 123 ) ).toEqual( { + type: 'UNREGISTER_TIP_INSTANCE', + tipId: 'test/tip-1', + instanceId: 123, + } ); + } ); + } ); + describe( 'dismissTip', () => { it( 'should return an DISMISS_TIP action', () => { expect( dismissTip( 'test/tip' ) ).toEqual( { diff --git a/packages/nux/src/store/test/reducer.js b/packages/nux/src/store/test/reducer.js index d2982a87d70501..81197e83b0942d 100644 --- a/packages/nux/src/store/test/reducer.js +++ b/packages/nux/src/store/test/reducer.js @@ -1,7 +1,12 @@ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + /** * Internal dependencies */ -import { guides, areTipsEnabled, dismissedTips } from '../reducer'; +import { guides, tipInstanceIds, areTipsEnabled, dismissedTips } from '../reducer'; describe( 'reducer', () => { describe( 'guides', () => { @@ -10,7 +15,8 @@ describe( 'reducer', () => { } ); it( 'should add a guide when it is triggered', () => { - const state = guides( [], { + const original = deepFreeze( [] ); + const state = guides( original, { type: 'TRIGGER_GUIDE', tipIds: [ 'test/tip-1', 'test/tip-2' ], } ); @@ -20,6 +26,66 @@ describe( 'reducer', () => { } ); } ); + describe( 'tipInstanceIds', () => { + it( 'should start out empty', () => { + expect( tipInstanceIds( undefined, {} ) ).toEqual( {} ); + } ); + + it( 'should register an initial tip instance', () => { + const original = deepFreeze( {} ); + const state = tipInstanceIds( original, { + type: 'REGISTER_TIP_INSTANCE', + tipId: 'test/tip-1', + instanceId: 123, + } ); + expect( state ).toEqual( { + 'test/tip-1': [ 123 ], + } ); + } ); + + it( 'should register an second tip instance', () => { + const original = deepFreeze( { + 'test/tip-1': [ 123 ], + } ); + const state = tipInstanceIds( original, { + type: 'REGISTER_TIP_INSTANCE', + tipId: 'test/tip-1', + instanceId: 456, + } ); + expect( state ).toEqual( { + 'test/tip-1': [ 123, 456 ], + } ); + } ); + + it( 'should not register duplicate tip instances', () => { + const original = deepFreeze( { + 'test/tip-1': [ 123 ], + } ); + const state = tipInstanceIds( original, { + type: 'REGISTER_TIP_INSTANCE', + tipId: 'test/tip-1', + instanceId: 123, + } ); + expect( state ).toEqual( { + 'test/tip-1': [ 123 ], + } ); + } ); + + it( 'should unregister a tip instance', () => { + const original = deepFreeze( { + 'test/tip-1': [ 123, 456 ], + } ); + const state = tipInstanceIds( original, { + type: 'UNREGISTER_TIP_INSTANCE', + tipId: 'test/tip-1', + instanceId: 123, + } ); + expect( state ).toEqual( { + 'test/tip-1': [ 456 ], + } ); + } ); + } ); + describe( 'areTipsEnabled', () => { it( 'should default to true', () => { expect( areTipsEnabled( undefined, {} ) ).toBe( true ); @@ -46,7 +112,8 @@ describe( 'reducer', () => { } ); it( 'should mark tips as dismissed', () => { - const state = dismissedTips( {}, { + const original = deepFreeze( {} ); + const state = dismissedTips( original, { type: 'DISMISS_TIP', id: 'test/tip', } ); @@ -56,10 +123,10 @@ describe( 'reducer', () => { } ); it( 'should reset if tips are enabled', () => { - const initialState = { + const original = deepFreeze( { 'test/tip': true, - }; - const state = dismissedTips( initialState, { + } ); + const state = dismissedTips( original, { type: 'ENABLE_TIPS', } ); expect( state ).toEqual( {} ); diff --git a/packages/nux/src/store/test/selectors.js b/packages/nux/src/store/test/selectors.js index ed63a7fa828de5..f807957abe46d5 100644 --- a/packages/nux/src/store/test/selectors.js +++ b/packages/nux/src/store/test/selectors.js @@ -11,6 +11,7 @@ describe( 'selectors', () => { [ 'test/tip-a', 'test/tip-b', 'test/tip-c' ], [ 'test/tip-α', 'test/tip-β', 'test/tip-γ' ], ], + tipInstanceIds: {}, preferences: { dismissedTips: { 'test/tip-1': true, @@ -56,6 +57,7 @@ describe( 'selectors', () => { it( 'should return true by default', () => { const state = { guides: [], + tipInstanceIds: {}, preferences: { areTipsEnabled: true, dismissedTips: {}, @@ -67,6 +69,7 @@ describe( 'selectors', () => { it( 'should return false if tips are disabled', () => { const state = { guides: [], + tipInstanceIds: {}, preferences: { areTipsEnabled: false, dismissedTips: {}, @@ -78,6 +81,7 @@ describe( 'selectors', () => { it( 'should return false if the tip is dismissed', () => { const state = { guides: [], + tipInstanceIds: {}, preferences: { areTipsEnabled: true, dismissedTips: { @@ -93,6 +97,7 @@ describe( 'selectors', () => { guides: [ [ 'test/tip-1', 'test/tip-2', 'test/tip-3' ], ], + tipInstanceIds: {}, preferences: { areTipsEnabled: true, dismissedTips: {}, @@ -100,12 +105,53 @@ describe( 'selectors', () => { }; expect( isTipVisible( state, 'test/tip-2' ) ).toBe( false ); } ); + + it( 'should return true if an instanceId that was registered first is provided', () => { + const state = { + guides: [], + tipInstanceIds: { + 'test/tip': [ 123, 456 ], + }, + preferences: { + areTipsEnabled: true, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip', 123 ) ).toBe( true ); + } ); + + it( 'should return false if an instanceId that was NOT registered is provided', () => { + const state = { + guides: [], + tipInstanceIds: {}, + preferences: { + areTipsEnabled: true, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip', 123 ) ).toBe( false ); + } ); + + it( 'should return false if an instanceId that was NOT registered first is provided', () => { + const state = { + guides: [], + tipInstanceIds: { + 'test/tip': [ 123, 456 ], + }, + preferences: { + areTipsEnabled: true, + dismissedTips: {}, + }, + }; + expect( isTipVisible( state, 'test/tip', 456 ) ).toBe( false ); + } ); } ); describe( 'areTipsEnabled', () => { it( 'should return true if tips are enabled', () => { const state = { guides: [], + tipInstanceIds: {}, preferences: { areTipsEnabled: true, dismissedTips: {}, @@ -117,6 +163,7 @@ describe( 'selectors', () => { it( 'should return false if tips are disabled', () => { const state = { guides: [], + tipInstanceIds: {}, preferences: { areTipsEnabled: false, dismissedTips: {},