diff --git a/docs/designers-developers/developers/data/data-core-edit-post.md b/docs/designers-developers/developers/data/data-core-edit-post.md index 03be0d1e9ff917..ddaac2ac0ca707 100644 --- a/docs/designers-developers/developers/data/data-core-edit-post.md +++ b/docs/designers-developers/developers/data/data-core-edit-post.md @@ -110,6 +110,18 @@ _Returns_ - `boolean`: Whether there are metaboxes or not. +# **isEditingTemplate** + +Returns true if the template editing mode is enabled. + +_Parameters_ + +- _state_ `Object`: Global application state. + +_Returns_ + +- `boolean`: Whether we're editing the template. + # **isEditorPanelEnabled** Returns true if the given panel is enabled, or false otherwise. Panels are @@ -381,6 +393,18 @@ _Parameters_ - _metaBoxesPerLocation_ `Object`: Meta boxes per location. +# **setIsEditingTemplate** + +Returns an action object used to switch to template editing. + +_Parameters_ + +- _value_ `boolean`: Is editing template. + +_Returns_ + +- `Object`: Action object. + # **setIsInserterOpened** Returns an action object used to open/close the inserter. diff --git a/lib/client-assets.php b/lib/client-assets.php index d17b40b801bebf..fb3b528a566426 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -649,3 +649,16 @@ function gutenberg_extend_block_editor_settings_with_default_editor_styles( $set return $settings; } add_filter( 'block_editor_settings', 'gutenberg_extend_block_editor_settings_with_default_editor_styles' ); + +/** + * Adds a flag to the editor settings to know whether we're in FSE theme or not. + * + * @param array $settings Default editor settings. + * + * @return array Filtered editor settings. + */ +function gutenberg_extend_block_editor_settings_with_fse_theme_flag( $settings ) { + $settings['isFSETheme'] = gutenberg_is_fse_theme(); + return $settings; +} +add_filter( 'block_editor_settings', 'gutenberg_extend_block_editor_settings_with_fse_theme_flag' ); diff --git a/packages/block-library/src/post-content/edit.js b/packages/block-library/src/post-content/edit.js index 855383fe19c4cd..d4c86853533d89 100644 --- a/packages/block-library/src/post-content/edit.js +++ b/packages/block-library/src/post-content/edit.js @@ -2,7 +2,6 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useSelect } from '@wordpress/data'; import { useBlockProps } from '@wordpress/block-editor'; /** @@ -13,19 +12,8 @@ import PostContentInnerBlocks from './inner-blocks'; export default function PostContentEdit( { context: { postId: contextPostId, postType: contextPostType }, } ) { - const { id: currentPostId, type: currentPostType } = useSelect( - ( select ) => select( 'core/editor' ).getCurrentPost() ?? {} - ); const blockProps = useBlockProps(); - - // Only render InnerBlocks if the context is different from the active post - // to avoid infinite recursion of post content. - if ( - contextPostId && - contextPostType && - contextPostId !== currentPostId && - contextPostType !== currentPostType - ) { + if ( contextPostId && contextPostType ) { return (
res.json() ); + + return data; + }, +}; + +export default controls; diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index fc2c051a52165a..c59d5bcf97ce35 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -13,6 +13,7 @@ import * as actions from './actions'; import * as resolvers from './resolvers'; import * as locksSelectors from './locks/selectors'; import * as locksActions from './locks/actions'; +import customControls from './controls'; import { defaultEntities, getMethodName } from './entities'; import { STORE_NAME } from './name'; @@ -58,7 +59,7 @@ const entityActions = defaultEntities.reduce( ( result, entity ) => { const storeConfig = { reducer, - controls, + controls: { ...customControls, ...controls }, actions: { ...actions, ...entityActions, ...locksActions }, selectors: { ...selectors, ...entitySelectors, ...locksSelectors }, resolvers: { ...resolvers, ...entityResolvers }, diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 9a1db4022b5580..3458c260d3f603 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -10,6 +10,10 @@ import { addQueryArgs } from '@wordpress/url'; import deprecated from '@wordpress/deprecated'; import { controls } from '@wordpress/data'; import { apiFetch } from '@wordpress/data-controls'; +/** + * Internal dependencies + */ +import { regularFetch } from './controls'; /** * Internal dependencies @@ -382,3 +386,38 @@ export function* getAutosaves( postType, postId ) { export function* getAutosave( postType, postId ) { yield controls.resolveSelect( 'core', 'getAutosaves', postType, postId ); } + +/** + * Retrieve the frontend template used for a given link. + * + * @param {string} link Link. + */ +export function* __experimentalGetTemplateForLink( link ) { + // Ideally this should be using an apiFetch call + // We could potentially do so by adding a "filter" to the `wp_template` end point. + // Also it seems the returned object is not a regular REST API post type. + const template = yield regularFetch( + addQueryArgs( link, { + '_wp-find-template': true, + } ) + ); + + if ( template === null ) { + return; + } + + yield getEntityRecord( 'postType', 'wp_template', template.ID ); + const record = yield controls.select( + 'core', + 'getEntityRecord', + 'postType', + 'wp_template', + template.ID + ); + + if ( record ) { + yield receiveEntityRecords( 'postType', 'wp_template', [ record ], { + 'find-template': link, + } ); + } +} diff --git a/packages/core-data/src/selectors.js b/packages/core-data/src/selectors.js index d00356fa3483e6..5f4a9501f9bd54 100644 --- a/packages/core-data/src/selectors.js +++ b/packages/core-data/src/selectors.js @@ -740,3 +740,19 @@ export const getReferenceByDistinctEdits = createSelector( state.undo.flattenedUndo, ] ); + +/** + * Retrieve the frontend template used for a given link. + * + * @param {Object} state Editor state. + * @param {string} link Link. + * + * @return {Object?} The template record. + */ +export function __experimentalGetTemplateForLink( state, link ) { + const records = getEntityRecords( state, 'postType', 'wp_template', { + 'find-template': link, + } ); + + return records?.length ? records[ 0 ] : null; +} diff --git a/packages/e2e-test-utils/README.md b/packages/e2e-test-utils/README.md index e3b052a81f4dbd..1ec564dcec4d94 100644 --- a/packages/e2e-test-utils/README.md +++ b/packages/e2e-test-utils/README.md @@ -446,6 +446,18 @@ Clicks on the button in the header which opens Document Settings sidebar when it Opens the global block inserter. +# **openPreviewPage** + +Opens the preview page of an edited post. + +_Parameters_ + +- _editorPage_ `Page`: puppeteer editor page. + +_Returns_ + +- `Page`: preview page. + # **openPublishPanel** Opens the publish panel. diff --git a/packages/e2e-test-utils/src/index.js b/packages/e2e-test-utils/src/index.js index 39660c8173c223..c3bdfa98177cc7 100644 --- a/packages/e2e-test-utils/src/index.js +++ b/packages/e2e-test-utils/src/index.js @@ -74,5 +74,6 @@ export { uninstallPlugin } from './uninstall-plugin'; export { visitAdminPage } from './visit-admin-page'; export { waitForWindowDimensions } from './wait-for-window-dimensions'; export { showBlockToolbar } from './show-block-toolbar'; +export { openPreviewPage } from './preview'; export * from './mocks'; diff --git a/packages/e2e-test-utils/src/preview.js b/packages/e2e-test-utils/src/preview.js new file mode 100644 index 00000000000000..90486bb174c65d --- /dev/null +++ b/packages/e2e-test-utils/src/preview.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { last } from 'lodash'; + +/** @typedef {import('puppeteer').Page} Page */ + +/** + * Opens the preview page of an edited post. + * + * @param {Page} editorPage puppeteer editor page. + * @return {Page} preview page. + */ +export async function openPreviewPage( editorPage = page ) { + let openTabs = await browser.pages(); + const expectedTabsCount = openTabs.length + 1; + await editorPage.click( '.block-editor-post-preview__button-toggle' ); + await editorPage.waitFor( '.edit-post-header-preview__button-external' ); + await editorPage.click( '.edit-post-header-preview__button-external' ); + + // Wait for the new tab to open. + while ( openTabs.length < expectedTabsCount ) { + await editorPage.waitFor( 1 ); + openTabs = await browser.pages(); + } + + const previewPage = last( openTabs ); + return previewPage; +} diff --git a/packages/e2e-tests/specs/editor/plugins/block-context.test.js b/packages/e2e-tests/specs/editor/plugins/block-context.test.js index 3a96eb3e141f6a..f09df529e70ce0 100644 --- a/packages/e2e-tests/specs/editor/plugins/block-context.test.js +++ b/packages/e2e-tests/specs/editor/plugins/block-context.test.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { last } from 'lodash'; - /** * WordPress dependencies */ @@ -12,29 +7,9 @@ import { deactivatePlugin, insertBlock, saveDraft, + openPreviewPage, } from '@wordpress/e2e-test-utils'; -async function openPreviewPage( editorPage ) { - let openTabs = await browser.pages(); - const expectedTabsCount = openTabs.length + 1; - await editorPage.click( '.block-editor-post-preview__button-toggle' ); - await editorPage.waitFor( '.edit-post-header-preview__button-external' ); - await editorPage.click( '.edit-post-header-preview__button-external' ); - - // Wait for the new tab to open. - while ( openTabs.length < expectedTabsCount ) { - await editorPage.waitFor( 1 ); - openTabs = await browser.pages(); - } - - const previewPage = last( openTabs ); - // Wait for the preview to load. We can't do interstitial detection here, - // because it might load too quickly for us to pick up, so we wait for - // the preview to load by waiting for the content to appear. - await previewPage.waitForSelector( '.entry-content' ); - return previewPage; -} - describe( 'Block context', () => { beforeAll( async () => { await activatePlugin( 'gutenberg-test-block-context' ); @@ -74,6 +49,7 @@ describe( 'Block context', () => { await insertBlock( 'Test Context Provider' ); const editorPage = page; const previewPage = await openPreviewPage( editorPage ); + await previewPage.waitForSelector( '.entry-content' ); // Check default context values are populated. let content = await previewPage.$eval( diff --git a/packages/e2e-tests/specs/editor/various/preview.test.js b/packages/e2e-tests/specs/editor/various/preview.test.js index 87c00255c508cf..d0996211a512c9 100644 --- a/packages/e2e-tests/specs/editor/various/preview.test.js +++ b/packages/e2e-tests/specs/editor/various/preview.test.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { last } from 'lodash'; - /** * WordPress dependencies */ @@ -15,31 +10,11 @@ import { deactivatePlugin, publishPost, saveDraft, + openPreviewPage, } from '@wordpress/e2e-test-utils'; /** @typedef {import('puppeteer').Page} Page */ -async function openPreviewPage( editorPage ) { - let openTabs = await browser.pages(); - const expectedTabsCount = openTabs.length + 1; - await editorPage.click( '.block-editor-post-preview__button-toggle' ); - await editorPage.waitFor( '.edit-post-header-preview__button-external' ); - await editorPage.click( '.edit-post-header-preview__button-external' ); - - // Wait for the new tab to open. - while ( openTabs.length < expectedTabsCount ) { - await editorPage.waitFor( 1 ); - openTabs = await browser.pages(); - } - - const previewPage = last( openTabs ); - // Wait for the preview to load. We can't do interstitial detection here, - // because it might load too quickly for us to pick up, so we wait for - // the preview to load by waiting for the title to appear. - await previewPage.waitForSelector( '.entry-title' ); - return previewPage; -} - /** * Given the Page instance for the editor, opens preview drodpdown, and * awaits the presence of the external preview selector. @@ -123,6 +98,7 @@ describe( 'Preview', () => { await editorPage.type( '.editor-post-title__input', 'Hello World' ); const previewPage = await openPreviewPage( editorPage ); + await previewPage.waitForSelector( '.entry-title' ); // When autosave completes for a new post, the URL of the editor should // update to include the ID. Use this to assert on preview URL. @@ -222,6 +198,7 @@ describe( 'Preview', () => { // Open the preview page. const previewPage = await openPreviewPage( editorPage ); + await previewPage.waitForSelector( '.entry-title' ); // Title in preview should match input. let previewTitle = await previewPage.$eval( @@ -282,6 +259,7 @@ describe( 'Preview with Custom Fields enabled', () => { // Open the preview page. const previewPage = await openPreviewPage( editorPage ); + await previewPage.waitForSelector( '.entry-title' ); // Check the title and preview match. let previewTitle = await previewPage.$eval( diff --git a/packages/e2e-tests/specs/experiments/post-editor-template-mode.test.js b/packages/e2e-tests/specs/experiments/post-editor-template-mode.test.js new file mode 100644 index 00000000000000..31a795a1b2d2f4 --- /dev/null +++ b/packages/e2e-tests/specs/experiments/post-editor-template-mode.test.js @@ -0,0 +1,85 @@ +/** + * WordPress dependencies + */ +import { + activateTheme, + createNewPost, + insertBlock, + saveDraft, + trashAllPosts, + openPreviewPage, + openDocumentSettingsSidebar, +} from '@wordpress/e2e-test-utils'; + +describe( 'Post Editor Template mode', () => { + beforeAll( async () => { + await activateTheme( 'twentytwentyone-blocks' ); + await trashAllPosts( 'wp_template' ); + await trashAllPosts( 'wp_template_part' ); + } ); + + afterAll( async () => { + await activateTheme( 'twentytwentyone' ); + } ); + + beforeEach( async () => { + await createNewPost(); + } ); + + it( 'Allow to switch to template mode, edit the template and check the result', async () => { + // Create a random post. + await page.type( '.editor-post-title__input', 'Just an FSE Post' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Hello World' ); + + // Unselect the blocks. + await page.evaluate( () => { + wp.data.dispatch( 'core/block-editor' ).clearSelectedBlock(); + } ); + + // Save the post + // Saving shouldn't be necessary but unfortunately, + // there's a template resolution bug forcing us to do so. + await saveDraft(); + await page.reload(); + + // Switch to template mode. + await openDocumentSettingsSidebar(); + const switchLink = await page.waitForSelector( + '.edit-post-post-template button' + ); + await switchLink.click(); + + // Check that we switched properly to edit mode. + await page.waitForXPath( + '//*[contains(@class, "components-snackbar")]/*[text()="Editing template. Changes made here affect all posts and pages that use the template."]' + ); + const title = await page.$eval( + '.edit-post-template-title', + ( el ) => el.innerText + ); + expect( title ).toContain( 'Editing template:' ); + + // Edit the template + await insertBlock( 'Paragraph' ); + await page.keyboard.type( + 'Just a random paragraph added to the template' + ); + + // Save changes + const doneButton = await page.waitForXPath( + `//button[contains(text(), 'Apply')]` + ); + await doneButton.click(); + const saveButton = await page.waitForXPath( + `//div[contains(@class, "entities-saved-states__panel-header")]/button[contains(text(), 'Save')]` + ); + await saveButton.click(); + + // Preview changes + const previewPage = await openPreviewPage(); + await previewPage.waitForXPath( + '//p[contains(text(), "Just a random paragraph added to the template")]' + ); + } ); +} ); diff --git a/packages/edit-post/src/components/header/header-toolbar/index.js b/packages/edit-post/src/components/header/header-toolbar/index.js index ad8737767a7f9a..1349d67ffbc612 100644 --- a/packages/edit-post/src/components/header/header-toolbar/index.js +++ b/packages/edit-post/src/components/header/header-toolbar/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + /** * WordPress dependencies */ @@ -25,6 +30,11 @@ import { import { plus } from '@wordpress/icons'; import { useRef } from '@wordpress/element'; +/** + * Internal dependencies + */ +import TemplateTitle from '../template-title'; + function HeaderToolbar() { const inserterButton = useRef(); const { setIsInserterOpened } = useDispatch( 'core/edit-post' ); @@ -36,6 +46,7 @@ function HeaderToolbar() { previewDeviceType, showIconLabels, isNavigationTool, + isTemplateMode, } = useSelect( ( select ) => { const { hasInserterItems, @@ -64,6 +75,7 @@ function HeaderToolbar() { 'showIconLabels' ), isNavigationTool: select( 'core/block-editor' ).isNavigationMode(), + isTemplateMode: select( 'core/edit-post' ).isEditingTemplate(), }; }, [] ); const isLargeViewport = useViewportMatch( 'medium' ); @@ -206,8 +218,17 @@ characters. */ ) }
+ + { displayBlockToolbar && ( -
+
) } diff --git a/packages/edit-post/src/components/header/header-toolbar/style.scss b/packages/edit-post/src/components/header/header-toolbar/style.scss index 84de5011a39de3..fc3bd59f4232fe 100644 --- a/packages/edit-post/src/components/header/header-toolbar/style.scss +++ b/packages/edit-post/src/components/header/header-toolbar/style.scss @@ -1,5 +1,6 @@ .edit-post-header-toolbar { display: inline-flex; + flex-grow: 1; align-items: center; border: none; @@ -117,25 +118,27 @@ // Move toolbar into top Editor Bar. @include break-wide { - position: static; - left: auto; - right: auto; - background: none; - border-bottom: none; - - .is-sidebar-opened & { + &:not(.is-pushed-down) { + position: static; + left: auto; right: auto; - } + background: none; + border-bottom: none; - .block-editor-block-toolbar { - border-left: $border-width solid $gray-300; - } + .is-sidebar-opened & { + right: auto; + } - .block-editor-block-toolbar .components-toolbar-group, - .block-editor-block-toolbar .components-toolbar { - $top-toolbar-padding: ($header-height - $grid-unit-60) / 2; - height: $header-height; - padding: $top-toolbar-padding 0; + .block-editor-block-toolbar { + border-left: $border-width solid $gray-300; + } + + .block-editor-block-toolbar .components-toolbar-group, + .block-editor-block-toolbar .components-toolbar { + $top-toolbar-padding: ($header-height - $grid-unit-60) / 2; + height: $header-height; + padding: $top-toolbar-padding 0; + } } } } diff --git a/packages/edit-post/src/components/header/index.js b/packages/edit-post/src/components/header/index.js index cd5457ff3d29e3..639ba8424aeab8 100644 --- a/packages/edit-post/src/components/header/index.js +++ b/packages/edit-post/src/components/header/index.js @@ -19,7 +19,8 @@ import HeaderToolbar from './header-toolbar'; import MoreMenu from './more-menu'; import PostPublishButtonOrToggle from './post-publish-button-or-toggle'; import { default as DevicePreview } from '../device-preview'; -import MainDashboardButton from '../header/main-dashboard-button'; +import MainDashboardButton from './main-dashboard-button'; +import TemplateSaveButton from './template-save-button'; function Header( { setEntitiesSavedStatesCallback } ) { const { @@ -28,6 +29,7 @@ function Header( { setEntitiesSavedStatesCallback } ) { isSaving, showIconLabels, hasReducedUI, + isEditingTemplate, } = useSelect( ( select ) => ( { hasActiveMetaboxes: select( 'core/edit-post' ).hasMetaBoxes(), @@ -41,6 +43,7 @@ function Header( { setEntitiesSavedStatesCallback } ) { hasReducedUI: select( 'core/edit-post' ).isFeatureActive( 'reducedUI' ), + isEditingTemplate: select( 'core/edit-post' ).isEditingTemplate(), } ), [] ); @@ -60,30 +63,35 @@ function Header( { setEntitiesSavedStatesCallback } ) {
- { ! isPublishSidebarOpened && ( - // This button isn't completely hidden by the publish sidebar. - // We can't hide the whole toolbar when the publish sidebar is open because - // we want to prevent mounting/unmounting the PostPublishButtonOrToggle DOM node. - // We track that DOM node to return focus to the PostPublishButtonOrToggle - // when the publish sidebar has been closed. - + { ! isEditingTemplate && ( + <> + { ! isPublishSidebarOpened && ( + // This button isn't completely hidden by the publish sidebar. + // We can't hide the whole toolbar when the publish sidebar is open because + // we want to prevent mounting/unmounting the PostPublishButtonOrToggle DOM node. + // We track that DOM node to return focus to the PostPublishButtonOrToggle + // when the publish sidebar has been closed. + + ) } + + + + ) } - - - + { isEditingTemplate && } { ( isLargeViewport || ! showIconLabels ) && ( <> diff --git a/packages/edit-post/src/components/header/template-save-button/index.js b/packages/edit-post/src/components/header/template-save-button/index.js new file mode 100644 index 00000000000000..57c660d65ef167 --- /dev/null +++ b/packages/edit-post/src/components/header/template-save-button/index.js @@ -0,0 +1,100 @@ +/** + * WordPress dependencies + */ +import { EntitiesSavedStates, store as editorStore } from '@wordpress/editor'; +import { Button } from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { useSelect, useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { ActionsPanelFill } from '../../layout/actions-panel'; +import { store as editPostStore } from '../../../store'; + +function TemplateSaveButton() { + const [ + isEntitiesReviewPanelOpen, + setIsEntitiesReviewPanelOpen, + ] = useState( false ); + const { setIsEditingTemplate } = useDispatch( editPostStore ); + const { editEntityRecord } = useDispatch( coreStore ); + const { getTemplateInfo, getEditedEntityRecord } = useSelect( + ( select ) => { + return { + getTemplateInfo: select( editorStore ) + .__experimentalGetTemplateInfo, + getEditedEntityRecord: select( coreStore ) + .getEditedEntityRecord, + }; + }, + [] + ); + return ( + <> + + + + { + // The logic here should be abstracted in the entities save handler/component + // and not duplicated accross multi-entity save behavior. + if ( entities?.length ) { + entities.forEach( ( entity ) => { + const edits = {}; + if ( + entity.kind === 'postType' && + entity.name === 'wp_template' + ) { + const record = getEditedEntityRecord( + entity.kind, + entity.name, + entity.key + ); + edits.title = + getTemplateInfo( record ).title ?? + entity.title ?? + record.slug; + } + + if ( + entity.kind === 'postType' && + [ + 'wp_template', + 'wp_template_part', + ].includes( entity.name ) + ) { + edits.status = 'publish'; + } + + editEntityRecord( + entity.kind, + entity.name, + entity.key, + edits + ); + } ); + } + setIsEntitiesReviewPanelOpen( false ); + if ( entities?.length ) { + setIsEditingTemplate( false ); + } + } } + /> + + + ); +} + +export default TemplateSaveButton; diff --git a/packages/edit-post/src/components/header/template-title/index.js b/packages/edit-post/src/components/header/template-title/index.js new file mode 100644 index 00000000000000..deb2b905ee9e34 --- /dev/null +++ b/packages/edit-post/src/components/header/template-title/index.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { store as editPostStore } from '../../../store'; + +function TemplateTitle() { + const { template, isEditing } = useSelect( ( select ) => { + const { getEditedPostAttribute } = select( editorStore ); + const { __experimentalGetTemplateForLink } = select( coreStore ); + const { isEditingTemplate } = select( editPostStore ); + const link = getEditedPostAttribute( 'link' ); + const _isEditing = isEditingTemplate(); + return { + template: _isEditing + ? __experimentalGetTemplateForLink( link ) + : null, + isEditing: _isEditing, + }; + }, [] ); + + if ( ! isEditing || ! template ) { + return null; + } + + return ( + + { + /* translators: 1: Template name. */ + sprintf( __( 'Editing template: %s' ), template.slug ) + } + + ); +} + +export default TemplateTitle; diff --git a/packages/edit-post/src/components/header/template-title/style.scss b/packages/edit-post/src/components/header/template-title/style.scss new file mode 100644 index 00000000000000..b4d6132c089757 --- /dev/null +++ b/packages/edit-post/src/components/header/template-title/style.scss @@ -0,0 +1,5 @@ +.edit-post-template-title { + display: inline-flex; + flex-grow: 1; + justify-content: center; +} diff --git a/packages/edit-post/src/components/layout/actions-panel.js b/packages/edit-post/src/components/layout/actions-panel.js index 5ac15a9c253c91..e1ad347a4bf7d7 100644 --- a/packages/edit-post/src/components/layout/actions-panel.js +++ b/packages/edit-post/src/components/layout/actions-panel.js @@ -3,7 +3,7 @@ */ import { EntitiesSavedStates, PostPublishPanel } from '@wordpress/editor'; import { useSelect, useDispatch } from '@wordpress/data'; -import { Button } from '@wordpress/components'; +import { Button, createSlotFill } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useCallback } from '@wordpress/element'; /** @@ -12,6 +12,10 @@ import { useCallback } from '@wordpress/element'; import PluginPostPublishPanel from '../sidebar/plugin-post-publish-panel'; import PluginPrePublishPanel from '../sidebar/plugin-pre-publish-panel'; +const { Fill, Slot } = createSlotFill( 'ActionsPanel' ); + +export const ActionsPanelFill = Fill; + export default function ActionsPanel( { setEntitiesSavedStatesCallback, closeEntitiesSavedStates, @@ -92,6 +96,7 @@ export default function ActionsPanel( { isOpen={ isEntitiesSavedStatesOpen } close={ closeEntitiesSavedStates } /> + { ! isEntitiesSavedStatesOpen && unmountableContent } ); diff --git a/packages/edit-post/src/components/sidebar/post-status/index.js b/packages/edit-post/src/components/sidebar/post-status/index.js index 11311e91849c2b..ad2437e4c1f1a0 100644 --- a/packages/edit-post/src/components/sidebar/post-status/index.js +++ b/packages/edit-post/src/components/sidebar/post-status/index.js @@ -18,6 +18,7 @@ import PostSlug from '../post-slug'; import PostFormat from '../post-format'; import PostPendingStatus from '../post-pending-status'; import PluginPostStatusInfo from '../plugin-post-status-info'; +import PostTemplate from '../post-template'; /** * Module Constants @@ -35,6 +36,7 @@ function PostStatus( { isOpened, onTogglePanel } ) { { ( fills ) => ( <> + diff --git a/packages/edit-post/src/components/sidebar/post-template/index.js b/packages/edit-post/src/components/sidebar/post-template/index.js new file mode 100644 index 00000000000000..9c3380860aacbf --- /dev/null +++ b/packages/edit-post/src/components/sidebar/post-template/index.js @@ -0,0 +1,91 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { PanelRow, Button } from '@wordpress/components'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { createInterpolateElement } from '@wordpress/element'; +import { store as editorStore } from '@wordpress/editor'; +import { store as coreStore } from '@wordpress/core-data'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { store as editPostStore } from '../../../store'; + +function PostTemplate() { + const { template, isEditing, isFSETheme } = useSelect( ( select ) => { + const { + getEditedPostAttribute, + __unstableIsAutodraftPost, + getCurrentPostType, + } = select( editorStore ); + const { __experimentalGetTemplateForLink } = select( coreStore ); + const { isEditingTemplate } = select( editPostStore ); + const link = getEditedPostAttribute( 'link' ); + const isFSEEnabled = select( editorStore ).getEditorSettings() + .isFSETheme; + return { + template: + isFSEEnabled && + link && + ! __unstableIsAutodraftPost() && + getCurrentPostType() !== 'wp_template' + ? __experimentalGetTemplateForLink( link ) + : null, + isEditing: isEditingTemplate(), + isFSETheme: isFSEEnabled, + }; + }, [] ); + const { setIsEditingTemplate } = useDispatch( editPostStore ); + const { createSuccessNotice } = useDispatch( noticesStore ); + + if ( ! isFSETheme || ! template ) { + return null; + } + + return ( + + { __( 'Template' ) } + { ! isEditing && ( + + { createInterpolateElement( + sprintf( + /* translators: 1: Template name. */ + __( '%s (Edit)' ), + template.slug + ), + { + a: ( + + ), + } + ) } + + ) } + { isEditing && ( + + { template.slug } + + ) } + + ); +} + +export default PostTemplate; diff --git a/packages/edit-post/src/components/sidebar/post-template/style.scss b/packages/edit-post/src/components/sidebar/post-template/style.scss new file mode 100644 index 00000000000000..d625e897224368 --- /dev/null +++ b/packages/edit-post/src/components/sidebar/post-template/style.scss @@ -0,0 +1,13 @@ +.edit-post-post-template { + width: 100%; + justify-content: left; + + span { + display: block; + width: 45%; + } +} + +.edit-post-post-template__value { + padding-left: 6px; +} diff --git a/packages/edit-post/src/components/visual-editor/index.js b/packages/edit-post/src/components/visual-editor/index.js index e51b8f4f691898..cf87daa479eda9 100644 --- a/packages/edit-post/src/components/visual-editor/index.js +++ b/packages/edit-post/src/components/visual-editor/index.js @@ -2,8 +2,8 @@ * WordPress dependencies */ import { - PostTitle, VisualEditorGlobalKeyboardShortcuts, + PostTitle, } from '@wordpress/editor'; import { WritingFlow, @@ -28,8 +28,15 @@ import { useSelect } from '@wordpress/data'; export default function VisualEditor() { const ref = useRef(); - const deviceType = useSelect( ( select ) => { - return select( 'core/edit-post' ).__experimentalGetPreviewDeviceType(); + const { deviceType, isTemplateMode } = useSelect( ( select ) => { + const { + isEditingTemplate, + __experimentalGetPreviewDeviceType, + } = select( 'core/edit-post' ); + return { + deviceType: __experimentalGetPreviewDeviceType(), + isTemplateMode: isEditingTemplate(), + }; }, [] ); const hasMetaBoxes = useSelect( ( select ) => select( 'core/edit-post' ).hasMetaBoxes(), @@ -61,9 +68,11 @@ export default function VisualEditor() { style={ resizedCanvasStyles || desktopCanvasStyles } > -
- -
+ { ! isTemplateMode && ( +
+ +
+ ) }
diff --git a/packages/edit-post/src/editor.js b/packages/edit-post/src/editor.js index 6694ffdfb34dcd..3f44768560bf5e 100644 --- a/packages/edit-post/src/editor.js +++ b/packages/edit-post/src/editor.js @@ -1,26 +1,24 @@ /** * External dependencies */ -import memize from 'memize'; import { size, map, without, omit } from 'lodash'; /** * WordPress dependencies */ import { store as blocksStore } from '@wordpress/blocks'; -import { withSelect, withDispatch } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { EditorProvider, ErrorBoundary, PostLockedModal, } from '@wordpress/editor'; -import { StrictMode, Component } from '@wordpress/element'; +import { StrictMode, useMemo } from '@wordpress/element'; import { KeyboardShortcuts, SlotFillProvider, DropZoneProvider, } from '@wordpress/components'; -import { compose } from '@wordpress/compose'; /** * Internal dependencies @@ -30,30 +28,78 @@ import Layout from './components/layout'; import EditorInitialization from './components/editor-initialization'; import EditPostSettings from './components/edit-post-settings'; -class Editor extends Component { - constructor() { - super( ...arguments ); - - this.getEditorSettings = memize( this.getEditorSettings, { - maxSize: 1, - } ); - } - - getEditorSettings( - settings, +function Editor( { + postId, + postType, + settings, + initialEdits, + onError, + ...props +} ) { + const { hasFixedToolbar, focusMode, hasReducedUI, hasThemeStyles, + post, + preferredStyleVariations, hiddenBlockTypes, blockTypes, - preferredStyleVariations, __experimentalLocalAutosaveInterval, - __experimentalSetIsInserterOpened, - updatePreferredStyleVariations, - keepCaretInsideBlock - ) { - settings = { + keepCaretInsideBlock, + isTemplateMode, + template, + } = useSelect( ( select ) => { + const { + isFeatureActive, + getPreference, + __experimentalGetPreviewDeviceType, + isEditingTemplate, + } = select( 'core/edit-post' ); + const { getEntityRecord, __experimentalGetTemplateForLink } = select( + 'core' + ); + const { getEditorSettings, __unstableIsAutodraftPost } = select( + 'core/editor' + ); + const { getBlockTypes } = select( blocksStore ); + const postObject = getEntityRecord( 'postType', postType, postId ); + const isFSETheme = getEditorSettings().isFSETheme; + + return { + hasFixedToolbar: + isFeatureActive( 'fixedToolbar' ) || + __experimentalGetPreviewDeviceType() !== 'Desktop', + focusMode: isFeatureActive( 'focusMode' ), + hasReducedUI: isFeatureActive( 'reducedUI' ), + hasThemeStyles: isFeatureActive( 'themeStyles' ), + preferredStyleVariations: getPreference( + 'preferredStyleVariations' + ), + hiddenBlockTypes: getPreference( 'hiddenBlockTypes' ), + blockTypes: getBlockTypes(), + __experimentalLocalAutosaveInterval: getPreference( + 'localAutosaveInterval' + ), + keepCaretInsideBlock: isFeatureActive( 'keepCaretInsideBlock' ), + isTemplateMode: isEditingTemplate(), + template: + isFSETheme && + postObject && + ! __unstableIsAutodraftPost() && + postType !== 'wp_template' + ? __experimentalGetTemplateForLink( postObject.link ) + : null, + post: postObject, + }; + } ); + + const { updatePreferredStyleVariations, setIsInserterOpened } = useDispatch( + 'core/edit-post' + ); + + const editorSettings = useMemo( () => { + const result = { ...( hasThemeStyles ? settings : omit( settings, [ 'defaultEditorStyles' ] ) ), @@ -67,7 +113,7 @@ class Editor extends Component { __experimentalLocalAutosaveInterval, // This is marked as experimental to give time for the quick inserter to mature. - __experimentalSetIsInserterOpened, + __experimentalSetIsInserterOpened: setIsInserterOpened, keepCaretInsideBlock, styles: hasThemeStyles ? settings.styles @@ -84,121 +130,61 @@ class Editor extends Component { ? map( blockTypes, 'name' ) : settings.allowedBlockTypes || []; - settings.allowedBlockTypes = without( + result.allowedBlockTypes = without( defaultAllowedBlockTypes, ...hiddenBlockTypes ); } - return settings; - } - - render() { - const { - settings, - hasFixedToolbar, - focusMode, - hasReducedUI, - hasThemeStyles, - post, - postId, - initialEdits, - onError, - hiddenBlockTypes, - blockTypes, - preferredStyleVariations, - __experimentalLocalAutosaveInterval, - setIsInserterOpened, - updatePreferredStyleVariations, - keepCaretInsideBlock, - ...props - } = this.props; - - if ( ! post ) { - return null; - } - - const editorSettings = this.getEditorSettings( - settings, - hasFixedToolbar, - focusMode, - hasReducedUI, - hasThemeStyles, - hiddenBlockTypes, - blockTypes, - preferredStyleVariations, - __experimentalLocalAutosaveInterval, - setIsInserterOpened, - updatePreferredStyleVariations, - keepCaretInsideBlock - ); + return result; + }, [ + settings, + hasFixedToolbar, + focusMode, + hasReducedUI, + hasThemeStyles, + hiddenBlockTypes, + blockTypes, + preferredStyleVariations, + __experimentalLocalAutosaveInterval, + setIsInserterOpened, + updatePreferredStyleVariations, + keepCaretInsideBlock, + ] ); - return ( - - - - - - - - - - - - - - - - - ); + if ( ! post ) { + return null; } -} -export default compose( [ - withSelect( ( select, { postId, postType } ) => { - const { - isFeatureActive, - getPreference, - __experimentalGetPreviewDeviceType, - } = select( 'core/edit-post' ); - const { getEntityRecord } = select( 'core' ); - const { getBlockTypes } = select( blocksStore ); + return ( + + + + + + + + + + + + + + + + + ); +} - return { - hasFixedToolbar: - isFeatureActive( 'fixedToolbar' ) || - __experimentalGetPreviewDeviceType() !== 'Desktop', - focusMode: isFeatureActive( 'focusMode' ), - hasReducedUI: isFeatureActive( 'reducedUI' ), - hasThemeStyles: isFeatureActive( 'themeStyles' ), - post: getEntityRecord( 'postType', postType, postId ), - preferredStyleVariations: getPreference( - 'preferredStyleVariations' - ), - hiddenBlockTypes: getPreference( 'hiddenBlockTypes' ), - blockTypes: getBlockTypes(), - __experimentalLocalAutosaveInterval: getPreference( - 'localAutosaveInterval' - ), - keepCaretInsideBlock: isFeatureActive( 'keepCaretInsideBlock' ), - }; - } ), - withDispatch( ( dispatch ) => { - const { - updatePreferredStyleVariations, - setIsInserterOpened, - } = dispatch( 'core/edit-post' ); - return { - updatePreferredStyleVariations, - setIsInserterOpened, - }; - } ), -] )( Editor ); +export default Editor; diff --git a/packages/edit-post/src/store/actions.js b/packages/edit-post/src/store/actions.js index 5dbbaed47b7096..95901295d0ca3b 100644 --- a/packages/edit-post/src/store/actions.js +++ b/packages/edit-post/src/store/actions.js @@ -422,3 +422,16 @@ export function setIsInserterOpened( value ) { value, }; } + +/** + * Returns an action object used to switch to template editing. + * + * @param {boolean} value Is editing template. + * @return {Object} Action object. + */ +export function setIsEditingTemplate( value ) { + return { + type: 'SET_IS_EDITING_TEMPLATE', + value, + }; +} diff --git a/packages/edit-post/src/store/reducer.js b/packages/edit-post/src/store/reducer.js index 934de97aa73c08..1f1e762f728135 100644 --- a/packages/edit-post/src/store/reducer.js +++ b/packages/edit-post/src/store/reducer.js @@ -252,6 +252,20 @@ function isInserterOpened( state = false, action ) { return state; } +/** + * Reducer tracking whether the inserter is open. + * + * @param {boolean} state + * @param {Object} action + */ +function isEditingTemplate( state = false, action ) { + switch ( action.type ) { + case 'SET_IS_EDITING_TEMPLATE': + return action.value; + } + return state; +} + const metaBoxes = combineReducers( { isSaving: isSavingMetaBoxes, locations: metaBoxLocations, @@ -265,4 +279,5 @@ export default combineReducers( { removedPanels, deviceType, isInserterOpened, + isEditingTemplate, } ); diff --git a/packages/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js index 68dcc83671ed00..69d1ded82cf568 100644 --- a/packages/edit-post/src/store/selectors.js +++ b/packages/edit-post/src/store/selectors.js @@ -324,3 +324,14 @@ export function __experimentalGetPreviewDeviceType( state ) { export function isInserterOpened( state ) { return state.isInserterOpened; } + +/** + * Returns true if the template editing mode is enabled. + * + * @param {Object} state Global application state. + * + * @return {boolean} Whether we're editing the template. + */ +export function isEditingTemplate( state ) { + return state.isEditingTemplate; +} diff --git a/packages/edit-post/src/style.scss b/packages/edit-post/src/style.scss index cc64fba010a678..f902e64e5848ba 100644 --- a/packages/edit-post/src/style.scss +++ b/packages/edit-post/src/style.scss @@ -3,6 +3,7 @@ @import "./components/header/fullscreen-mode-close/style.scss"; @import "./components/header/header-toolbar/style.scss"; @import "./components/header/more-menu/style.scss"; +@import "./components/header/template-title/style.scss"; @import "./components/keyboard-shortcut-help-modal/style.scss"; @import "./components/layout/style.scss"; @import "./components/manage-blocks-modal/style.scss"; @@ -15,6 +16,7 @@ @import "./components/sidebar/post-schedule/style.scss"; @import "./components/sidebar/post-slug/style.scss"; @import "./components/sidebar/post-status/style.scss"; +@import "./components/sidebar/post-template/style.scss"; @import "./components/sidebar/post-visibility/style.scss"; @import "./components/sidebar/settings-header/style.scss"; @import "./components/text-editor/style.scss"; @@ -68,12 +70,12 @@ body.block-editor-page { right: 0; bottom: 0; left: 0; - min-height: calc(100vh - #{ $admin-bar-height-big }); + min-height: calc(100vh - #{$admin-bar-height-big}); } // The WP header height changes at this breakpoint. @include break-medium { - min-height: calc(100vh - #{ $admin-bar-height }); + min-height: calc(100vh - #{$admin-bar-height}); body.is-fullscreen-mode & { min-height: 100vh; diff --git a/packages/editor/src/components/entities-saved-states/index.js b/packages/editor/src/components/entities-saved-states/index.js index 546a0b38107e7e..d4d31398b94bc2 100644 --- a/packages/editor/src/components/entities-saved-states/index.js +++ b/packages/editor/src/components/entities-saved-states/index.js @@ -6,7 +6,7 @@ import { some, groupBy } from 'lodash'; /** * WordPress dependencies */ -import { Button } from '@wordpress/components'; +import { Button, withFocusReturn } from '@wordpress/components'; import { __, sprintf, _n } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; import { useState, useCallback } from '@wordpress/element'; @@ -47,7 +47,7 @@ const PLACEHOLDER_PHRASES = { ), }; -export default function EntitiesSavedStates( { isOpen, close } ) { +function EntitiesSavedStates( { isOpen, close } ) { const { dirtyEntityRecords } = useSelect( ( select ) => { return { dirtyEntityRecords: select( @@ -165,3 +165,5 @@ export default function EntitiesSavedStates( { isOpen, close } ) { ) : null; } + +export default withFocusReturn( EntitiesSavedStates ); diff --git a/packages/editor/src/components/entities-saved-states/style.scss b/packages/editor/src/components/entities-saved-states/style.scss index ece02ec75a38d4..e2e9203dec07ba 100644 --- a/packages/editor/src/components/entities-saved-states/style.scss +++ b/packages/editor/src/components/entities-saved-states/style.scss @@ -18,7 +18,6 @@ } @include break-medium() { - z-index: z-index(".entities-saved-states__panel {greater than small}"); top: $admin-bar-height; left: auto; width: $sidebar-width; diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index bd0ffb99021a2a..6c0d3f090b1ef1 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -27,6 +27,7 @@ import { ReusableBlocksMenuItems } from '@wordpress/reusable-blocks'; import withRegistryProvider from './with-registry-provider'; import { mediaUpload } from '../../utils'; import ConvertToGroupButtons from '../convert-to-group-buttons'; +import serializeBlocks from '../../store/utils/serialize-blocks'; /** * Fetches link suggestions from the API. This function is an exact copy of a function found at: @@ -214,6 +215,10 @@ class EditorProvider extends Component { } getDefaultBlockContext( postId, postType ) { + // To avoid infinite loops, the template CPT shouldn't provide itself as a post content. + if ( postType === 'wp_template' ) { + return {}; + } return { postId, postType }; } @@ -225,6 +230,13 @@ class EditorProvider extends Component { if ( this.props.settings !== prevProps.settings ) { this.props.updateEditorSettings( this.props.settings ); } + if ( + this.props.__unstableTemplate && + this.props.__unstableTemplate.id !== + prevProps.__unstableTemplate?.id + ) { + this.props.setupTemplate( this.props.__unstableTemplate ); + } } componentWillUnmount() { @@ -297,21 +309,21 @@ class EditorProvider extends Component { export default compose( [ withRegistryProvider, - withSelect( ( select ) => { + withSelect( ( select, { __unstableTemplate, post } ) => { const { canUserUseUnfilteredHTML, __unstableIsEditorReady: isEditorReady, - getEditorBlocks, getEditorSelectionStart, getEditorSelectionEnd, isPostTitleSelected, } = select( 'core/editor' ); - const { canUser } = select( 'core' ); + const { canUser, getEditedEntityRecord } = select( 'core' ); + const { id, type } = __unstableTemplate ?? post; return { canUserUseUnfilteredHTML: canUserUseUnfilteredHTML(), isReady: isEditorReady(), - blocks: getEditorBlocks(), + blocks: getEditedEntityRecord( 'postType', type, id ).blocks, selectionStart: getEditorSelectionStart(), selectionEnd: getEditorSelectionEnd(), reusableBlocks: select( 'core' ).getEntityRecords( @@ -327,30 +339,66 @@ export default compose( [ isPostTitleSelected: isPostTitleSelected && isPostTitleSelected(), }; } ), - withDispatch( ( dispatch ) => { + withDispatch( ( dispatch, props ) => { const { setupEditor, updatePostLock, - resetEditorBlocks, updateEditorSettings, __experimentalTearDownEditor, undo, + __unstableSetupTemplate, } = dispatch( 'core/editor' ); const { createWarningNotice } = dispatch( 'core/notices' ); + const { __unstableCreateUndoLevel, editEntityRecord } = dispatch( + 'core' + ); + + // This is not breaking the withDispatch rule. + // eslint-disable-next-line no-restricted-syntax + function updateBlocks( blocks, options ) { + const { + post, + __unstableTemplate: template, + blocks: currentBlocks, + } = props; + const { id, type } = template ?? post; + const { + __unstableShouldCreateUndoLevel, + selectionStart, + selectionEnd, + } = options; + const edits = { blocks, selectionStart, selectionEnd }; + + if ( __unstableShouldCreateUndoLevel !== false ) { + const noChange = currentBlocks === edits.blocks; + if ( noChange ) { + return __unstableCreateUndoLevel( 'postType', type, id ); + } + + // We create a new function here on every persistent edit + // to make sure the edit makes the post dirty and creates + // a new undo level. + edits.content = ( { blocks: blocksForSerialization = [] } ) => + serializeBlocks( blocksForSerialization ); + } + + editEntityRecord( 'postType', type, id, edits ); + } return { setupEditor, updatePostLock, createWarningNotice, - resetEditorBlocks, + resetEditorBlocks: updateBlocks, updateEditorSettings, resetEditorBlocksWithoutUndoLevel( blocks, options ) { - resetEditorBlocks( blocks, { + updateBlocks( blocks, { ...options, __unstableShouldCreateUndoLevel: false, } ); }, tearDownEditor: __experimentalTearDownEditor, + setupTemplate: __unstableSetupTemplate, undo, }; } ), diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 51eba62c14505e..4b857af7276423 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -76,6 +76,27 @@ export function* setupEditor( post, edits, template ) { } } +/** + * Initiliazes an FSE template into the core-data store. + * We could avoid this action entirely by having a fallback if the edit is undefined. + * + * @param {Object} template Template object. + */ +export function* __unstableSetupTemplate( template ) { + const blocks = parse( template.content.raw ); + yield controls.dispatch( + 'core', + 'editEntityRecord', + 'postType', + template.type, + template.id, + { + blocks, + }, + { __unstableShouldCreateUndoLevel: false } + ); +} + /** * Returns an action object signalling that the editor is being destroyed and * that any necessary state or side-effect cleanup should occur. diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index 337d560998c5cf..5849de6a6521fa 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -1269,6 +1269,20 @@ export function getEditorBlocks( state ) { return getEditedPostAttribute( state, 'blocks' ) || EMPTY_ARRAY; } +/** + * Checks whether a post is an auto-draft ignoring the optimistic transaction. + * This selector shouldn't be necessary. It's currently used as a workaround + * to avoid template resolution for auto-drafts which has a backend bug. + * + * @param {Object} state State. + * @return {boolean} Whether the post is "auto-draft" on the backend. + */ +export function __unstableIsAutodraftPost( state ) { + const post = getCurrentPost( state ); + const isSaving = isSavingPost( state ); + return isSaving || post.status === 'auto-draft'; +} + /** * A block selection object. *