diff --git a/packages/block-directory/src/components/downloadable-block-header/index.js b/packages/block-directory/src/components/downloadable-block-header/index.js index 0c37daef7bd65f..a35935d60a973a 100644 --- a/packages/block-directory/src/components/downloadable-block-header/index.js +++ b/packages/block-directory/src/components/downloadable-block-header/index.js @@ -1,8 +1,9 @@ /** * WordPress dependencies */ -import { Button } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { withSelect } from '@wordpress/data'; /** * Internal dependencies @@ -10,11 +11,12 @@ import { __, sprintf } from '@wordpress/i18n'; import { BlockIcon } from '@wordpress/block-editor'; import BlockRatings from '../block-ratings'; -function DownloadableBlockHeader( { +export function DownloadableBlockHeader( { icon, title, rating, ratingCount, + isLoading, onClick, } ) { return ( @@ -45,15 +47,23 @@ function DownloadableBlockHeader( { ); } -export default DownloadableBlockHeader; +export default withSelect( ( select ) => { + return { + isLoading: select( 'core/block-directory' ).isInstalling(), + }; +} )( DownloadableBlockHeader ); diff --git a/packages/block-directory/src/components/downloadable-block-header/test/index.js b/packages/block-directory/src/components/downloadable-block-header/test/index.js index 96ba6247170cb9..3bc26f312e9bc5 100644 --- a/packages/block-directory/src/components/downloadable-block-header/test/index.js +++ b/packages/block-directory/src/components/downloadable-block-header/test/index.js @@ -7,21 +7,27 @@ import { shallow } from 'enzyme'; * WordPress dependencies */ import { BlockIcon } from '@wordpress/block-editor'; +import { Button } from '@wordpress/components'; /** * Internal dependencies */ -import DownloadableBlockHeader from '../index'; +import { DownloadableBlockHeader } from '../index'; import { pluginWithImg, pluginWithIcon } from './fixtures'; -const getContainer = ( { icon, title, rating, ratingCount } ) => { +const getContainer = ( + { icon, title, rating, ratingCount }, + onClick = jest.fn(), + isLoading = false +) => { return shallow( {} } + onClick={ onClick } title={ title } rating={ rating } ratingCount={ ratingCount } + isLoading={ isLoading } /> ); }; @@ -50,4 +56,28 @@ describe( 'DownloadableBlockHeader', () => { expect( wrapper.find( BlockIcon ) ).toHaveLength( 1 ); } ); } ); + + describe( 'user interaction', () => { + test( 'should trigger the onClick function', () => { + const onClickMock = jest.fn(); + const wrapper = getContainer( pluginWithIcon, onClickMock ); + const event = { + preventDefault: jest.fn(), + }; + wrapper.find( Button ).simulate( 'click', event ); + expect( onClickMock ).toHaveBeenCalledTimes( 1 ); + expect( event.preventDefault ).toHaveBeenCalled(); + } ); + + test( 'should not trigger the onClick function if loading', () => { + const onClickMock = jest.fn(); + const wrapper = getContainer( pluginWithIcon, onClickMock, true ); + const event = { + preventDefault: jest.fn(), + }; + wrapper.find( Button ).simulate( 'click', event ); + expect( event.preventDefault ).toHaveBeenCalled(); + expect( onClickMock ).toHaveBeenCalledTimes( 0 ); + } ); + } ); } ); diff --git a/packages/block-directory/src/components/downloadable-block-list-item/index.js b/packages/block-directory/src/components/downloadable-block-list-item/index.js index 3eb69eba7d2d70..6b8e85e4cdab78 100644 --- a/packages/block-directory/src/components/downloadable-block-list-item/index.js +++ b/packages/block-directory/src/components/downloadable-block-list-item/index.js @@ -1,9 +1,10 @@ /** * Internal dependencies */ -import DownloadableBlockHeader from '../downloadable-block-header'; import DownloadableBlockAuthorInfo from '../downloadable-block-author-info'; +import DownloadableBlockHeader from '../downloadable-block-header'; import DownloadableBlockInfo from '../downloadable-block-info'; +import DownloadableBlockNotice from '../downloadable-block-notice'; function DownloadableBlockListItem( { item, onClick } ) { const { @@ -32,6 +33,10 @@ function DownloadableBlockListItem( { item, onClick } ) { />
+ { + if ( ! errorNotices[ block.id ] ) { + return null; + } + + // A Failed install is the default error as its the first step + let copy = __( 'Block could not be added.' ); + + if ( errorNotices[ block.id ] === DOWNLOAD_ERROR_NOTICE_ID ) { + copy = __( + 'Block could not be added. There is a problem with the block.' + ); + } + + return ( + +
+ { copy } +
+ +
+ ); +}; + +export default withSelect( ( select ) => { + return { + errorNotices: select( 'core/block-directory' ).getErrorNotices(), + }; +} )( DownloadableBlockNotice ); diff --git a/packages/block-directory/src/components/downloadable-block-notice/style.scss b/packages/block-directory/src/components/downloadable-block-notice/style.scss new file mode 100644 index 00000000000000..418940f70bbc99 --- /dev/null +++ b/packages/block-directory/src/components/downloadable-block-notice/style.scss @@ -0,0 +1,8 @@ +.block-directory-downloadable-blocks__notice { + margin: 0 0 16px; +} + +.block-directory-downloadable-blocks__notice-content { + padding-right: 12px; + margin-bottom: 8px; +} diff --git a/packages/block-directory/src/components/downloadable-block-notice/test/fixtures/index.js b/packages/block-directory/src/components/downloadable-block-notice/test/fixtures/index.js new file mode 100644 index 00000000000000..c91c7b0c586958 --- /dev/null +++ b/packages/block-directory/src/components/downloadable-block-notice/test/fixtures/index.js @@ -0,0 +1,3 @@ +export const plugin = { + id: 'boxer-block', +}; diff --git a/packages/block-directory/src/components/downloadable-block-notice/test/index.js b/packages/block-directory/src/components/downloadable-block-notice/test/index.js new file mode 100644 index 00000000000000..d33f1206aa649d --- /dev/null +++ b/packages/block-directory/src/components/downloadable-block-notice/test/index.js @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { DownloadableBlockNotice } from '../index'; +import { plugin } from './fixtures'; + +import { INSTALL_ERROR_NOTICE_ID } from '../../../store/constants'; + +const getContainer = ( { block, onClick = jest.fn(), errorNotices = {} } ) => { + return shallow( + + ); +}; + +describe( 'DownloadableBlockNotice', () => { + describe( 'Rendering', () => { + it( 'should return null when there are no error notices', () => { + const wrapper = getContainer( { block: plugin } ); + expect( wrapper.isEmptyRender() ).toBe( true ); + } ); + + it( 'should return something when there are error notices', () => { + const errorNotices = { + [ plugin.id ]: INSTALL_ERROR_NOTICE_ID, + }; + const wrapper = getContainer( { block: plugin, errorNotices } ); + expect( wrapper.length ).toBeGreaterThan( 0 ); + } ); + } ); + + describe( 'Behavior', () => { + it( 'should trigger the callback on button click', () => { + const errorNotices = { + [ plugin.id ]: INSTALL_ERROR_NOTICE_ID, + }; + + const onClick = jest.fn(); + const wrapper = getContainer( { + block: plugin, + onClick, + errorNotices, + } ); + + wrapper.find( Button ).simulate( 'click', { event: {} } ); + + expect( onClick ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/packages/block-directory/src/components/downloadable-blocks-list/index.js b/packages/block-directory/src/components/downloadable-blocks-list/index.js index b63c40d34871f3..f94f95d5a454e7 100644 --- a/packages/block-directory/src/components/downloadable-blocks-list/index.js +++ b/packages/block-directory/src/components/downloadable-blocks-list/index.js @@ -6,28 +6,29 @@ import { noop } from 'lodash'; /** * WordPress dependencies */ -import { - getBlockMenuDefaultClassName, - unregisterBlockType, -} from '@wordpress/blocks'; +import { getBlockMenuDefaultClassName } from '@wordpress/blocks'; import { withDispatch } from '@wordpress/data'; import { compose } from '@wordpress/compose'; -import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ import DownloadableBlockListItem from '../downloadable-block-list-item'; +import { + DOWNLOAD_ERROR_NOTICE_ID, + INSTALL_ERROR_NOTICE_ID, +} from '../../store/constants'; -const DOWNLOAD_ERROR_NOTICE_ID = 'block-download-error'; -const INSTALL_ERROR_NOTICE_ID = 'block-install-error'; - -function DownloadableBlocksList( { +export function DownloadableBlocksList( { items, onHover = noop, children, - downloadAndInstallBlock, + install, } ) { + if ( ! items.length ) { + return null; + } + return ( /* * Disable reason: The `list` ARIA role is redundant but @@ -35,14 +36,14 @@ function DownloadableBlocksList( { */ /* eslint-disable jsx-a11y/no-redundant-roles */
    - { items && - items.map( ( item ) => ( + { items.map( ( item ) => { + return ( { - downloadAndInstallBlock( item ); + install( item ); onHover( null ); } } onFocus={ () => onHover( item ) } @@ -51,7 +52,8 @@ function DownloadableBlocksList( { onBlur={ () => onHover( null ) } item={ item } /> - ) ) } + ); + } ) } { children }
/* eslint-enable jsx-a11y/no-redundant-roles */ @@ -59,78 +61,62 @@ function DownloadableBlocksList( { } export default compose( - withDispatch( ( dispatch, props ) => { - const { installBlock, downloadBlock } = dispatch( - 'core/block-directory' - ); - const { createErrorNotice, removeNotice } = dispatch( 'core/notices' ); - const { removeBlocks } = dispatch( 'core/block-editor' ); + withDispatch( ( dispatch, props, { select } ) => { + const { + downloadBlock, + installBlock, + setErrorNotice, + clearErrorNotice, + setIsInstalling, + } = dispatch( 'core/block-directory' ); const { onSelect } = props; + const errorNotices = select( 'core/block-directory' ).getErrorNotices(); - return { - downloadAndInstallBlock: ( item ) => { - const onDownloadError = () => { - createErrorNotice( __( 'Block previews can’t load.' ), { - id: DOWNLOAD_ERROR_NOTICE_ID, - actions: [ - { - label: __( 'Retry' ), - onClick: () => { - removeNotice( DOWNLOAD_ERROR_NOTICE_ID ); - downloadBlock( - item, - onSuccess, - onDownloadError - ); - }, - }, - ], - } ); - }; + const downloadAssets = ( item ) => { + clearErrorNotice( item.id ); + setIsInstalling( true ); - const onSuccess = () => { - const createdBlock = onSelect( item ); + const onDownloadError = () => { + setErrorNotice( item.id, DOWNLOAD_ERROR_NOTICE_ID ); + setIsInstalling( false ); + }; + + const onDownloadSuccess = () => { + onSelect( item ); + setIsInstalling( false ); + }; - const onInstallBlockError = () => { - createErrorNotice( - __( "Block previews can't install." ), - { - id: INSTALL_ERROR_NOTICE_ID, - actions: [ - { - label: __( 'Retry' ), - onClick: () => { - removeNotice( - INSTALL_ERROR_NOTICE_ID - ); - installBlock( - item, - noop, - onInstallBlockError - ); - }, - }, - { - label: __( 'Remove' ), - onClick: () => { - removeNotice( - INSTALL_ERROR_NOTICE_ID - ); - removeBlocks( - createdBlock.clientId - ); - unregisterBlockType( item.name ); - }, - }, - ], - } - ); - }; + downloadBlock( item, onDownloadSuccess, onDownloadError ); + }; + + const installPlugin = ( item, onSuccess ) => { + if ( + errorNotices[ item.id ] && + errorNotices[ item.id ] === DOWNLOAD_ERROR_NOTICE_ID + ) { + // Install has already run & the error was in downloading the assets, so we + // can skip the install step to prevent re-downloading the plugin. + return onSuccess(); + } + + clearErrorNotice( item.id ); + setIsInstalling( true ); + + const onInstallBlockError = () => { + setErrorNotice( item.id, INSTALL_ERROR_NOTICE_ID ); + setIsInstalling( false ); + }; - installBlock( item, noop, onInstallBlockError ); + installBlock( item, onSuccess, onInstallBlockError ); + }; + + return { + install( item ) { + const onSuccess = () => { + downloadAssets( item ); }; - downloadBlock( item, onSuccess, onDownloadError ); + installPlugin( item, onSuccess ); }, }; } ) diff --git a/packages/block-directory/src/components/downloadable-blocks-list/test/fixtures/index.js b/packages/block-directory/src/components/downloadable-blocks-list/test/fixtures/index.js new file mode 100644 index 00000000000000..899f7f663bac08 --- /dev/null +++ b/packages/block-directory/src/components/downloadable-blocks-list/test/fixtures/index.js @@ -0,0 +1,24 @@ +export const plugin = { + name: 'boxer/boxer', + title: 'Boxer', + description: + 'Boxer is a Block that puts your WordPress posts into boxes on a page.', + id: 'boxer-block', + icon: 'block-default', + rating: 5, + rating_count: 1, + active_installs: 0, + author_block_rating: 5, + author_block_count: '1', + author: 'CK Lee', + assets: [ + 'http://plugins.svn.wordpress.org/boxer-block/trunk/build/index.js', + 'http://plugins.svn.wordpress.org/boxer-block/trunk/build/view.js', + ], + humanized_updated: '3 months ago', +}; + +export const items = [ + plugin, + { ...plugin, name: 'my-block/test', id: 'my-block' }, +]; diff --git a/packages/block-directory/src/components/downloadable-blocks-list/test/index.js b/packages/block-directory/src/components/downloadable-blocks-list/test/index.js new file mode 100644 index 00000000000000..d2bbec0021b314 --- /dev/null +++ b/packages/block-directory/src/components/downloadable-blocks-list/test/index.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { DownloadableBlocksList } from '../index'; +import DownloadableBlockListItem from '../../downloadable-block-list-item'; +import { items, plugin } from './fixtures'; + +const getContainer = ( { + blocks, + selectMock = jest.fn(), + hoverMock = jest.fn(), + isLoading = false, + errorNotices = {}, + install = jest.fn(), +} ) => { + return shallow( + + ); +}; + +describe( 'DownloadableBlocksList', () => { + describe( 'List rendering', () => { + it( 'should render and empty list', () => { + const wrapper = getContainer( { blocks: [] } ); + expect( wrapper.isEmptyRender() ).toBe( true ); + } ); + + it( 'should render plugins items into the list', () => { + const wrapper = getContainer( { blocks: items } ); + + expect( wrapper.find( DownloadableBlockListItem ).length ).toBe( + items.length + ); + } ); + } ); + describe( 'Behaviour', () => { + it( 'should try to install the block plugin', () => { + const install = jest.fn(); + const errorNotices = {}; + + const wrapper = getContainer( { + blocks: [ plugin ], + install, + errorNotices, + } ); + const listItems = wrapper.find( DownloadableBlockListItem ); + + listItems.get( 0 ).props.onClick(); + + expect( install ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/packages/block-directory/src/store/actions.js b/packages/block-directory/src/store/actions.js index 09ba6381b59ae0..deac6f47dd0874 100644 --- a/packages/block-directory/src/store/actions.js +++ b/packages/block-directory/src/store/actions.js @@ -150,3 +150,45 @@ export function removeInstalledBlockType( item ) { item, }; } + +/** + * Returns an action object used to indicate install in progress + * + * @param {boolean} isInstalling Boolean value that tells state whether installation is occurring + * + */ +export function setIsInstalling( isInstalling ) { + return { + type: 'SET_INSTALLING_BLOCK', + isInstalling, + }; +} + +/** + * Sets an error notice string to be displayed to the user + * + * @param {string} blockId The ID of the block plugin. eg: my-block + * @param {string} noticeId The ID of the message used to determine which notice to show. + * + */ +export function setErrorNotice( blockId, noticeId ) { + return { + type: 'SET_ERROR_NOTICE_ID', + blockId, + noticeId, + }; +} + +/** + * Sets the error noticeId to empty for specific block + * + * @param {string} blockId The ID of the block plugin. eg: my-block + * + */ +export function clearErrorNotice( blockId ) { + return { + type: 'SET_ERROR_NOTICE_ID', + blockId, + noticeId: '', + }; +} diff --git a/packages/block-directory/src/store/constants.js b/packages/block-directory/src/store/constants.js new file mode 100644 index 00000000000000..625c780faf399e --- /dev/null +++ b/packages/block-directory/src/store/constants.js @@ -0,0 +1,13 @@ +/** + * ID of error when downloading block fails + * + * @type {string} + */ +export const DOWNLOAD_ERROR_NOTICE_ID = 'block-download-error'; + +/** + * ID of error when installing block fails + * + * @type {string} + */ +export const INSTALL_ERROR_NOTICE_ID = 'block-install-error'; diff --git a/packages/block-directory/src/store/reducer.js b/packages/block-directory/src/store/reducer.js index c0993c56dfaf64..6dd02788297977 100644 --- a/packages/block-directory/src/store/reducer.js +++ b/packages/block-directory/src/store/reducer.js @@ -28,9 +28,10 @@ export const downloadableBlocks = ( case 'RECEIVE_DOWNLOADABLE_BLOCKS': return { ...state, - results: Object.assign( {}, state.results, { + results: { + ...state.results, [ action.filterValue ]: action.downloadableBlocks, - } ), + }, isRequestingDownloadableBlocks: false, }; } @@ -48,6 +49,7 @@ export const downloadableBlocks = ( export const blockManagement = ( state = { installedBlockTypes: [], + isInstalling: false, }, action ) => { @@ -67,12 +69,17 @@ export const blockManagement = ( ( blockType ) => blockType.name !== action.item.name ), }; + case 'SET_INSTALLING_BLOCK': + return { + ...state, + isInstalling: action.isInstalling, + }; } return state; }; /** - * Reducer returns whether the user can install blocks. + * Reducer returning an array of downloadable blocks. * * @param {Object} state Current state. * @param {Object} action Dispatched action. @@ -87,8 +94,36 @@ export function hasPermission( state = true, action ) { return state; } +/** + * Reducer returning an object of error notices. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export const errorNotices = ( + state = { + notices: {}, + }, + action +) => { + switch ( action.type ) { + case 'SET_ERROR_NOTICE_ID': + return { + ...state, + notices: { + ...state.notices, + [ action.blockId ]: action.noticeId, + }, + }; + } + return state; +}; + export default combineReducers( { downloadableBlocks, blockManagement, hasPermission, + errorNotices, } ); diff --git a/packages/block-directory/src/store/selectors.js b/packages/block-directory/src/store/selectors.js index 7a1778b1cc09a6..e3851b2b8eb41f 100644 --- a/packages/block-directory/src/store/selectors.js +++ b/packages/block-directory/src/store/selectors.js @@ -45,3 +45,25 @@ export function hasInstallBlocksPermission( state ) { export function getInstalledBlockTypes( state ) { return state.blockManagement.installedBlockTypes; } + +/** + * Returns true if application is calling install endpoint. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether its currently installing + */ +export function isInstalling( state ) { + return state.blockManagement.isInstalling; +} + +/** + * Returns the error notices + * + * @param {Object} state Global application state. + * + * @return {Object} Object with error notices. + */ +export function getErrorNotices( state ) { + return state.errorNotices.notices; +} diff --git a/packages/block-directory/src/store/test/actions.js b/packages/block-directory/src/store/test/actions.js new file mode 100644 index 00000000000000..113d40d7b0ef6e --- /dev/null +++ b/packages/block-directory/src/store/test/actions.js @@ -0,0 +1,148 @@ +/** + * WordPress dependencies + */ +import * as blockFunctions from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { downloadBlock, installBlock } from '../actions'; +import * as controls from '../controls'; + +const ACTIONS = { + apiFetch: 'API_FETCH', + addInstalledBlockType: 'ADD_INSTALLED_BLOCK_TYPE', + removeInstalledBlockType: 'REMOVE_INSTALLED_BLOCK_TYPE', +}; + +jest.mock( '@wordpress/blocks' ); + +describe( 'actions', () => { + const item = { id: 'block/block', name: 'Test Block' }; + const blockPlugin = { + assets: [ 'http://www.wordpress.org/plugins/fakeasset.js' ], + }; + const getBlockTypeMock = jest.spyOn( blockFunctions, 'getBlockTypes' ); + jest.spyOn( controls, 'apiFetch' ); + jest.spyOn( controls, 'loadAssets' ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + afterAll( () => { + jest.resetAllMocks(); + } ); + + const callsTheApi = ( generator ) => { + return expect( generator.next( { success: true } ).value.type ).toEqual( + ACTIONS.apiFetch + ); + }; + + const expectTest = ( hasCall, noCall ) => { + expect( hasCall ).toHaveBeenCalledTimes( 1 ); + expect( noCall ).toHaveBeenCalledTimes( 0 ); + }; + + const expectSuccess = ( onSuccess, onError ) => { + expectTest( onSuccess, onError ); + }; + + const expectError = ( onSuccess, onError ) => { + expectTest( onError, onSuccess ); + }; + + describe( 'downloadBlock', () => { + it( 'should throw error if the plugin has no assets', () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + + const generator = downloadBlock( + { + assets: [], + }, + onSuccess, + onError + ); + + // Move onto the onError callback + generator.next(); + + expectError( onSuccess, onError ); + } ); + + it( 'should call on success function', () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + + // The block is registered + getBlockTypeMock.mockReturnValue( [ item ] ); + + const generator = downloadBlock( blockPlugin, onSuccess, onError ); + + // Trigger the loading of assets + generator.next(); + + // Trigger the block check via getBlockTypes + generator.next(); + + expectSuccess( onSuccess, onError ); + } ); + + it( 'should call on error when no blocks are returned', () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + + // The block is not registered + getBlockTypeMock.mockReturnValue( [] ); + + const generator = downloadBlock( blockPlugin, onSuccess, onError ); + + // Trigger the loading of assets + generator.next(); + + //Complete + generator.next(); + + expectError( onSuccess, onError ); + } ); + } ); + + describe( 'installBlock', () => { + it( 'should install a block successfully', () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + + const generator = installBlock( item, onSuccess, onError ); + + // It triggers API_FETCH that wraps @wordpress/api-fetch + callsTheApi( generator ); + + // It triggers ADD_INSTALLED_BLOCK_TYPE + expect( generator.next( { success: true } ).value.type ).toEqual( + ACTIONS.addInstalledBlockType + ); + + // Move on to success + generator.next(); + + expectSuccess( onSuccess, onError ); + } ); + + it( 'should trigger error state when error is thrown', () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + + const generator = installBlock( item, onSuccess, onError ); + + // It triggers API_FETCH that wraps @wordpress/api-fetch + callsTheApi( generator ); + + // Move on to error + generator.next(); + + expectError( onSuccess, onError ); + } ); + } ); +} ); diff --git a/packages/block-directory/src/style.scss b/packages/block-directory/src/style.scss index 14f33e678ede45..e0286f8fc42ae0 100644 --- a/packages/block-directory/src/style.scss +++ b/packages/block-directory/src/style.scss @@ -5,3 +5,4 @@ @import "./components/downloadable-blocks-list/style.scss"; @import "./components/downloadable-blocks-panel/style.scss"; @import "./components/block-ratings/style.scss"; +@import "./components/downloadable-block-notice/style.scss";