diff --git a/package-lock.json b/package-lock.json index 98865c9d041a5..834bb344016d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54694,6 +54694,7 @@ "@babel/runtime": "7.25.7", "@wordpress/api-fetch": "*", "@wordpress/blob": "*", + "@wordpress/block-editor": "*", "@wordpress/blocks": "*", "@wordpress/components": "*", "@wordpress/compose": "*", diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index 167af583ed9dd..d8247a2037049 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -132,6 +132,7 @@ $z-layers: ( ".editor-action-modal": 1000001, ".editor-post-template__swap-template-modal": 1000001, ".edit-site-template-panel__replace-template-modal": 1000001, + ".fields-controls__template-modal": 1000001, // Note: The ConfirmDialog component's z-index is being set to 1000001 in packages/components/src/confirm-dialog/styles.ts // because it uses emotion and not sass. We need it to render on top its parent popover. diff --git a/packages/edit-site/src/components/post-edit/index.js b/packages/edit-site/src/components/post-edit/index.js index 3e75ef71d1ac9..9a99a987089c1 100644 --- a/packages/edit-site/src/components/post-edit/index.js +++ b/packages/edit-site/src/components/post-edit/index.js @@ -19,6 +19,8 @@ import { privateApis as editorPrivateApis } from '@wordpress/editor'; */ import Page from '../page'; import { unlock } from '../../lock-unlock'; +import usePatternSettings from '../page-patterns/use-pattern-settings'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; const { PostCardPanel, usePostFields } = unlock( editorPrivateApis ); @@ -85,6 +87,12 @@ function PostEditForm( { postType, postId } ) { 'slug', 'parent', 'comment_status', + { + label: __( 'Template' ), + labelPosition: 'side', + id: 'template', + layout: 'regular', + }, ].filter( ( field ) => ids.length === 1 || @@ -123,6 +131,32 @@ function PostEditForm( { postType, postId } ) { setMultiEdits( {} ); }, [ ids ] ); + const { ExperimentalBlockEditorProvider } = unlock( + blockEditorPrivateApis + ); + const settings = usePatternSettings(); + + /** + * The template field depends on the block editor settings. + * This is a workaround to ensure that the block editor settings are available. + * For more information, see: https://github.com/WordPress/gutenberg/issues/67521 + */ + const fieldsWithDependency = useMemo( () => { + return fields.map( ( field ) => { + if ( field.id === 'template' ) { + return { + ...field, + Edit: ( data ) => ( + + + + ), + }; + } + return field; + } ); + }, [ fields, settings ] ); + return ( { ids.length === 1 && ( @@ -130,7 +164,7 @@ function PostEditForm( { postType, postId } ) { ) } diff --git a/packages/editor/src/dataviews/store/private-actions.ts b/packages/editor/src/dataviews/store/private-actions.ts index 9e8d184e34d3a..c1afe1eae4e20 100644 --- a/packages/editor/src/dataviews/store/private-actions.ts +++ b/packages/editor/src/dataviews/store/private-actions.ts @@ -33,6 +33,7 @@ import { statusField, authorField, titleField, + templateField, } from '@wordpress/fields'; import duplicateTemplatePart from '../actions/duplicate-template-part'; @@ -171,6 +172,7 @@ export const registerPostTypeSchema = postTypeConfig.supports?.[ 'page-attributes' ] && parentField, postTypeConfig.supports?.comments && commentStatusField, passwordField, + templateField, ].filter( Boolean ); registry.batch( () => { diff --git a/packages/fields/README.md b/packages/fields/README.md index e6cf6d3007ed9..4eec0b044c389 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -98,6 +98,10 @@ Undocumented declaration. Status field for BasePost. +### templateField + +Undocumented declaration. + ### titleField Undocumented declaration. diff --git a/packages/fields/package.json b/packages/fields/package.json index eb60f448fc13e..beaaf4f981301 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -35,6 +35,7 @@ "@babel/runtime": "7.25.7", "@wordpress/api-fetch": "*", "@wordpress/blob": "*", + "@wordpress/block-editor": "*", "@wordpress/blocks": "*", "@wordpress/components": "*", "@wordpress/compose": "*", diff --git a/packages/fields/src/actions/utils.ts b/packages/fields/src/actions/utils.ts index 8f990fb1168fc..d0c2a522a8823 100644 --- a/packages/fields/src/actions/utils.ts +++ b/packages/fields/src/actions/utils.ts @@ -30,7 +30,9 @@ export function isTemplateOrTemplatePart( return p.type === TEMPLATE_POST_TYPE || p.type === TEMPLATE_PART_POST_TYPE; } -export function getItemTitle( item: Post ): string { +export function getItemTitle( item: { + title: string | { rendered: string } | { raw: string }; +} ) { if ( typeof item.title === 'string' ) { return decodeEntities( item.title ); } diff --git a/packages/fields/src/fields/index.ts b/packages/fields/src/fields/index.ts index 5ea4235af1d96..2cdf89ee13fb0 100644 --- a/packages/fields/src/fields/index.ts +++ b/packages/fields/src/fields/index.ts @@ -2,6 +2,7 @@ export { default as slugField } from './slug'; export { default as titleField } from './title'; export { default as orderField } from './order'; export { default as featuredImageField } from './featured-image'; +export { default as templateField } from './template'; export { default as parentField } from './parent'; export { default as passwordField } from './password'; export { default as statusField } from './status'; diff --git a/packages/fields/src/fields/template/index.ts b/packages/fields/src/fields/template/index.ts new file mode 100644 index 0000000000000..7315b4ba349b1 --- /dev/null +++ b/packages/fields/src/fields/template/index.ts @@ -0,0 +1,22 @@ +/** + * WordPress dependencies + */ +import type { Field } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +import { __ } from '@wordpress/i18n'; +import type { BasePost } from '../../types'; +import { TemplateEdit } from './template-edit'; + +const templateField: Field< BasePost > = { + id: 'template', + type: 'text', + label: __( 'Template' ), + getValue: ( { item } ) => item.template, + Edit: TemplateEdit, + enableSorting: false, +}; + +export default templateField; diff --git a/packages/fields/src/fields/template/style.scss b/packages/fields/src/fields/template/style.scss new file mode 100644 index 0000000000000..a0c2fafec7389 --- /dev/null +++ b/packages/fields/src/fields/template/style.scss @@ -0,0 +1,23 @@ +.fields-controls__template-modal { + z-index: z-index(".fields-controls__template-modal"); +} + +.fields-controls__template-content .block-editor-block-patterns-list { + column-count: 2; + column-gap: $grid-unit-30; + + // Small top padding required to avoid cutting off the visible outline when hovering items + padding-top: $border-width-focus-fallback; + + @include break-medium() { + column-count: 3; + } + + @include break-wide() { + column-count: 4; + } + + .block-editor-block-patterns-list__list-item { + break-inside: avoid-column; + } +} diff --git a/packages/fields/src/fields/template/template-edit.tsx b/packages/fields/src/fields/template/template-edit.tsx new file mode 100644 index 0000000000000..c17364568a457 --- /dev/null +++ b/packages/fields/src/fields/template/template-edit.tsx @@ -0,0 +1,210 @@ +/** + * WordPress dependencies + */ +import { useCallback, useMemo, useState } from '@wordpress/element'; +// @ts-ignore +import { parse } from '@wordpress/blocks'; +import type { WpTemplate } from '@wordpress/core-data'; +import { store as coreStore } from '@wordpress/core-data'; +import type { DataFormControlProps } from '@wordpress/dataviews'; + +/** + * Internal dependencies + */ +// @ts-expect-error block-editor is not typed correctly. +import { __experimentalBlockPatternsList as BlockPatternsList } from '@wordpress/block-editor'; +import { + Button, + Dropdown, + MenuGroup, + MenuItem, + Modal, +} from '@wordpress/components'; +import { useAsyncList } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { __ } from '@wordpress/i18n'; +import { getItemTitle } from '../../actions/utils'; +import type { BasePost } from '../../types'; +import { unlock } from '../../lock-unlock'; + +export const TemplateEdit = ( { + data, + field, + onChange, +}: DataFormControlProps< BasePost > ) => { + const { id } = field; + const postType = data.type; + const postId = + typeof data.id === 'number' ? data.id : parseInt( data.id, 10 ); + const slug = data.slug; + + const { availableTemplates, templates } = useSelect( + ( select ) => { + const allTemplates = + select( coreStore ).getEntityRecords< WpTemplate >( + 'postType', + 'wp_template', + { + per_page: -1, + post_type: postType, + } + ) ?? []; + + const { getHomePage, getPostsPageId } = unlock( + select( coreStore ) + ); + + const isPostsPage = getPostsPageId() === +postId; + const isFrontPage = + postType === 'page' && getHomePage()?.postId === +postId; + + const allowSwitchingTemplate = ! isPostsPage && ! isFrontPage; + + return { + templates: allTemplates, + availableTemplates: allowSwitchingTemplate + ? allTemplates.filter( + ( template ) => + template.is_custom && + template.slug !== data.template && + !! template.content.raw // Skip empty templates. + ) + : [], + }; + }, + [ data.template, postId, postType ] + ); + + const templatesAsPatterns = useMemo( + () => + availableTemplates.map( ( template ) => ( { + name: template.slug, + blocks: parse( template.content.raw ), + title: decodeEntities( template.title.rendered ), + id: template.id, + } ) ), + [ availableTemplates ] + ); + + const shownTemplates = useAsyncList( templatesAsPatterns ); + + const value = field.getValue( { item: data } ); + + const currentTemplate = useSelect( + ( select ) => { + const foundTemplate = templates?.find( + ( template ) => template.slug === value + ); + + if ( foundTemplate ) { + return foundTemplate; + } + + let slugToCheck; + // In `draft` status we might not have a slug available, so we use the `single` + // post type templates slug(ex page, single-post, single-product etc..). + // Pages do not need the `single` prefix in the slug to be prioritized + // through template hierarchy. + if ( slug ) { + slugToCheck = + postType === 'page' + ? `${ postType }-${ slug }` + : `single-${ postType }-${ slug }`; + } else { + slugToCheck = + postType === 'page' ? 'page' : `single-${ postType }`; + } + + if ( postType ) { + const templateId = select( coreStore ).getDefaultTemplateId( { + slug: slugToCheck, + } ); + + return select( coreStore ).getEntityRecord( + 'postType', + 'wp_template', + templateId + ); + } + }, + [ postType, slug, templates, value ] + ); + + const [ showModal, setShowModal ] = useState( false ); + + const onChangeControl = useCallback( + ( newValue: string ) => + onChange( { + [ id ]: newValue, + } ), + [ id, onChange ] + ); + + return ( +
+ ( + + ) } + renderContent={ ( { onToggle } ) => ( + + { + setShowModal( true ); + onToggle(); + } } + > + { __( 'Swap template' ) } + + { + // The default template in a post is indicated by an empty string + value !== '' && ( + { + onChangeControl( '' ); + onToggle(); + } } + > + { __( 'Use default template' ) } + + ) + } + + ) } + /> + { showModal && ( + setShowModal( false ) } + overlayClassName="fields-controls__template-modal" + isFullScreen + > +
+ { + onChangeControl( template.name ); + setShowModal( false ); + } } + /> +
+
+ ) } +
+ ); +}; diff --git a/packages/fields/src/style.scss b/packages/fields/src/style.scss index 1639f455ba093..6a854c33e2734 100644 --- a/packages/fields/src/style.scss +++ b/packages/fields/src/style.scss @@ -1,2 +1,3 @@ @import "./fields/slug/style.scss"; @import "./fields/featured-image/style.scss"; +@import "./fields/template/style.scss"; diff --git a/packages/fields/tsconfig.json b/packages/fields/tsconfig.json index 5c4b91e88f895..380a6734e346b 100644 --- a/packages/fields/tsconfig.json +++ b/packages/fields/tsconfig.json @@ -26,6 +26,7 @@ { "path": "../private-apis" }, { "path": "../router" }, { "path": "../url" }, + { "path": "../block-editor" }, { "path": "../warning" } ], "include": [ "src" ]