diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js index 87ef64deccc6f..07b7d5c764c73 100644 --- a/packages/block-editor/src/components/index.js +++ b/packages/block-editor/src/components/index.js @@ -145,6 +145,7 @@ export { default as WritingFlow } from './writing-flow'; export { default as useBlockDisplayInformation } from './use-block-display-information'; export { default as __unstableIframe } from './iframe'; export { default as __experimentalUseNoRecursiveRenders } from './use-no-recursive-renders'; +export { default as __experimentalBlockPatternsList } from './block-patterns-list'; /* * State Related Components diff --git a/packages/block-library/src/template-part/edit/index.js b/packages/block-library/src/template-part/edit/index.js index 9ee220f672cb6..35fa5f705e638 100644 --- a/packages/block-library/src/template-part/edit/index.js +++ b/packages/block-library/src/template-part/edit/index.js @@ -15,22 +15,28 @@ import { store as blockEditorStore, } from '@wordpress/block-editor'; import { - Dropdown, ToolbarGroup, ToolbarButton, Spinner, + Modal, } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; +import { useState } from '@wordpress/element'; /** * Internal dependencies */ import TemplatePartPlaceholder from './placeholder'; -import TemplatePartSelection from './selection'; +import TemplatePartSelectionModal from './selection-modal'; import { TemplatePartAdvancedControls } from './advanced-controls'; import TemplatePartInnerBlocks from './inner-blocks'; import { createTemplatePartId } from './utils/create-template-part-id'; +import { + useAlternativeBlockPatterns, + useAlternativeTemplateParts, + useTemplatePartArea, +} from './utils/hooks'; export default function TemplatePartEdit( { attributes, @@ -39,29 +45,22 @@ export default function TemplatePartEdit( { } ) { const { slug, theme, tagName, layout = {} } = attributes; const templatePartId = createTemplatePartId( theme, slug ); - const [ hasAlreadyRendered, RecursionProvider ] = useNoRecursiveRenders( templatePartId ); + const [ + isTemplatePartSelectionOpen, + setIsTemplatePartSelectionOpen, + ] = useState( false ); // Set the postId block attribute if it did not exist, // but wait until the inner blocks have loaded to allow // new edits to trigger this. - const { - isResolved, - innerBlocks, - isMissing, - defaultWrapper, - area, - enableSelection, - hasResolvedReplacements, - } = useSelect( + const { isResolved, innerBlocks, isMissing, area } = useSelect( ( select ) => { - const { - getEditedEntityRecord, - getEntityRecords, - hasFinishedResolution, - } = select( coreStore ); + const { getEditedEntityRecord, hasFinishedResolution } = select( + coreStore + ); const { getBlocks } = select( blockEditorStore ); const getEntityArgs = [ @@ -73,20 +72,6 @@ export default function TemplatePartEdit( { ? getEditedEntityRecord( ...getEntityArgs ) : null; const _area = entityRecord?.area || attributes.area; - - // Check whether other entities exist for switching/selection. - const availableReplacementArgs = [ - 'postType', - 'wp_template_part', - _area && 'uncategorized' !== _area && { area: _area }, - ]; - const matchingReplacements = getEntityRecords( - ...availableReplacementArgs - ); - const _enableSelection = templatePartId - ? matchingReplacements?.length > 1 - : matchingReplacements?.length > 0; - const hasResolvedEntity = templatePartId ? hasFinishedResolution( 'getEditedEntityRecord', @@ -94,33 +79,26 @@ export default function TemplatePartEdit( { ) : false; - // FIXME: @wordpress/block-library should not depend on @wordpress/editor. - // Blocks can be loaded into a *non-post* block editor. - // eslint-disable-next-line @wordpress/data-no-store-string-literals - const defaultWrapperElement = select( 'core/editor' ) - .__experimentalGetDefaultTemplatePartAreas() - .find( ( { area: value } ) => value === _area )?.area_tag; - return { innerBlocks: getBlocks( clientId ), isResolved: hasResolvedEntity, isMissing: hasResolvedEntity && isEmpty( entityRecord ), - defaultWrapper: defaultWrapperElement || 'div', area: _area, - enableSelection: _enableSelection, - hasResolvedReplacements: hasFinishedResolution( - 'getEntityRecords', - availableReplacementArgs - ), }; }, [ templatePartId, clientId ] ); - + const { templateParts } = useAlternativeTemplateParts( + area, + templatePartId + ); + const blockPatterns = useAlternativeBlockPatterns( area, clientId ); + const hasReplacements = !! templateParts.length || !! blockPatterns.length; + const areaObject = useTemplatePartArea( area ); const blockProps = useBlockProps(); const isPlaceholder = ! slug; const isEntityAvailable = ! isPlaceholder && ! isMissing && isResolved; - const TagName = tagName || defaultWrapper; + const TagName = tagName || areaObject.tagName; // We don't want to render a missing state if we have any inner blocks. // A new template part is automatically created if we have any inner blocks but no entity. @@ -160,43 +138,31 @@ export default function TemplatePartEdit( { setAttributes={ setAttributes } isEntityAvailable={ isEntityAvailable } templatePartId={ templatePartId } - defaultWrapper={ defaultWrapper } + defaultWrapper={ areaObject.tagName } /> { isPlaceholder && ( + setIsTemplatePartSelectionOpen( true ) + } /> ) } - { isEntityAvailable && enableSelection && ( + { isEntityAvailable && hasReplacements && ( - ( - - { __( 'Replace' ) } - - ) } - renderContent={ ( { onClose } ) => ( - - ) } - /> + + setIsTemplatePartSelectionOpen( true ) + } + > + { __( 'Replace' ) } + ) } @@ -215,6 +181,30 @@ export default function TemplatePartEdit( { ) } + { isTemplatePartSelectionOpen && ( + + setIsTemplatePartSelectionOpen( false ) + } + > + + setIsTemplatePartSelectionOpen( false ) + } + /> + + ) } ); } diff --git a/packages/block-library/src/template-part/edit/placeholder.js b/packages/block-library/src/template-part/edit/placeholder.js new file mode 100644 index 0000000000000..ff43ee5644ad7 --- /dev/null +++ b/packages/block-library/src/template-part/edit/placeholder.js @@ -0,0 +1,78 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Placeholder, Button, Spinner } from '@wordpress/components'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + useAlternativeBlockPatterns, + useAlternativeTemplateParts, + useCreateTemplatePartFromBlocks, + useTemplatePartArea, +} from './utils/hooks'; +import TitleModal from './title-modal'; + +export default function TemplatePartPlaceholder( { + area, + clientId, + templatePartId, + onOpenSelectionModal, + setAttributes, +} ) { + const { templateParts, isResolving } = useAlternativeTemplateParts( + area, + templatePartId + ); + const blockPatterns = useAlternativeBlockPatterns( area, clientId ); + const [ showTitleModal, setShowTitleModal ] = useState( false ); + const areaObject = useTemplatePartArea( area ); + const createFromBlocks = useCreateTemplatePartFromBlocks( + area, + setAttributes + ); + + return ( + + { isResolving && } + + { ! isResolving && + !! ( templateParts.length || blockPatterns.length ) && ( + + ) } + + { ! isResolving && ( + + ) } + { showTitleModal && ( + setShowTitleModal( false ) } + onSubmit={ ( title ) => { + createFromBlocks( [], title ); + } } + /> + ) } + + ); +} diff --git a/packages/block-library/src/template-part/edit/placeholder/index.js b/packages/block-library/src/template-part/edit/placeholder/index.js deleted file mode 100644 index 64a730535c0f5..0000000000000 --- a/packages/block-library/src/template-part/edit/placeholder/index.js +++ /dev/null @@ -1,172 +0,0 @@ -/** - * External dependencies - */ -import { find, kebabCase } from 'lodash'; - -/** - * WordPress dependencies - */ -import { __, sprintf } from '@wordpress/i18n'; -import { useCallback, useState } from '@wordpress/element'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { Placeholder, Dropdown, Button, Spinner } from '@wordpress/components'; -import { serialize } from '@wordpress/blocks'; -import { store as coreStore } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import TemplatePartSelection from '../selection'; -import PatternsSetup from './patterns-setup'; - -const PLACEHOLDER_STEPS = { - initial: 1, - patterns: 2, -}; - -export default function TemplatePartPlaceholder( { - area, - clientId, - setAttributes, - enableSelection, - hasResolvedReplacements, -} ) { - const { saveEntityRecord } = useDispatch( coreStore ); - const [ step, setStep ] = useState( PLACEHOLDER_STEPS.initial ); - - const { areaIcon, areaLabel } = useSelect( - ( select ) => { - // FIXME: @wordpress/block-library should not depend on @wordpress/editor. - // Blocks can be loaded into a *non-post* block editor. - // eslint-disable-next-line @wordpress/data-no-store-string-literals - const definedAreas = select( - 'core/editor' - ).__experimentalGetDefaultTemplatePartAreas(); - - const selectedArea = find( definedAreas, { area } ); - const defaultArea = find( definedAreas, { area: 'uncategorized' } ); - - return { - areaIcon: selectedArea?.icon || defaultArea?.icon, - areaLabel: selectedArea?.label || __( 'Template Part' ), - }; - }, - [ area ] - ); - - const onCreate = useCallback( - async ( - startingBlocks = [], - title = __( 'Untitled Template Part' ) - ) => { - // If we have `area` set from block attributes, means an exposed - // block variation was inserted. So add this prop to the template - // part entity on creation. Afterwards remove `area` value from - // block attributes. - const record = { - title, - slug: kebabCase( title ), - content: serialize( startingBlocks ), - // `area` is filterable on the server and defaults to `UNCATEGORIZED` - // if provided value is not allowed. - area, - }; - const templatePart = await saveEntityRecord( - 'postType', - 'wp_template_part', - record - ); - setAttributes( { - slug: templatePart.slug, - theme: templatePart.theme, - area: undefined, - } ); - }, - [ setAttributes, area ] - ); - - return ( - <> - { step === PLACEHOLDER_STEPS.initial && ( - - { ! hasResolvedReplacements ? ( - - ) : ( - ( - <> - { enableSelection && ( - - ) } - - - ) } - renderContent={ ( { onClose } ) => ( - - ) } - /> - ) } - - ) } - { step === PLACEHOLDER_STEPS.patterns && ( - - setStep( PLACEHOLDER_STEPS.initial ) - } - /> - ) } - - ); -} diff --git a/packages/block-library/src/template-part/edit/placeholder/patterns-setup.js b/packages/block-library/src/template-part/edit/placeholder/patterns-setup.js deleted file mode 100644 index bd28c4679fb31..0000000000000 --- a/packages/block-library/src/template-part/edit/placeholder/patterns-setup.js +++ /dev/null @@ -1,124 +0,0 @@ -/** - * WordPress dependencies - */ -import { __experimentalBlockPatternSetup as BlockPatternSetup } from '@wordpress/block-editor'; -import { useEffect, useState } from '@wordpress/element'; -import { __, sprintf } from '@wordpress/i18n'; -import { - TextControl, - Flex, - FlexItem, - Button, - Modal, - Placeholder, -} from '@wordpress/components'; - -export default function PatternsSetup( { - area, - areaLabel, - areaIcon, - clientId, - onCreate, - resetPlaceholder, -} ) { - const blockNameWithArea = area - ? `core/template-part/${ area }` - : 'core/template-part'; - - // Restructure onCreate to set the blocks on local state. - // Add modal to confirm title and trigger onCreate. - const [ title, setTitle ] = useState( __( 'Untitled Template Part' ) ); - const [ startingBlocks, setStartingBlocks ] = useState( [] ); - const [ isTitleStep, setIsTitleStep ] = useState( false ); - - const selectPattern = ( selectedPattern ) => { - setStartingBlocks( selectedPattern ); - setIsTitleStep( true ); - }; - - const submitForCreation = ( event ) => { - event.preventDefault(); - onCreate( startingBlocks, title ); - }; - - return ( - <> - - } - onBlockPatternSelect={ selectPattern } - filterPatternsFn={ ( pattern ) => - pattern?.blockTypes?.some?.( - ( blockType ) => blockType === blockNameWithArea - ) - } - /> - { isTitleStep && ( - -
- - - - - - - - - - -
- ) } - - ); -} - -function StartBlankComponent( { setTitleStep, areaLabel, areaIcon } ) { - useEffect( () => { - setTitleStep( true ); - }, [] ); - return ( - - ); -} diff --git a/packages/block-library/src/template-part/edit/selection-modal.js b/packages/block-library/src/template-part/edit/selection-modal.js new file mode 100644 index 0000000000000..a9e0ed68255ce --- /dev/null +++ b/packages/block-library/src/template-part/edit/selection-modal.js @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ +import { useCallback, useMemo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { useDispatch } from '@wordpress/data'; +import { parse } from '@wordpress/blocks'; +import { useAsyncList } from '@wordpress/compose'; +import { + __experimentalBlockPatternsList as BlockPatternsList, + store as blockEditorStore, +} from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { + useAlternativeBlockPatterns, + useAlternativeTemplateParts, + useCreateTemplatePartFromBlocks, +} from './utils/hooks'; +import { createTemplatePartId } from './utils/create-template-part-id'; + +export default function TemplatePartSelectionModal( { + setAttributes, + onClose, + templatePartId = null, + area, + clientId, +} ) { + // When the templatePartId is undefined, + // it means the user is creating a new one from the placeholder. + const isReplacingTemplatePartContent = !! templatePartId; + const { templateParts } = useAlternativeTemplateParts( + area, + templatePartId + ); + // We can map template parts to block patters to reuse the BlockPatternsList UI + const templartPartsAsBlockPatterns = useMemo( () => { + return templateParts.map( ( templatePart ) => ( { + name: createTemplatePartId( templatePart.theme, templatePart.slug ), + title: templatePart.title.rendered, + blocks: parse( templatePart.content.raw ), + templatePart, + } ) ); + }, [ templateParts ] ); + const shownTemplateParts = useAsyncList( templartPartsAsBlockPatterns ); + const { createSuccessNotice } = useDispatch( noticesStore ); + const blockPatterns = useAlternativeBlockPatterns( area, clientId ); + const shownBlockPatterns = useAsyncList( blockPatterns ); + const { replaceInnerBlocks } = useDispatch( blockEditorStore ); + + const onTemplatePartSelect = useCallback( ( templatePart ) => { + setAttributes( { + slug: templatePart.slug, + theme: templatePart.theme, + area: undefined, + } ); + createSuccessNotice( + sprintf( + /* translators: %s: template part title. */ + __( 'Template Part "%s" inserted.' ), + templatePart.title?.rendered || templatePart.slug + ), + { + type: 'snackbar', + } + ); + onClose(); + }, [] ); + + const createFromBlocks = useCreateTemplatePartFromBlocks( + area, + setAttributes + ); + + return ( + <> +
+ { !! templartPartsAsBlockPatterns.length && ( +
+

{ __( 'Existing template parts' ) }

+ { + onTemplatePartSelect( pattern.templatePart ); + } } + /> +
+ ) } + + { !! blockPatterns.length && ( +
+

{ __( 'Patterns' ) }

+ { + if ( isReplacingTemplatePartContent ) { + replaceInnerBlocks( clientId, blocks ); + } else { + createFromBlocks( blocks, pattern.title ); + } + + onClose(); + } } + /> +
+ ) } +
+ + ); +} diff --git a/packages/block-library/src/template-part/edit/selection/index.js b/packages/block-library/src/template-part/edit/selection/index.js deleted file mode 100644 index e96ae8f7200a4..0000000000000 --- a/packages/block-library/src/template-part/edit/selection/index.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * WordPress dependencies - */ -import { SearchControl } from '@wordpress/components'; -import { useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import TemplatePartPreviews from './template-part-previews'; - -export default function TemplatePartSelection( { - setAttributes, - onClose, - area, - templatePartId = null, -} ) { - const [ filterValue, setFilterValue ] = useState( '' ); - return ( -
- -
- -
-
- ); -} diff --git a/packages/block-library/src/template-part/edit/selection/template-part-previews.js b/packages/block-library/src/template-part/edit/selection/template-part-previews.js deleted file mode 100644 index 612629358d8b8..0000000000000 --- a/packages/block-library/src/template-part/edit/selection/template-part-previews.js +++ /dev/null @@ -1,372 +0,0 @@ -/** - * External dependencies - */ -import { groupBy, deburr, flatten } from 'lodash'; - -/** - * WordPress dependencies - */ -import { useSelect, useDispatch } from '@wordpress/data'; -import { parse } from '@wordpress/blocks'; -import { useMemo, useCallback } from '@wordpress/element'; -import { ENTER, SPACE } from '@wordpress/keycodes'; -import { __, sprintf } from '@wordpress/i18n'; -import { BlockPreview } from '@wordpress/block-editor'; -import { - __unstableComposite as Composite, - __unstableCompositeItem as CompositeItem, - Icon, - __unstableUseCompositeState as useCompositeState, -} from '@wordpress/components'; -import { useAsyncList } from '@wordpress/compose'; -import { store as noticesStore } from '@wordpress/notices'; -import { store as coreStore } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import { createTemplatePartId } from '../utils/create-template-part-id'; - -function getAreaGroupTitle( areaLabel ) { - return sprintf( - // Translators: %s for the area the template part is assigned to (Header, Footer, General, etc.) - __( 'Area: %s' ), - areaLabel - ); -} -function PreviewPlaceholder() { - return ( -
- ); -} - -function TemplatePartItem( { - templatePart, - setAttributes, - onClose, - composite, -} ) { - const { - slug, - theme, - title: { rendered: title }, - } = templatePart; - // The 'raw' property is not defined for a brief period in the save cycle. - // The fallback prevents an error in the parse function while saving. - const content = templatePart.content.raw || ''; - const blocks = useMemo( () => parse( content ), [ content ] ); - const { createSuccessNotice } = useDispatch( noticesStore ); - - const onClick = useCallback( () => { - setAttributes( { slug, theme, area: undefined } ); - createSuccessNotice( - sprintf( - /* translators: %s: template part title. */ - __( 'Template Part "%s" inserted.' ), - title || slug - ), - { - type: 'snackbar', - } - ); - onClose(); - }, [ slug, theme ] ); - - return ( - { - if ( ENTER === event.keyCode || SPACE === event.keyCode ) { - onClick(); - } - } } - tabIndex={ 0 } - aria-label={ title || slug } - { ...composite } - > - -
- { title || slug } -
-
- ); -} - -function PanelGroup( { title, icon, children } ) { - return ( - <> -
- - { title } - - -
-
- { children } -
- - ); -} - -function TemplatePartsByArea( { - templateParts, - setAttributes, - onClose, - composite, - area = 'uncategorized', - labelsByArea, -} ) { - const { templatePartsByArea, templatePartsToShow } = useMemo( () => { - const _templatePartsToShow = - templateParts.filter( - ( templatePart ) => - 'uncategorized' === area || templatePart.area === area - ) || []; - const _templatePartsByArea = Object.values( - groupBy( _templatePartsToShow, 'area' ) - ); - const orderedTemplatePartsToShow = flatten( _templatePartsToShow ); - return { - templatePartsByArea: _templatePartsByArea, - templatePartsToShow: orderedTemplatePartsToShow, - }; - }, [ templateParts, area ] ); - - const currentShownTPs = useAsyncList( templatePartsToShow ); - - if ( ! templatePartsToShow.length ) { - return ( - - { sprintf( - // Translators: %s for the template part variation ("Header", "Footer", "Template Part"). - __( - 'There is no other %s available. If you are looking for another type of template part, try searching for it using the input above.' - ), - area && area !== 'uncategorized' - ? labelsByArea[ area ] || area - : __( 'Template Part' ) - ) } - - ); - } - - return templatePartsByArea.map( ( templatePartList ) => { - return ( - - { templatePartList.map( ( templatePart ) => { - return currentShownTPs.includes( templatePart ) ? ( - - ) : ( - - ); - } ) } - - ); - } ); -} - -function TemplatePartSearchResults( { - templateParts, - setAttributes, - filterValue, - onClose, - composite, - labelsByArea, -} ) { - const { filteredTPs, groupedResults } = useMemo( () => { - // Filter based on value. - // Remove diacritics and convert to lowercase to normalize. - const normalizedFilterValue = deburr( filterValue ).toLowerCase(); - const searchResults = templateParts.filter( - ( { title: { rendered: title }, area } ) => - deburr( title ) - .toLowerCase() - .includes( normalizedFilterValue ) || - // Since diacritics can be used in theme names, remove them for the comparison. - deburr( labelsByArea[ area ] ) - .toLowerCase() - .includes( normalizedFilterValue ) - ); - // Order based on value location. - searchResults.sort( ( a, b ) => { - // First prioritize index found in title. - // Deburr for diacritics. - const indexInTitleA = deburr( a.title.rendered ) - .toLowerCase() - .indexOf( normalizedFilterValue ); - const indexInTitleB = deburr( b.title.rendered ) - .toLowerCase() - .indexOf( normalizedFilterValue ); - if ( indexInTitleA !== -1 && indexInTitleB !== -1 ) { - return indexInTitleA - indexInTitleB; - } else if ( indexInTitleA !== -1 ) { - return -1; - } else if ( indexInTitleB !== -1 ) { - return 1; - } - // Second prioritize index found in area. - return ( - deburr( labelsByArea[ a.area ] ) - .toLowerCase() - .indexOf( normalizedFilterValue ) - - deburr( labelsByArea[ b.area ] ) - .toLowerCase() - .indexOf( normalizedFilterValue ) - ); - } ); - // Group filtered results together if their neighbors share the same area. - // This helps not show redundant panel groups side by side in the results. - const _groupedResults = []; - for ( let i = 0; i < searchResults.length; i++ ) { - if ( - i !== 0 && - searchResults[ i ].area === searchResults[ i - 1 ].area - ) { - _groupedResults[ _groupedResults.length - 1 ].push( - searchResults[ i ] - ); - } else { - _groupedResults.push( [ searchResults[ i ] ] ); - } - } - return { - filteredTPs: searchResults, - groupedResults: _groupedResults, - }; - }, [ filterValue, templateParts ] ); - - const currentShownTPs = useAsyncList( filteredTPs ); - - return groupedResults.map( ( group ) => ( - - { group.map( ( templatePart ) => - currentShownTPs.includes( templatePart ) ? ( - - ) : ( - - ) - ) } - - ) ); -} - -export default function TemplatePartPreviews( { - setAttributes, - filterValue, - onClose, - area, - templatePartId, -} ) { - const composite = useCompositeState(); - - const { templateParts, labelsByArea } = useSelect( ( select ) => { - const _templateParts = ( - select( coreStore ).getEntityRecords( - 'postType', - 'wp_template_part', - { - per_page: -1, - } - ) || [] - ).filter( - ( templatePart ) => - createTemplatePartId( - templatePart.theme, - templatePart.slug - ) !== templatePartId - ); - - // FIXME: @wordpress/block-library should not depend on @wordpress/editor. - // Blocks can be loaded into a *non-post* block editor. - // eslint-disable-next-line @wordpress/data-no-store-string-literals - const definedAreas = select( - 'core/editor' - ).__experimentalGetDefaultTemplatePartAreas(); - const _labelsByArea = {}; - definedAreas.forEach( ( item ) => { - _labelsByArea[ item.area ] = item.label; - } ); - - return { - templateParts: _templateParts, - labelsByArea: _labelsByArea, - }; - }, [] ); - - if ( ! templateParts || ! templateParts.length ) { - return ( - - { __( 'There are no existing template parts to select.' ) } - - ); - } - - if ( filterValue ) { - return ( - - - - ); - } - - return ( - - - - ); -} diff --git a/packages/block-library/src/template-part/edit/title-modal.js b/packages/block-library/src/template-part/edit/title-modal.js new file mode 100644 index 0000000000000..f5336e718e57a --- /dev/null +++ b/packages/block-library/src/template-part/edit/title-modal.js @@ -0,0 +1,59 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { + TextControl, + Flex, + FlexItem, + Button, + Modal, +} from '@wordpress/components'; + +export default function TitleModal( { areaLabel, onClose, onSubmit } ) { + // Restructure onCreate to set the blocks on local state. + // Add modal to confirm title and trigger onCreate. + const [ title, setTitle ] = useState( __( 'Untitled Template Part' ) ); + + const submitForCreation = ( event ) => { + event.preventDefault(); + onSubmit( title ); + }; + + return ( + +
+ + + + + + + +
+ ); +} diff --git a/packages/block-library/src/template-part/edit/utils/hooks.js b/packages/block-library/src/template-part/edit/utils/hooks.js new file mode 100644 index 0000000000000..010e7f87eb80b --- /dev/null +++ b/packages/block-library/src/template-part/edit/utils/hooks.js @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import { find, kebabCase } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useMemo } from '@wordpress/element'; +import { serialize } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { createTemplatePartId } from './create-template-part-id'; + +/** + * Retrieves the available template parts for the given area. + * + * @param {string} area Template part area. + * @param {string} excludedId Template part ID to exclude. + * + * @return {{ templateParts: Array, isResolving: boolean }} array of template parts. + */ +export function useAlternativeTemplateParts( area, excludedId ) { + const { templateParts, isResolving } = useSelect( ( select ) => { + const { getEntityRecords, isResolving: _isResolving } = select( + coreStore + ); + const query = { per_page: -1 }; + return { + templateParts: getEntityRecords( + 'postType', + 'wp_template_part', + query + ), + isLoading: _isResolving( 'getEntityRecords', [ + 'postType', + 'wp_template_part', + query, + ] ), + }; + }, [] ); + + const filteredTemplateParts = useMemo( () => { + if ( ! templateParts ) { + return []; + } + return ( + templateParts.filter( + ( templatePart ) => + createTemplatePartId( + templatePart.theme, + templatePart.slug + ) !== excludedId && + ( ! area || + 'uncategorized' === area || + templatePart.area === area ) + ) || [] + ); + }, [ templateParts, area ] ); + + return { + templateParts: filteredTemplateParts, + isResolving, + }; +} + +/** + * Retrieves the available block patterns for the given area. + * + * @param {string} area Template part area. + * @param {string} clientId Block Client ID. (The container of the block can impact allowed blocks). + * + * @return {Array} array of block patterns. + */ +export function useAlternativeBlockPatterns( area, clientId ) { + return useSelect( + ( select ) => { + const blockNameWithArea = area + ? `core/template-part/${ area }` + : 'core/template-part'; + const { + getBlockRootClientId, + __experimentalGetPatternsByBlockTypes, + } = select( blockEditorStore ); + const rootClientId = getBlockRootClientId( clientId ); + return __experimentalGetPatternsByBlockTypes( + blockNameWithArea, + rootClientId + ); + }, + [ area, clientId ] + ); +} + +export function useCreateTemplatePartFromBlocks( area, setAttributes ) { + const { saveEntityRecord } = useDispatch( coreStore ); + + return async ( blocks = [], title = __( 'Untitled Template Part' ) ) => { + // If we have `area` set from block attributes, means an exposed + // block variation was inserted. So add this prop to the template + // part entity on creation. Afterwards remove `area` value from + // block attributes. + const record = { + title, + slug: kebabCase( title ), + content: serialize( blocks ), + // `area` is filterable on the server and defaults to `UNCATEGORIZED` + // if provided value is not allowed. + area, + }; + const templatePart = await saveEntityRecord( + 'postType', + 'wp_template_part', + record + ); + setAttributes( { + slug: templatePart.slug, + theme: templatePart.theme, + area: undefined, + } ); + }; +} + +/** + * Retrieves the template part area object. + * + * @param {string} area Template part area identifier. + * + * @return {{icon: Object, label: string, tagName: string}} Template Part area. + */ +export function useTemplatePartArea( area ) { + return useSelect( + ( select ) => { + // FIXME: @wordpress/block-library should not depend on @wordpress/editor. + // Blocks can be loaded into a *non-post* block editor. + // eslint-disable-next-line @wordpress/data-no-store-string-literals + const definedAreas = select( + 'core/editor' + ).__experimentalGetDefaultTemplatePartAreas(); + + const selectedArea = find( definedAreas, { area } ); + const defaultArea = find( definedAreas, { area: 'uncategorized' } ); + + return { + icon: selectedArea?.icon || defaultArea?.icon, + label: selectedArea?.label || __( 'Template Part' ), + tagName: selectedArea?.area_tag ?? 'div', + }; + }, + [ area ] + ); +} diff --git a/packages/block-library/src/template-part/editor.scss b/packages/block-library/src/template-part/editor.scss index ea6e348d63838..1e7c7608860f0 100644 --- a/packages/block-library/src/template-part/editor.scss +++ b/packages/block-library/src/template-part/editor.scss @@ -1,82 +1,24 @@ - -.wp-block-template-part__placeholder-preview-dropdown-content, -.wp-block-template-part__preview-dropdown-content { - .components-popover__content { - min-width: 320px; - padding: 0; - } -} - -.wp-block-template-part__selection-preview-search-form { - border-bottom: $border-width solid $gray-300; -} - -.wp-block-template-part__selection-preview-container { - background: $white; - padding-bottom: 16px; - - .wp-block-template-part__selection-preview-item { - border-radius: $radius-block-ui; - cursor: pointer; - margin-top: $grid-unit-20; - transition: all 0.05s ease-in-out; - border: $border-width solid $gray-300; - width: 100%; - background-color: $white; - - &:hover { - border: $border-width solid var(--wp-admin-theme-color); - } - - &:focus { - box-shadow: inset 0 0 0 1px $white, 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); - - // Windows High Contrast mode will show this outline, but not the box-shadow. - outline: 2px solid transparent; - } - - &.is-placeholder { - min-height: 100px; - } - - .block-editor-block-preview__container { - border-bottom: $border-width solid $gray-300; - } +.block-editor-template-part__selection-modal { + // To keep modal dimensions consistent as subsections are navigated, width + // and height are used instead of max-(width/height). + @include break-small() { + width: calc(100% - #{ $grid-unit-20 * 2 }); + height: calc(100% - #{ $header-height * 2 }); } - - .wp-block-template-part__selection-preview-item-title { - padding: $grid-unit-05; - font-size: $helptext-font-size; - text-align: left; + @include break-medium() { + width: $break-medium - $grid-unit-20 * 2; } - - .wp-block-template-part__selection-panel-group-header { - padding: $grid-unit-20 $grid-unit-20 0; - } - - .wp-block-template-part__selection-panel-group-content { - padding: 0 $grid-unit-20; - } - - .wp-block-template-part__selection-panel-group-title { - color: var(--wp-admin-theme-color); - text-transform: uppercase; - font-size: 11px; - font-weight: 500; + @include break-large() { + height: 70%; } } -.wp-block-template-part__placeholder-create-new__title-form { - .wp-block-template-part__placeholder-create-new__title-form-actions { - padding-top: $grid-unit-15; - // Unsure why these styles need to be added since we are using the Flex component with - // flex-end setting. Created issue https://github.com/WordPress/gutenberg/issues/33735 to - // attempt document this issue. - display: flex; - justify-content: flex-end; +.block-library-template-part__selection-content .block-editor-block-patterns-list { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: $grid-unit-10; - .components-flex-item { - margin-left: $grid-unit-15; - } + .block-editor-block-patterns-list__list-item { + margin-bottom: 0; } } diff --git a/packages/e2e-tests/specs/site-editor/template-part.test.js b/packages/e2e-tests/specs/site-editor/template-part.test.js index 33158e21a7750..8b815d2c36212 100644 --- a/packages/e2e-tests/specs/site-editor/template-part.test.js +++ b/packages/e2e-tests/specs/site-editor/template-part.test.js @@ -260,10 +260,10 @@ describe( 'Template Part', () => { const savePostSelector = '.edit-site-save-button__button'; const templatePartSelector = '*[data-type="core/template-part"]'; const activatedTemplatePartSelector = `${ templatePartSelector }.block-editor-block-list__layout`; - const createNewButtonSelector = - '//button[contains(text(), "New template part")]'; + const startBlockButtonSelector = + '//button[contains(text(), "Start blank")]'; const chooseExistingButtonSelector = - '//button[contains(text(), "Choose existing")]'; + '//button[contains(text(), "Choose")]'; const confirmTitleButtonSelector = '.wp-block-template-part__placeholder-create-new__title-form .components-button.is-primary'; @@ -273,13 +273,10 @@ describe( 'Template Part', () => { // Create new template part. await insertBlock( 'Template Part' ); - await siteEditorCanvas.waitForXPath( - chooseExistingButtonSelector - ); - const [ createNewButton ] = await siteEditorCanvas.$x( - createNewButtonSelector + const startBlankButton = await siteEditorCanvas.waitForXPath( + startBlockButtonSelector ); - await createNewButton.click(); + await startBlankButton.click(); const confirmTitleButton = await page.waitForSelector( confirmTitleButtonSelector ); @@ -318,13 +315,16 @@ describe( 'Template Part', () => { chooseExistingButtonSelector ); await chooseExistingButton.click(); - await page.waitForSelector( - '.wp-block-template-part__selection-preview-container' - ); const preview = await page.waitForSelector( - '.wp-block-template-part__selection-preview-item[aria-label="Create New"]' + '.block-editor-block-patterns-list__item' ); await preview.click(); + + // Wait for the template parts to load properly. + await siteEditorCanvas.waitForSelector( + '[data-type="core/template-part"] > p:first-child' + ); + // We now have the same template part two times in the page, so check accordingly. const paragraphs = await siteEditorCanvas.$$eval( '[data-type="core/template-part"] > p:first-child',