diff --git a/docs/contributors/code/coding-guidelines.md b/docs/contributors/code/coding-guidelines.md index 52fa92440b2653..63114073b1b802 100644 --- a/docs/contributors/code/coding-guidelines.md +++ b/docs/contributors/code/coding-guidelines.md @@ -720,7 +720,7 @@ function getConfigurationValue( key ) { } ``` -When documenting a [function type](https://github.com/WordPress/gutenberg/blob/add/typescript-jsdoc-guidelines/docs/contributors/coding-guidelines.md#record-types), you must always include the `void` return value type, as otherwise the function is inferred to return a mixed ("any") value, not a void result. +When documenting a [function type](#generic-types), you must always include the `void` return value type, as otherwise the function is inferred to return a mixed ("any") value, not a void result. ```js /** diff --git a/packages/base-styles/_default-custom-properties.scss b/packages/base-styles/_default-custom-properties.scss index 5760753c48ce85..98bb9ae75ea674 100644 --- a/packages/base-styles/_default-custom-properties.scss +++ b/packages/base-styles/_default-custom-properties.scss @@ -5,5 +5,7 @@ @include admin-scheme(#007cba); --wp-block-synced-color: #7a00df; --wp-block-synced-color--rgb: #{hex-to-rgb(#7a00df)}; - --wp-bound-block-color: #9747ff; + // This CSS variable is not used in Gutenberg project, + // but is maintained for backwards compatibility. + --wp-bound-block-color: var(--wp-block-synced-color); } diff --git a/packages/block-editor/src/components/block-bindings-toolbar-indicator/style.scss b/packages/block-editor/src/components/block-bindings-toolbar-indicator/style.scss index f4fb768b6ec37a..e1ce8bac6064b9 100644 --- a/packages/block-editor/src/components/block-bindings-toolbar-indicator/style.scss +++ b/packages/block-editor/src/components/block-bindings-toolbar-indicator/style.scss @@ -4,7 +4,7 @@ justify-content: center; padding: 6px; svg { - fill: var(--wp-bound-block-color); + fill: var(--wp-block-synced-color); } } diff --git a/packages/block-editor/src/components/block-list/content.scss b/packages/block-editor/src/components/block-list/content.scss index 869b565fec3694..77c94bbc2fad0f 100644 --- a/packages/block-editor/src/components/block-list/content.scss +++ b/packages/block-editor/src/components/block-list/content.scss @@ -300,31 +300,31 @@ _::-webkit-full-page-media, _:future, :root .has-multi-selection .block-editor-b } } -@keyframes block-editor-has-editable-outline__fade-out-animation { +// Add fade in/out background for editable blocks in synced patterns. +@keyframes block-editor-is-editable__animation { from { - border-color: rgba(var(--wp-admin-theme-color--rgb), 1); + background-color: rgba(var(--wp-admin-theme-color--rgb), 0.1); } to { - border-color: rgba(var(--wp-admin-theme-color--rgb), 0); + background-color: rgba(var(--wp-admin-theme-color--rgb), 0); } } -.is-root-container:not([inert]) .block-editor-block-list__block.has-editable-outline { - &::after { - content: ""; - position: absolute; - pointer-events: none; - top: 0; - left: 0; - right: 0; - bottom: 0; - border: 1px dotted rgba(var(--wp-admin-theme-color--rgb), 1); - border-radius: $radius-block-ui; - animation: block-editor-has-editable-outline__fade-out-animation 0.3s ease-out; - animation-delay: 1s; - animation-fill-mode: forwards; - @include reduce-motion("animation"); - } +.is-root-container:not([inert]) .block-editor-block-list__block.is-reusable.is-selected .block-editor-block-list__block.has-editable-outline::after { + animation-name: block-editor-is-editable__animation; + animation-duration: 0.8s; + animation-timing-function: ease-out; + animation-delay: 0.1s; + animation-fill-mode: backwards; + border-radius: $radius-block-ui; + bottom: 0; + content: ""; + left: 0; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + @include reduce-motion("animation"); } // Spotlight mode. Fade out blocks unless they contain a selected block. diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index c929c1014dc030..af98f24b98785e 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -130,7 +130,11 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { const hasBlockBindings = !! blockEditContext[ blockBindingsKey ]; const bindingsStyle = hasBlockBindings && canBindBlock( name ) - ? { '--wp-admin-theme-color': 'var(--wp-bound-block-color)' } + ? { + '--wp-admin-theme-color': 'var(--wp-block-synced-color)', + '--wp-admin-theme-color--rgb': + 'var(--wp-block-synced-color--rgb)', + } : {}; // Ensures it warns only inside the `edit` implementation for the block. diff --git a/packages/block-editor/src/components/iframe/index.js b/packages/block-editor/src/components/iframe/index.js index e59e564da3e6a8..293825a521e599 100644 --- a/packages/block-editor/src/components/iframe/index.js +++ b/packages/block-editor/src/components/iframe/index.js @@ -269,29 +269,29 @@ function Iframe( { useEffect( () => cleanup, [ cleanup ] ); + scale = + typeof scale === 'function' + ? scale( contentWidth, contentHeight ) + : scale; + useEffect( () => { if ( ! iframeDocument ) { return; } - const _scale = - typeof scale === 'function' - ? scale( contentWidth, contentHeight ) - : scale; - - if ( _scale !== 1 ) { + if ( scale !== 1 ) { // Hack to get proper margins when scaling the iframe document. - const bottomFrameSize = frameSize - contentHeight * ( 1 - _scale ); + const bottomFrameSize = frameSize - contentHeight * ( 1 - scale ); iframeDocument.body.classList.add( 'is-zoomed-out' ); - iframeDocument.documentElement.style.transform = `scale( ${ _scale } )`; + iframeDocument.documentElement.style.transform = `scale( ${ scale } )`; iframeDocument.documentElement.style.marginTop = `${ frameSize }px`; // TODO: `marginBottom` doesn't work in Firefox. We need another way to do this. iframeDocument.documentElement.style.marginBottom = `${ bottomFrameSize }px`; - if ( iframeWindowInnerHeight > contentHeight * _scale ) { + if ( iframeWindowInnerHeight > contentHeight * scale ) { iframeDocument.body.style.minHeight = `${ Math.floor( - ( iframeWindowInnerHeight - 2 * frameSize ) / _scale + ( iframeWindowInnerHeight - 2 * frameSize ) / scale ) }px`; } diff --git a/packages/block-library/src/template-part/editor.scss b/packages/block-library/src/template-part/editor.scss index 17f2e79212a799..0c5e12cb88bdf4 100644 --- a/packages/block-library/src/template-part/editor.scss +++ b/packages/block-library/src/template-part/editor.scss @@ -23,8 +23,10 @@ z-index: z-index(".block-library-template-part__selection-search"); } -.is-outline-mode .block-editor-block-list__block:not(.remove-outline).wp-block-template-part, -.is-outline-mode .block-editor-block-list__block:not(.remove-outline).is-reusable { +// Removed .is-outline-mode so colors take effect properly in the block editor. +// Will be a better result when outlines are not shadows, but outlines, so we can target outline-color, not redefined the entire shadow. +.block-editor-block-list__block:not(.remove-outline).wp-block-template-part, +.block-editor-block-list__block:not(.remove-outline).is-reusable { &.is-highlighted::after, &.is-selected::after { box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-block-synced-color); diff --git a/packages/edit-site/src/components/page-pages/index.js b/packages/edit-site/src/components/page-pages/index.js index 0c38d369e54f71..dce6f748b651d6 100644 --- a/packages/edit-site/src/components/page-pages/index.js +++ b/packages/edit-site/src/components/page-pages/index.js @@ -189,6 +189,16 @@ function FeaturedImage( { item, viewType } ) { ); } +const PAGE_ACTIONS = [ + 'edit-post', + 'view-post', + 'restore', + 'permanently-delete', + 'view-post-revisions', + 'rename-post', + 'move-to-trash', +]; + export default function PagePages() { const postType = 'page'; const [ view, setView ] = useView( postType ); @@ -353,7 +363,7 @@ export default function PagePages() { }, [ history ] ); - const actions = usePostActions( onActionPerformed ); + const actions = usePostActions( onActionPerformed, PAGE_ACTIONS ); const onChangeView = useCallback( ( newView ) => { if ( newView.type !== view.type ) { diff --git a/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js b/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js index 9030b3bbc73c70..50baed5658e922 100644 --- a/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js +++ b/packages/edit-site/src/components/page-patterns/dataviews-pattern-actions.js @@ -23,6 +23,7 @@ import { useState } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; import { decodeEntities } from '@wordpress/html-entities'; import { store as reusableBlocksStore } from '@wordpress/reusable-blocks'; +import { store as editorStore } from '@wordpress/editor'; import { privateApis as routerPrivateApis } from '@wordpress/router'; import { privateApis as patternsPrivateApis } from '@wordpress/patterns'; @@ -198,7 +199,7 @@ export const deleteAction = { useDispatch( reusableBlocksStore ); const { createErrorNotice, createSuccessNotice } = useDispatch( noticesStore ); - const { removeTemplates } = unlock( useDispatch( editSiteStore ) ); + const { removeTemplates } = unlock( useDispatch( editorStore ) ); const deletePattern = async () => { const promiseResult = await Promise.allSettled( diff --git a/packages/edit-site/src/components/page-templates-template-parts/actions.js b/packages/edit-site/src/components/page-templates-template-parts/actions.js deleted file mode 100644 index d4038b5efeb58a..00000000000000 --- a/packages/edit-site/src/components/page-templates-template-parts/actions.js +++ /dev/null @@ -1,275 +0,0 @@ -/** - * WordPress dependencies - */ -import { __, sprintf, _n } from '@wordpress/i18n'; -import { useDispatch } from '@wordpress/data'; -import { useState } from '@wordpress/element'; -import { store as coreStore } from '@wordpress/core-data'; -import { store as noticesStore } from '@wordpress/notices'; -import { decodeEntities } from '@wordpress/html-entities'; -import { - Button, - TextControl, - __experimentalText as Text, - __experimentalHStack as HStack, - __experimentalVStack as VStack, -} from '@wordpress/components'; - -/** - * Internal dependencies - */ -import { unlock } from '../../lock-unlock'; -import { store as editSiteStore } from '../../store'; -import isTemplateRevertable from '../../utils/is-template-revertable'; -import isTemplateRemovable from '../../utils/is-template-removable'; -import { TEMPLATE_POST_TYPE } from '../../utils/constants'; - -export const resetTemplateAction = { - id: 'reset-template', - label: __( 'Reset' ), - isEligible: isTemplateRevertable, - supportsBulk: true, - hideModalHeader: true, - RenderModal: ( { items, closeModal, onPerform } ) => { - const { revertTemplate } = useDispatch( editSiteStore ); - const { saveEditedEntityRecord } = useDispatch( coreStore ); - const { createSuccessNotice, createErrorNotice } = - useDispatch( noticesStore ); - const onConfirm = async () => { - try { - for ( const template of items ) { - await revertTemplate( template, { - allowUndo: false, - } ); - await saveEditedEntityRecord( - 'postType', - template.type, - template.id - ); - } - - createSuccessNotice( - items.length > 1 - ? sprintf( - /* translators: The number of items. */ - __( '%s items reset.' ), - items.length - ) - : sprintf( - /* translators: The template/part's name. */ - __( '"%s" reset.' ), - decodeEntities( items[ 0 ].title.rendered ) - ), - { - type: 'snackbar', - id: 'edit-site-template-reverted', - } - ); - } catch ( error ) { - let fallbackErrorMessage; - if ( items[ 0 ].type === TEMPLATE_POST_TYPE ) { - fallbackErrorMessage = - items.length === 1 - ? __( - 'An error occurred while reverting the template.' - ) - : __( - 'An error occurred while reverting the templates.' - ); - } else { - fallbackErrorMessage = - items.length === 1 - ? __( - 'An error occurred while reverting the template part.' - ) - : __( - 'An error occurred while reverting the template parts.' - ); - } - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : fallbackErrorMessage; - - createErrorNotice( errorMessage, { type: 'snackbar' } ); - } - }; - return ( - - - { __( 'Reset to default and clear all customizations?' ) } - - - - - - - ); - }, -}; - -export const deleteTemplateAction = { - id: 'delete-template', - label: __( 'Delete' ), - isEligible: isTemplateRemovable, - supportsBulk: true, - hideModalHeader: true, - RenderModal: ( { items: templates, closeModal, onPerform } ) => { - const { removeTemplates } = unlock( useDispatch( editSiteStore ) ); - return ( - - - { templates.length > 1 - ? sprintf( - // translators: %d: number of items to delete. - _n( - 'Delete %d item?', - 'Delete %d items?', - templates.length - ), - templates.length - ) - : sprintf( - // translators: %s: The template or template part's titles - __( 'Delete "%s"?' ), - decodeEntities( - templates?.[ 0 ]?.title?.rendered - ) - ) } - - - - - - - ); - }, -}; - -export const renameTemplateAction = { - id: 'rename-template', - label: __( 'Rename' ), - isEligible: ( template ) => { - // We can only remove templates or template parts that can be removed. - // Additionally in the case of templates, we can only remove custom templates. - if ( - ! isTemplateRemovable( template ) || - ( template.type === TEMPLATE_POST_TYPE && ! template.is_custom ) - ) { - return false; - } - return true; - }, - RenderModal: ( { items: templates, closeModal } ) => { - const template = templates[ 0 ]; - const title = decodeEntities( template.title.rendered ); - const [ editedTitle, setEditedTitle ] = useState( title ); - const { - editEntityRecord, - __experimentalSaveSpecifiedEntityEdits: saveSpecifiedEntityEdits, - } = useDispatch( coreStore ); - const { createSuccessNotice, createErrorNotice } = - useDispatch( noticesStore ); - async function onTemplateRename( event ) { - event.preventDefault(); - try { - await editEntityRecord( - 'postType', - template.type, - template.id, - { - title: editedTitle, - } - ); - // Update state before saving rerenders the list. - setEditedTitle( '' ); - closeModal(); - // Persist edited entity. - await saveSpecifiedEntityEdits( - 'postType', - template.type, - template.id, - [ 'title' ], // Only save title to avoid persisting other edits. - { - throwOnError: true, - } - ); - createSuccessNotice( - template.type === TEMPLATE_POST_TYPE - ? __( 'Template renamed.' ) - : __( 'Template part renamed.' ), - { - type: 'snackbar', - } - ); - } catch ( error ) { - const fallbackErrorMessage = - template.type === TEMPLATE_POST_TYPE - ? __( 'An error occurred while renaming the template.' ) - : __( - 'An error occurred while renaming the template part.' - ); - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : fallbackErrorMessage; - - createErrorNotice( errorMessage, { type: 'snackbar' } ); - } - } - return ( -
- - - - - - - -
- ); - }, -}; diff --git a/packages/edit-site/src/components/page-templates-template-parts/index.js b/packages/edit-site/src/components/page-templates-template-parts/index.js index f12306f6727ce5..598637f98b8115 100644 --- a/packages/edit-site/src/components/page-templates-template-parts/index.js +++ b/packages/edit-site/src/components/page-templates-template-parts/index.js @@ -42,11 +42,7 @@ import { LAYOUT_TABLE, LAYOUT_LIST, } from '../../utils/constants'; -import { - resetTemplateAction, - deleteTemplateAction, - renameTemplateAction, -} from './actions'; + import usePatternSettings from '../page-patterns/use-pattern-settings'; import { unlock } from '../../lock-unlock'; import AddNewTemplatePart from './add-new-template-part'; @@ -201,6 +197,14 @@ function Preview( { item, viewType } ) { ); } +const TEMPLATE_ACTIONS = [ + 'edit-post', + 'reset-template', + 'rename-template', + 'view-post-revisions', + 'delete-template', +]; + export default function PageTemplatesTemplateParts( { postType } ) { const { params } = useLocation(); const { activeView = 'all', layout } = params; @@ -361,20 +365,8 @@ export default function PageTemplatesTemplateParts( { postType } ) { }, [ history ] ); - const [ editAction, viewRevisionsAction ] = usePostActions( - onActionPerformed, - [ 'edit-post', 'view-post-revisions' ] - ); - const actions = useMemo( - () => [ - editAction, - resetTemplateAction, - renameTemplateAction, - viewRevisionsAction, - deleteTemplateAction, - ], - [ editAction, viewRevisionsAction ] - ); + + const actions = usePostActions( onActionPerformed, TEMPLATE_ACTIONS ); const onChangeView = useCallback( ( newView ) => { diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js index dfe8f81ca21ccf..2806568d627c84 100644 --- a/packages/edit-site/src/store/actions.js +++ b/packages/edit-site/src/store/actions.js @@ -1,12 +1,8 @@ /** * WordPress dependencies */ -import apiFetch from '@wordpress/api-fetch'; -import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; +import { parse } from '@wordpress/blocks'; import deprecated from '@wordpress/deprecated'; -import { addQueryArgs } from '@wordpress/url'; -import { __ } from '@wordpress/i18n'; -import { store as noticesStore } from '@wordpress/notices'; import { store as coreStore } from '@wordpress/core-data'; import { store as interfaceStore } from '@wordpress/interface'; import { store as blockEditorStore } from '@wordpress/block-editor'; @@ -17,13 +13,12 @@ import { store as preferencesStore } from '@wordpress/preferences'; * Internal dependencies */ import { STORE_NAME as editSiteStoreName } from './constants'; -import isTemplateRevertable from '../utils/is-template-revertable'; import { TEMPLATE_POST_TYPE, TEMPLATE_PART_POST_TYPE, NAVIGATION_POST_TYPE, } from '../utils/constants'; -import { removeTemplates } from './private-actions'; +import { unlock } from '../lock-unlock'; /** * Dispatches an action that toggles a feature flag. @@ -133,9 +128,13 @@ export const addTemplate = * * @param {Object} template The template object. */ -export const removeTemplate = ( template ) => { - return removeTemplates( [ template ] ); -}; +export const removeTemplate = + ( template ) => + ( { registry } ) => { + return unlock( registry.dispatch( editorStore ) ).removeTemplates( [ + template, + ] ); + }; /** * Action that sets a template part. @@ -345,130 +344,14 @@ export function setIsSaveViewOpened( isOpen ) { * reverting the template. Default true. */ export const revertTemplate = - ( template, { allowUndo = true } = {} ) => - async ( { registry } ) => { - const noticeId = 'edit-site-template-reverted'; - registry.dispatch( noticesStore ).removeNotice( noticeId ); - if ( ! isTemplateRevertable( template ) ) { - registry - .dispatch( noticesStore ) - .createErrorNotice( __( 'This template is not revertable.' ), { - type: 'snackbar', - } ); - return; - } - - try { - const templateEntityConfig = registry - .select( coreStore ) - .getEntityConfig( 'postType', template.type ); - - if ( ! templateEntityConfig ) { - registry - .dispatch( noticesStore ) - .createErrorNotice( - __( - 'The editor has encountered an unexpected error. Please reload.' - ), - { type: 'snackbar' } - ); - return; - } - - const fileTemplatePath = addQueryArgs( - `${ templateEntityConfig.baseURL }/${ template.id }`, - { context: 'edit', source: 'theme' } - ); - - const fileTemplate = await apiFetch( { path: fileTemplatePath } ); - if ( ! fileTemplate ) { - registry - .dispatch( noticesStore ) - .createErrorNotice( - __( - 'The editor has encountered an unexpected error. Please reload.' - ), - { type: 'snackbar' } - ); - return; - } - - const serializeBlocks = ( { - blocks: blocksForSerialization = [], - } ) => __unstableSerializeAndClean( blocksForSerialization ); - - const edited = registry - .select( coreStore ) - .getEditedEntityRecord( - 'postType', - template.type, - template.id - ); - - // We are fixing up the undo level here to make sure we can undo - // the revert in the header toolbar correctly. - registry.dispatch( coreStore ).editEntityRecord( - 'postType', - template.type, - template.id, - { - content: serializeBlocks, // Required to make the `undo` behave correctly. - blocks: edited.blocks, // Required to revert the blocks in the editor. - source: 'custom', // required to avoid turning the editor into a dirty state - }, - { - undoIgnore: true, // Required to merge this edit with the last undo level. - } - ); - - const blocks = parse( fileTemplate?.content?.raw ); - registry - .dispatch( coreStore ) - .editEntityRecord( 'postType', template.type, fileTemplate.id, { - content: serializeBlocks, - blocks, - source: 'theme', - } ); - - if ( allowUndo ) { - const undoRevert = () => { - registry - .dispatch( coreStore ) - .editEntityRecord( - 'postType', - template.type, - edited.id, - { - content: serializeBlocks, - blocks: edited.blocks, - source: 'custom', - } - ); - }; - - registry - .dispatch( noticesStore ) - .createSuccessNotice( __( 'Template reset.' ), { - type: 'snackbar', - id: noticeId, - actions: [ - { - label: __( 'Undo' ), - onClick: undoRevert, - }, - ], - } ); - } - } catch ( error ) { - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : __( 'Template revert failed. Please reload.' ); - registry - .dispatch( noticesStore ) - .createErrorNotice( errorMessage, { type: 'snackbar' } ); - } + ( template, options ) => + ( { registry } ) => { + return unlock( registry.dispatch( editorStore ) ).revertTemplate( + template, + options + ); }; + /** * Action that opens an editor sidebar. * diff --git a/packages/edit-site/src/store/private-actions.js b/packages/edit-site/src/store/private-actions.js index fd23903a6a05e4..0aaee3def948c1 100644 --- a/packages/edit-site/src/store/private-actions.js +++ b/packages/edit-site/src/store/private-actions.js @@ -4,15 +4,6 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as preferencesStore } from '@wordpress/preferences'; import { store as editorStore } from '@wordpress/editor'; -import { store as coreStore } from '@wordpress/core-data'; -import { store as noticesStore } from '@wordpress/notices'; -import { __, sprintf } from '@wordpress/i18n'; -import { decodeEntities } from '@wordpress/html-entities'; - -/** - * Internal dependencies - */ -import { TEMPLATE_POST_TYPE } from '../utils/constants'; /** * Action that switches the canvas mode. @@ -62,127 +53,3 @@ export const setEditorCanvasContainerView = view, } ); }; - -/** - * Action that removes an array of templates. - * - * @param {Array} items An array of template or template part objects to remove. - */ -export const removeTemplates = - ( items ) => - async ( { registry } ) => { - const isTemplate = items[ 0 ].type === TEMPLATE_POST_TYPE; - const promiseResult = await Promise.allSettled( - items.map( ( item ) => { - return registry - .dispatch( coreStore ) - .deleteEntityRecord( - 'postType', - item.type, - item.id, - { force: true }, - { throwOnError: true } - ); - } ) - ); - - // If all the promises were fulfilled with sucess. - if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) { - let successMessage; - - if ( items.length === 1 ) { - // Depending on how the entity was retrieved its title might be - // an object or simple string. - const title = - typeof items[ 0 ].title === 'string' - ? items[ 0 ].title - : items[ 0 ].title?.rendered; - successMessage = sprintf( - /* translators: The template/part's name. */ - __( '"%s" deleted.' ), - decodeEntities( title ) - ); - } else { - successMessage = isTemplate - ? __( 'Templates deleted.' ) - : __( 'Template parts deleted.' ); - } - - registry - .dispatch( noticesStore ) - .createSuccessNotice( successMessage, { - type: 'snackbar', - id: 'site-editor-template-deleted-success', - } ); - } else { - // If there was at lease one failure. - let errorMessage; - // If we were trying to delete a single template. - if ( promiseResult.length === 1 ) { - if ( promiseResult[ 0 ].reason?.message ) { - errorMessage = promiseResult[ 0 ].reason.message; - } else { - errorMessage = isTemplate - ? __( 'An error occurred while deleting the template.' ) - : __( - 'An error occurred while deleting the template part.' - ); - } - // If we were trying to delete a multiple templates - } else { - const errorMessages = new Set(); - const failedPromises = promiseResult.filter( - ( { status } ) => status === 'rejected' - ); - for ( const failedPromise of failedPromises ) { - if ( failedPromise.reason?.message ) { - errorMessages.add( failedPromise.reason.message ); - } - } - if ( errorMessages.size === 0 ) { - errorMessage = isTemplate - ? __( - 'An error occurred while deleting the templates.' - ) - : __( - 'An error occurred while deleting the template parts.' - ); - } else if ( errorMessages.size === 1 ) { - errorMessage = isTemplate - ? sprintf( - /* translators: %s: an error message */ - __( - 'An error occurred while deleting the templates: %s' - ), - [ ...errorMessages ][ 0 ] - ) - : sprintf( - /* translators: %s: an error message */ - __( - 'An error occurred while deleting the template parts: %s' - ), - [ ...errorMessages ][ 0 ] - ); - } else { - errorMessage = isTemplate - ? sprintf( - /* translators: %s: a list of comma separated error messages */ - __( - 'Some errors occurred while deleting the templates: %s' - ), - [ ...errorMessages ].join( ',' ) - ) - : sprintf( - /* translators: %s: a list of comma separated error messages */ - __( - 'Some errors occurred while deleting the template parts: %s' - ), - [ ...errorMessages ].join( ',' ) - ); - } - } - registry - .dispatch( noticesStore ) - .createErrorNotice( errorMessage, { type: 'snackbar' } ); - } - }; diff --git a/packages/editor/src/components/post-actions/actions.js b/packages/editor/src/components/post-actions/actions.js index 42ebf3561305c4..8dded54a6a7564 100644 --- a/packages/editor/src/components/post-actions/actions.js +++ b/packages/editor/src/components/post-actions/actions.js @@ -18,6 +18,14 @@ import { __experimentalVStack as VStack, } from '@wordpress/components'; +/** + * Internal dependencies + */ +import { TEMPLATE_ORIGINS, TEMPLATE_POST_TYPE } from '../../store/constants'; +import { store as editorStore } from '../../store'; +import { unlock } from '../../lock-unlock'; +import isTemplateRevertable from '../../store/utils/is-template-revertable'; + function getItemTitle( item ) { if ( typeof item.title === 'string' ) { return decodeEntities( item.title ); @@ -25,7 +33,7 @@ function getItemTitle( item ) { return decodeEntities( item.title?.rendered || '' ); } -export const trashPostAction = { +const trashPostAction = { id: 'move-to-trash', label: __( 'Move to Trash' ), isPrimary: true, @@ -164,7 +172,7 @@ export const trashPostAction = { }, }; -export function usePermanentlyDeletePostAction() { +function usePermanentlyDeletePostAction() { const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); const { deleteEntityRecord } = useDispatch( coreStore ); @@ -271,7 +279,7 @@ export function usePermanentlyDeletePostAction() { ); } -export function useRestorePostAction() { +function useRestorePostAction() { const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); const { editEntityRecord, saveEditedEntityRecord } = @@ -357,7 +365,7 @@ export function useRestorePostAction() { ); } -export const viewPostAction = { +const viewPostAction = { id: 'view-post', label: __( 'View' ), isPrimary: true, @@ -374,7 +382,7 @@ export const viewPostAction = { }, }; -export const editPostAction = { +const editPostAction = { id: 'edit-post', label: __( 'Edit' ), isPrimary: true, @@ -388,7 +396,7 @@ export const editPostAction = { } }, }; -export const postRevisionsAction = { +const postRevisionsAction = { id: 'view-post-revisions', label: __( 'View revisions' ), isPrimary: false, @@ -414,7 +422,7 @@ export const postRevisionsAction = { }, }; -export const renamePostAction = { +const renamePostAction = { id: 'rename-post', label: __( 'Rename' ), isEligible( post ) { @@ -491,6 +499,273 @@ export const renamePostAction = { }, }; +const resetTemplateAction = { + id: 'reset-template', + label: __( 'Reset' ), + isEligible: isTemplateRevertable, + supportsBulk: true, + hideModalHeader: true, + RenderModal: ( { items, closeModal, onActionPerformed } ) => { + const { revertTemplate } = unlock( useDispatch( editorStore ) ); + const { saveEditedEntityRecord } = useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + const onConfirm = async () => { + try { + for ( const template of items ) { + await revertTemplate( template, { + allowUndo: false, + } ); + await saveEditedEntityRecord( + 'postType', + template.type, + template.id + ); + } + + createSuccessNotice( + items.length > 1 + ? sprintf( + /* translators: The number of items. */ + __( '%s items reset.' ), + items.length + ) + : sprintf( + /* translators: The template/part's name. */ + __( '"%s" reset.' ), + decodeEntities( items[ 0 ].title.rendered ) + ), + { + type: 'snackbar', + id: 'edit-site-template-reverted', + } + ); + } catch ( error ) { + let fallbackErrorMessage; + if ( items[ 0 ].type === TEMPLATE_POST_TYPE ) { + fallbackErrorMessage = + items.length === 1 + ? __( + 'An error occurred while reverting the template.' + ) + : __( + 'An error occurred while reverting the templates.' + ); + } else { + fallbackErrorMessage = + items.length === 1 + ? __( + 'An error occurred while reverting the template part.' + ) + : __( + 'An error occurred while reverting the template parts.' + ); + } + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : fallbackErrorMessage; + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + }; + return ( + + + { __( 'Reset to default and clear all customizations?' ) } + + + + + + + ); + }, +}; + +/** + * Check if a template is removable. + * Copy from packages/edit-site/src/utils/is-template-removable.js. + * + * @param {Object} template The template entity to check. + * @return {boolean} Whether the template is revertable. + */ +function isTemplateRemovable( template ) { + if ( ! template ) { + return false; + } + + return ( + template.source === TEMPLATE_ORIGINS.custom && ! template.has_theme_file + ); +} + +const deleteTemplateAction = { + id: 'delete-template', + label: __( 'Delete' ), + isEligible: isTemplateRemovable, + supportsBulk: true, + hideModalHeader: true, + RenderModal: ( { items: templates, closeModal, onActionPerformed } ) => { + const { removeTemplates } = unlock( useDispatch( editorStore ) ); + return ( + + + { templates.length > 1 + ? sprintf( + // translators: %d: number of items to delete. + _n( + 'Delete %d item?', + 'Delete %d items?', + templates.length + ), + templates.length + ) + : sprintf( + // translators: %s: The template or template part's titles + __( 'Delete "%s"?' ), + decodeEntities( + templates?.[ 0 ]?.title?.rendered + ) + ) } + + + + + + + ); + }, +}; + +const renameTemplateAction = { + id: 'rename-template', + label: __( 'Rename' ), + isEligible: ( template ) => { + // We can only remove templates or template parts that can be removed. + // Additionally in the case of templates, we can only remove custom templates. + if ( + ! isTemplateRemovable( template ) || + ( template.type === TEMPLATE_POST_TYPE && ! template.is_custom ) + ) { + return false; + } + return true; + }, + RenderModal: ( { items: templates, closeModal } ) => { + const template = templates[ 0 ]; + const title = decodeEntities( template.title.rendered ); + const [ editedTitle, setEditedTitle ] = useState( title ); + const { + editEntityRecord, + __experimentalSaveSpecifiedEntityEdits: saveSpecifiedEntityEdits, + } = useDispatch( coreStore ); + const { createSuccessNotice, createErrorNotice } = + useDispatch( noticesStore ); + async function onTemplateRename( event ) { + event.preventDefault(); + try { + await editEntityRecord( + 'postType', + template.type, + template.id, + { + title: editedTitle, + } + ); + // Update state before saving rerenders the list. + setEditedTitle( '' ); + closeModal(); + // Persist edited entity. + await saveSpecifiedEntityEdits( + 'postType', + template.type, + template.id, + [ 'title' ], // Only save title to avoid persisting other edits. + { + throwOnError: true, + } + ); + createSuccessNotice( + template.type === TEMPLATE_POST_TYPE + ? __( 'Template renamed.' ) + : __( 'Template part renamed.' ), + { + type: 'snackbar', + } + ); + } catch ( error ) { + const fallbackErrorMessage = + template.type === TEMPLATE_POST_TYPE + ? __( 'An error occurred while renaming the template.' ) + : __( + 'An error occurred while renaming the template part.' + ); + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : fallbackErrorMessage; + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + } + return ( +
+ + + + + + + +
+ ); + }, +}; + export function usePostActions( onActionPerformed, actionIds = null ) { const permanentlyDeletePostAction = usePermanentlyDeletePostAction(); const restorePostAction = useRestorePostAction(); @@ -499,11 +774,14 @@ export function usePostActions( onActionPerformed, actionIds = null ) { // By default, return all actions... const defaultActions = [ editPostAction, + resetTemplateAction, viewPostAction, restorePostAction, + deleteTemplateAction, permanentlyDeletePostAction, postRevisionsAction, renamePostAction, + renameTemplateAction, trashPostAction, ]; diff --git a/packages/editor/src/store/constants.js b/packages/editor/src/store/constants.js index 43b5491394fc81..73ce13066a6df3 100644 --- a/packages/editor/src/store/constants.js +++ b/packages/editor/src/store/constants.js @@ -21,3 +21,8 @@ export const AUTOSAVE_PROPERTIES = [ 'title', 'excerpt', 'content' ]; export const TEMPLATE_POST_TYPE = 'wp_template'; export const TEMPLATE_PART_POST_TYPE = 'wp_template_part'; export const PATTERN_POST_TYPE = 'wp_block'; +export const TEMPLATE_ORIGINS = { + custom: 'custom', + theme: 'theme', + plugin: 'plugin', +}; diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js index 3e87df48123452..0c76ffb6960ec1 100644 --- a/packages/editor/src/store/private-actions.js +++ b/packages/editor/src/store/private-actions.js @@ -2,10 +2,20 @@ * WordPress dependencies */ import { store as coreStore } from '@wordpress/core-data'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as preferencesStore } from '@wordpress/preferences'; +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import { parse, __unstableSerializeAndClean } from '@wordpress/blocks'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import isTemplateRevertable from './utils/is-template-revertable'; +import { TEMPLATE_POST_TYPE } from './constants'; /** * Returns an action object used to set which template is currently being used/edited. @@ -220,3 +230,261 @@ export const saveDirtyEntities = ) ); }; + +/** + * Reverts a template to its original theme-provided file. + * + * @param {Object} template The template to revert. + * @param {Object} [options] + * @param {boolean} [options.allowUndo] Whether to allow the user to undo + * reverting the template. Default true. + */ +export const revertTemplate = + ( template, { allowUndo = true } = {} ) => + async ( { registry } ) => { + const noticeId = 'edit-site-template-reverted'; + registry.dispatch( noticesStore ).removeNotice( noticeId ); + if ( ! isTemplateRevertable( template ) ) { + registry + .dispatch( noticesStore ) + .createErrorNotice( __( 'This template is not revertable.' ), { + type: 'snackbar', + } ); + return; + } + + try { + const templateEntityConfig = registry + .select( coreStore ) + .getEntityConfig( 'postType', template.type ); + + if ( ! templateEntityConfig ) { + registry + .dispatch( noticesStore ) + .createErrorNotice( + __( + 'The editor has encountered an unexpected error. Please reload.' + ), + { type: 'snackbar' } + ); + return; + } + + const fileTemplatePath = addQueryArgs( + `${ templateEntityConfig.baseURL }/${ template.id }`, + { context: 'edit', source: 'theme' } + ); + + const fileTemplate = await apiFetch( { path: fileTemplatePath } ); + if ( ! fileTemplate ) { + registry + .dispatch( noticesStore ) + .createErrorNotice( + __( + 'The editor has encountered an unexpected error. Please reload.' + ), + { type: 'snackbar' } + ); + return; + } + + const serializeBlocks = ( { + blocks: blocksForSerialization = [], + } ) => __unstableSerializeAndClean( blocksForSerialization ); + + const edited = registry + .select( coreStore ) + .getEditedEntityRecord( + 'postType', + template.type, + template.id + ); + + // We are fixing up the undo level here to make sure we can undo + // the revert in the header toolbar correctly. + registry.dispatch( coreStore ).editEntityRecord( + 'postType', + template.type, + template.id, + { + content: serializeBlocks, // Required to make the `undo` behave correctly. + blocks: edited.blocks, // Required to revert the blocks in the editor. + source: 'custom', // required to avoid turning the editor into a dirty state + }, + { + undoIgnore: true, // Required to merge this edit with the last undo level. + } + ); + + const blocks = parse( fileTemplate?.content?.raw ); + registry + .dispatch( coreStore ) + .editEntityRecord( 'postType', template.type, fileTemplate.id, { + content: serializeBlocks, + blocks, + source: 'theme', + } ); + + if ( allowUndo ) { + const undoRevert = () => { + registry + .dispatch( coreStore ) + .editEntityRecord( + 'postType', + template.type, + edited.id, + { + content: serializeBlocks, + blocks: edited.blocks, + source: 'custom', + } + ); + }; + + registry + .dispatch( noticesStore ) + .createSuccessNotice( __( 'Template reset.' ), { + type: 'snackbar', + id: noticeId, + actions: [ + { + label: __( 'Undo' ), + onClick: undoRevert, + }, + ], + } ); + } + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'Template revert failed. Please reload.' ); + registry + .dispatch( noticesStore ) + .createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + }; + +/** + * Action that removes an array of templates. + * + * @param {Array} items An array of template or template part objects to remove. + */ +export const removeTemplates = + ( items ) => + async ( { registry } ) => { + const isTemplate = items[ 0 ].type === TEMPLATE_POST_TYPE; + const promiseResult = await Promise.allSettled( + items.map( ( item ) => { + return registry + .dispatch( coreStore ) + .deleteEntityRecord( + 'postType', + item.type, + item.id, + { force: true }, + { throwOnError: true } + ); + } ) + ); + + // If all the promises were fulfilled with sucess. + if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) { + let successMessage; + + if ( items.length === 1 ) { + // Depending on how the entity was retrieved its title might be + // an object or simple string. + const title = + typeof items[ 0 ].title === 'string' + ? items[ 0 ].title + : items[ 0 ].title?.rendered; + successMessage = sprintf( + /* translators: The template/part's name. */ + __( '"%s" deleted.' ), + decodeEntities( title ) + ); + } else { + successMessage = isTemplate + ? __( 'Templates deleted.' ) + : __( 'Template parts deleted.' ); + } + + registry + .dispatch( noticesStore ) + .createSuccessNotice( successMessage, { + type: 'snackbar', + id: 'site-editor-template-deleted-success', + } ); + } else { + // If there was at lease one failure. + let errorMessage; + // If we were trying to delete a single template. + if ( promiseResult.length === 1 ) { + if ( promiseResult[ 0 ].reason?.message ) { + errorMessage = promiseResult[ 0 ].reason.message; + } else { + errorMessage = isTemplate + ? __( 'An error occurred while deleting the template.' ) + : __( + 'An error occurred while deleting the template part.' + ); + } + // If we were trying to delete a multiple templates + } else { + const errorMessages = new Set(); + const failedPromises = promiseResult.filter( + ( { status } ) => status === 'rejected' + ); + for ( const failedPromise of failedPromises ) { + if ( failedPromise.reason?.message ) { + errorMessages.add( failedPromise.reason.message ); + } + } + if ( errorMessages.size === 0 ) { + errorMessage = isTemplate + ? __( + 'An error occurred while deleting the templates.' + ) + : __( + 'An error occurred while deleting the template parts.' + ); + } else if ( errorMessages.size === 1 ) { + errorMessage = isTemplate + ? sprintf( + /* translators: %s: an error message */ + __( + 'An error occurred while deleting the templates: %s' + ), + [ ...errorMessages ][ 0 ] + ) + : sprintf( + /* translators: %s: an error message */ + __( + 'An error occurred while deleting the template parts: %s' + ), + [ ...errorMessages ][ 0 ] + ); + } else { + errorMessage = isTemplate + ? sprintf( + /* translators: %s: a list of comma separated error messages */ + __( + 'Some errors occurred while deleting the templates: %s' + ), + [ ...errorMessages ].join( ',' ) + ) + : sprintf( + /* translators: %s: a list of comma separated error messages */ + __( + 'Some errors occurred while deleting the template parts: %s' + ), + [ ...errorMessages ].join( ',' ) + ); + } + } + registry + .dispatch( noticesStore ) + .createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + }; diff --git a/packages/editor/src/store/utils/is-template-revertable.js b/packages/editor/src/store/utils/is-template-revertable.js new file mode 100644 index 00000000000000..efe4647f212801 --- /dev/null +++ b/packages/editor/src/store/utils/is-template-revertable.js @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { TEMPLATE_ORIGINS } from '../constants'; + +// Copy of the function from packages/edit-site/src/utils/is-template-revertable.js + +/** + * Check if a template is revertable to its original theme-provided template file. + * + * @param {Object} template The template entity to check. + * @return {boolean} Whether the template is revertable. + */ +export default function isTemplateRevertable( template ) { + if ( ! template ) { + return false; + } + /* eslint-disable camelcase */ + return ( + template?.source === TEMPLATE_ORIGINS.custom && template?.has_theme_file + ); + /* eslint-enable camelcase */ +} diff --git a/packages/interface/src/components/interface-skeleton/index.js b/packages/interface/src/components/interface-skeleton/index.js index 83ab8f6abf5a8a..d4d895e4e90b23 100644 --- a/packages/interface/src/components/interface-skeleton/index.js +++ b/packages/interface/src/components/interface-skeleton/index.js @@ -10,15 +10,23 @@ import { forwardRef, useEffect } from '@wordpress/element'; import { __unstableUseNavigateRegions as useNavigateRegions, __unstableMotion as motion, + __unstableAnimatePresence as AnimatePresence, } from '@wordpress/components'; import { __, _x } from '@wordpress/i18n'; -import { useMergeRefs } from '@wordpress/compose'; +import { + useMergeRefs, + useReducedMotion, + useViewportMatch, +} from '@wordpress/compose'; /** * Internal dependencies */ import NavigableRegion from '../navigable-region'; +const SECONDARY_SIDEBAR_WIDTH = 350; +const ANIMATION_DURATION = 0.25; + function useHTMLClass( className ) { useEffect( () => { const element = @@ -42,6 +50,12 @@ const headerVariants = { distractionFreeInactive: { opacity: 1, transition: { delay: 0 } }, }; +const secondarySidebarVariants = { + open: { width: SECONDARY_SIDEBAR_WIDTH }, + closed: { width: 0 }, + mobileOpen: { width: '100vw' }, +}; + function InterfaceSkeleton( { isDistractionFree, @@ -62,6 +76,13 @@ function InterfaceSkeleton( }, ref ) { + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const disableMotion = useReducedMotion(); + const defaultTransition = { + type: 'tween', + duration: disableMotion ? 0 : ANIMATION_DURATION, + ease: 'easeOut', + }; const navigateRegionsProps = useNavigateRegions( shortcuts ); useHTMLClass( 'interface-interface-skeleton__html-container' ); @@ -133,14 +154,35 @@ function InterfaceSkeleton( ) }
- { !! secondarySidebar && ( - - { secondarySidebar } - - ) } + + { !! secondarySidebar && ( + +
+ { secondarySidebar } +
+
+ ) } +
{ !! notices && (
{ notices }