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

Implemented nesting support in document outline. #5314

Merged
merged 1 commit into from Apr 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
39 changes: 30 additions & 9 deletions editor/components/document-outline/index.js
@@ -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
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
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
@@ -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
@@ -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
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
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