diff --git a/packages/block-directory/src/components/auto-block-uninstaller/index.js b/packages/block-directory/src/components/auto-block-uninstaller/index.js
new file mode 100644
index 00000000000000..c7e804f9f09233
--- /dev/null
+++ b/packages/block-directory/src/components/auto-block-uninstaller/index.js
@@ -0,0 +1,30 @@
+/**
+ * WordPress dependencies
+ */
+import { unregisterBlockType } from '@wordpress/blocks';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { useEffect } from '@wordpress/element';
+
+export default function AutoBlockUninstaller() {
+ const { uninstallBlockType } = useDispatch( 'core/block-directory' );
+
+ const shouldRemoveBlockTypes = useSelect( ( select ) => {
+ const { isAutosavingPost, isSavingPost } = select( 'core/editor' );
+ return isSavingPost() && ! isAutosavingPost();
+ } );
+
+ const unusedBlockTypes = useSelect( ( select ) =>
+ select( 'core/block-directory' ).getUnusedBlockTypes()
+ );
+
+ useEffect( () => {
+ if ( shouldRemoveBlockTypes && unusedBlockTypes.length ) {
+ unusedBlockTypes.forEach( ( blockType ) => {
+ uninstallBlockType( blockType );
+ unregisterBlockType( blockType.name );
+ } );
+ }
+ }, [ shouldRemoveBlockTypes ] );
+
+ return null;
+}
diff --git a/packages/block-directory/src/plugins/index.js b/packages/block-directory/src/plugins/index.js
index f1e013a8d0ddee..c35f5d811b2768 100644
--- a/packages/block-directory/src/plugins/index.js
+++ b/packages/block-directory/src/plugins/index.js
@@ -6,6 +6,7 @@ import { registerPlugin } from '@wordpress/plugins';
/**
* Internal dependencies
*/
+import AutoBlockUninstaller from '../components/auto-block-uninstaller';
import InserterMenuDownloadableBlocksPanel from './inserter-menu-downloadable-blocks-panel';
import InstalledBlocksPrePublishPanel from './installed-blocks-pre-publish-panel';
@@ -13,6 +14,7 @@ registerPlugin( 'block-directory', {
render() {
return (
<>
+
>
diff --git a/packages/block-directory/src/plugins/installed-blocks-pre-publish-panel/index.js b/packages/block-directory/src/plugins/installed-blocks-pre-publish-panel/index.js
index 0b32c1ee29bc62..c3c2ca5925933e 100644
--- a/packages/block-directory/src/plugins/installed-blocks-pre-publish-panel/index.js
+++ b/packages/block-directory/src/plugins/installed-blocks-pre-publish-panel/index.js
@@ -12,7 +12,7 @@ import CompactList from '../../components/compact-list';
export default function InstalledBlocksPrePublishPanel() {
const newBlockTypes = useSelect( ( select ) =>
- select( 'core/block-directory' ).getInstalledBlockTypes()
+ select( 'core/block-directory' ).getNewBlockTypes()
);
if ( ! newBlockTypes.length ) {
diff --git a/packages/block-directory/src/store/actions.js b/packages/block-directory/src/store/actions.js
index bcbed555d16a2c..beea3d05ab01e9 100644
--- a/packages/block-directory/src/store/actions.js
+++ b/packages/block-directory/src/store/actions.js
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
-import { apiFetch, select } from '@wordpress/data-controls';
+import { apiFetch, dispatch, select } from '@wordpress/data-controls';
/**
* Internal dependencies
@@ -91,6 +91,34 @@ export function* installBlockType( block ) {
return success;
}
+/**
+ * Action triggered to uninstall a block plugin.
+ *
+ * @param {Object} block The blockType object.
+ */
+export function* uninstallBlockType( block ) {
+ const { id } = block;
+ try {
+ const response = yield apiFetch( {
+ path: '__experimental/block-directory/uninstall',
+ data: {
+ slug: id,
+ },
+ method: 'DELETE',
+ } );
+ if ( response !== true ) {
+ throw new Error( __( 'Unable to uninstall this block.' ) );
+ }
+ yield removeInstalledBlockType( block );
+ } catch ( error ) {
+ yield dispatch(
+ 'core/notices',
+ 'createErrorNotice',
+ error.message || __( 'An error occurred.' )
+ );
+ }
+}
+
/**
* Returns an action object used to add a newly installed block type.
*
@@ -105,6 +133,20 @@ export function addInstalledBlockType( item ) {
};
}
+/**
+ * Returns an action object used to remove a newly installed block type.
+ *
+ * @param {string} item The block item with the block id and name.
+ *
+ * @return {Object} Action object.
+ */
+export function removeInstalledBlockType( item ) {
+ return {
+ type: 'REMOVE_INSTALLED_BLOCK_TYPE',
+ item,
+ };
+}
+
/**
* Returns an action object used to indicate install in progress
*
diff --git a/packages/block-directory/src/store/selectors.js b/packages/block-directory/src/store/selectors.js
index bf472cd727dc3e..81d6e8ed89c1ed 100644
--- a/packages/block-directory/src/store/selectors.js
+++ b/packages/block-directory/src/store/selectors.js
@@ -1,3 +1,13 @@
+/**
+ * WordPress dependencies
+ */
+import { createRegistrySelector } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import hasBlockType from './utils/has-block-type';
+
/**
* Returns true if application is requesting for downloadable blocks.
*
@@ -46,6 +56,54 @@ export function getInstalledBlockTypes( state ) {
return state.blockManagement.installedBlockTypes;
}
+/**
+ * Returns block types that have been installed on the server and used in the
+ * current post.
+ *
+ * @param {Object} state Global application state.
+ *
+ * @return {Array} Block type items.
+ */
+export const getNewBlockTypes = createRegistrySelector(
+ ( select ) => ( state ) => {
+ const usedBlockTree = select( 'core/block-editor' ).getBlocks();
+ const installedBlockTypes = getInstalledBlockTypes( state );
+
+ const newBlockTypes = [];
+ installedBlockTypes.forEach( ( blockType ) => {
+ if ( hasBlockType( blockType, usedBlockTree ) ) {
+ newBlockTypes.push( blockType );
+ }
+ } );
+
+ return newBlockTypes;
+ }
+);
+
+/**
+ * Returns the block types that have been installed on the server but are not
+ * used in the current post.
+ *
+ * @param {Object} state Global application state.
+ *
+ * @return {Array} Block type items.
+ */
+export const getUnusedBlockTypes = createRegistrySelector(
+ ( select ) => ( state ) => {
+ const usedBlockTree = select( 'core/block-editor' ).getBlocks();
+ const installedBlockTypes = getInstalledBlockTypes( state );
+
+ const newBlockTypes = [];
+ installedBlockTypes.forEach( ( blockType ) => {
+ if ( ! hasBlockType( blockType, usedBlockTree ) ) {
+ newBlockTypes.push( blockType );
+ }
+ } );
+
+ return newBlockTypes;
+ }
+);
+
/**
* Returns true if application is calling install endpoint.
*
diff --git a/packages/block-directory/src/store/test/actions.js b/packages/block-directory/src/store/test/actions.js
index b51301fc904c47..eaac933fb0a65c 100644
--- a/packages/block-directory/src/store/test/actions.js
+++ b/packages/block-directory/src/store/test/actions.js
@@ -1,7 +1,7 @@
/**
* Internal dependencies
*/
-import { installBlockType } from '../actions';
+import { installBlockType, uninstallBlockType } from '../actions';
describe( 'actions', () => {
const item = {
@@ -126,4 +126,48 @@ describe( 'actions', () => {
} );
} );
} );
+
+ describe( 'uninstallBlockType', () => {
+ it( 'should uninstall a block successfully', () => {
+ const generator = uninstallBlockType( item );
+
+ expect( generator.next().value ).toMatchObject( {
+ type: 'API_FETCH',
+ } );
+
+ expect( generator.next( true ).value ).toEqual( {
+ type: 'REMOVE_INSTALLED_BLOCK_TYPE',
+ item,
+ } );
+
+ expect( generator.next() ).toEqual( {
+ value: undefined,
+ done: true,
+ } );
+ } );
+
+ it( "should set a global notice if the plugin can't uninstall", () => {
+ const generator = uninstallBlockType( item );
+
+ expect( generator.next().value ).toMatchObject( {
+ type: 'API_FETCH',
+ } );
+
+ const apiError = {
+ code: 'could_not_remove_plugin',
+ message: 'Could not fully remove the plugin .',
+ data: null,
+ };
+ expect( generator.next( apiError ).value ).toMatchObject( {
+ type: 'DISPATCH',
+ actionName: 'createErrorNotice',
+ storeKey: 'core/notices',
+ } );
+
+ expect( generator.next() ).toEqual( {
+ value: undefined,
+ done: true,
+ } );
+ } );
+ } );
} );
diff --git a/packages/block-directory/src/store/test/fixtures/index.js b/packages/block-directory/src/store/test/fixtures/index.js
index 0f7cc95dc8a6b4..29ff9d8f071c8e 100644
--- a/packages/block-directory/src/store/test/fixtures/index.js
+++ b/packages/block-directory/src/store/test/fixtures/index.js
@@ -18,7 +18,33 @@ export const downloadableBlock = {
humanizedUpdated: '3 months ago',
};
-export const installedItem = {
+export const blockTypeInstalled = {
id: 'boxer-block',
name: 'boxer/boxer',
};
+
+export const blockTypeUnused = {
+ id: 'example-block',
+ name: 'fake/unused',
+};
+
+export const blockList = [
+ {
+ clientId: 1,
+ name: 'core/paragraph',
+ attributes: {},
+ innerBlocks: [],
+ },
+ {
+ clientId: 2,
+ name: 'boxer/boxer',
+ attributes: {},
+ innerBlocks: [],
+ },
+ {
+ clientId: 3,
+ name: 'core/heading',
+ attributes: {},
+ innerBlocks: [],
+ },
+];
diff --git a/packages/block-directory/src/store/test/reducer.js b/packages/block-directory/src/store/test/reducer.js
index fa7c55251b3d6b..897217ea0f85b4 100644
--- a/packages/block-directory/src/store/test/reducer.js
+++ b/packages/block-directory/src/store/test/reducer.js
@@ -12,7 +12,7 @@ import {
errorNotices,
hasPermission,
} from '../reducer';
-import { installedItem, downloadableBlock } from './fixtures';
+import { blockTypeInstalled, downloadableBlock } from './fixtures';
describe( 'state', () => {
describe( 'downloadableBlocks()', () => {
@@ -72,7 +72,7 @@ describe( 'state', () => {
const initialState = deepFreeze( { installedBlockTypes: [] } );
const state = blockManagement( initialState, {
type: 'ADD_INSTALLED_BLOCK_TYPE',
- item: installedItem,
+ item: blockTypeInstalled,
} );
expect( state.installedBlockTypes ).toHaveLength( 1 );
@@ -80,11 +80,11 @@ describe( 'state', () => {
it( 'should remove item from the installedBlockTypesList', () => {
const initialState = deepFreeze( {
- installedBlockTypes: [ installedItem ],
+ installedBlockTypes: [ blockTypeInstalled ],
} );
const state = blockManagement( initialState, {
type: 'REMOVE_INSTALLED_BLOCK_TYPE',
- item: installedItem,
+ item: blockTypeInstalled,
} );
expect( state.installedBlockTypes ).toHaveLength( 0 );
diff --git a/packages/block-directory/src/store/test/selectors.js b/packages/block-directory/src/store/test/selectors.js
index fc702d93c1d26d..690bec529018a8 100644
--- a/packages/block-directory/src/store/test/selectors.js
+++ b/packages/block-directory/src/store/test/selectors.js
@@ -1,12 +1,19 @@
/**
* Internal dependencies
*/
-import { downloadableBlock } from './fixtures';
+import {
+ blockList,
+ blockTypeInstalled,
+ blockTypeUnused,
+ downloadableBlock,
+} from './fixtures';
import {
getDownloadableBlocks,
getErrorNotices,
getErrorNoticeForBlock,
getInstalledBlockTypes,
+ getNewBlockTypes,
+ getUnusedBlockTypes,
isInstalling,
} from '../selectors';
@@ -24,6 +31,76 @@ describe( 'selectors', () => {
} );
} );
+ describe( 'getNewBlockTypes', () => {
+ it( 'should retrieve the block types that are installed and in the post content', () => {
+ getNewBlockTypes.registry = {
+ select: jest.fn( () => ( { getBlocks: () => blockList } ) ),
+ };
+ const state = {
+ blockManagement: {
+ installedBlockTypes: [
+ blockTypeInstalled,
+ blockTypeUnused,
+ ],
+ },
+ };
+ const blockTypes = getNewBlockTypes( state );
+ expect( blockTypes ).toHaveLength( 1 );
+ expect( blockTypes[ 0 ] ).toEqual( blockTypeInstalled );
+ } );
+
+ it( 'should return an empty array if no blocks are used', () => {
+ getNewBlockTypes.registry = {
+ select: jest.fn( () => ( { getBlocks: () => [] } ) ),
+ };
+ const state = {
+ blockManagement: {
+ installedBlockTypes: [
+ blockTypeInstalled,
+ blockTypeUnused,
+ ],
+ },
+ };
+ const blockTypes = getNewBlockTypes( state );
+ expect( blockTypes ).toHaveLength( 0 );
+ } );
+ } );
+
+ describe( 'getUnusedBlockTypes', () => {
+ it( 'should retrieve the block types that are installed but not used', () => {
+ getUnusedBlockTypes.registry = {
+ select: jest.fn( () => ( { getBlocks: () => blockList } ) ),
+ };
+ const state = {
+ blockManagement: {
+ installedBlockTypes: [
+ blockTypeInstalled,
+ blockTypeUnused,
+ ],
+ },
+ };
+ const blockTypes = getUnusedBlockTypes( state );
+ expect( blockTypes ).toHaveLength( 1 );
+ expect( blockTypes[ 0 ] ).toEqual( blockTypeUnused );
+ } );
+
+ it( 'should return all block types if no blocks are used', () => {
+ getUnusedBlockTypes.registry = {
+ select: jest.fn( () => ( { getBlocks: () => [] } ) ),
+ };
+ const state = {
+ blockManagement: {
+ installedBlockTypes: [
+ blockTypeInstalled,
+ blockTypeUnused,
+ ],
+ },
+ };
+ const blockTypes = getUnusedBlockTypes( state );
+ expect( blockTypes ).toHaveLength( 2 );
+ } );
+ } );
+
describe( 'getErrorNotices', () => {
const state = {
errorNotices: {
diff --git a/packages/block-directory/src/store/utils/has-block-type.js b/packages/block-directory/src/store/utils/has-block-type.js
new file mode 100644
index 00000000000000..28274cbd04eece
--- /dev/null
+++ b/packages/block-directory/src/store/utils/has-block-type.js
@@ -0,0 +1,24 @@
+/**
+ * Check if a block list contains a specific block type. Recursively searches
+ * through `innerBlocks` if they exist.
+ *
+ * @param {Object} blockType A block object to search for.
+ * @param {Object[]} blocks The list of blocks to look through.
+ *
+ * @return {boolean} Whether the blockType is found.
+ */
+export default function hasBlockType( blockType, blocks = [] ) {
+ if ( ! blocks.length ) {
+ return false;
+ }
+ if ( blocks.some( ( { name } ) => name === blockType.name ) ) {
+ return true;
+ }
+ for ( let i = 0; i < blocks.length; i++ ) {
+ if ( hasBlockType( blockType, blocks[ i ].innerBlocks ) ) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/packages/block-directory/src/store/utils/test/has-block-type.js b/packages/block-directory/src/store/utils/test/has-block-type.js
new file mode 100644
index 00000000000000..b8104b59d54bf9
--- /dev/null
+++ b/packages/block-directory/src/store/utils/test/has-block-type.js
@@ -0,0 +1,42 @@
+/**
+ * Internal dependencies
+ */
+import {
+ blockList,
+ blockTypeInstalled,
+ blockTypeUnused,
+} from '../../test/fixtures';
+import hasBlockType from '../has-block-type';
+
+describe( 'hasBlockType', () => {
+ it( 'should find the block', () => {
+ const found = hasBlockType( blockTypeInstalled, blockList );
+ expect( found ).toBe( true );
+ } );
+
+ it( 'should not find the unused block', () => {
+ const found = hasBlockType( blockTypeUnused, blockList );
+ expect( found ).toBe( false );
+ } );
+
+ it( 'should find the block in innerBlocks', () => {
+ const innerBlockList = [
+ ...blockList,
+ {
+ clientId: 4,
+ name: 'core/cover',
+ attributes: {},
+ innerBlocks: [
+ {
+ clientId: 5,
+ name: blockTypeUnused.name,
+ attributes: {},
+ innerBlocks: [],
+ },
+ ],
+ },
+ ];
+ const found = hasBlockType( blockTypeUnused, innerBlockList );
+ expect( found ).toBe( true );
+ } );
+} );