Skip to content

Commit 862d719

Browse files
authored
Block removal prompt: let consumers pass their own rules (#51841)
* Block removal prompt: let consumers pass their own rules Following up on #51145, this untangles `edit-site` from `block-editor` by removing the hard-coded set of rules `blockTypePromptMessages` from the generic `BlockRemovalWarningModal` component. Rules are now to be passed to that component by whichever block editor is using it. Names and comments have been updated accordingly and improved. * Site editor: Add e2e test for block removal prompt
1 parent d9ee487 commit 862d719

File tree

7 files changed

+146
-65
lines changed

7 files changed

+146
-65
lines changed

packages/block-editor/src/components/block-removal-warning-modal/index.js

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,61 +16,47 @@ import { __ } from '@wordpress/i18n';
1616
import { store as blockEditorStore } from '../../store';
1717
import { unlock } from '../../lock-unlock';
1818

19-
// In certain editing contexts, we'd like to prevent accidental removal of
20-
// important blocks. For example, in the site editor, the Query Loop block is
21-
// deemed important. In such cases, we'll ask the user for confirmation that
22-
// they intended to remove such block(s).
23-
//
24-
// @see https://github.com/WordPress/gutenberg/pull/51145
25-
export const blockTypePromptMessages = {
26-
'core/query': __( 'Query Loop displays a list of posts or pages.' ),
27-
'core/post-content': __(
28-
'Post Content displays the content of a post or page.'
29-
),
30-
};
31-
32-
export function BlockRemovalWarningModal() {
19+
export function BlockRemovalWarningModal( { rules } ) {
3320
const { clientIds, selectPrevious, blockNamesForPrompt } = useSelect(
3421
( select ) =>
3522
unlock( select( blockEditorStore ) ).getRemovalPromptData()
3623
);
3724

3825
const {
39-
clearRemovalPrompt,
40-
toggleRemovalPromptSupport,
26+
clearBlockRemovalPrompt,
27+
setBlockRemovalRules,
4128
privateRemoveBlocks,
4229
} = unlock( useDispatch( blockEditorStore ) );
4330

44-
// Signalling the removal prompt is in place.
31+
// Load block removal rules, simultaneously signalling that the block
32+
// removal prompt is in place.
4533
useEffect( () => {
46-
toggleRemovalPromptSupport( true );
34+
setBlockRemovalRules( rules );
4735
return () => {
48-
toggleRemovalPromptSupport( false );
36+
setBlockRemovalRules();
4937
};
50-
}, [ toggleRemovalPromptSupport ] );
38+
}, [ rules, setBlockRemovalRules ] );
5139

5240
if ( ! blockNamesForPrompt ) {
5341
return;
5442
}
5543

5644
const onConfirmRemoval = () => {
5745
privateRemoveBlocks( clientIds, selectPrevious, /* force */ true );
58-
clearRemovalPrompt();
46+
clearBlockRemovalPrompt();
5947
};
6048

6149
return (
6250
<Modal
6351
title={ __( 'Are you sure?' ) }
64-
onRequestClose={ clearRemovalPrompt }
52+
onRequestClose={ clearBlockRemovalPrompt }
6553
>
6654
{ blockNamesForPrompt.length === 1 ? (
67-
<p>{ blockTypePromptMessages[ blockNamesForPrompt[ 0 ] ] }</p>
55+
<p>{ rules[ blockNamesForPrompt[ 0 ] ] }</p>
6856
) : (
6957
<ul style={ { listStyleType: 'disc', paddingLeft: '1rem' } }>
7058
{ blockNamesForPrompt.map( ( name ) => (
71-
<li key={ name }>
72-
{ blockTypePromptMessages[ name ] }
73-
</li>
59+
<li key={ name }>{ rules[ name ] }</li>
7460
) ) }
7561
</ul>
7662
) }
@@ -80,7 +66,7 @@ export function BlockRemovalWarningModal() {
8066
: __( 'Removing this block is not advised.' ) }
8167
</p>
8268
<HStack justify="right">
83-
<Button variant="tertiary" onClick={ clearRemovalPrompt }>
69+
<Button variant="tertiary" onClick={ clearBlockRemovalPrompt }>
8470
{ __( 'Cancel' ) }
8571
</Button>
8672
<Button variant="primary" onClick={ onConfirmRemoval }>

packages/block-editor/src/store/private-actions.js

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,6 @@
33
*/
44
import { Platform } from '@wordpress/element';
55

6-
/**
7-
* Internal dependencies
8-
*/
9-
import { blockTypePromptMessages } from '../components/block-removal-warning-modal';
10-
116
const castArray = ( maybeArray ) =>
127
Array.isArray( maybeArray ) ? maybeArray : [ maybeArray ];
138

@@ -158,23 +153,22 @@ export const privateRemoveBlocks =
158153
// confirmation that they intended to remove such block(s). However,
159154
// the editor instance is responsible for presenting those confirmation
160155
// prompts to the user. Any instance opting into removal prompts must
161-
// register using `toggleRemovalPromptSupport()`.
156+
// register using `setBlockRemovalRules()`.
162157
//
163158
// @see https://github.com/WordPress/gutenberg/pull/51145
164-
if ( ! forceRemove && select.isRemovalPromptSupported() ) {
159+
const rules = ! forceRemove && select.getBlockRemovalRules();
160+
if ( rules ) {
165161
const blockNamesForPrompt = new Set();
166162

167163
// Given a list of client IDs of blocks that the user intended to
168164
// remove, perform a tree search (BFS) to find all block names
169165
// corresponding to "important" blocks, i.e. blocks that require a
170166
// removal prompt.
171-
//
172-
// @see blockTypePromptMessages
173167
const queue = [ ...clientIds ];
174168
while ( queue.length ) {
175169
const clientId = queue.shift();
176170
const blockName = select.getBlockName( clientId );
177-
if ( blockTypePromptMessages[ blockName ] ) {
171+
if ( rules[ blockName ] ) {
178172
blockNamesForPrompt.add( blockName );
179173
}
180174
const innerBlocks = select.getBlockOrder( clientId );
@@ -185,7 +179,7 @@ export const privateRemoveBlocks =
185179
// skip any other steps (thus postponing actual removal).
186180
if ( blockNamesForPrompt.size ) {
187181
dispatch(
188-
displayRemovalPrompt(
182+
displayBlockRemovalPrompt(
189183
clientIds,
190184
selectPrevious,
191185
Array.from( blockNamesForPrompt )
@@ -237,24 +231,27 @@ export const ensureDefaultBlock =
237231
* Returns an action object used in signalling that a block removal prompt must
238232
* be displayed.
239233
*
240-
* Contrast with `toggleRemovalPromptSupport`.
234+
* Contrast with `setBlockRemovalRules`.
241235
*
242236
* @param {string|string[]} clientIds Client IDs of blocks to remove.
243237
* @param {boolean} selectPrevious True if the previous block
244238
* or the immediate parent
245239
* (if no previous block exists)
246240
* should be selected
247241
* when a block is removed.
248-
* @param {string[]} blockNamesForPrompt Names of blocks requiring user
242+
* @param {string[]} blockNamesForPrompt Names of the blocks that
243+
* triggered the need for
244+
* confirmation before removal.
245+
*
249246
* @return {Object} Action object.
250247
*/
251-
export function displayRemovalPrompt(
248+
function displayBlockRemovalPrompt(
252249
clientIds,
253250
selectPrevious,
254251
blockNamesForPrompt
255252
) {
256253
return {
257-
type: 'DISPLAY_REMOVAL_PROMPT',
254+
type: 'DISPLAY_BLOCK_REMOVAL_PROMPT',
258255
clientIds,
259256
selectPrevious,
260257
blockNamesForPrompt,
@@ -268,24 +265,36 @@ export function displayRemovalPrompt(
268265
*
269266
* @return {Object} Action object.
270267
*/
271-
export function clearRemovalPrompt() {
268+
export function clearBlockRemovalPrompt() {
272269
return {
273-
type: 'CLEAR_REMOVAL_PROMPT',
270+
type: 'CLEAR_BLOCK_REMOVAL_PROMPT',
274271
};
275272
}
276273

277274
/**
278-
* Returns an action object used in signalling that a removal prompt display
279-
* mechanism is available or unavailable in the current editor.
275+
* Returns an action object used to set up any rules that a block editor may
276+
* provide in order to prevent a user from accidentally removing certain
277+
* blocks. These rules are then used to display a confirmation prompt to the
278+
* user. For instance, in the Site Editor, the Query Loop block is important
279+
* enough to warrant such confirmation.
280+
*
281+
* IMPORTANT: Registering rules implicitly signals to the `privateRemoveBlocks`
282+
* action that the editor will be responsible for displaying block removal
283+
* prompts and confirming deletions. This action is meant to be used by
284+
* component `BlockRemovalWarningModal` only.
285+
*
286+
* The data is a record whose keys are block types (e.g. 'core/query') and
287+
* whose values are the explanation to be shown to users (e.g. 'Query Loop
288+
* displays a list of posts or pages.').
280289
*
281-
* Contrast with `displayRemovalPrompt`.
290+
* Contrast with `displayBlockRemovalPrompt`.
282291
*
283-
* @param {boolean} status Whether a prompt display mechanism exists.
292+
* @param {Record<string,string>|false} rules Block removal rules.
284293
* @return {Object} Action object.
285294
*/
286-
export function toggleRemovalPromptSupport( status = true ) {
295+
export function setBlockRemovalRules( rules = false ) {
287296
return {
288-
type: 'TOGGLE_REMOVAL_PROMPT_SUPPORT',
289-
status,
297+
type: 'SET_BLOCK_REMOVAL_RULES',
298+
rules,
290299
};
291300
}

packages/block-editor/src/store/private-selectors.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,6 @@ export function getRemovalPromptData( state ) {
205205
*
206206
* @return {boolean} Whether removal prompt exists.
207207
*/
208-
export function isRemovalPromptSupported( state ) {
209-
return state.isRemovalPromptSupported;
208+
export function getBlockRemovalRules( state ) {
209+
return state.blockRemovalRules;
210210
}

packages/block-editor/src/store/reducer.js

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,32 +1480,40 @@ export function isSelectionEnabled( state = true, action ) {
14801480
*/
14811481
function removalPromptData( state = false, action ) {
14821482
switch ( action.type ) {
1483-
case 'DISPLAY_REMOVAL_PROMPT':
1483+
case 'DISPLAY_BLOCK_REMOVAL_PROMPT':
14841484
const { clientIds, selectPrevious, blockNamesForPrompt } = action;
14851485
return {
14861486
clientIds,
14871487
selectPrevious,
14881488
blockNamesForPrompt,
14891489
};
1490-
case 'CLEAR_REMOVAL_PROMPT':
1490+
case 'CLEAR_BLOCK_REMOVAL_PROMPT':
14911491
return false;
14921492
}
14931493

14941494
return state;
14951495
}
14961496

14971497
/**
1498-
* Reducer prompt availability state.
1498+
* Reducer returning any rules that a block editor may provide in order to
1499+
* prevent a user from accidentally removing certain blocks. These rules are
1500+
* then used to display a confirmation prompt to the user. For instance, in the
1501+
* Site Editor, the Query Loop block is important enough to warrant such
1502+
* confirmation.
1503+
*
1504+
* The data is a record whose keys are block types (e.g. 'core/query') and
1505+
* whose values are the explanation to be shown to users (e.g. 'Query Loop
1506+
* displays a list of posts or pages.').
14991507
*
15001508
* @param {boolean} state Current state.
15011509
* @param {Object} action Dispatched action.
15021510
*
1503-
* @return {boolean} Updated state.
1511+
* @return {Record<string,string>} Updated state.
15041512
*/
1505-
function isRemovalPromptSupported( state = false, action ) {
1513+
function blockRemovalRules( state = false, action ) {
15061514
switch ( action.type ) {
1507-
case 'TOGGLE_REMOVAL_PROMPT_SUPPORT':
1508-
return action.status;
1515+
case 'SET_BLOCK_REMOVAL_RULES':
1516+
return action.rules;
15091517
}
15101518

15111519
return state;
@@ -1930,7 +1938,7 @@ const combinedReducers = combineReducers( {
19301938
blockVisibility,
19311939
blockEditingModes,
19321940
removalPromptData,
1933-
isRemovalPromptSupported,
1941+
blockRemovalRules,
19341942
} );
19351943

19361944
function withAutomaticChangeReset( reducer ) {

packages/block-editor/src/store/test/actions.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,7 @@ describe( 'actions', () => {
618618
const select = {
619619
getBlockRootClientId: () => undefined,
620620
canRemoveBlocks: () => true,
621-
isRemovalPromptSupported: () => false,
621+
getBlockRemovalRules: () => false,
622622
};
623623
const dispatch = Object.assign( jest.fn(), {
624624
selectPreviousBlock: jest.fn(),
@@ -729,7 +729,7 @@ describe( 'actions', () => {
729729
const select = {
730730
getBlockRootClientId: () => null,
731731
canRemoveBlocks: () => true,
732-
isRemovalPromptSupported: () => false,
732+
getBlockRemovalRules: () => false,
733733
};
734734
const dispatch = Object.assign( jest.fn(), {
735735
selectPreviousBlock: jest.fn(),
@@ -754,7 +754,7 @@ describe( 'actions', () => {
754754
const select = {
755755
getBlockRootClientId: () => null,
756756
canRemoveBlocks: () => true,
757-
isRemovalPromptSupported: () => false,
757+
getBlockRemovalRules: () => false,
758758
};
759759
const dispatch = Object.assign( jest.fn(), {
760760
selectPreviousBlock: jest.fn(),

packages/edit-site/src/components/editor/index.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ const typeLabels = {
6363
wp_block: __( 'Pattern' ),
6464
};
6565

66+
// Prevent accidental removal of certain blocks, asking the user for
67+
// confirmation.
68+
const blockRemovalRules = {
69+
'core/query': __( 'Query Loop displays a list of posts or pages.' ),
70+
'core/post-content': __(
71+
'Post Content displays the content of a post or page.'
72+
),
73+
};
74+
6675
export default function Editor( { isLoading } ) {
6776
const {
6877
record: editedPost,
@@ -197,7 +206,9 @@ export default function Editor( { isLoading } ) {
197206
{ showVisualEditor && editedPost && (
198207
<>
199208
<BlockEditor />
200-
<BlockRemovalWarningModal />
209+
<BlockRemovalWarningModal
210+
rules={ blockRemovalRules }
211+
/>
201212
</>
202213
) }
203214
{ editorMode === 'text' &&
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
5+
6+
test.describe( 'Site editor block removal prompt', () => {
7+
test.beforeAll( async ( { requestUtils } ) => {
8+
await requestUtils.activateTheme( 'emptytheme' );
9+
} );
10+
11+
test.afterAll( async ( { requestUtils } ) => {
12+
await requestUtils.activateTheme( 'twentytwentyone' );
13+
} );
14+
15+
test.beforeEach( async ( { admin, editor } ) => {
16+
await admin.visitSiteEditor( {
17+
postId: 'emptytheme//index',
18+
postType: 'wp_template',
19+
} );
20+
await editor.canvas.click( 'body' );
21+
} );
22+
23+
test( 'should appear when attempting to remove Query Block', async ( {
24+
page,
25+
} ) => {
26+
// Open and focus List View
27+
const topBar = page.getByRole( 'region', { name: 'Editor top bar' } );
28+
await topBar.getByRole( 'button', { name: 'List View' } ).click();
29+
30+
// Select and try to remove Query Loop block
31+
const listView = page.getByRole( 'region', { name: 'List View' } );
32+
await listView.getByRole( 'link', { name: 'Query Loop' } ).click();
33+
await page.keyboard.press( 'Backspace' );
34+
35+
// Expect the block removal prompt to have appeared
36+
await expect(
37+
page.getByText( 'Query Loop displays a list of posts or pages.' )
38+
).toBeVisible();
39+
} );
40+
41+
test( 'should not appear when attempting to remove something else', async ( {
42+
editor,
43+
page,
44+
} ) => {
45+
// Open and focus List View
46+
const topBar = page.getByRole( 'region', { name: 'Editor top bar' } );
47+
await topBar.getByRole( 'button', { name: 'List View' } ).click();
48+
49+
// Select Query Loop list item
50+
const listView = page.getByRole( 'region', { name: 'List View' } );
51+
await listView.getByRole( 'link', { name: 'Query Loop' } ).click();
52+
53+
// Reveal its inner blocks in the list view
54+
await page.keyboard.press( 'ArrowRight' );
55+
56+
// Select and remove its Post Template inner block
57+
await listView.getByRole( 'link', { name: 'Post Template' } ).click();
58+
await page.keyboard.press( 'Backspace' );
59+
60+
// Expect the block to have been removed with no prompt
61+
await expect(
62+
editor.canvas.getByRole( 'document', {
63+
name: 'Block: Post Template',
64+
} )
65+
).toBeHidden();
66+
} );
67+
} );

0 commit comments

Comments
 (0)