Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Columns block: Prevent reducing column count from deleting content #34893

Draft
wants to merge 4 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
175 changes: 58 additions & 117 deletions packages/block-library/src/columns/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import { __ } from '@wordpress/i18n';
import {
Notice,
PanelBody,
RangeControl,
ToggleControl,
__experimentalToggleGroupControl as ToggleGroupControl,
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';

import {
InspectorControls,
useInnerBlocksProps,
Expand All @@ -25,20 +25,14 @@ import {
} from '@wordpress/block-editor';
import { useDispatch, useSelect, useRegistry } from '@wordpress/data';
import {
createBlock,
createBlocksFromInnerBlocksTemplate,
store as blocksStore,
} from '@wordpress/blocks';

/**
* Internal dependencies
*/
import {
hasExplicitPercentColumnWidths,
getMappedColumnWidths,
getRedistributedColumnWidths,
toWidthPrecision,
} from './utils';
import { getRevisedColumns, getDispensableIndexes } from './utils';

const DEFAULT_BLOCK = {
name: 'core/column',
Expand All @@ -49,129 +43,76 @@ function ColumnInspectorControls( {
setAttributes,
isStackedOnMobile,
} ) {
const { count, canInsertColumnBlock, minCount } = useSelect(
const { columns, canInsertColumn } = useSelect(
( select ) => {
const {
canInsertBlockType,
canRemoveBlock,
getBlocks,
getBlockCount,
} = select( blockEditorStore );
const innerBlocks = getBlocks( clientId );

// Get the indexes of columns for which removal is prevented.
// The highest index will be used to determine the minimum column count.
const preventRemovalBlockIndexes = innerBlocks.reduce(
( acc, block, index ) => {
if ( ! canRemoveBlock( block.clientId ) ) {
acc.push( index );
}
return acc;
},
[]
);

const selectors = select( blockEditorStore );
return {
count: getBlockCount( clientId ),
canInsertColumnBlock: canInsertBlockType(
columns: selectors.getBlocks( clientId ),
canInsertColumn: selectors.canInsertBlockType(
'core/column',
clientId
),
minCount: Math.max( ...preventRemovalBlockIndexes ) + 1,
};
},
[ clientId ]
);
const { getBlocks } = useSelect( blockEditorStore );
const { replaceInnerBlocks } = useDispatch( blockEditorStore );

/**
* Updates the column count, including necessary revisions to child Column
* blocks to grant required or redistribute available space.
*
* @param {number} previousColumns Previous column count.
* @param {number} newColumns New column count.
*/
function updateColumns( previousColumns, newColumns ) {
let innerBlocks = getBlocks( clientId );
const hasExplicitWidths = hasExplicitPercentColumnWidths( innerBlocks );

// Redistribute available width for existing inner blocks.
const isAddingColumn = newColumns > previousColumns;

if ( isAddingColumn && hasExplicitWidths ) {
// If adding a new column, assign width to the new column equal to
// as if it were `1 / columns` of the total available space.
const newColumnWidth = toWidthPrecision( 100 / newColumns );
const newlyAddedColumns = newColumns - previousColumns;

// Redistribute in consideration of pending block insertion as
// constraining the available working width.
const widths = getRedistributedColumnWidths(
innerBlocks,
100 - newColumnWidth * newlyAddedColumns
);

innerBlocks = [
...getMappedColumnWidths( innerBlocks, widths ),
...Array.from( {
length: newlyAddedColumns,
} ).map( () => {
return createBlock( 'core/column', {
width: `${ newColumnWidth }%`,
} );
} ),
];
} else if ( isAddingColumn ) {
innerBlocks = [
...innerBlocks,
...Array.from( {
length: newColumns - previousColumns,
} ).map( () => {
return createBlock( 'core/column' );
} ),
];
} else if ( newColumns < previousColumns ) {
// The removed column will be the last of the inner blocks.
innerBlocks = innerBlocks.slice(
0,
-( previousColumns - newColumns )
);
if ( hasExplicitWidths ) {
// Redistribute as if block is already removed.
const widths = getRedistributedColumnWidths( innerBlocks, 100 );

innerBlocks = getMappedColumnWidths( innerBlocks, widths );
}
let quantityControl;
if ( canInsertColumn ) {
const updateColumns = ( nextCount ) => {
const revisedColumns = getRevisedColumns( columns, nextCount );
replaceInnerBlocks( clientId, revisedColumns );
};
// TODO: here canRemoveBlock is no longer used and it’d be due diligence to make
// sure there’s not a need to use it. getDispensableIndexes just checks attributes.lock.remove
// but maybe that can suffice in this context.
const dispensableColumns = getDispensableIndexes( columns );
const count = columns.length;
const countMin = Math.max( 1, count - dispensableColumns.length );
const countMax = 6;
const optionList = [];
for ( let i = 1; i <= countMax; i++ ) {
const disabled = i < countMin;
const itemProps = { disabled, value: i, label: i, key: i };
optionList.push( <ToggleGroupControlOption { ...itemProps } /> );
}

replaceInnerBlocks( clientId, innerBlocks );
if ( count > countMax ) {
const itemProps = { value: count, label: count, key: count };
optionList.push( <ToggleGroupControlOption { ...itemProps } /> );
}
let help;
if ( countMin > 6 ) {
help = `Options disabled due to six or more columns being nonempty or locked.`;
} else if ( countMin > 1 ) {
help = `Options for fewer than ${ countMin } disabled due to some columns being nonempty or locked.`;
}
quantityControl = (
<>
<ToggleGroupControl
help={ help }
isBlock
label={ __( 'Columns' ) }
onChange={ updateColumns }
value={ count }
__next40pxDefaultSize
__nextHasNoMarginBottom
>
{ optionList }
</ToggleGroupControl>
{ count > 6 && (
<Notice status="warning" isDismissible={ false }>
{ __(
'This column count exceeds the recommended amount and may cause visual breakage.'
) }
</Notice>
) }
</>
);
}

return (
<PanelBody title={ __( 'Settings' ) }>
{ canInsertColumnBlock && (
<>
<RangeControl
__nextHasNoMarginBottom
__next40pxDefaultSize
label={ __( 'Columns' ) }
value={ count }
onChange={ ( value ) =>
updateColumns( count, Math.max( minCount, value ) )
}
min={ Math.max( 1, minCount ) }
max={ Math.max( 6, count ) }
/>
{ count > 6 && (
<Notice status="warning" isDismissible={ false }>
{ __(
'This column count exceeds the recommended amount and may cause visual breakage.'
) }
</Notice>
) }
</>
) }
{ quantityControl }
<ToggleControl
__nextHasNoMarginBottom
label={ __( 'Stack on mobile' ) }
Expand Down
86 changes: 86 additions & 0 deletions packages/block-library/src/columns/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* WordPress dependencies
*/
import { createBlock } from '@wordpress/blocks';

/**
* Returns a column width attribute value rounded to standard precision.
* Returns `undefined` if the value is not a valid finite number.
Expand Down Expand Up @@ -174,3 +179,84 @@ export function getWidthWithUnit( width, unit ) {
export function isPercentageUnit( unit ) {
return unit === '%';
}

/**
* Returns indexes of inner blocks that aren’t locked from removal and have no
* inner blocks of their own.
*
* @param {Array} blockList A block list as returned by `getBlocks`.
*/
export function getDispensableIndexes( blockList ) {
return blockList.reduce(
( vacants, { attributes, innerBlocks: { length } }, index ) =>
attributes.lock?.remove || length ? vacants : [ ...vacants, index ],
[]
);
}

/**
* Creates new child Column blocks by adding or removing columns and revises
* existant column widths to grant required or redistribute available space.
* When removing columns it does not remove any that are locked or have their
* own children.
*
* @param {Array} currentBlocks Current inner blocks.
* @param {number} newCount New column count.
*/
export function getRevisedColumns( currentBlocks, newCount ) {
const currentCount = currentBlocks.length;
const hasExplicitWidths = hasExplicitPercentColumnWidths( currentBlocks );

// Redistribute available width for existing inner blocks.
const isAddingColumn = newCount > currentCount;

let innerBlocks;
if ( isAddingColumn && hasExplicitWidths ) {
// If adding a new column, assign width to the new column equal to
// as if it were `1 / columns` of the total available space.
const newColumnWidth = toWidthPrecision( 100 / newCount );

// Redistribute in consideration of pending block insertion as
// constraining the available working width.
const widths = getRedistributedColumnWidths(
currentBlocks,
100 - newColumnWidth
);

innerBlocks = [
...getMappedColumnWidths( currentBlocks, widths ),
...Array.from( {
length: newCount - currentCount,
} ).map( () => {
return createBlock( 'core/column', {
width: `${ newColumnWidth }%`,
} );
} ),
];
} else if ( isAddingColumn ) {
innerBlocks = [
...currentBlocks,
...Array.from( {
length: newCount - currentCount,
} ).map( () => {
return createBlock( 'core/column' );
} ),
];
} else {
// Removes dispensable columns.
const dispensableIndexes = getDispensableIndexes( currentBlocks );
const difference = currentCount - newCount;
const indexesToRemove = dispensableIndexes.slice( -difference );
innerBlocks = currentBlocks.filter(
( item, index ) => ! indexesToRemove.includes( index )
);

if ( hasExplicitWidths ) {
// Redistribute as if block is already removed.
const widths = getRedistributedColumnWidths( innerBlocks, 100 );

innerBlocks = getMappedColumnWidths( innerBlocks, widths );
}
}
return innerBlocks;
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
.click();

// Tweak the columns count.
await page.getByRole( 'spinbutton', { name: 'Columns' } ).fill( '3' );
await page.getByRole( 'radio', { name: '3' } ).check();

// Wait for the new column block to appear in the list view
const column = listView.getByRole( 'gridcell', {
Expand Down Expand Up @@ -129,8 +129,8 @@
// Navigate to the block settings sidebar and tweak the column count.
await pageUtils.pressKeys( 'Tab', { times: 5 } );
await expect(
page.getByRole( 'slider', { name: 'Columns' } )
page.getByRole( 'radiogroup', { name: 'Columns' } )
).toBeFocused();

Check failure on line 133 in test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js

View workflow job for this annotation

GitHub Actions / Playwright - 3

[chromium] › editor/various/block-hierarchy-navigation.spec.js:95:2 › Navigating the block hierarchy › should navigate block hierarchy using only the keyboard

1) [chromium] › editor/various/block-hierarchy-navigation.spec.js:95:2 › Navigating the block hierarchy › should navigate block hierarchy using only the keyboard Error: Timed out 5000ms waiting for expect(locator).toBeFocused() Locator: getByRole('radiogroup', { name: 'Columns' }) Expected: focused Received: inactive Call log: - expect.toBeFocused with timeout 5000ms - waiting for getByRole('radiogroup', { name: 'Columns' }) - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control cc-c-c-b-aaeadc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control cc-c-c-b-aaeadc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control cc-c-c-b-aaeadc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control cc-c-c-b-aaeadc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control cc-c-c-b-aaeadc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control cc-c-c-b-aaeadc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control cc-c-c-b-aaeadc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control cc-c-c-b-aaeadc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control cc-c-c-b-aaeadc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" 131 | await expect( 132 | page.getByRole( 'radiogroup', { name: 'Columns' } ) > 133 | ).toBeFocused(); | ^ 134 | await page.keyboard.press( 'ArrowRight' ); 135 | 136 | // Navigate to the third column in the columns block via List View. at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js:133:5

Check failure on line 133 in test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js

View workflow job for this annotation

GitHub Actions / Playwright - 3

[chromium] › editor/various/block-hierarchy-navigation.spec.js:95:2 › Navigating the block hierarchy › should navigate block hierarchy using only the keyboard

1) [chromium] › editor/various/block-hierarchy-navigation.spec.js:95:2 › Navigating the block hierarchy › should navigate block hierarchy using only the keyboard Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: Timed out 5000ms waiting for expect(locator).toBeFocused() Locator: getByRole('radiogroup', { name: 'Columns' }) Expected: focused Received: inactive Call log: - expect.toBeFocused with timeout 5000ms - waiting for getByRole('radiogroup', { name: 'Columns' }) - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control bfffc-f-a-f-eacf-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control bfffc-f-a-f-eacf-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control bfffc-f-a-f-eacf-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control bfffc-f-a-f-eacf-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control bfffc-f-a-f-eacf-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control bfffc-f-a-f-eacf-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control bfffc-f-a-f-eacf-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control bfffc-f-a-f-eacf-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control bfffc-f-a-f-eacf-o41qmk e19lxcc00">…</div> - unexpected value "not focused" 131 | await expect( 132 | page.getByRole( 'radiogroup', { name: 'Columns' } ) > 133 | ).toBeFocused(); | ^ 134 | await page.keyboard.press( 'ArrowRight' ); 135 | 136 | // Navigate to the third column in the columns block via List View. at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js:133:5

Check failure on line 133 in test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js

View workflow job for this annotation

GitHub Actions / Playwright - 3

[chromium] › editor/various/block-hierarchy-navigation.spec.js:95:2 › Navigating the block hierarchy › should navigate block hierarchy using only the keyboard

1) [chromium] › editor/various/block-hierarchy-navigation.spec.js:95:2 › Navigating the block hierarchy › should navigate block hierarchy using only the keyboard Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: Timed out 5000ms waiting for expect(locator).toBeFocused() Locator: getByRole('radiogroup', { name: 'Columns' }) Expected: focused Received: inactive Call log: - expect.toBeFocused with timeout 5000ms - waiting for getByRole('radiogroup', { name: 'Columns' }) - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control dff-e--bdc-ffaecc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control dff-e--bdc-ffaecc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control dff-e--bdc-ffaecc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control dff-e--bdc-ffaecc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control dff-e--bdc-ffaecc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control dff-e--bdc-ffaecc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control dff-e--bdc-ffaecc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control dff-e--bdc-ffaecc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" - locator resolved to <div role="radiogroup" data-wp-c16t="true" aria-label="Columns" data-wp-component="ToggleGroupControl" id="toggle-group-control-as-radio-group-4" class="components-toggle-group-control dff-e--bdc-ffaecc-o41qmk e19lxcc00">…</div> - unexpected value "not focused" 131 | await expect( 132 | page.getByRole( 'radiogroup', { name: 'Columns' } ) > 133 | ).toBeFocused(); | ^ 134 | await page.keyboard.press( 'ArrowRight' ); 135 | 136 | // Navigate to the third column in the columns block via List View. at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/block-hierarchy-navigation.spec.js:133:5
await page.keyboard.press( 'ArrowRight' );

// Navigate to the third column in the columns block via List View.
Expand Down
Loading