Skip to content

Commit

Permalink
Implemented nesting support in document outline. (#5314)
Browse files Browse the repository at this point in the history
Before nested headings were totally ignored now we show them with a representation of their nesting path.
  • Loading branch information
jorgefilipecosta authored Apr 6, 2018
1 parent ab8ddc2 commit 80185d7
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 23 deletions.
39 changes: 30 additions & 9 deletions editor/components/document-outline/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { countBy, filter, get } from 'lodash';
import { countBy, flatMap, get } from 'lodash';

/**
* WordPress dependencies
Expand Down Expand Up @@ -55,11 +55,36 @@ const getHeadingLevel = heading => {
return 6;
}
};
/**
* Returns an array of heading blocks enhanced with the following properties:
* path - An array of blocks that are ancestors of the heading starting from a top-level node.
* Can be an empty array if the heading is a top-level node (is not nested inside another block).
* level - An integer with the heading level.
* isEmpty - Flag indicating if the heading has no content.
*
* @param {?Array} blocks An array of blocks.
* @param {?Array} path An array of blocks that are ancestors of the blocks passed as blocks.
*
* @return {Array} An array of heading blocks enhanced with the properties described above.
*/
const computeOutlineHeadings = ( blocks = [], path = [] ) => {
return flatMap( blocks, ( block = {} ) => {
if ( block.name === 'core/heading' ) {
return {
...block,
path,
level: getHeadingLevel( block ),
isEmpty: isEmptyHeading( block ),
};
}
return computeOutlineHeadings( block.innerBlocks, [ ...path, block ] );
} );
};

const isEmptyHeading = heading => ! heading.attributes.content || heading.attributes.content.length === 0;

export const DocumentOutline = ( { blocks = [], title, onSelect, isTitleSupported } ) => {
const headings = filter( blocks, ( block ) => block.name === 'core/heading' );
const headings = computeOutlineHeadings( blocks );

if ( headings.length < 1 ) {
return null;
Expand All @@ -79,12 +104,7 @@ export const DocumentOutline = ( { blocks = [], title, onSelect, isTitleSupporte
};

const hasTitle = isTitleSupported && title;
const items = headings.map( ( heading ) => ( {
...heading,
level: getHeadingLevel( heading ),
isEmpty: isEmptyHeading( heading ),
} ) );
const countByLevel = countBy( items, 'level' );
const countByLevel = countBy( headings, 'level' );
const hasMultipleH1 = countByLevel[ 1 ] > 1;

return (
Expand All @@ -99,7 +119,7 @@ export const DocumentOutline = ( { blocks = [], title, onSelect, isTitleSupporte
{ title }
</DocumentOutlineItem>
) }
{ items.map( ( item, index ) => {
{ headings.map( ( item, index ) => {
// Headings remain the same, go up by one, or down by any amount.
// Otherwise there are missing levels.
const isIncorrectLevel = item.level > prevHeadingLevel + 1;
Expand All @@ -118,6 +138,7 @@ export const DocumentOutline = ( { blocks = [], title, onSelect, isTitleSupporte
level={ `H${ item.level }` }
isValid={ isValid }
onClick={ () => onSelectHeading( item.uid ) }
path={ item.path }
>
{ item.isEmpty ? emptyHeadingContent : item.attributes.content }
{ isIncorrectLevel && incorrectLevelContent }
Expand Down
15 changes: 15 additions & 0 deletions editor/components/document-outline/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ import classnames from 'classnames';
*/
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import BlockTitle from '../block-title';

const TableOfContentsItem = ( {
children,
isValid,
level,
onClick,
path = [],
} ) => (
<li
className={ classnames(
Expand All @@ -28,6 +34,15 @@ const TableOfContentsItem = ( {
onClick={ onClick }
>
<span className="document-outline__emdash" aria-hidden="true"></span>
{
// path is an array of nodes that are ancestors of the heading starting in the top level node.
// This mapping renders each ancestor to make it easier for the user to know where the headings are nested.
path.map( ( { uid }, index ) => (
<strong key={ index } className="document-outline__level">
<BlockTitle uid={ uid } />
</strong>
) )
}
<strong className="document-outline__level">
{ level }
</strong>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ exports[`DocumentOutline header blocks present should match snapshot 1`] = `
key="0"
level="H2"
onClick={[Function]}
path={Array []}
>
Heading parent
</TableOfContentsItem>
Expand All @@ -18,6 +19,7 @@ exports[`DocumentOutline header blocks present should match snapshot 1`] = `
key="1"
level="H3"
onClick={[Function]}
path={Array []}
>
Heading child
</TableOfContentsItem>
Expand All @@ -35,6 +37,7 @@ exports[`DocumentOutline header blocks present should render warnings for multip
key="0"
level="H1"
onClick={[Function]}
path={Array []}
>
Heading 1
<br
Expand All @@ -51,6 +54,7 @@ exports[`DocumentOutline header blocks present should render warnings for multip
key="1"
level="H1"
onClick={[Function]}
path={Array []}
>
Heading 1
<br
Expand Down
34 changes: 33 additions & 1 deletion editor/components/document-outline/test/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { shallow } from 'enzyme';
import { mount, shallow } from 'enzyme';

/**
* WordPress dependencies
Expand All @@ -13,6 +13,8 @@ import { createBlock, registerCoreBlocks } from '@wordpress/blocks';
*/
import { DocumentOutline } from '../';

jest.mock( '../../block-title', () => () => 'Block Title' );

describe( 'DocumentOutline', () => {
registerCoreBlocks();

Expand All @@ -30,6 +32,8 @@ describe( 'DocumentOutline', () => {
nodeName: 'H3',
} );

const nestedHeading = createBlock( 'core/columns', undefined, [ headingChild ] );

describe( 'no header blocks present', () => {
it( 'should not render when no blocks provided', () => {
const wrapper = shallow( <DocumentOutline /> );
Expand Down Expand Up @@ -74,4 +78,32 @@ describe( 'DocumentOutline', () => {
expect( wrapper ).toMatchSnapshot();
} );
} );

describe( 'nested headings', () => {
it( 'should render even if the heading is nested', () => {
const tableOfContentItemsSelector = 'TableOfContentsItem';
const outlineLevelsSelector = '.document-outline__level';
const outlineItemContentSelector = '.document-outline__item-content';

const blocks = [ headingParent, nestedHeading ];
const wrapper = mount( <DocumentOutline blocks={ blocks } /> );

//heading parent and nested heading should appear as items
const tableOfContentItems = wrapper.find( tableOfContentItemsSelector );
expect( tableOfContentItems ).toHaveLength( 2 );

//heading parent test
const firstItemLevels = tableOfContentItems.at( 0 ).find( outlineLevelsSelector );
expect( firstItemLevels ).toHaveLength( 1 );
expect( firstItemLevels.at( 0 ).text() ).toEqual( 'H2' );
expect( tableOfContentItems.at( 0 ).find( outlineItemContentSelector ).text() ).toEqual( 'Heading parent' );

//nested heading test
const secondItemLevels = tableOfContentItems.at( 1 ).find( outlineLevelsSelector );
expect( secondItemLevels ).toHaveLength( 2 );
expect( secondItemLevels.at( 0 ).text() ).toEqual( 'Block Title' );
expect( secondItemLevels.at( 1 ).text() ).toEqual( 'H3' );
expect( tableOfContentItems.at( 1 ).find( outlineItemContentSelector ).text() ).toEqual( 'Heading child' );
} );
} );
} );
22 changes: 9 additions & 13 deletions editor/components/table-of-contents/panel.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
/**
* External dependencies
*/
import { countBy } from 'lodash';

/**
* WordPress dependencies
*/
Expand All @@ -16,9 +11,7 @@ import { withSelect } from '@wordpress/data';
import WordCount from '../word-count';
import DocumentOutline from '../document-outline';

function TableOfContentsPanel( { blocks } ) {
const blockCount = countBy( blocks, 'name' );

function TableOfContentsPanel( { headingCount, paragraphCount, numberOfBlocks } ) {
return (
<Fragment>
<div
Expand All @@ -34,23 +27,23 @@ function TableOfContentsPanel( { blocks } ) {
<div className="table-of-contents__count">
{ __( 'Headings' ) }
<span className="table-of-contents__number">
{ blockCount[ 'core/heading' ] || 0 }
{ headingCount }
</span>
</div>
<div className="table-of-contents__count">
{ __( 'Paragraphs' ) }
<span className="table-of-contents__number">
{ blockCount[ 'core/paragraph' ] || 0 }
{ paragraphCount }
</span>
</div>
<div className="table-of-contents__count">
{ __( 'Blocks' ) }
<span className="table-of-contents__number">
{ blocks.length }
{ numberOfBlocks }
</span>
</div>
</div>
{ blockCount[ 'core/heading' ] > 0 && (
{ headingCount > 0 && (
<Fragment>
<hr />
<span className="table-of-contents__title">
Expand All @@ -64,7 +57,10 @@ function TableOfContentsPanel( { blocks } ) {
}

export default withSelect( ( select ) => {
const { getGlobalBlockCount } = select( 'core/editor' );
return {
blocks: select( 'core/editor' ).getBlocks(),
headingCount: getGlobalBlockCount( 'core/heading' ),
paragraphCount: getGlobalBlockCount( 'core/paragraph' ),
numberOfBlocks: getGlobalBlockCount(),
};
} )( TableOfContentsPanel );
26 changes: 26 additions & 0 deletions editor/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
has,
last,
reduce,
size,
compact,
find,
unionWith,
Expand Down Expand Up @@ -475,6 +476,31 @@ export const getBlocks = createSelector(
]
);

/**
* Returns the total number of blocks, or the total number of blocks with a specific name in a post.
* The number returned includes nested blocks.
*
* @param {Object} state Global application state.
* @param {?String} blockName Optional block name, if specified only blocks of that type will be counted.
*
* @return {number} Number of blocks in the post, or number of blocks with name equal to blockName.
*/
export const getGlobalBlockCount = createSelector(
( state, blockName ) => {
if ( ! blockName ) {
return size( state.editor.present.blocksByUid );
}
return reduce(
state.editor.present.blocksByUid,
( count, block ) => block.name === blockName ? count + 1 : count,
0
);
},
( state ) => [
state.editor.present.blocksByUid,
]
);

export const getBlocksByUID = createSelector(
( state, uids ) => {
return map( uids, ( uid ) => getBlock( state, uid ) );
Expand Down
47 changes: 47 additions & 0 deletions editor/store/test/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const {
getSelectedBlock,
getBlockRootUID,
getEditedPostAttribute,
getGlobalBlockCount,
getMultiSelectedBlockUids,
getMultiSelectedBlocks,
getMultiSelectedBlocksStartUid,
Expand Down Expand Up @@ -1486,6 +1487,52 @@ describe( 'selectors', () => {
} );
} );

describe( 'getGlobalBlockCount', () => {
it( 'should return the global number of top-level blocks in the post', () => {
const state = {
editor: {
present: {
blocksByUid: {
23: { uid: 23, name: 'core/heading', attributes: {} },
123: { uid: 123, name: 'core/paragraph', attributes: {} },
},
},
},
};

expect( getGlobalBlockCount( state ) ).toBe( 2 );
} );

it( 'should return the global umber of blocks of a given type', () => {
const state = {
editor: {
present: {
blocksByUid: {
123: { uid: 123, name: 'core/columns', attributes: {} },
456: { uid: 456, name: 'core/paragraph', attributes: {} },
789: { uid: 789, name: 'core/paragraph', attributes: {} },
124: { uid: 123, name: 'core/heading', attributes: {} },
},
},
},
};

expect( getGlobalBlockCount( state, 'core/heading' ) ).toBe( 1 );
} );

it( 'should return 0 if no blocks exist', () => {
const state = {
editor: {
present: {
blocksByUid: {
},
},
},
};
expect( getGlobalBlockCount( state ) ).toBe( 0 );
expect( getGlobalBlockCount( state, 'core/heading' ) ).toBe( 0 );
} );
} );
describe( 'getSelectedBlock', () => {
it( 'should return null if no block is selected', () => {
const state = {
Expand Down

0 comments on commit 80185d7

Please sign in to comment.