Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2d11a45
Show warning on critical block removal
tellthemachines Jun 1, 2023
c1912d9
Extract prompt display logic into a hook
tellthemachines Jun 5, 2023
405c26b
Revert formatting change.
tellthemachines Jun 6, 2023
79ba8ad
Prompt for removal of all the blocks
tellthemachines Jun 6, 2023
15959b5
Move prompt state handling out of BlockList and into self
mcsf Jun 6, 2023
ce307ad
findCriticalBlocks: don't dismiss children of matching node
mcsf Jun 6, 2023
e5ddee6
findCriticalBlocks -> getBlocksToPromptFor
mcsf Jun 6, 2023
df68c00
Drop isBlockCritical()
mcsf Jun 6, 2023
042063a
Redesign removal modal
mcsf Jun 6, 2023
fb4c79d
Move prompt into site editor.
tellthemachines Jun 7, 2023
d3a12c8
Reset removalPromptExists upon listener unmount
mcsf Jun 20, 2023
c9952f1
Let action removeBlocks handle prompts and confirmations
mcsf Jun 20, 2023
33afda6
Add private action `privateRemoveBlocks` to hide extended interface
mcsf Jun 20, 2023
7482ba3
Fix unit tests
tellthemachines Jun 21, 2023
a5cce0a
Try: Dispatch setRemovalPromptStatus in edit-site init
mcsf Jun 21, 2023
b26efc4
Revert "Try: Dispatch setRemovalPromptStatus in edit-site init"
mcsf Jun 21, 2023
0e88e1a
Make all actions & selectors private. Rename things.
mcsf Jun 21, 2023
29f99c1
Make BlockRemovalWarningModal private
mcsf Jun 21, 2023
bb0cd7c
Cleanup: Remove BlockList changes from branch
mcsf Jun 22, 2023
c27ab49
Tweak removal message for Query. Tweak comments.
mcsf Jun 22, 2023
65df9cf
Split action into displayRemovalPrompt & clearRemovalPrompt
mcsf Jun 22, 2023
02df539
Rename setRemovalPromptStatus to toggleRemovalPromptSupport
mcsf Jun 22, 2023
d2b6a9f
Rename isRemovalPromptDisplayed to getRemovalPromptData
mcsf Jun 22, 2023
5d1c3e2
Add missing @return to displayRemovalPrompt
mcsf Jun 22, 2023
e180954
Tweak modal copy per feedback
mcsf Jun 22, 2023
23f034d
Turns out private selectors are attached to the thunk proxy!
mcsf Jun 22, 2023
6a0a806
Don't export the new reducers
mcsf Jun 22, 2023
628207a
Fix tests
mcsf Jun 22, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* WordPress dependencies
*/
import { useEffect } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import {
Modal,
Button,
__experimentalHStack as HStack,
} from '@wordpress/components';
import { __, _n } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../store';
import { unlock } from '../../lock-unlock';

// In certain editing contexts, we'd like to prevent accidental removal of
// important blocks. For example, in the site editor, the Query Loop block is
// deemed important. In such cases, we'll ask the user for confirmation that
// they intended to remove such block(s).
//
// @see https://github.com/WordPress/gutenberg/pull/51145
export const blockTypePromptMessages = {
'core/query': __( 'Query Loop displays a list of posts or pages.' ),
'core/post-content': __(
'Post Content displays the content of a post or page.'
),
};
Comment on lines +19 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now this entangles block-editor with edit-site, since these are rules that only apply to the site editor. I don't want to hold up this PR any longer, but a future improvement could be to let the consumer (the component rendering BlockRemovalWarningModal) provide their own rules. Maybe:

<BlockRemovalWarningModal rules={ myPromptMessages } />

As you can tell, I'm undecided on the terminology: are these messages? Rules? Warnings?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-> #51841


export function BlockRemovalWarningModal() {
const { clientIds, selectPrevious, blockNamesForPrompt } = useSelect(
( select ) =>
unlock( select( blockEditorStore ) ).getRemovalPromptData()
);

const {
clearRemovalPrompt,
toggleRemovalPromptSupport,
privateRemoveBlocks,
} = unlock( useDispatch( blockEditorStore ) );

// Signalling the removal prompt is in place.
useEffect( () => {
toggleRemovalPromptSupport( true );
return () => {
toggleRemovalPromptSupport( false );
};
}, [ toggleRemovalPromptSupport ] );

if ( ! blockNamesForPrompt ) {
return;
}

const onConfirmRemoval = () => {
privateRemoveBlocks( clientIds, selectPrevious, /* force */ true );
clearRemovalPrompt();
};

return (
<Modal
title={ __( 'Are you sure?' ) }
onRequestClose={ clearRemovalPrompt }
>
{ blockNamesForPrompt.length === 1 ? (
<p>{ blockTypePromptMessages[ blockNamesForPrompt[ 0 ] ] }</p>
) : (
<ul style={ { listStyleType: 'disc', paddingLeft: '1rem' } }>
{ blockNamesForPrompt.map( ( name ) => (
<li key={ name }>
{ blockTypePromptMessages[ name ] }
</li>
) ) }
</ul>
) }
<p>
{ _n(
'Removing this block is not advised.',
'Removing these blocks is not advised.',
blockNamesForPrompt.length
) }
</p>
<HStack justify="right">
<Button variant="tertiary" onClick={ clearRemovalPrompt }>
{ __( 'Cancel' ) }
</Button>
<Button variant="primary" onClick={ onConfirmRemoval }>
{ __( 'Delete' ) }
</Button>
</HStack>
</Modal>
);
}
2 changes: 2 additions & 0 deletions packages/block-editor/src/private-apis.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { cleanEmptyObject } from './hooks/utils';
import { useBlockEditingMode } from './components/block-editing-mode';
import BlockQuickNavigation from './components/block-quick-navigation';
import { LayoutStyle } from './components/block-list/layout';
import { BlockRemovalWarningModal } from './components/block-removal-warning-modal';
import { useLayoutClasses, useLayoutStyles } from './hooks';

/**
Expand All @@ -31,6 +32,7 @@ lock( privateApis, {
useBlockEditingMode,
BlockQuickNavigation,
LayoutStyle,
BlockRemovalWarningModal,
useLayoutClasses,
useLayoutStyles,
} );
63 changes: 7 additions & 56 deletions packages/block-editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,40 +25,17 @@ import {
retrieveSelectedAttribute,
START_OF_SELECTED_AREA,
} from '../utils/selection';
import { __experimentalUpdateSettings } from './private-actions';
import {
__experimentalUpdateSettings,
ensureDefaultBlock,
privateRemoveBlocks,
} from './private-actions';

/** @typedef {import('../components/use-on-block-drop/types').WPDropOperation} WPDropOperation */

const castArray = ( maybeArray ) =>
Array.isArray( maybeArray ) ? maybeArray : [ maybeArray ];

/**
* Action which will insert a default block insert action if there
* are no other blocks at the root of the editor. This action should be used
* in actions which may result in no blocks remaining in the editor (removal,
* replacement, etc).
*/
const ensureDefaultBlock =
() =>
( { select, dispatch } ) => {
// To avoid a focus loss when removing the last block, assure there is
// always a default block if the last of the blocks have been removed.
const count = select.getBlockCount();
if ( count > 0 ) {
return;
}

// If there's an custom appender, don't insert default block.
// We have to remember to manually move the focus elsewhere to
// prevent it from being lost though.
const { __unstableHasCustomAppender } = select.getSettings();
if ( __unstableHasCustomAppender ) {
return;
}

dispatch.insertDefaultBlock();
};

/**
* Action that resets blocks state to the specified array of blocks, taking precedence
* over any other content reflected as an edit in state.
Expand Down Expand Up @@ -1195,34 +1172,8 @@ export const mergeBlocks =
* should be selected
* when a block is removed.
*/
export const removeBlocks =
( clientIds, selectPrevious = true ) =>
( { select, dispatch } ) => {
if ( ! clientIds || ! clientIds.length ) {
return;
}

clientIds = castArray( clientIds );
const rootClientId = select.getBlockRootClientId( clientIds[ 0 ] );
const canRemoveBlocks = select.canRemoveBlocks(
clientIds,
rootClientId
);

if ( ! canRemoveBlocks ) {
return;
}

if ( selectPrevious ) {
dispatch.selectPreviousBlock( clientIds[ 0 ], selectPrevious );
}

dispatch( { type: 'REMOVE_BLOCKS', clientIds } );

// To avoid a focus loss when removing the last block, assure there is
// always a default block if the last of the blocks have been removed.
dispatch( ensureDefaultBlock() );
};
export const removeBlocks = ( clientIds, selectPrevious = true ) =>
privateRemoveBlocks( clientIds, selectPrevious );

/**
* Returns an action object used in signalling that the block with the
Expand Down
193 changes: 193 additions & 0 deletions packages/block-editor/src/store/private-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
*/
import { Platform } from '@wordpress/element';

/**
* Internal dependencies
*/
import { blockTypePromptMessages } from '../components/block-removal-warning-modal';

const castArray = ( maybeArray ) =>
Array.isArray( maybeArray ) ? maybeArray : [ maybeArray ];

/**
* A list of private/experimental block editor settings that
* should not become a part of the WordPress public API.
Expand Down Expand Up @@ -105,3 +113,188 @@ export function unsetBlockEditingMode( clientId = '' ) {
clientId,
};
}

/**
* Yields action objects used in signalling that the blocks corresponding to
* the set of specified client IDs are to be removed.
*
* Compared to `removeBlocks`, this private interface exposes an additional
* parameter; see `forceRemove`.
*
* @param {string|string[]} clientIds Client IDs of blocks to remove.
* @param {boolean} selectPrevious True if the previous block
* or the immediate parent
* (if no previous block exists)
* should be selected
* when a block is removed.
* @param {boolean} forceRemove Whether to force the operation,
* bypassing any checks for certain
* block types.
*/
export const privateRemoveBlocks =
( clientIds, selectPrevious = true, forceRemove = false ) =>
( { select, dispatch } ) => {
if ( ! clientIds || ! clientIds.length ) {
return;
}

clientIds = castArray( clientIds );
const rootClientId = select.getBlockRootClientId( clientIds[ 0 ] );
const canRemoveBlocks = select.canRemoveBlocks(
clientIds,
rootClientId
);

if ( ! canRemoveBlocks ) {
return;
}

// In certain editing contexts, we'd like to prevent accidental removal
// of important blocks. For example, in the site editor, the Query Loop
// block is deemed important. In such cases, we'll ask the user for
// confirmation that they intended to remove such block(s). However,
// the editor instance is responsible for presenting those confirmation
// prompts to the user. Any instance opting into removal prompts must
// register using `toggleRemovalPromptSupport()`.
//
// @see https://github.com/WordPress/gutenberg/pull/51145
if (
! forceRemove &&
// FIXME: Without this existence check, the unit tests for
// `__experimentalDeleteReusableBlock` in
// `packages/reusable-blocks/src/store/test/actions.js` fail due to
// the fact that the `registry` object passed to the thunk actions
// doesn't include this private action. This needs to be
// investigated to understand whether it's a real smell or if it's
// because not all store code has been updated to accommodate
// private selectors.
select.isRemovalPromptSupported &&
select.isRemovalPromptSupported()
Comment on lines +163 to +172
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jsnajdr: I quickly chatted with Adam about this. Is it possible that we are missing something in our Jest setup to accommodate private selectors?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not because of Jest, but because of the cumbersome way how we register the block-editor store, with the deprecated registerStore function, forced to use this way by the persist plugin.

export const store = createReduxStore( STORE_NAME, {
	...storeConfig,
	persist: [ 'preferences' ],
} );

// We will be able to use the `register` function once we switch
// the "preferences" persistence to use the new preferences package.
const registeredStore = registerStore( STORE_NAME, {
	...storeConfig,
	persist: [ 'preferences' ],
} );
unlock( registeredStore ).registerPrivateActions( privateActions );
unlock( registeredStore ).registerPrivateSelectors( privateSelectors );

This code:

  1. creates a store store descriptor for the block-editor store.
  2. instantiates and registers the block-editor store, but using a different descriptor, the one created internally inside registerStore.
  3. registers private actions and selectors to that one instance (!) that was just created.

The unit test that is failing is creating a new registry, and registers (instantiates) a block-editor store in this registry, using the store descriptor.

But nobody ever added the private actions and selectors to this descriptor!

The short-term solution is to register the private actions/selectors also to store. The long-term solution is to finish and merge #39632 (by @talldan), stop using the persist plugin, and stop using registerStore for block-editor.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks very much for the explanation! I agree that merging #39632 should be the way forward. In the meantime, if you like, I opened #52088 for the short-term solution.

) {
const blockNamesForPrompt = new Set();

// Given a list of client IDs of blocks that the user intended to
// remove, perform a tree search (BFS) to find all block names
// corresponding to "important" blocks, i.e. blocks that require a
// removal prompt.
//
// @see blockTypePromptMessages
const queue = [ ...clientIds ];
while ( queue.length ) {
const clientId = queue.shift();
const blockName = select.getBlockName( clientId );
if ( blockTypePromptMessages[ blockName ] ) {
blockNamesForPrompt.add( blockName );
}
const innerBlocks = select.getBlockOrder( clientId );
queue.push( ...innerBlocks );
}

// If any such blocks were found, trigger the removal prompt and
// skip any other steps (thus postponing actual removal).
if ( blockNamesForPrompt.size ) {
dispatch(
displayRemovalPrompt(
clientIds,
selectPrevious,
Array.from( blockNamesForPrompt )
)
);
return;
}
}

if ( selectPrevious ) {
dispatch.selectPreviousBlock( clientIds[ 0 ], selectPrevious );
}

dispatch( { type: 'REMOVE_BLOCKS', clientIds } );

// To avoid a focus loss when removing the last block, assure there is
// always a default block if the last of the blocks have been removed.
dispatch( ensureDefaultBlock() );
};

/**
* Action which will insert a default block insert action if there
* are no other blocks at the root of the editor. This action should be used
* in actions which may result in no blocks remaining in the editor (removal,
* replacement, etc).
*/
export const ensureDefaultBlock =
() =>
( { select, dispatch } ) => {
// To avoid a focus loss when removing the last block, assure there is
// always a default block if the last of the blocks have been removed.
const count = select.getBlockCount();
if ( count > 0 ) {
return;
}

// If there's an custom appender, don't insert default block.
// We have to remember to manually move the focus elsewhere to
// prevent it from being lost though.
const { __unstableHasCustomAppender } = select.getSettings();
if ( __unstableHasCustomAppender ) {
return;
}

dispatch.insertDefaultBlock();
};

/**
* Returns an action object used in signalling that a block removal prompt must
* be displayed.
*
* Contrast with `toggleRemovalPromptSupport`.
*
* @param {string|string[]} clientIds Client IDs of blocks to remove.
* @param {boolean} selectPrevious True if the previous block
* or the immediate parent
* (if no previous block exists)
* should be selected
* when a block is removed.
* @param {string[]} blockNamesForPrompt Names of blocks requiring user
* @return {Object} Action object.
*/
export function displayRemovalPrompt(
clientIds,
selectPrevious,
blockNamesForPrompt
) {
return {
type: 'DISPLAY_REMOVAL_PROMPT',
clientIds,
selectPrevious,
blockNamesForPrompt,
};
}

/**
* Returns an action object used in signalling that a block removal prompt must
* be cleared, either be cause the user has confirmed or canceled the request
* for removal.
*
* @return {Object} Action object.
*/
export function clearRemovalPrompt() {
return {
type: 'CLEAR_REMOVAL_PROMPT',
};
}

/**
* Returns an action object used in signalling that a removal prompt display
* mechanism is available or unavailable in the current editor.
*
* Contrast with `displayRemovalPrompt`.
*
* @param {boolean} status Whether a prompt display mechanism exists.
* @return {Object} Action object.
*/
export function toggleRemovalPromptSupport( status = true ) {
return {
type: 'TOGGLE_REMOVAL_PROMPT_SUPPORT',
status,
};
}
Loading