diff --git a/packages/block-editor/src/components/alignment-toolbar/index.js b/packages/block-editor/src/components/alignment-toolbar/index.js index 981e8d6f9914d3..aa0233ae0f7518 100644 --- a/packages/block-editor/src/components/alignment-toolbar/index.js +++ b/packages/block-editor/src/components/alignment-toolbar/index.js @@ -27,7 +27,15 @@ const DEFAULT_ALIGNMENT_CONTROLS = [ }, ]; -export function AlignmentToolbar( { value, onChange, alignmentControls = DEFAULT_ALIGNMENT_CONTROLS, isCollapsed = true } ) { +export function AlignmentToolbar( props ) { + const { + value, + onChange, + alignmentControls = DEFAULT_ALIGNMENT_CONTROLS, + label = __( 'Change text alignment' ), + isCollapsed = true, + } = props; + function applyOrUnset( align ) { return () => onChange( value === align ? undefined : align ); } @@ -38,7 +46,7 @@ export function AlignmentToolbar( { value, onChange, alignmentControls = DEFAULT { const { align } = control; const isActive = ( value === align ); diff --git a/packages/block-library/src/table/block.json b/packages/block-library/src/table/block.json index a96913bbc12b5b..539c4df88ab7c7 100644 --- a/packages/block-library/src/table/block.json +++ b/packages/block-library/src/table/block.json @@ -34,6 +34,11 @@ "type": "string", "source": "attribute", "attribute": "scope" + }, + "align": { + "type": "string", + "source": "attribute", + "attribute": "data-align" } } } @@ -64,6 +69,11 @@ "type": "string", "source": "attribute", "attribute": "scope" + }, + "align": { + "type": "string", + "source": "attribute", + "attribute": "data-align" } } } @@ -94,6 +104,11 @@ "type": "string", "source": "attribute", "attribute": "scope" + }, + "align": { + "type": "string", + "source": "attribute", + "attribute": "data-align" } } } diff --git a/packages/block-library/src/table/edit.js b/packages/block-library/src/table/edit.js index 3c5f6c11fe2776..cb7324a3efe77e 100644 --- a/packages/block-library/src/table/edit.js +++ b/packages/block-library/src/table/edit.js @@ -14,6 +14,7 @@ import { PanelColorSettings, createCustomColorsHOC, BlockIcon, + AlignmentToolbar, } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; import { @@ -31,13 +32,15 @@ import { */ import { createTable, - updateCellContent, + updateSelectedCell, + getCellAttribute, insertRow, deleteRow, insertColumn, deleteColumn, toggleSection, isEmptyTableSection, + isCellSelected, } from './state'; import icon from './icon'; @@ -64,6 +67,24 @@ const BACKGROUND_COLORS = [ }, ]; +const ALIGNMENT_CONTROLS = [ + { + icon: 'editor-alignleft', + title: __( 'Align Column Left' ), + align: 'left', + }, + { + icon: 'editor-aligncenter', + title: __( 'Align Column Center' ), + align: 'center', + }, + { + icon: 'editor-alignright', + title: __( 'Align Column Right' ), + align: 'right', + }, +]; + const withCustomBackgroundColors = createCustomColorsHOC( BACKGROUND_COLORS ); export class TableEdit extends Component { @@ -87,6 +108,8 @@ export class TableEdit extends Component { this.onDeleteColumn = this.onDeleteColumn.bind( this ); this.onToggleHeaderSection = this.onToggleHeaderSection.bind( this ); this.onToggleFooterSection = this.onToggleFooterSection.bind( this ); + this.onChangeColumnAlignment = this.onChangeColumnAlignment.bind( this ); + this.getCellAlignment = this.getCellAlignment.bind( this ); this.state = { initialRowCount: 2, @@ -156,14 +179,73 @@ export class TableEdit extends Component { } const { attributes, setAttributes } = this.props; - const { section, rowIndex, columnIndex } = selectedCell; - setAttributes( updateCellContent( attributes, { - section, - rowIndex, - columnIndex, - content, - } ) ); + setAttributes( updateSelectedCell( + attributes, + selectedCell, + ( cellAttributes ) => ( { ...cellAttributes, content } ), + ) ); + } + + /** + * Align text within the a column. + * + * @param {string} align The new alignment to apply to the column. + */ + onChangeColumnAlignment( align ) { + const { selectedCell } = this.state; + + if ( ! selectedCell ) { + return; + } + + // Convert the cell selection to a column selection so that alignment + // is applied to the entire column. + const columnSelection = { + type: 'column', + columnIndex: selectedCell.columnIndex, + }; + + const { attributes, setAttributes } = this.props; + const newAttributes = updateSelectedCell( + attributes, + columnSelection, + ( cellAttributes ) => ( { ...cellAttributes, align } ), + ); + setAttributes( newAttributes ); + } + + /** + * Get the alignment of the currently selected cell. + * + * @return {string} The new alignment to apply to the column. + */ + getCellAlignment() { + const { selectedCell } = this.state; + + if ( ! selectedCell ) { + return; + } + + const { attributes } = this.props; + + return getCellAttribute( attributes, selectedCell, 'align' ); + } + + /** + * Add or remove a `head` table section. + */ + onToggleHeaderSection() { + const { attributes, setAttributes } = this.props; + setAttributes( toggleSection( attributes, 'head' ) ); + } + + /** + * Add or remove a `foot` table section. + */ + onToggleFooterSection() { + const { attributes, setAttributes } = this.props; + setAttributes( toggleSection( attributes, 'foot' ) ); } /** @@ -179,11 +261,11 @@ export class TableEdit extends Component { } const { attributes, setAttributes } = this.props; - const { section, rowIndex } = selectedCell; + const { sectionName, rowIndex } = selectedCell; this.setState( { selectedCell: null } ); setAttributes( insertRow( attributes, { - section, + sectionName, rowIndex: rowIndex + delta, } ) ); } @@ -202,16 +284,6 @@ export class TableEdit extends Component { this.onInsertRow( 1 ); } - onToggleHeaderSection() { - const { attributes, setAttributes } = this.props; - setAttributes( toggleSection( attributes, 'head' ) ); - } - - onToggleFooterSection() { - const { attributes, setAttributes } = this.props; - setAttributes( toggleSection( attributes, 'foot' ) ); - } - /** * Deletes the currently selected row. */ @@ -223,10 +295,10 @@ export class TableEdit extends Component { } const { attributes, setAttributes } = this.props; - const { section, rowIndex } = selectedCell; + const { sectionName, rowIndex } = selectedCell; this.setState( { selectedCell: null } ); - setAttributes( deleteRow( attributes, { section, rowIndex } ) ); + setAttributes( deleteRow( attributes, { sectionName, rowIndex } ) ); } /** @@ -275,23 +347,28 @@ export class TableEdit extends Component { } const { attributes, setAttributes } = this.props; - const { section, columnIndex } = selectedCell; + const { sectionName, columnIndex } = selectedCell; this.setState( { selectedCell: null } ); - setAttributes( deleteColumn( attributes, { section, columnIndex } ) ); + setAttributes( deleteColumn( attributes, { sectionName, columnIndex } ) ); } /** * Creates an onFocus handler for a specified cell. * - * @param {Object} selectedCell Object with `section`, `rowIndex`, and + * @param {Object} cellLocation Object with `section`, `rowIndex`, and * `columnIndex` properties. * * @return {Function} Function to call on focus. */ - createOnFocus( selectedCell ) { + createOnFocus( cellLocation ) { return () => { - this.setState( { selectedCell } ); + this.setState( { + selectedCell: { + ...cellLocation, + type: 'cell', + }, + } ); }; } @@ -351,32 +428,31 @@ export class TableEdit extends Component { * * @return {Object} React element for the section. */ - renderSection( { type, rows } ) { + renderSection( { name, rows } ) { if ( isEmptyTableSection( rows ) ) { return null; } - const Tag = `t${ type }`; + const Tag = `t${ name }`; const { selectedCell } = this.state; return ( { rows.map( ( { cells }, rowIndex ) => ( - { cells.map( ( { content, tag: CellTag, scope }, columnIndex ) => { - const isSelected = selectedCell && ( - type === selectedCell.section && - rowIndex === selectedCell.rowIndex && - columnIndex === selectedCell.columnIndex - ); - - const cell = { - section: type, + { cells.map( ( { content, tag: CellTag, scope, align }, columnIndex ) => { + const cellLocation = { + sectionName: name, rowIndex, columnIndex, }; - const cellClasses = classnames( { 'is-selected': isSelected } ); + const isSelected = isCellSelected( cellLocation, selectedCell ); + + const cellClasses = classnames( { + 'is-selected': isSelected, + [ `has-text-align-${ align }` ]: align, + } ); return ( ); @@ -467,6 +543,13 @@ export class TableEdit extends Component { controls={ this.getTableControls() } /> + this.onChangeColumnAlignment( nextAlign ) } + onHover={ this.onHoverAlignment } + /> @@ -502,9 +585,9 @@ export class TableEdit extends Component {
-
-
-
+
+
+
diff --git a/packages/block-library/src/table/save.js b/packages/block-library/src/table/save.js index 086a0221c045b5..0911f20fe85bfd 100644 --- a/packages/block-library/src/table/save.js +++ b/packages/block-library/src/table/save.js @@ -40,14 +40,22 @@ export default function save( { attributes } ) { { rows.map( ( { cells }, rowIndex ) => ( - { cells.map( ( { content, tag, scope }, cellIndex ) => - - ) } + { cells.map( ( { content, tag, scope, align }, cellIndex ) => { + const cellClasses = classnames( { + [ `has-text-align-${ align }` ]: align, + } ); + + return ( + + ); + } ) } ) ) } diff --git a/packages/block-library/src/table/state.js b/packages/block-library/src/table/state.js index 4aab2f3ee3d54b..89fd1c0227ef35 100644 --- a/packages/block-library/src/table/state.js +++ b/packages/block-library/src/table/state.js @@ -1,7 +1,9 @@ /** * External dependencies */ -import { times, get, mapValues, every } from 'lodash'; +import { times, get, mapValues, every, pick } from 'lodash'; + +const INHERITED_COLUMN_ATTRIBUTES = [ 'align' ]; /** * Creates a table state. @@ -26,70 +28,153 @@ export function createTable( { } /** - * Updates cell content in the table state. + * Returns the first row in the table. * - * @param {Object} state Current table state. - * @param {string} options.section Section of the cell to update. - * @param {number} options.rowIndex Row index of the cell to update. - * @param {number} options.columnIndex Column index of the cell to update. - * @param {Array} options.content Content to set for the cell. + * @param {Object} state Current table state. * - * @return {Object} New table state. + * @return {Object} The first table row. */ -export function updateCellContent( state, { - section, - rowIndex, - columnIndex, - content, -} ) { - return { - [ section ]: state[ section ].map( ( row, currentRowIndex ) => { - if ( currentRowIndex !== rowIndex ) { +export function getFirstRow( state ) { + if ( ! isEmptyTableSection( state.head ) ) { + return state.head[ 0 ]; + } + if ( ! isEmptyTableSection( state.body ) ) { + return state.body[ 0 ]; + } + if ( ! isEmptyTableSection( state.foot ) ) { + return state.foot[ 0 ]; + } +} + +/** + * Gets an attribute for a cell. + * + * @param {Object} state Current table state. + * @param {Object} cellLocation The location of the cell + * @param {string} attributeName The name of the attribute to get the value of. + * + * @return {*} The attribute value. + */ +export function getCellAttribute( state, cellLocation, attributeName ) { + const { + sectionName, + rowIndex, + columnIndex, + } = cellLocation; + return get( state, [ sectionName, rowIndex, 'cells', columnIndex, attributeName ] ); +} + +/** + * Returns updated cell attributes after applying the `updateCell` function to the selection. + * + * @param {Object} state The block attributes. + * @param {Object} selection The selection of cells to update. + * @param {Function} updateCell A function to update the selected cell attributes. + * + * @return {Object} New table state including the updated cells. + */ +export function updateSelectedCell( state, selection, updateCell ) { + if ( ! selection ) { + return state; + } + + const tableSections = pick( state, [ 'head', 'body', 'foot' ] ); + const { + sectionName: selectionSectionName, + rowIndex: selectionRowIndex, + } = selection; + + return mapValues( tableSections, ( section, sectionName ) => { + if ( selectionSectionName && selectionSectionName !== sectionName ) { + return section; + } + + return section.map( ( row, rowIndex ) => { + if ( selectionRowIndex && selectionRowIndex !== rowIndex ) { return row; } return { - cells: row.cells.map( ( cell, currentColumnIndex ) => { - if ( currentColumnIndex !== columnIndex ) { - return cell; + cells: row.cells.map( ( cellAttributes, columnIndex ) => { + const cellLocation = { + sectionName, + columnIndex, + rowIndex, + }; + + if ( ! isCellSelected( cellLocation, selection ) ) { + return cellAttributes; } - return { - ...cell, - content, - }; + return updateCell( cellAttributes ); } ), }; - } ), - }; + } ); + } ); +} + +/** + * Returns whether the cell at `cellLocation` is included in the selection `selection`. + * + * @param {Object} cellLocation An object containing cell location properties. + * @param {Object} selection An object containing selection properties. + * + * @return {boolean} True if the cell is selected, false otherwise. + */ +export function isCellSelected( cellLocation, selection ) { + if ( ! cellLocation || ! selection ) { + return false; + } + + switch ( selection.type ) { + case 'column': + return selection.type === 'column' && cellLocation.columnIndex === selection.columnIndex; + case 'cell': + return selection.type === 'cell' && + cellLocation.sectionName === selection.sectionName && + cellLocation.columnIndex === selection.columnIndex && + cellLocation.rowIndex === selection.rowIndex; + } } /** * Inserts a row in the table state. * - * @param {Object} state Current table state. - * @param {string} options.section Section in which to insert the row. - * @param {number} options.rowIndex Row index at which to insert the row. + * @param {Object} state Current table state. + * @param {string} options.sectionName Section in which to insert the row. + * @param {number} options.rowIndex Row index at which to insert the row. * * @return {Object} New table state. */ export function insertRow( state, { - section, + sectionName, rowIndex, columnCount, } ) { - const cellCount = columnCount || state[ section ][ 0 ].cells.length; + const firstRow = getFirstRow( state ); + const cellCount = columnCount === undefined ? get( firstRow, [ 'cells', 'length' ] ) : columnCount; + + // Bail early if the function cannot determine how many cells to add. + if ( ! cellCount ) { + return state; + } return { - [ section ]: [ - ...state[ section ].slice( 0, rowIndex ), + [ sectionName ]: [ + ...state[ sectionName ].slice( 0, rowIndex ), { - cells: times( cellCount, () => ( { - content: '', - tag: section === 'head' ? 'th' : 'td', - } ) ), + cells: times( cellCount, ( index ) => { + const firstCellInColumn = get( firstRow, [ 'cells', index ], {} ); + const inheritedAttributes = pick( firstCellInColumn, INHERITED_COLUMN_ATTRIBUTES ); + + return { + ...inheritedAttributes, + content: '', + tag: sectionName === 'head' ? 'th' : 'td', + }; + } ), }, - ...state[ section ].slice( rowIndex ), + ...state[ sectionName ].slice( rowIndex ), ], }; } @@ -97,18 +182,18 @@ export function insertRow( state, { /** * Deletes a row from the table state. * - * @param {Object} state Current table state. - * @param {string} options.section Section in which to delete the row. - * @param {number} options.rowIndex Row index to delete. + * @param {Object} state Current table state. + * @param {string} options.sectionName Section in which to delete the row. + * @param {number} options.rowIndex Row index to delete. * * @return {Object} New table state. */ export function deleteRow( state, { - section, + sectionName, rowIndex, } ) { return { - [ section ]: state[ section ].filter( ( row, index ) => index !== rowIndex ), + [ sectionName ]: state[ sectionName ].filter( ( row, index ) => index !== rowIndex ), }; } @@ -116,7 +201,6 @@ export function deleteRow( state, { * Inserts a column in the table state. * * @param {Object} state Current table state. - * @param {string} options.section Section in which to insert the column. * @param {number} options.columnIndex Column index at which to insert the column. * * @return {Object} New table state. @@ -124,7 +208,9 @@ export function deleteRow( state, { export function insertColumn( state, { columnIndex, } ) { - return mapValues( state, ( section, sectionName ) => { + const tableSections = pick( state, [ 'head', 'body', 'foot' ] ); + + return mapValues( tableSections, ( section, sectionName ) => { // Bail early if the table section is empty. if ( isEmptyTableSection( section ) ) { return section; @@ -155,7 +241,6 @@ export function insertColumn( state, { * Deletes a column from the table state. * * @param {Object} state Current table state. - * @param {string} options.section Section in which to delete the column. * @param {number} options.columnIndex Column index to delete. * * @return {Object} New table state. @@ -163,7 +248,9 @@ export function insertColumn( state, { export function deleteColumn( state, { columnIndex, } ) { - return mapValues( state, ( section ) => { + const tableSections = pick( state, [ 'head', 'body', 'foot' ] ); + + return mapValues( tableSections, ( section ) => { // Bail early if the table section is empty. if ( isEmptyTableSection( section ) ) { return section; @@ -178,33 +265,33 @@ export function deleteColumn( state, { /** * Toggles the existance of a section. * - * @param {Object} state Current table state. - * @param {string} section Name of the section to toggle. + * @param {Object} state Current table state. + * @param {string} sectionName Name of the section to toggle. * * @return {Object} New table state. */ -export function toggleSection( state, section ) { +export function toggleSection( state, sectionName ) { // Section exists, replace it with an empty row to remove it. - if ( ! isEmptyTableSection( state[ section ] ) ) { - return { [ section ]: [] }; + if ( ! isEmptyTableSection( state[ sectionName ] ) ) { + return { [ sectionName ]: [] }; } // Get the length of the first row of the body to use when creating the header. const columnCount = get( state, [ 'body', 0, 'cells', 'length' ], 1 ); // Section doesn't exist, insert an empty row to create the section. - return insertRow( state, { section, rowIndex: 0, columnCount } ); + return insertRow( state, { sectionName, rowIndex: 0, columnCount } ); } /** * Determines whether a table section is empty. * - * @param {Object} sectionRows Table section state. + * @param {Object} section Table section state. * * @return {boolean} True if the table section is empty, false otherwise. */ -export function isEmptyTableSection( sectionRows ) { - return ! sectionRows || ! sectionRows.length || every( sectionRows, isEmptyRow ); +export function isEmptyTableSection( section ) { + return ! section || ! section.length || every( section, isEmptyRow ); } /** diff --git a/packages/block-library/src/table/test/state.js b/packages/block-library/src/table/test/state.js index cce7fa9836bc70..b946d45fcb50b7 100644 --- a/packages/block-library/src/table/test/state.js +++ b/packages/block-library/src/table/test/state.js @@ -8,7 +8,8 @@ import deepFreeze from 'deep-freeze'; */ import { createTable, - updateCellContent, + getFirstRow, + getCellAttribute, insertRow, deleteRow, insertColumn, @@ -16,6 +17,8 @@ import { toggleSection, isEmptyTableSection, isEmptyRow, + isCellSelected, + updateSelectedCell, } from '../state'; const table = deepFreeze( { @@ -47,6 +50,32 @@ const table = deepFreeze( { ], } ); +const tableWithHead = deepFreeze( { + head: [ + { + cells: [ + { + content: 'test', + tag: 'th', + }, + ], + }, + ], +} ); + +const tableWithFoot = deepFreeze( { + foot: [ + { + cells: [ + { + content: 'test', + tag: 'td', + }, + ], + }, + ], +} ); + const tableWithContent = deepFreeze( { body: [ { @@ -76,6 +105,36 @@ const tableWithContent = deepFreeze( { ], } ); +const tableWithAttribute = deepFreeze( { + body: [ + { + cells: [ + { + content: '', + tag: 'td', + }, + { + content: '', + tag: 'td', + }, + ], + }, + { + cells: [ + { + content: '', + tag: 'td', + }, + { + testAttr: 'testVal', + content: '', + tag: 'td', + }, + ], + }, + ], +} ); + describe( 'createTable', () => { it( 'should create a table', () => { const state = createTable( { rowCount: 2, columnCount: 2 } ); @@ -84,23 +143,41 @@ describe( 'createTable', () => { } ); } ); -describe( 'updateCellContent', () => { - it( 'should update cell content', () => { - const state = updateCellContent( table, { - section: 'body', +describe( 'getFirstRow', () => { + it( 'returns the first row in the head when the body is the first table section', () => { + expect( getFirstRow( tableWithHead ) ).toBe( tableWithHead.head[ 0 ] ); + } ); + + it( 'returns the first row in the body when the body is the first table section', () => { + expect( getFirstRow( table ) ).toBe( table.body[ 0 ] ); + } ); + + it( 'returns the first row in the foot when the body is the first table section', () => { + expect( getFirstRow( tableWithFoot ) ).toBe( tableWithFoot.foot[ 0 ] ); + } ); + + it( 'returns `undefined` for an empty table', () => { + expect( getFirstRow( {} ) ).toBeUndefined(); + } ); +} ); + +describe( 'getCellAttribute', () => { + it( 'should get the cell attribute', () => { + const cellLocation = { + sectionName: 'body', rowIndex: 1, columnIndex: 1, - content: 'test', - } ); + }; + const state = getCellAttribute( tableWithAttribute, cellLocation, 'testAttr' ); - expect( state ).toEqual( tableWithContent ); + expect( state ).toBe( 'testVal' ); } ); } ); describe( 'insertRow', () => { it( 'should insert row', () => { const state = insertRow( tableWithContent, { - section: 'body', + sectionName: 'body', rowIndex: 2, } ); @@ -150,7 +227,7 @@ describe( 'insertRow', () => { it( 'allows the number of columns to be specified', () => { const state = insertRow( tableWithContent, { - section: 'body', + sectionName: 'body', rowIndex: 2, columnCount: 4, } ); @@ -207,11 +284,16 @@ describe( 'insertRow', () => { expect( state ).toEqual( expected ); } ); - it( 'adds `th` cells to the head', () => { - const tableWithHead = { - head: [ + it( 'inherits the `align` property from the first cell in the column when adding a new row', () => { + const tableWithAlignment = { + body: [ { cells: [ + { + align: 'right', + content: 'test', + tag: 'th', + }, { content: '', tag: 'th', @@ -221,15 +303,20 @@ describe( 'insertRow', () => { ], }; - const state = insertRow( tableWithHead, { - section: 'head', + const state = insertRow( tableWithAlignment, { + sectionName: 'body', rowIndex: 1, } ); - const expected = { - head: [ + expect( state ).toEqual( { + body: [ { cells: [ + { + align: 'right', + content: 'test', + tag: 'th', + }, { content: '', tag: 'th', @@ -239,21 +326,27 @@ describe( 'insertRow', () => { { cells: [ { + align: 'right', content: '', - tag: 'th', + tag: 'td', + }, + { + content: '', + tag: 'td', }, ], }, ], - }; - - expect( state ).toEqual( expected ); + } ); } ); -} ); -describe( 'insertColumn', () => { - it( 'inserts before existing content by default', () => { - const tableWithHead = { + it( 'adds `th` cells to the head', () => { + const state = insertRow( tableWithHead, { + sectionName: 'head', + rowIndex: 1, + } ); + + const expected = { head: [ { cells: [ @@ -263,9 +356,43 @@ describe( 'insertColumn', () => { }, ], }, + { + cells: [ + { + content: '', + tag: 'th', + }, + ], + }, ], }; + expect( state ).toEqual( expected ); + } ); + + it( 'should have no effect if `columnCount` is not provided and the table has no existing rows', () => { + const existingState = { body: {} }; + const newState = insertRow( existingState, { + sectionName: 'body', + rowIndex: 0, + } ); + + expect( newState ).toBe( existingState ); + } ); + + it( 'should have no effect if `columnCount` is `0`', () => { + const state = insertRow( tableWithHead, { + sectionName: 'head', + rowIndex: 1, + columnCount: 0, + } ); + + expect( state ).toBe( tableWithHead ); + } ); +} ); + +describe( 'insertColumn', () => { + it( 'inserts before existing content by default', () => { const state = insertColumn( tableWithHead, { columnIndex: 0, } ); @@ -336,19 +463,6 @@ describe( 'insertColumn', () => { } ); it( 'adds `th` cells to the head', () => { - const tableWithHead = { - head: [ - { - cells: [ - { - content: '', - tag: 'th', - }, - ], - }, - ], - }; - const state = insertColumn( tableWithHead, { columnIndex: 1, } ); @@ -358,7 +472,7 @@ describe( 'insertColumn', () => { { cells: [ { - content: '', + content: 'test', tag: 'th', }, { @@ -374,7 +488,7 @@ describe( 'insertColumn', () => { } ); it( 'avoids adding cells to empty rows', () => { - const tableWithHead = { + const tableWithEmptyRow = { head: [ { cells: [ @@ -390,7 +504,7 @@ describe( 'insertColumn', () => { ], }; - const state = insertColumn( tableWithHead, { + const state = insertColumn( tableWithEmptyRow, { columnIndex: 0, } ); @@ -417,8 +531,8 @@ describe( 'insertColumn', () => { expect( state ).toEqual( expected ); } ); - it( 'adds cells across table sections that already have cells', () => { - const tableWithHead = { + it( 'adds cells across table sections that already have rows', () => { + const tableWithAllSections = { head: [ { cells: [ @@ -451,7 +565,7 @@ describe( 'insertColumn', () => { ], }; - const state = insertColumn( tableWithHead, { + const state = insertColumn( tableWithAllSections, { columnIndex: 1, } ); @@ -504,7 +618,7 @@ describe( 'insertColumn', () => { } ); it( 'adds cells only to rows that have enough cells when rows have an unequal number of cells', () => { - const tableWithHead = { + const tableWithUnequalColumns = { head: [ { cells: [ @@ -545,7 +659,7 @@ describe( 'insertColumn', () => { ], }; - const state = insertColumn( tableWithHead, { + const state = insertColumn( tableWithUnequalColumns, { columnIndex: 3, } ); @@ -601,7 +715,7 @@ describe( 'insertColumn', () => { describe( 'deleteRow', () => { it( 'should delete row', () => { const state = deleteRow( tableWithContent, { - section: 'body', + sectionName: 'body', rowIndex: 0, } ); @@ -629,7 +743,6 @@ describe( 'deleteRow', () => { describe( 'deleteColumn', () => { it( 'should delete column', () => { const state = deleteColumn( tableWithContent, { - section: 'body', columnIndex: 0, } ); @@ -679,7 +792,6 @@ describe( 'deleteColumn', () => { ], }; const state = deleteColumn( tableWithOneColumn, { - section: 'body', columnIndex: 0, } ); @@ -732,7 +844,6 @@ describe( 'deleteColumn', () => { ], }; const state = deleteColumn( tableWithOneColumn, { - section: 'body', columnIndex: 0, } ); @@ -788,7 +899,6 @@ describe( 'deleteColumn', () => { }; const state = deleteColumn( tableWithOneColumn, { - section: 'body', columnIndex: 1, } ); @@ -831,19 +941,6 @@ describe( 'deleteColumn', () => { describe( 'toggleSection', () => { it( 'removes rows from the head section if the table already has them', () => { - const tableWithHead = { - head: [ - { - cells: [ - { - content: '', - tag: 'th', - }, - ], - }, - ], - }; - const state = toggleSection( tableWithHead, 'head' ); const expected = { @@ -854,11 +951,11 @@ describe( 'toggleSection', () => { } ); it( 'adds a row to the head section if the table has none', () => { - const tableWithHead = { + const tableWithEmptyHead = { head: [], }; - const state = toggleSection( tableWithHead, 'head' ); + const state = toggleSection( tableWithEmptyHead, 'head' ); const expected = { head: [ @@ -877,7 +974,7 @@ describe( 'toggleSection', () => { } ); it( 'uses the number of cells in the first row of the body for the added table row', () => { - const tableWithHead = { + const tableWithEmptyHead = { head: [], body: [ { @@ -899,7 +996,7 @@ describe( 'toggleSection', () => { ], }; - const state = toggleSection( tableWithHead, 'head' ); + const state = toggleSection( tableWithEmptyHead, 'head' ); const expected = { head: [ @@ -996,3 +1093,122 @@ describe( 'isEmptyRow', () => { expect( isEmptyRow( row ) ).toBe( false ); } ); } ); + +describe( 'isCellSelected', () => { + it( 'returns false when no cellLocation is provided', () => { + const tableSelection = { type: 'table' }; + + expect( isCellSelected( undefined, tableSelection ) ).toBe( false ); + } ); + + it( 'returns false when no selection is provided', () => { + const cellLocation = { sectionName: 'head', columnIndex: 0, rowIndex: 0 }; + + expect( isCellSelected( cellLocation ) ).toBe( false ); + } ); + + it( `considers only cells with the same columnIndex to be selected when the selection.type is 'column'`, () => { + // Valid locations and selections. + const headCellLocationA = { sectionName: 'head', columnIndex: 0, rowIndex: 0 }; + const headCellLocationB = { sectionName: 'head', columnIndex: 0, rowIndex: 1 }; + const bodyCellLocationA = { sectionName: 'body', columnIndex: 0, rowIndex: 0 }; + const bodyCellLocationB = { sectionName: 'body', columnIndex: 0, rowIndex: 1 }; + const footCellLocationA = { sectionName: 'foot', columnIndex: 0, rowIndex: 0 }; + const footCellLocationB = { sectionName: 'foot', columnIndex: 0, rowIndex: 1 }; + const columnSelection = { type: 'column', columnIndex: 0 }; + + // Invalid locations and selections. + const otherColumnCellLocationA = { sectionName: 'head', columnIndex: 1, rowIndex: 0 }; + const otherColumnCellLocationB = { sectionName: 'body', columnIndex: 2, rowIndex: 0 }; + const otherColumnCellLocationC = { sectionName: 'foot', columnIndex: 3, rowIndex: 0 }; + + expect( isCellSelected( headCellLocationA, columnSelection ) ).toBe( true ); + expect( isCellSelected( headCellLocationB, columnSelection ) ).toBe( true ); + expect( isCellSelected( bodyCellLocationA, columnSelection ) ).toBe( true ); + expect( isCellSelected( bodyCellLocationB, columnSelection ) ).toBe( true ); + expect( isCellSelected( footCellLocationA, columnSelection ) ).toBe( true ); + expect( isCellSelected( footCellLocationB, columnSelection ) ).toBe( true ); + expect( isCellSelected( otherColumnCellLocationA, columnSelection ) ).toBe( false ); + expect( isCellSelected( otherColumnCellLocationB, columnSelection ) ).toBe( false ); + expect( isCellSelected( otherColumnCellLocationC, columnSelection ) ).toBe( false ); + } ); + + it( `considers only cells with the same section, columnIndex and rowIndex to be selected when the selection.type is 'cell'`, () => { + // Valid locations and selections. + const cellLocation = { sectionName: 'head', columnIndex: 0, rowIndex: 0 }; + const cellSelection = { type: 'cell', sectionName: 'head', rowIndex: 0, columnIndex: 0 }; + + // Invalid locations and selections. + const otherColumnCellLocation = { sectionName: 'head', columnIndex: 1, rowIndex: 0 }; + const otherRowCellLocation = { sectionName: 'head', columnIndex: 0, rowIndex: 1 }; + const bodyCellLocation = { sectionName: 'body', columnIndex: 0, rowIndex: 0 }; + const footCellLocation = { sectionName: 'foot', columnIndex: 0, rowIndex: 0 }; + + expect( isCellSelected( cellLocation, cellSelection ) ).toBe( true ); + expect( isCellSelected( otherColumnCellLocation, cellSelection ) ).toBe( false ); + expect( isCellSelected( otherRowCellLocation, cellSelection ) ).toBe( false ); + expect( isCellSelected( bodyCellLocation, cellSelection ) ).toBe( false ); + expect( isCellSelected( footCellLocation, cellSelection ) ).toBe( false ); + } ); +} ); + +describe( 'updateSelectedCell', () => { + it( 'returns an unchanged table state if there is no selection', () => { + const updated = updateSelectedCell( table, undefined, ( cell ) => ( { ...cell, content: 'test' } ) ); + expect( table ).toEqual( updated ); + } ); + + it( 'returns an unchanged table state if the selection is outside the bounds of the table', () => { + const cellSelection = { type: 'cell', sectionName: 'body', rowIndex: 100, columnIndex: 100 }; + const updated = updateSelectedCell( table, cellSelection, ( cell ) => ( { ...cell, content: 'test' } ) ); + expect( table ).toEqual( updated ); + } ); + + it( 'updates only the individual cell when the selection type is `cell`', () => { + const cellSelection = { type: 'cell', sectionName: 'body', rowIndex: 0, columnIndex: 0 }; + const updated = updateSelectedCell( table, cellSelection, ( cell ) => ( { ...cell, content: 'test' } ) ); + + expect( updated ).toEqual( { + body: [ + { + cells: [ + { + ...table.body[ 0 ].cells[ 0 ], + content: 'test', + }, + table.body[ 0 ].cells[ 1 ], + ], + }, + table.body[ 1 ], + ], + } ); + } ); + + it( 'updates every cell in the column when the selection type is `column`', () => { + const cellSelection = { type: 'column', columnIndex: 1 }; + const updated = updateSelectedCell( table, cellSelection, ( cell ) => ( { ...cell, content: 'test' } ) ); + + expect( updated ).toEqual( { + body: [ + { + cells: [ + table.body[ 0 ].cells[ 0 ], + { + ...table.body[ 0 ].cells[ 1 ], + content: 'test', + }, + ], + }, + { + cells: [ + table.body[ 1 ].cells[ 0 ], + { + ...table.body[ 1 ].cells[ 1 ], + content: 'test', + }, + ], + }, + ], + } ); + } ); +} ); diff --git a/packages/e2e-tests/specs/blocks/__snapshots__/table.test.js.snap b/packages/e2e-tests/specs/blocks/__snapshots__/table.test.js.snap index 1065dc2615e2e5..37189e26d2f43f 100644 --- a/packages/e2e-tests/specs/blocks/__snapshots__/table.test.js.snap +++ b/packages/e2e-tests/specs/blocks/__snapshots__/table.test.js.snap @@ -12,6 +12,12 @@ exports[`Table allows adding and deleting columns across the table header, body " `; +exports[`Table allows columns to be aligned 1`] = ` +" +
NoneTo the leftCenteredRight aligned
+" +`; + exports[`Table allows header and footer rows to be switched on and off 1`] = ` "
header
body
footer
diff --git a/packages/e2e-tests/specs/blocks/table.test.js b/packages/e2e-tests/specs/blocks/table.test.js index 8da1506c1c51ec..19e9a0ca51bef3 100644 --- a/packages/e2e-tests/specs/blocks/table.test.js +++ b/packages/e2e-tests/specs/blocks/table.test.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import { capitalize } from 'lodash'; + /** * WordPress dependencies */ @@ -10,6 +15,17 @@ import { const createButtonSelector = "//div[@data-type='core/table']//button[text()='Create Table']"; +/** + * Utility function for changing the selected cell alignment. + * + * @param {string} align The alignment (one of 'left', 'center', or 'right'). + */ +async function changeCellAlignment( align ) { + await clickBlockToolbarButton( 'Change column alignment' ); + const alignButton = await page.$x( `//button[text()='Align Column ${ capitalize( align ) }']` ); + await alignButton[ 0 ].click(); +} + describe( 'Table', () => { beforeEach( async () => { await createNewPost(); @@ -29,22 +45,22 @@ describe( 'Table', () => { await page.keyboard.press( 'Backspace' ); await page.keyboard.type( '5' ); - // // Check for existence of the row count field. + // Check for existence of the row count field. const rowCountLabel = await page.$x( "//div[@data-type='core/table']//label[text()='Row Count']" ); expect( rowCountLabel ).toHaveLength( 1 ); - // // Modify the row count. + // Modify the row count. await rowCountLabel[ 0 ].click(); const currentRowCount = await page.evaluate( () => document.activeElement.value ); expect( currentRowCount ).toBe( '2' ); await page.keyboard.press( 'Backspace' ); await page.keyboard.type( '10' ); - // // Create the table. + // Create the table. const createButton = await page.$x( createButtonSelector ); await createButton[ 0 ].click(); - // // Expect the post content to have a correctly sized table. + // Expect the post content to have a correctly sized table. expect( await getEditedPostContent() ).toMatchSnapshot(); } ); @@ -154,4 +170,40 @@ describe( 'Table', () => { // Expect the table to have 2 columns across the header, body and footer. expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'allows columns to be aligned', async () => { + await insertBlock( 'Table' ); + + const [ columnCountLabel ] = await page.$x( "//div[@data-type='core/table']//label[text()='Column Count']" ); + await columnCountLabel.click(); + await page.keyboard.press( 'Backspace' ); + await page.keyboard.type( '4' ); + + // Create the table. + const [ createButton ] = await page.$x( createButtonSelector ); + await createButton.click(); + + // Click the first cell and add some text. Don't align. + const cells = await page.$$( '.wp-block-table__cell-content' ); + await cells[ 0 ].click(); + await page.keyboard.type( 'None' ); + + // Click to the next cell and add some text. Align left. + await cells[ 1 ].click(); + await page.keyboard.type( 'To the left' ); + await changeCellAlignment( 'left' ); + + // Click the next cell and add some text. Align center. + await cells[ 2 ].click(); + await page.keyboard.type( 'Centered' ); + await changeCellAlignment( 'center' ); + + // Tab to the next cell and add some text. Align right. + await cells[ 3 ].click(); + await page.keyboard.type( 'Right aligned' ); + await changeCellAlignment( 'right' ); + + // Expect the post to have the correct written content inside the table. + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } );