Skip to content

Commit

Permalink
PLANET-7652 Made Table of Content Block editable
Browse files Browse the repository at this point in the history
- Added code to convert ToC block to a static list
- List block is used to build new content which is editable
- Also added code to ensure page navigation for links when converted
  • Loading branch information
Osong-Michael committed Dec 16, 2024
1 parent 2cab2f5 commit 4655ba0
Show file tree
Hide file tree
Showing 4 changed files with 284 additions and 34 deletions.
205 changes: 171 additions & 34 deletions assets/src/blocks/TableOfContents/TableOfContentsEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,109 @@ import {makeHierarchical} from './makeHierarchical';
import {getHeadingsFromBlocks} from './getHeadingsFromBlocks';
import {deepClone} from '../../functions/deepClone';

const {useSelect} = wp.data;
const {InspectorControls, RichText} = wp.blockEditor;
const {Button, PanelBody} = wp.components;
const TRANSLATION_ID = 'planet4-blocks-backend';

const BLOCK_TITLE = 'table-of-contents';

const BLOCK_NAME = {
TABLE_OF_CONTENTS: 'planet4-blocks/submenu',
EDITOR: 'core/block-editor',
LIST: 'core/list',
LIST_ITEM: 'core/list-item',
HEADING: 'core/heading',
GROUP: 'core/group',
};

const CLASS_NAME = {
HELP: 'components-base-control__help',
LIST: 'list-style',
};

const {useSelect, select, dispatch} = wp.data;
const {InspectorControls, RichText, BlockControls} = wp.blockEditor;
const {Button, PanelBody, ToolbarItem} = wp.components;
const {createBlock} = wp.blocks;
const {__} = wp.i18n;

/**
* Renders the edit view of the Table of Contents block with controls for managing levels.
*
* @param {Object} attributes - The block attributes.
* @param {Function} setAttributes - Function to update block attributes.
* @return {JSX.Element} The rendered edit view.
*/
const renderEdit = (attributes, setAttributes) => {
/**
* Adds a new level to the Table of Contents.
*/
function addLevel() {
const [previousLastLevel] = attributes.levels.slice(-1);
const newLevel = previousLastLevel.heading + 1;
setAttributes({levels: attributes.levels.concat({heading: newLevel, link: false, style: 'none'})});
}

/**
* Updates the heading level for a specific item.
*
* @param {number} index - Index of the level to update.
* @param {string} value - New heading value.
*/
function onHeadingChange(index, value) {
const levels = deepClone(attributes.levels);
levels[index].heading = Number(value);
setAttributes({levels});
}

/**
* Updates the link attribute for a specific item.
*
* @param {number} index - Index of the level to update.
* @param {string} value - New link value.
*/
function onLinkChange(index, value) {
const levels = deepClone(attributes.levels);
levels[index].link = value;
setAttributes({levels});
}

/**
* Updates the style attribute for a specific item.
*
* @param {number} index - Index of the level to update.
* @param {string} value - New style value, can be "none", "bullet", or "number".
*/
function onStyleChange(index, value) {
const levels = deepClone(attributes.levels);
levels[index].style = value; // Possible values: "none", "bullet", "number"
levels[index].style = value;
setAttributes({levels});
}

/**
* Removes the last level from the Table of Contents.
*/
function removeLevel() {
setAttributes({levels: attributes.levels.slice(0, -1)});
}

/**
* Gets the minimum heading level for a specific index.
*
* @param {Object} attr - Block attributes.
* @param {number} index - Index of the level.
* @return {number|null} Minimum heading value or null for the first index.
*/
function getMinLevel(attr, index) {
if (index === 0) {
return null;
}

return attr.levels[index - 1].heading;
}

return (
<InspectorControls>
<PanelBody title={__('Settings', 'planet4-blocks-backend')}>
<p className="components-base-control__help">
{__('Choose the headings to be displayed in the table of contents.', 'planet4-blocks-backend')}
<PanelBody title={__('Settings', TRANSLATION_ID)}>
<p className={CLASS_NAME.HELP}>
{__('Choose the headings to be displayed in the table of contents.', TRANSLATION_ID)}
</p>
{attributes.levels.map((level, i) => (
<TableOfContentsLevel
Expand All @@ -70,19 +126,19 @@ const renderEdit = (attributes, setAttributes) => {
disabled={attributes.levels.length >= 3 || attributes.levels.slice(-1)[0].heading === 0}
style={{marginRight: 5}}
>
{__('Add level', 'planet4-blocks-backend')}
{__('Add level', TRANSLATION_ID)}
</Button>
<Button
variant="secondary"
onClick={removeLevel}
disabled={attributes.levels.length <= 1}
>
{__('Remove level', 'planet4-blocks-backend')}
{__('Remove level', TRANSLATION_ID)}
</Button>
</PanelBody>
<PanelBody title={__('Learn more about this block', 'planet4-blocks-backend')} initialOpen={false}>
<p className="components-base-control__help">
<a target="_blank" href="https://planet4.greenpeace.org/content/blocks/table-of-contents/" rel="noreferrer">
<PanelBody title={__('Learn more about this block', TRANSLATION_ID)} initialOpen={false}>
<p className={CLASS_NAME.HELP}>
<a target="_blank" href={`https://planet4.greenpeace.org/content/blocks/${BLOCK_TITLE}/`} rel="noreferrer">
P4 Handbook P4 Table of Contents
</a>
{' '} &#128203;
Expand All @@ -92,6 +148,14 @@ const renderEdit = (attributes, setAttributes) => {
);
};

/**
* Renders the view of the Table of Contents block.
*
* @param {Object} attributes - The block attributes.
* @param {Function} setAttributes - Function to update block attributes.
* @param {string} className - The CSS class for the block.
* @return {JSX.Element} The rendered view.
*/
const renderView = (attributes, setAttributes, className) => {
const {
title,
Expand All @@ -101,34 +165,107 @@ const renderView = (attributes, setAttributes, className) => {
exampleMenuItems,
} = attributes;

const blocks = useSelect(select => select('core/block-editor').getBlocks(), null);

const blocks = useSelect(wpSelect => wpSelect(BLOCK_NAME.EDITOR).getBlocks(), null);
const flatHeadings = getHeadingsFromBlocks(blocks, levels);

const menuItems = isExample ? exampleMenuItems : makeHierarchical(flatHeadings);

const style = getTableOfContentsStyle(className, submenu_style);

return (
<section className={`block table-of-contents-block table-of-contents-${style} ${className ?? ''}`}>
<RichText
tagName="h2"
placeholder={__('Enter title', 'planet4-blocks-backend')}
value={title}
onChange={titl => setAttributes({title: titl})}
withoutInteractiveFormatting
allowedFormats={[]}
/>
{menuItems.length > 0 ?
<TableOfContentsItems menuItems={menuItems} /> :
<div className="EmptyMessage">
{__('There are not any pre-established headings that this block can display in the form of a table of content. Please add headings to your page or choose another heading size.', 'planet4-blocks-backend')}
</div>
}
</section>
<>
<BlockControls>
<ToolbarItem
as={Button}
onClick={() => convertIntoListBlock(menuItems)}
>
{__('Convert to static list', TRANSLATION_ID)}
</ToolbarItem>
</BlockControls>
<section className={`block ${BLOCK_TITLE}-block ${BLOCK_TITLE}-${style} ${className ?? ''}`}>
<RichText
tagName="h2"
placeholder={__('Enter title', TRANSLATION_ID)}
value={title}
onChange={titl => setAttributes({title: titl})}
withoutInteractiveFormatting
allowedFormats={[]}
/>
{menuItems.length > 0 ? (
<TableOfContentsItems menuItems={menuItems} />
) : (
<div className="EmptyMessage">
{__('There are not any pre-established headings that this block can display in the form of a table of content. Please add headings to your page or choose another heading size.', TRANSLATION_ID)}
</div>
)}
</section>
</>
);
};

/**
* Creates a list block with list item blocks based on the given items.
*
* @param {Array} items - The items to create list blocks from. Each item should have `text`, `shouldLink`, and `children`.
* @return {Object} The core/list block with the nested structure.
*/
const createListBlocks = items => {
const innerBlocks = [];

items.forEach(item => {
let content = item.text;

if (item.shouldLink) {
content = `<a class="icon-link table-of-contents-link" href="#${item.anchor}">${content}</a>`;
}

const newInnerBlock = createBlock(BLOCK_NAME.LIST_ITEM, {className: `${CLASS_NAME.LIST} ${CLASS_NAME.LIST}-${item.style}`, content});

if (item.children && item.children.length > 0) {
const childListBlock = createListBlocks(item.children);
newInnerBlock.innerBlocks = [childListBlock];
}

innerBlocks.push(newInnerBlock);
});

return createBlock(BLOCK_NAME.LIST, {}, innerBlocks);
};

/**
* Converts the given menu items into a static list block and replaces the current block.
*
* @param {Array} menuItems - The menu items to convert into a list block.
*/
const convertIntoListBlock = menuItems => {
if (!menuItems) {
return;
}

const blockList = select(BLOCK_NAME.EDITOR).getBlocks();
const blockIndex = blockList.findIndex(block => block.name === BLOCK_NAME.TABLE_OF_CONTENTS);

if (blockIndex === -1) {
return;
}

const blockAttrs = blockList[blockIndex].attributes;

const headingBlock = createBlock(BLOCK_NAME.HEADING, {content: blockAttrs.title});
const listBlocks = createListBlocks(menuItems);
const groupBlock = createBlock(BLOCK_NAME.GROUP, {className: `${BLOCK_TITLE} ${blockAttrs.className}`}, [headingBlock, listBlocks]);

dispatch(BLOCK_NAME.EDITOR).insertBlock(groupBlock, blockIndex);
dispatch(BLOCK_NAME.EDITOR).removeBlock(blockList[blockIndex].clientId);
};

/**
* Renders the Table of Contents block editor.
*
* @param {Object} props - The component props.
* @param {Object} props.attributes - The block attributes.
* @param {Function} props.setAttributes - Function to update block attributes.
* @param {boolean} props.isSelected - Indicates if the block is selected.
* @param {string} props.className - The CSS class for the block.
* @return {JSX.Element} The Table of Contents editor component.
*/
export const TableOfContentsEditor = ({attributes, setAttributes, isSelected, className}) => (
<>
{isSelected && renderEdit(attributes, setAttributes)}
Expand Down
6 changes: 6 additions & 0 deletions assets/src/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import {setupClickabelActionsListCards} from './actions_list_clickable_cards';
import {removeNoPostText} from './query-no-posts';
import {removeRelatedPostsSection} from './remove_related_section_no_posts';
import {setupCountrySelector} from './country_selector';
import {createLinkForToCLinksToPageElements} from './setup_toc_navigation';

function requireAll(r) {
r.keys().forEach(r);
}

requireAll(require.context('../images/icons/', true, /\.svg$/));

// Nested lists also get returned in the first array
const tableOfContentsList = document.querySelectorAll('.table-of-contents .wp-block-list')[0];
const allListElements = tableOfContentsList.querySelectorAll('li');

setupCookies();
setupHeader();
setupLoadMore();
Expand All @@ -31,3 +36,4 @@ removeNoPostText();
removeRelatedPostsSection();
setupClickabelActionsListCards();
setupCountrySelector();
createLinkForToCLinksToPageElements(allListElements);
26 changes: 26 additions & 0 deletions assets/src/js/setup_toc_navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const updatePageElementId = (text, id) => {
const allHeadings = document.querySelectorAll('.wp-block-heading');
allHeadings.forEach(heading => {
if (heading.textContent.trim() === text) {
heading.setAttribute('id', id);
}
});
};

export const createLinkForToCLinksToPageElements = tocElements => {
tocElements.forEach(li => {
const hasNestedUl = li.querySelector('ul');
const isALink = li.querySelector('a');

if (isALink) {
const listElementText = isALink.textContent.trim();
const idTagToSet = isALink.getAttribute('href').slice(1);
updatePageElementId(listElementText, idTagToSet);
}

if (hasNestedUl) {
const subLists = hasNestedUl.querySelectorAll('li');
createLinkForToCLinksToPageElements(subLists);
}
});
};
Loading

0 comments on commit 4655ba0

Please sign in to comment.