UNPKG

@wordpress/block-library

Version:
735 lines (705 loc) 18.5 kB
/** * External dependencies */ import clsx from 'clsx'; /** * WordPress dependencies */ import { PanelBody, Placeholder, QueryControls, RadioControl, RangeControl, Spinner, ToggleControl, ToolbarGroup, __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOptionIcon as ToggleGroupControlOptionIcon, __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, } from '@wordpress/components'; import { __, _x, sprintf } from '@wordpress/i18n'; import { dateI18n, format, getSettings } from '@wordpress/date'; import { InspectorControls, BlockControls, __experimentalImageSizeControl as ImageSizeControl, useBlockProps, store as blockEditorStore, } from '@wordpress/block-editor'; import { useSelect, useDispatch } from '@wordpress/data'; import { pin, list, grid, alignNone, positionLeft, positionCenter, positionRight, } from '@wordpress/icons'; import { store as coreStore } from '@wordpress/core-data'; import { store as noticeStore } from '@wordpress/notices'; import { useInstanceId } from '@wordpress/compose'; import { createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies */ import { MIN_EXCERPT_LENGTH, MAX_EXCERPT_LENGTH, MAX_POSTS_COLUMNS, DEFAULT_EXCERPT_LENGTH, } from './constants'; import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; /** * Module Constants */ const CATEGORIES_LIST_QUERY = { per_page: -1, _fields: 'id,name', context: 'view', }; const USERS_LIST_QUERY = { per_page: -1, has_published_posts: [ 'post' ], context: 'view', }; const imageAlignmentOptions = [ { value: 'none', icon: alignNone, label: __( 'None' ), }, { value: 'left', icon: positionLeft, label: __( 'Left' ), }, { value: 'center', icon: positionCenter, label: __( 'Center' ), }, { value: 'right', icon: positionRight, label: __( 'Right' ), }, ]; function getFeaturedImageDetails( post, size ) { const image = post._embedded?.[ 'wp:featuredmedia' ]?.[ '0' ]; return { url: image?.media_details?.sizes?.[ size ]?.source_url ?? image?.source_url, alt: image?.alt_text, }; } function getCurrentAuthor( post ) { return post._embedded?.author?.[ 0 ]; } function Controls( { attributes, setAttributes, postCount } ) { const { postsToShow, order, orderBy, categories, selectedAuthor, displayFeaturedImage, displayPostContentRadio, displayPostContent, displayPostDate, displayAuthor, postLayout, columns, excerptLength, featuredImageAlign, featuredImageSizeSlug, featuredImageSizeWidth, featuredImageSizeHeight, addLinkToFeaturedImage, } = attributes; const { imageSizes, defaultImageWidth, defaultImageHeight, categoriesList, authorList, } = useSelect( ( select ) => { const { getEntityRecords, getUsers } = select( coreStore ); const settings = select( blockEditorStore ).getSettings(); return { defaultImageWidth: settings.imageDimensions?.[ featuredImageSizeSlug ] ?.width ?? 0, defaultImageHeight: settings.imageDimensions?.[ featuredImageSizeSlug ] ?.height ?? 0, imageSizes: settings.imageSizes, categoriesList: getEntityRecords( 'taxonomy', 'category', CATEGORIES_LIST_QUERY ), authorList: getUsers( USERS_LIST_QUERY ), }; }, [ featuredImageSizeSlug ] ); const dropdownMenuProps = useToolsPanelDropdownMenuProps(); const imageSizeOptions = imageSizes .filter( ( { slug } ) => slug !== 'full' ) .map( ( { name, slug } ) => ( { value: slug, label: name, } ) ); const categorySuggestions = categoriesList?.reduce( ( accumulator, category ) => ( { ...accumulator, [ category.name ]: category, } ), {} ) ?? {}; const selectCategories = ( tokens ) => { const hasNoSuggestion = tokens.some( ( token ) => typeof token === 'string' && ! categorySuggestions[ token ] ); if ( hasNoSuggestion ) { return; } // Categories that are already will be objects, while new additions will be strings (the name). // allCategories nomalizes the array so that they are all objects. const allCategories = tokens.map( ( token ) => { return typeof token === 'string' ? categorySuggestions[ token ] : token; } ); // We do nothing if the category is not selected // from suggestions. if ( allCategories.includes( null ) ) { return false; } setAttributes( { categories: allCategories } ); }; return ( <> <ToolsPanel label={ __( 'Post content' ) } resetAll={ () => setAttributes( { displayPostContent: false, displayPostContentRadio: 'excerpt', excerptLength: DEFAULT_EXCERPT_LENGTH, } ) } dropdownMenuProps={ dropdownMenuProps } > <ToolsPanelItem hasValue={ () => !! displayPostContent } label={ __( 'Display post content' ) } onDeselect={ () => setAttributes( { displayPostContent: false } ) } isShownByDefault > <ToggleControl __nextHasNoMarginBottom label={ __( 'Display post content' ) } checked={ displayPostContent } onChange={ ( value ) => setAttributes( { displayPostContent: value } ) } /> </ToolsPanelItem> { displayPostContent && ( <ToolsPanelItem hasValue={ () => displayPostContentRadio !== 'excerpt' } label={ __( 'Content length' ) } onDeselect={ () => setAttributes( { displayPostContentRadio: 'excerpt', } ) } isShownByDefault > <RadioControl label={ __( 'Content length' ) } selected={ displayPostContentRadio } options={ [ { label: __( 'Excerpt' ), value: 'excerpt' }, { label: __( 'Full post' ), value: 'full_post', }, ] } onChange={ ( value ) => setAttributes( { displayPostContentRadio: value, } ) } /> </ToolsPanelItem> ) } { displayPostContent && displayPostContentRadio === 'excerpt' && ( <ToolsPanelItem hasValue={ () => excerptLength !== DEFAULT_EXCERPT_LENGTH } label={ __( 'Max number of words' ) } onDeselect={ () => setAttributes( { excerptLength: DEFAULT_EXCERPT_LENGTH, } ) } isShownByDefault > <RangeControl __nextHasNoMarginBottom __next40pxDefaultSize label={ __( 'Max number of words' ) } value={ excerptLength } onChange={ ( value ) => setAttributes( { excerptLength: value } ) } min={ MIN_EXCERPT_LENGTH } max={ MAX_EXCERPT_LENGTH } /> </ToolsPanelItem> ) } </ToolsPanel> <ToolsPanel label={ __( 'Post meta' ) } resetAll={ () => setAttributes( { displayAuthor: false, displayPostDate: false, } ) } dropdownMenuProps={ dropdownMenuProps } > <ToolsPanelItem hasValue={ () => !! displayAuthor } label={ __( 'Display author name' ) } onDeselect={ () => setAttributes( { displayAuthor: false } ) } isShownByDefault > <ToggleControl __nextHasNoMarginBottom label={ __( 'Display author name' ) } checked={ displayAuthor } onChange={ ( value ) => setAttributes( { displayAuthor: value } ) } /> </ToolsPanelItem> <ToolsPanelItem hasValue={ () => !! displayPostDate } label={ __( 'Display post date' ) } onDeselect={ () => setAttributes( { displayPostDate: false } ) } isShownByDefault > <ToggleControl __nextHasNoMarginBottom label={ __( 'Display post date' ) } checked={ displayPostDate } onChange={ ( value ) => setAttributes( { displayPostDate: value } ) } /> </ToolsPanelItem> </ToolsPanel> <PanelBody title={ __( 'Featured image' ) }> <ToggleControl __nextHasNoMarginBottom label={ __( 'Display featured image' ) } checked={ displayFeaturedImage } onChange={ ( value ) => setAttributes( { displayFeaturedImage: value } ) } /> { displayFeaturedImage && ( <> <ImageSizeControl onChange={ ( value ) => { const newAttrs = {}; if ( value.hasOwnProperty( 'width' ) ) { newAttrs.featuredImageSizeWidth = value.width; } if ( value.hasOwnProperty( 'height' ) ) { newAttrs.featuredImageSizeHeight = value.height; } setAttributes( newAttrs ); } } slug={ featuredImageSizeSlug } width={ featuredImageSizeWidth } height={ featuredImageSizeHeight } imageWidth={ defaultImageWidth } imageHeight={ defaultImageHeight } imageSizeOptions={ imageSizeOptions } imageSizeHelp={ __( 'Select the size of the source image.' ) } onChangeImage={ ( value ) => setAttributes( { featuredImageSizeSlug: value, featuredImageSizeWidth: undefined, featuredImageSizeHeight: undefined, } ) } /> <ToggleGroupControl className="editor-latest-posts-image-alignment-control" __nextHasNoMarginBottom __next40pxDefaultSize label={ __( 'Image alignment' ) } value={ featuredImageAlign || 'none' } onChange={ ( value ) => setAttributes( { featuredImageAlign: value !== 'none' ? value : undefined, } ) } > { imageAlignmentOptions.map( ( { value, icon, label } ) => { return ( <ToggleGroupControlOptionIcon key={ value } value={ value } icon={ icon } label={ label } /> ); } ) } </ToggleGroupControl> <ToggleControl __nextHasNoMarginBottom label={ __( 'Add link to featured image' ) } checked={ addLinkToFeaturedImage } onChange={ ( value ) => setAttributes( { addLinkToFeaturedImage: value, } ) } /> </> ) } </PanelBody> <PanelBody title={ __( 'Sorting and filtering' ) }> <QueryControls { ...{ order, orderBy } } numberOfItems={ postsToShow } onOrderChange={ ( value ) => setAttributes( { order: value } ) } onOrderByChange={ ( value ) => setAttributes( { orderBy: value } ) } onNumberOfItemsChange={ ( value ) => setAttributes( { postsToShow: value } ) } categorySuggestions={ categorySuggestions } onCategoryChange={ selectCategories } selectedCategories={ categories } onAuthorChange={ ( value ) => setAttributes( { selectedAuthor: '' !== value ? Number( value ) : undefined, } ) } authorList={ authorList ?? [] } selectedAuthorId={ selectedAuthor } /> { postLayout === 'grid' && ( <RangeControl __nextHasNoMarginBottom __next40pxDefaultSize label={ __( 'Columns' ) } value={ columns } onChange={ ( value ) => setAttributes( { columns: value } ) } min={ 2 } max={ ! postCount ? MAX_POSTS_COLUMNS : Math.min( MAX_POSTS_COLUMNS, postCount ) } required /> ) } </PanelBody> </> ); } export default function LatestPostsEdit( { attributes, setAttributes } ) { const instanceId = useInstanceId( LatestPostsEdit ); const { postsToShow, order, orderBy, categories, selectedAuthor, displayFeaturedImage, displayPostContentRadio, displayPostContent, displayPostDate, displayAuthor, postLayout, columns, excerptLength, featuredImageAlign, featuredImageSizeSlug, featuredImageSizeWidth, featuredImageSizeHeight, addLinkToFeaturedImage, } = attributes; const { latestPosts } = useSelect( ( select ) => { const { getEntityRecords } = select( coreStore ); const catIds = categories && categories.length > 0 ? categories.map( ( cat ) => cat.id ) : []; const latestPostsQuery = Object.fromEntries( Object.entries( { categories: catIds, author: selectedAuthor, order, orderby: orderBy, per_page: postsToShow, _embed: 'author,wp:featuredmedia', ignore_sticky: true, } ).filter( ( [ , value ] ) => typeof value !== 'undefined' ) ); return { latestPosts: getEntityRecords( 'postType', 'post', latestPostsQuery ), }; }, [ postsToShow, order, orderBy, categories, selectedAuthor ] ); // If a user clicks to a link prevent redirection and show a warning. const { createWarningNotice } = useDispatch( noticeStore ); const showRedirectionPreventedNotice = ( event ) => { event.preventDefault(); createWarningNotice( __( 'Links are disabled in the editor.' ), { id: `block-library/core/latest-posts/redirection-prevented/${ instanceId }`, type: 'snackbar', } ); }; const hasPosts = !! latestPosts?.length; const inspectorControls = ( <InspectorControls> <Controls attributes={ attributes } setAttributes={ setAttributes } postCount={ latestPosts?.length ?? 0 } /> </InspectorControls> ); const blockProps = useBlockProps( { className: clsx( { 'wp-block-latest-posts__list': true, 'is-grid': postLayout === 'grid', 'has-dates': displayPostDate, 'has-author': displayAuthor, [ `columns-${ columns }` ]: postLayout === 'grid', } ), } ); if ( ! hasPosts ) { return ( <div { ...blockProps }> { inspectorControls } <Placeholder icon={ pin } label={ __( 'Latest Posts' ) }> { ! Array.isArray( latestPosts ) ? ( <Spinner /> ) : ( __( 'No posts found.' ) ) } </Placeholder> </div> ); } // Removing posts from display should be instant. const displayPosts = latestPosts.length > postsToShow ? latestPosts.slice( 0, postsToShow ) : latestPosts; const layoutControls = [ { icon: list, title: _x( 'List view', 'Latest posts block display setting' ), onClick: () => setAttributes( { postLayout: 'list' } ), isActive: postLayout === 'list', }, { icon: grid, title: _x( 'Grid view', 'Latest posts block display setting' ), onClick: () => setAttributes( { postLayout: 'grid' } ), isActive: postLayout === 'grid', }, ]; const dateFormat = getSettings().formats.date; return ( <> { inspectorControls } <BlockControls> <ToolbarGroup controls={ layoutControls } /> </BlockControls> <ul { ...blockProps }> { displayPosts.map( ( post ) => { const titleTrimmed = post.title.rendered.trim(); let excerpt = post.excerpt.rendered; const currentAuthor = getCurrentAuthor( post ); const excerptElement = document.createElement( 'div' ); excerptElement.innerHTML = excerpt; excerpt = excerptElement.textContent || excerptElement.innerText || ''; const { url: imageSourceUrl, alt: featuredImageAlt } = getFeaturedImageDetails( post, featuredImageSizeSlug ); const imageClasses = clsx( { 'wp-block-latest-posts__featured-image': true, [ `align${ featuredImageAlign }` ]: !! featuredImageAlign, } ); const renderFeaturedImage = displayFeaturedImage && imageSourceUrl; const featuredImage = renderFeaturedImage && ( <img src={ imageSourceUrl } alt={ featuredImageAlt } style={ { maxWidth: featuredImageSizeWidth, maxHeight: featuredImageSizeHeight, } } /> ); const needsReadMore = excerptLength < excerpt.trim().split( ' ' ).length && post.excerpt.raw === ''; const postExcerpt = needsReadMore ? ( <> { excerpt .trim() .split( ' ', excerptLength ) .join( ' ' ) } { createInterpolateElement( sprintf( /* translators: 1: Hidden accessibility text: Post title */ __( '… <a>Read more<span>: %1$s</span></a>' ), titleTrimmed || __( '(no title)' ) ), { a: ( // eslint-disable-next-line jsx-a11y/anchor-has-content <a className="wp-block-latest-posts__read-more" href={ post.link } rel="noopener noreferrer" onClick={ showRedirectionPreventedNotice } /> ), span: ( <span className="screen-reader-text" /> ), } ) } </> ) : ( excerpt ); return ( <li key={ post.id }> { renderFeaturedImage && ( <div className={ imageClasses }> { addLinkToFeaturedImage ? ( <a href={ post.link } rel="noreferrer noopener" onClick={ showRedirectionPreventedNotice } > { featuredImage } </a> ) : ( featuredImage ) } </div> ) } <a className="wp-block-latest-posts__post-title" href={ post.link } rel="noreferrer noopener" dangerouslySetInnerHTML={ !! titleTrimmed ? { __html: titleTrimmed, } : undefined } onClick={ showRedirectionPreventedNotice } > { ! titleTrimmed ? __( '(no title)' ) : null } </a> { displayAuthor && currentAuthor && ( <div className="wp-block-latest-posts__post-author"> { sprintf( /* translators: byline. %s: author. */ __( 'by %s' ), currentAuthor.name ) } </div> ) } { displayPostDate && post.date_gmt && ( <time dateTime={ format( 'c', post.date_gmt ) } className="wp-block-latest-posts__post-date" > { dateI18n( dateFormat, post.date_gmt ) } </time> ) } { displayPostContent && displayPostContentRadio === 'excerpt' && ( <div className="wp-block-latest-posts__post-excerpt"> { postExcerpt } </div> ) } { displayPostContent && displayPostContentRadio === 'full_post' && ( <div className="wp-block-latest-posts__post-full-content" dangerouslySetInnerHTML={ { __html: post.content.raw.trim(), } } /> ) } </li> ); } ) } </ul> </> ); }