diff --git a/docs/reference-guides/data/data-core-edit-site.md b/docs/reference-guides/data/data-core-edit-site.md index 0cac2268b2ab29..6dea8e9b77d1b2 100644 --- a/docs/reference-guides/data/data-core-edit-site.md +++ b/docs/reference-guides/data/data-core-edit-site.md @@ -291,7 +291,7 @@ _Parameters_ _Returns_ -- `number`: The resolved template ID for the page route. +- `Object`: Action object. ### setHasPageContentFocus diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index 4f8bbab5a16095..12443a30a96656 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -127,6 +127,7 @@ $z-layers: ( ".block-editor-template-part__selection-modal": 1000001, ".block-editor-block-rename-modal": 1000001, ".edit-site-list__rename-modal": 1000001, + ".edit-site-swap-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/core-data/src/hooks/use-entity-record.ts b/packages/core-data/src/hooks/use-entity-record.ts index 01eee562644cf1..7f3630b1dd4ceb 100644 --- a/packages/core-data/src/hooks/use-entity-record.ts +++ b/packages/core-data/src/hooks/use-entity-record.ts @@ -155,8 +155,8 @@ export default function useEntityRecord< RecordType >( const mutations = useMemo( () => ( { - edit: ( record ) => - editEntityRecord( kind, name, recordId, record ), + edit: ( record, editOptions: any = {} ) => + editEntityRecord( kind, name, recordId, record, editOptions ), save: ( saveOptions: any = {} ) => saveEditedEntityRecord( kind, name, recordId, { throwOnError: true, diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js index b49e8ac459e3fe..2295ee12f45049 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/edit-template.js @@ -2,21 +2,31 @@ * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; -import { useMemo } from '@wordpress/element'; import { decodeEntities } from '@wordpress/html-entities'; -import { BlockContextProvider, BlockPreview } from '@wordpress/block-editor'; -import { Button, __experimentalVStack as VStack } from '@wordpress/components'; +import { + DropdownMenu, + MenuGroup, + MenuItem, + __experimentalHStack as HStack, + __experimentalText as Text, +} from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; -import { parse } from '@wordpress/blocks'; /** * Internal dependencies */ import { store as editSiteStore } from '../../../store'; +import SwapTemplateButton from './swap-template-button'; +import ResetDefaultTemplate from './reset-default-template'; + +const POPOVER_PROPS = { + className: 'edit-site-page-panels-edit-template__dropdown', + placement: 'bottom-start', +}; export default function EditTemplate() { - const { context, hasResolved, template } = useSelect( ( select ) => { + const { hasResolved, template } = useSelect( ( select ) => { const { getEditedPostContext, getEditedPostType, getEditedPostId } = select( editSiteStore ); const { getEditedEntityRecord, hasFinishedResolution } = @@ -39,39 +49,43 @@ export default function EditTemplate() { const { setHasPageContentFocus } = useDispatch( editSiteStore ); - const blockContext = useMemo( - () => ( { ...context, postType: null, postId: null } ), - [ context ] - ); - - const blocks = useMemo( - () => - template.blocks ?? - ( template.content && typeof template.content !== 'function' - ? parse( template.content ) - : [] ), - [ template.blocks, template.content ] - ); - if ( ! hasResolved ) { return null; } return ( - -
{ decodeEntities( template.title ) }
-
- - - -
- -
+ { ( { onClose } ) => ( + <> + + { + setHasPageContentFocus( false ); + onClose(); + } } + > + { __( 'Edit template' ) } + + + + + + ) } + + ); } diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/hooks.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/hooks.js new file mode 100644 index 00000000000000..3000d21ab13662 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/hooks.js @@ -0,0 +1,83 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../../store'; + +export function useEditedPostContext() { + return useSelect( + ( select ) => select( editSiteStore ).getEditedPostContext(), + [] + ); +} + +export function useIsPostsPage() { + const { postId } = useEditedPostContext(); + return useSelect( + ( select ) => + +postId === + select( coreStore ).getEntityRecord( 'root', 'site' ) + ?.page_for_posts, + [ postId ] + ); +} + +function useTemplates() { + return useSelect( + ( select ) => + select( coreStore ).getEntityRecords( 'postType', 'wp_template', { + per_page: -1, + post_type: 'page', + } ), + [] + ); +} + +export function useAvailableTemplates() { + const currentTemplateSlug = useCurrentTemplateSlug(); + const isPostsPage = useIsPostsPage(); + const templates = useTemplates(); + return useMemo( + () => + // The posts page template cannot be changed. + ! isPostsPage && + templates?.filter( + ( template ) => + template.is_custom && + template.slug !== currentTemplateSlug && + !! template.content.raw // Skip empty templates. + ), + [ templates, currentTemplateSlug, isPostsPage ] + ); +} + +export function useCurrentTemplateSlug() { + const { postType, postId } = useEditedPostContext(); + const templates = useTemplates(); + const entityTemplate = useSelect( + ( select ) => { + const post = select( coreStore ).getEditedEntityRecord( + 'postType', + postType, + postId + ); + return post?.template; + }, + [ postType, postId ] + ); + + if ( ! entityTemplate ) { + return; + } + // If a page has a `template` set and is not included in the list + // of the theme's templates, do not return it, in order to resolve + // to the current theme's default template. + return templates?.find( ( template ) => template.slug === entityTemplate ) + ?.slug; +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js index 69971d1ad413ae..df59dffe66be69 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/index.js @@ -20,7 +20,6 @@ import { store as editSiteStore } from '../../../store'; import SidebarCard from '../sidebar-card'; import PageContent from './page-content'; import PageSummary from './page-summary'; -import EditTemplate from './edit-template'; export default function PagePanels() { const { id, type, hasResolved, status, date, password, title, modified } = @@ -81,9 +80,6 @@ export default function PagePanels() { - - - ); } diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js index 3dce743b298d45..c4dafeab6cb372 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/page-summary.js @@ -7,6 +7,7 @@ import { __experimentalVStack as VStack } from '@wordpress/components'; */ import PageStatus from './page-status'; import PublishDate from './publish-date'; +import EditTemplate from './edit-template'; export default function PageSummary( { status, @@ -30,6 +31,7 @@ export default function PageSummary( { postId={ postId } postType={ postType } /> + ); } diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/reset-default-template.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/reset-default-template.js new file mode 100644 index 00000000000000..bc61b82a8d0057 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/reset-default-template.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { MenuGroup, MenuItem } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useEntityRecord } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { + useCurrentTemplateSlug, + useEditedPostContext, + useIsPostsPage, +} from './hooks'; +import { store as editSiteStore } from '../../../store'; + +export default function ResetDefaultTemplate( { onClick } ) { + const currentTemplateSlug = useCurrentTemplateSlug(); + const isPostsPage = useIsPostsPage(); + const { postType, postId } = useEditedPostContext(); + const entity = useEntityRecord( 'postType', postType, postId ); + const { setPage } = useDispatch( editSiteStore ); + // The default template in a post is indicated by an empty string. + if ( ! currentTemplateSlug || isPostsPage ) { + return null; + } + return ( + + { + entity.edit( { template: '' }, { undoIgnore: true } ); + onClick(); + await setPage( { + context: { postType, postId }, + } ); + } } + > + { __( 'Reset' ) } + + + ); +} diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss index 8c10b32085612b..aedcf5e46ca9ea 100644 --- a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/style.scss @@ -1,12 +1,37 @@ -.edit-site-page-panels__edit-template-preview { - border: 1px solid $gray-200; - height: 200px; - max-height: 200px; - overflow: hidden; +.edit-site-swap-template-modal { + z-index: z-index(".edit-site-swap-template-modal"); } -.edit-site-page-panels__edit-template-button { - justify-content: center; +.edit-site-page-panels__swap-template__confirm-modal__actions { + margin-top: $grid-unit-30; +} + +.edit-site-page-panels__swap-template__modal-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; + } + + .block-editor-block-patterns-list__item { + // Avoid to override the BlockPatternList component + // default hover and focus styles. + &:not(:focus):not(:hover) .block-editor-block-preview__container { + box-shadow: 0 0 0 1px $gray-300; + } + } } .edit-site-change-status__content { @@ -36,15 +61,21 @@ .edit-site-summary-field { .components-dropdown { - flex-grow: 1; + width: 70%; } .edit-site-summary-field__trigger { - width: 100%; + max-width: 100%; + + // Truncate + display: block; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .edit-site-summary-field__label { width: 30%; } } - diff --git a/packages/edit-site/src/components/sidebar-edit-mode/page-panels/swap-template-button.js b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/swap-template-button.js new file mode 100644 index 00000000000000..fee4f22a3ae2bc --- /dev/null +++ b/packages/edit-site/src/components/sidebar-edit-mode/page-panels/swap-template-button.js @@ -0,0 +1,82 @@ +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { useMemo, useState, useCallback } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +import { __experimentalBlockPatternsList as BlockPatternsList } from '@wordpress/block-editor'; +import { MenuItem, Modal } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useEntityRecord } from '@wordpress/core-data'; +import { parse } from '@wordpress/blocks'; +import { useAsyncList } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { store as editSiteStore } from '../../../store'; +import { useAvailableTemplates, useEditedPostContext } from './hooks'; + +export default function SwapTemplateButton( { onClick } ) { + const [ showModal, setShowModal ] = useState( false ); + const availableTemplates = useAvailableTemplates(); + const onClose = useCallback( () => { + setShowModal( false ); + }, [] ); + const { postType, postId } = useEditedPostContext(); + const entitiy = useEntityRecord( 'postType', postType, postId ); + const { setPage } = useDispatch( editSiteStore ); + if ( ! availableTemplates?.length ) { + return null; + } + const onTemplateSelect = async ( template ) => { + entitiy.edit( { template: template.name }, { undoIgnore: true } ); + await setPage( { + context: { postType, postId }, + } ); + onClose(); // Close the template suggestions modal first. + onClick(); + }; + return ( + <> + setShowModal( true ) }> + { __( 'Swap template' ) } + + { showModal && ( + +
+ +
+
+ ) } + + ); +} + +function TemplatesList( { onSelect } ) { + const availableTemplates = useAvailableTemplates(); + 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 ); + return ( + + ); +} diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js index 0ad521a9c9a54e..f690075c311489 100644 --- a/packages/edit-site/src/store/actions.js +++ b/packages/edit-site/src/store/actions.js @@ -4,7 +4,7 @@ import apiFetch from '@wordpress/api-fetch'; import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; import deprecated from '@wordpress/deprecated'; -import { addQueryArgs, getPathAndQueryString } from '@wordpress/url'; +import { addQueryArgs } from '@wordpress/url'; import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; @@ -233,7 +233,7 @@ export function setHomeTemplateId() { * * @param {Object} context The context object. * - * @return {number} The resolved template ID for the page route. + * @return {Object} Action object. */ export function setEditedPostContext( context ) { return { @@ -257,22 +257,48 @@ export function setEditedPostContext( context ) { export const setPage = ( page ) => async ( { dispatch, registry } ) => { - if ( ! page.path && page.context?.postId ) { - const entity = await registry + let template; + const getDefaultTemplate = async ( slug ) => + apiFetch( { + path: addQueryArgs( '/wp/v2/templates/lookup', { + slug: `page-${ slug }`, + } ), + } ); + + if ( page.path ) { + template = await registry + .resolveSelect( coreStore ) + .__experimentalGetTemplateForLink( page.path ); + } else { + const editedEntity = await registry .resolveSelect( coreStore ) - .getEntityRecord( + .getEditedEntityRecord( 'postType', - page.context.postType || 'post', - page.context.postId + page.context?.postType || 'post', + page.context?.postId ); - // If the entity is undefined for some reason, path will resolve to "/" - page.path = getPathAndQueryString( entity?.link ); + const currentTemplateSlug = editedEntity?.template; + if ( currentTemplateSlug ) { + const currentTemplate = ( + await registry + .resolveSelect( coreStore ) + .getEntityRecords( 'postType', 'wp_template', { + per_page: -1, + } ) + )?.find( ( { slug } ) => slug === currentTemplateSlug ); + if ( currentTemplate ) { + template = currentTemplate; + } else { + // If a page has a `template` set and is not included in the list + // of the current theme's templates, query for current theme's default template. + template = await getDefaultTemplate( editedEntity?.link ); + } + } else { + // Page's `template` is empty, that indicates we need to use the default template for the page. + template = await getDefaultTemplate( editedEntity?.link ); + } } - const template = await registry - .resolveSelect( coreStore ) - .__experimentalGetTemplateForLink( page.path ); - if ( ! template ) { return; } diff --git a/test/e2e/specs/site-editor/pages.spec.js b/test/e2e/specs/site-editor/pages.spec.js index 1c0cb8c4b69686..e6a0b7f266cbf4 100644 --- a/test/e2e/specs/site-editor/pages.spec.js +++ b/test/e2e/specs/site-editor/pages.spec.js @@ -3,33 +3,48 @@ */ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +async function draftNewPage( page ) { + await page.getByRole( 'button', { name: 'Pages' } ).click(); + await page.getByRole( 'button', { name: 'Draft a new page' } ).click(); + await page + .locator( 'role=dialog[name="Draft a new page"i]' ) + .locator( 'role=textbox[name="Page title"i]' ) + .fill( 'Test Page' ); + await page.keyboard.press( 'Enter' ); + await expect( + page.locator( + `role=button[name="Dismiss this notice"i] >> text='"Test Page" successfully created.'` + ) + ).toBeVisible(); +} + test.describe( 'Pages', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.activateTheme( 'emptytheme' ); + await Promise.all( [ + requestUtils.deleteAllTemplates( 'wp_template' ), + requestUtils.deleteAllPages(), + ] ); } ); test.afterAll( async ( { requestUtils } ) => { await requestUtils.activateTheme( 'twentytwentyone' ); + await Promise.all( [ + requestUtils.deleteAllTemplates( 'wp_template' ), + requestUtils.deleteAllPages(), + ] ); } ); - test.beforeEach( async ( { admin } ) => { + test.beforeEach( async ( { requestUtils, admin } ) => { + await Promise.all( [ + requestUtils.deleteAllTemplates( 'wp_template' ), + requestUtils.deleteAllPages(), + ] ); await admin.visitSiteEditor(); } ); test( 'create a new page', async ( { page, editor } ) => { - // Draft a new page. - await page.getByRole( 'button', { name: 'Pages' } ).click(); - await page.getByRole( 'button', { name: 'Draft a new page' } ).click(); - await page - .locator( 'role=dialog[name="Draft a new page"i]' ) - .locator( 'role=textbox[name="Page title"i]' ) - .fill( 'Test Page' ); - await page.keyboard.press( 'Enter' ); - await expect( - page.locator( - `role=button[name="Dismiss this notice"i] >> text='"Test Page" successfully created.'` - ) - ).toBeVisible(); + await draftNewPage( page ); // Insert into Page Content using default block. await editor.canvas @@ -79,15 +94,11 @@ test.describe( 'Pages', () => { // Switch to template editing focus. await editor.openDocumentSettingsSidebar(); - await expect( - page.locator( - '.edit-site-page-panels__edit-template-preview iframe' - ) - ).toBeVisible(); await page .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Edit template' } ) + .getByRole( 'button', { name: 'Template options' } ) .click(); + await page.getByRole( 'button', { name: 'Edit template' } ).click(); await expect( editor.canvas.getByRole( 'document', { name: 'Block: Content', @@ -125,4 +136,81 @@ test.describe( 'Pages', () => { ) ).toBeVisible(); } ); + test( 'swap template and reset to default', async ( { + admin, + page, + editor, + } ) => { + // Create a custom template first. + const templateName = 'demo'; + await page.getByRole( 'button', { name: 'Templates' } ).click(); + await page.getByRole( 'button', { name: 'Add New Template' } ).click(); + await page + .getByRole( 'button', { + name: 'A custom template can be manually applied to any post or page.', + } ) + .click(); + // Fill the template title and submit. + const newTemplateDialog = page.locator( + 'role=dialog[name="Create custom template"i]' + ); + const templateNameInput = newTemplateDialog.locator( + 'role=textbox[name="Name"i]' + ); + await templateNameInput.fill( templateName ); + await page.keyboard.press( 'Enter' ); + await page + .locator( '.block-editor-block-patterns-list__list-item' ) + .click(); + await editor.saveSiteEditorEntities(); + await admin.visitSiteEditor(); + + // Create new page that has the default template so as to swap it. + await draftNewPage( page ); + await editor.openDocumentSettingsSidebar(); + const templateOptionsButton = page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Template options' } ); + await expect( templateOptionsButton ).toHaveText( 'Single Entries' ); + await templateOptionsButton.click(); + await page + .getByRole( 'menu', { name: 'Template options' } ) + .getByText( 'Swap template' ) + .click(); + const templateItem = page.locator( + '.block-editor-block-patterns-list__item-title' + ); + // Empty theme's custom template with `postTypes: ['post']`, should not be suggested. + await expect( templateItem ).toHaveCount( 1 ); + await templateItem.click(); + await expect( templateOptionsButton ).toHaveText( 'demo' ); + await editor.saveSiteEditorEntities(); + + // Now reset, and apply the default template back. + await templateOptionsButton.click(); + const resetButton = page + .getByRole( 'menu', { name: 'Template options' } ) + .getByText( 'Reset' ); + await expect( resetButton ).toBeVisible(); + await resetButton.click(); + await expect( templateOptionsButton ).toHaveText( 'Single Entries' ); + } ); + test( 'swap template options should respect the declared `postTypes`', async ( { + page, + editor, + } ) => { + await draftNewPage( page ); + await editor.openDocumentSettingsSidebar(); + const templateOptionsButton = page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Template options' } ); + await templateOptionsButton.click(); + // Empty theme has only one custom template with `postTypes: ['post']`, + // so it should not be suggested. + await expect( + page + .getByRole( 'menu', { name: 'Template options' } ) + .getByText( 'Swap template' ) + ).toHaveCount( 0 ); + } ); } ); diff --git a/test/emptytheme/templates/custom-template.html b/test/emptytheme/templates/custom-template.html new file mode 100644 index 00000000000000..e4e8c11a39ef6f --- /dev/null +++ b/test/emptytheme/templates/custom-template.html @@ -0,0 +1,3 @@ + +

Custom template for Posts

+ diff --git a/test/emptytheme/theme.json b/test/emptytheme/theme.json index b28e6c9f274b2f..d95ed844e6b1cb 100644 --- a/test/emptytheme/theme.json +++ b/test/emptytheme/theme.json @@ -8,5 +8,12 @@ "wideSize": "1100px" } }, + "customTemplates": [ + { + "name": "custom-template", + "title": "Custom", + "postTypes": [ "post" ] + } + ], "patterns": [ "short-text-surrounded-by-round-images", "partner-logos" ] }