From 5717332a4edfaa9920f121019e054abb324d3a7b Mon Sep 17 00:00:00 2001 From: Jerry Jones Date: Mon, 12 Feb 2024 09:16:44 -0600 Subject: [PATCH 01/15] Fix incorrect useAnchor positioning when switching from virtual to rich text elements (#58900) When creating a link or inline text color, we start with a virtual element and then after creating the link/adding a color, it switches from a virtual to rich text element ( or ). We want to recompute that we've changed elements and reanchor appropriately when this happens. Also, useAnchor was adding focus and selectionChange events that were only used by a previous version of the link control UX where the link popover would show based on the caret position. If the caret was within the link, we would show the link popover. This is no longer the case, so we can remove these event listeners. --- packages/format-library/src/link/inline.js | 22 +---------- .../format-library/src/text-color/index.js | 1 + .../format-library/src/text-color/inline.js | 14 +------ .../rich-text/src/component/use-anchor.js | 38 +++++++------------ 4 files changed, 17 insertions(+), 58 deletions(-) diff --git a/packages/format-library/src/link/inline.js b/packages/format-library/src/link/inline.js index 7d0594981c290..b01f37e9657bb 100644 --- a/packages/format-library/src/link/inline.js +++ b/packages/format-library/src/link/inline.js @@ -21,7 +21,6 @@ import { import { __experimentalLinkControl as LinkControl, store as blockEditorStore, - useCachedTruthy, } from '@wordpress/block-editor'; import { useSelect } from '@wordpress/data'; @@ -195,28 +194,9 @@ function InlineLinkUI( { const popoverAnchor = useAnchor( { editableContentElement: contentRef.current, - settings, + settings: { ...settings, isActive }, } ); - // As you change the link by interacting with the Link UI - // the return value of document.getSelection jumps to the field you're editing, - // not the highlighted text. Given that useAnchor uses document.getSelection, - // it will return null, since it can't find the element within the Link UI. - // This caches the last truthy value of the selection anchor reference. - // This ensures the Popover is positioned correctly on initial submission of the link. - const cachedRect = useCachedTruthy( popoverAnchor.getBoundingClientRect() ); - - // If the link is not active (i.e. it is a new link) then we need to - // override the getBoundingClientRect method on the anchor element - // to return the cached value of the selection represented by the text - // that the user selected to be linked. - // If the link is active (i.e. it is an existing link) then we allow - // the default behaviour of the popover anchor to be used. This will get - // the anchor based on the `` element in the rich text. - if ( ! isActive ) { - popoverAnchor.getBoundingClientRect = () => cachedRect; - } - async function handleCreate( pageTitle ) { const page = await createPageEntity( { title: pageTitle, diff --git a/packages/format-library/src/text-color/index.js b/packages/format-library/src/text-color/index.js index 8a28cb69549cd..464c2eec7c065 100644 --- a/packages/format-library/src/text-color/index.js +++ b/packages/format-library/src/text-color/index.js @@ -120,6 +120,7 @@ function TextColorEdit( { value={ value } onChange={ onChange } contentRef={ contentRef } + isActive={ isActive } /> ) } diff --git a/packages/format-library/src/text-color/inline.js b/packages/format-library/src/text-color/inline.js index 98ced2cca01e2..8bd4ae33c7844 100644 --- a/packages/format-library/src/text-color/inline.js +++ b/packages/format-library/src/text-color/inline.js @@ -15,7 +15,6 @@ import { getColorObjectByColorValue, getColorObjectByAttributeValues, store as blockEditorStore, - useCachedTruthy, } from '@wordpress/block-editor'; import { Popover, @@ -147,22 +146,13 @@ export default function InlineColorUI( { onChange, onClose, contentRef, + isActive, } ) { const popoverAnchor = useAnchor( { editableContentElement: contentRef.current, - settings, + settings: { ...settings, isActive }, } ); - /* - As you change the text color by typing a HEX value into a field, - the return value of document.getSelection jumps to the field you're editing, - not the highlighted text. Given that useAnchor uses document.getSelection, - it will return null, since it can't find the element within the HEX input. - This caches the last truthy value of the selection anchor reference. - */ - const cachedRect = useCachedTruthy( popoverAnchor.getBoundingClientRect() ); - popoverAnchor.getBoundingClientRect = () => cachedRect; - return ( getAnchor( editableContentElement, tagName, className ) ); + const wasActive = usePrevious( isActive ); useLayoutEffect( () => { if ( ! editableContentElement ) return; const { ownerDocument } = editableContentElement; - function callback() { + if ( + editableContentElement === ownerDocument.activeElement || + // When a link is created, we need to attach the popover to the newly created anchor. + ( ! wasActive && isActive ) || + // Sometimes we're _removing_ an active anchor, such as the inline color popover. + // When we add the color, it switches from a virtual anchor to a `` element. + // When we _remove_ the color, it switches from a `` element to a virtual anchor. + ( wasActive && ! isActive ) + ) { setAnchor( getAnchor( editableContentElement, tagName, className ) ); } - - function attach() { - ownerDocument.addEventListener( 'selectionchange', callback ); - } - - function detach() { - ownerDocument.removeEventListener( 'selectionchange', callback ); - } - - if ( editableContentElement === ownerDocument.activeElement ) { - attach(); - } - - editableContentElement.addEventListener( 'focusin', attach ); - editableContentElement.addEventListener( 'focusout', detach ); - - return () => { - detach(); - - editableContentElement.removeEventListener( 'focusin', attach ); - editableContentElement.removeEventListener( 'focusout', detach ); - }; - }, [ editableContentElement, tagName, className ] ); + }, [ editableContentElement, tagName, className, isActive, wasActive ] ); return anchor; } From 59fe11bfb4a79adddca972ed83e042dbac7e3f0b Mon Sep 17 00:00:00 2001 From: Jerry Jones Date: Mon, 12 Feb 2024 09:17:57 -0600 Subject: [PATCH 02/15] Close link preview if collapsed selection when creating link (#58896) Return caret to after the link boundary so typing can continue if no text is selected when creating a link. --- packages/format-library/src/link/index.js | 13 +------ packages/format-library/src/link/inline.js | 44 ++++++++++++++++------ 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/packages/format-library/src/link/index.js b/packages/format-library/src/link/index.js index eb4beebce3f25..61b6f974cdce3 100644 --- a/packages/format-library/src/link/index.js +++ b/packages/format-library/src/link/index.js @@ -48,17 +48,6 @@ function Edit( { return; } - // Close the Link popover if there is no active selection - // after the link was added - this can happen if the user - // adds a link without any text selected. - // We assume that if there is no active selection after - // link insertion there are no active formats. - if ( ! value.activeFormats ) { - editableContentElement.focus(); - setAddingLink( false ); - return; - } - function handleClick( event ) { // There is a situation whereby there is an existing link in the rich text // and the user clicks on the leftmost edge of that link and fails to activate @@ -78,7 +67,7 @@ function Edit( { return () => { editableContentElement.removeEventListener( 'click', handleClick ); }; - }, [ contentRef, isActive, addingLink, value ] ); + }, [ contentRef, isActive ] ); function addLink( target ) { const text = getTextContent( slice( value ) ); diff --git a/packages/format-library/src/link/inline.js b/packages/format-library/src/link/inline.js index b01f37e9657bb..1e48d0c8fa47f 100644 --- a/packages/format-library/src/link/inline.js +++ b/packages/format-library/src/link/inline.js @@ -22,7 +22,7 @@ import { __experimentalLinkControl as LinkControl, store as blockEditorStore, } from '@wordpress/block-editor'; -import { useSelect } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; /** * Internal dependencies @@ -52,15 +52,22 @@ function InlineLinkUI( { // Get the text content minus any HTML tags. const richTextText = richLinkTextValue.text; - const { createPageEntity, userCanCreatePages } = useSelect( ( select ) => { - const { getSettings } = select( blockEditorStore ); - const _settings = getSettings(); - - return { - createPageEntity: _settings.__experimentalCreatePageEntity, - userCanCreatePages: _settings.__experimentalUserCanCreatePages, - }; - }, [] ); + const { selectionChange } = useDispatch( blockEditorStore ); + + const { createPageEntity, userCanCreatePages, selectionStart } = useSelect( + ( select ) => { + const { getSettings, getSelectionStart } = + select( blockEditorStore ); + const _settings = getSettings(); + + return { + createPageEntity: _settings.__experimentalCreatePageEntity, + userCanCreatePages: _settings.__experimentalUserCanCreatePages, + selectionStart: getSelectionStart(), + }; + }, + [] + ); const linkValue = useMemo( () => ( { @@ -122,8 +129,23 @@ function InlineLinkUI( { inserted, linkFormat, value.start, - value.end + newText.length + value.start + newText.length ); + + onChange( newValue ); + + // Close the Link UI. + stopAddingLink(); + + // Move the selection to the end of the inserted link outside of the format boundary + // so the user can continue typing after the link. + selectionChange( { + clientId: selectionStart.clientId, + identifier: selectionStart.attributeKey, + start: value.start + newText.length + 1, + } ); + + return; } else if ( newText === richTextText ) { newValue = applyFormat( value, linkFormat ); } else { From f1b8ebb818efe4e1a57026e02236c58a19282dbe Mon Sep 17 00:00:00 2001 From: David Arenas Date: Mon, 12 Feb 2024 16:28:06 +0100 Subject: [PATCH 03/15] Pagination Numbers: Add `data-wp-key` to pagination numbers if enhanced pagination is enabled (#58189) * Add `data-wp-key` to pagination numbers * Use tag index as the `data-wp-key` value Co-authored-by: DAreRodz Co-authored-by: SantosGuillamot Co-authored-by: c4rl0sbr4v0 --- .../src/query-pagination-numbers/index.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index 2f9370751f6d2..e6f8b46111040 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -91,14 +91,17 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo } if ( $enhanced_pagination ) { - $p = new WP_HTML_Tag_Processor( $content ); + $p = new WP_HTML_Tag_Processor( $content ); + $tag_index = 0; while ( $p->next_tag( - array( - 'tag_name' => 'a', - 'class_name' => 'page-numbers', - ) + array( 'class_name' => 'page-numbers' ) ) ) { - $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); + if ( null === $p->get_attribute( 'data-wp-key' ) ) { + $p->set_attribute( 'data-wp-key', 'index-' . $tag_index++ ); + } + if ( 'A' === $p->get_tag() ) { + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); + } } $content = $p->get_updated_html(); } From 9458c1f1c69fa968b9fe0007441c4bfe6e068805 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 12 Feb 2024 16:38:39 +0100 Subject: [PATCH 04/15] Editor: Remove the 'all' rendering mode (#58935) Co-authored-by: youknowriad Co-authored-by: ellatrix --- docs/reference-guides/data/data-core-editor.md | 2 +- packages/edit-post/src/editor.js | 5 +---- packages/edit-post/src/index.js | 10 ++-------- packages/edit-post/src/store/selectors.js | 2 +- .../block-editor/use-site-editor-settings.js | 4 +++- packages/editor/src/components/editor-canvas/index.js | 2 +- packages/editor/src/store/actions.js | 2 +- packages/editor/src/store/reducer.js | 2 +- 8 files changed, 11 insertions(+), 18 deletions(-) diff --git a/docs/reference-guides/data/data-core-editor.md b/docs/reference-guides/data/data-core-editor.md index 5cae475965401..7208302204164 100644 --- a/docs/reference-guides/data/data-core-editor.md +++ b/docs/reference-guides/data/data-core-editor.md @@ -1409,7 +1409,7 @@ Returns an action used to set the rendering mode of the post editor. We support _Parameters_ -- _mode_ `string`: Mode (one of 'post-only', 'template-locked' or 'all'). +- _mode_ `string`: Mode (one of 'post-only' or 'template-locked'). ### setTemplateValidity diff --git a/packages/edit-post/src/editor.js b/packages/edit-post/src/editor.js index 7c572c4a91fcf..7b961098fc3da 100644 --- a/packages/edit-post/src/editor.js +++ b/packages/edit-post/src/editor.js @@ -89,15 +89,13 @@ function Editor( { ); const { updatePreferredStyleVariations } = useDispatch( editPostStore ); - const defaultRenderingMode = - currentPost.postType === 'wp_template' ? 'all' : 'post-only'; const editorSettings = useMemo( () => ( { ...settings, onNavigateToEntityRecord, onNavigateToPreviousEntityRecord, - defaultRenderingMode, + defaultRenderingMode: 'post-only', __experimentalPreferredStyleVariations: { value: preferredStyleVariations, onChange: updatePreferredStyleVariations, @@ -111,7 +109,6 @@ function Editor( { updatePreferredStyleVariations, onNavigateToEntityRecord, onNavigateToPreviousEntityRecord, - defaultRenderingMode, ] ); diff --git a/packages/edit-post/src/index.js b/packages/edit-post/src/index.js index 78c79227c9d89..08bc7c5aa7002 100644 --- a/packages/edit-post/src/index.js +++ b/packages/edit-post/src/index.js @@ -103,10 +103,7 @@ export function initializeEditor( 'blockEditor.__unstableCanInsertBlockType', 'removeTemplatePartsFromInserter', ( canInsert, blockType ) => { - if ( - select( editorStore ).getRenderingMode() === 'post-only' && - blockType.name === 'core/template-part' - ) { + if ( blockType.name === 'core/template-part' ) { return false; } return canInsert; @@ -128,10 +125,7 @@ export function initializeEditor( rootClientId, { getBlockParentsByBlockName } ) => { - if ( - select( editorStore ).getRenderingMode() === 'post-only' && - blockType.name === 'core/post-content' - ) { + if ( blockType.name === 'core/post-content' ) { return ( getBlockParentsByBlockName( rootClientId, 'core/query' ) .length > 0 diff --git a/packages/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js index 1a39a516da8f5..f5b4a27e158ea 100644 --- a/packages/edit-post/src/store/selectors.js +++ b/packages/edit-post/src/store/selectors.js @@ -556,7 +556,7 @@ export const isEditingTemplate = createRegistrySelector( ( select ) => () => { since: '6.5', alternative: `select( 'core/editor' ).getRenderingMode`, } ); - return select( editorStore ).getRenderingMode() !== 'post-only'; + return select( editorStore ).getCurrentPostType() !== 'post-only'; } ); /** diff --git a/packages/edit-site/src/components/block-editor/use-site-editor-settings.js b/packages/edit-site/src/components/block-editor/use-site-editor-settings.js index a99ea62726895..0c7dbe8ec3d04 100644 --- a/packages/edit-site/src/components/block-editor/use-site-editor-settings.js +++ b/packages/edit-site/src/components/block-editor/use-site-editor-settings.js @@ -144,7 +144,9 @@ export function useSpecificEditorSettings() { [] ); const archiveLabels = useArchiveLabel( templateSlug ); - const defaultRenderingMode = postWithTemplate ? 'template-locked' : 'all'; + const defaultRenderingMode = postWithTemplate + ? 'template-locked' + : 'post-only'; const onNavigateToPreviousEntityRecord = useNavigateToPreviousEntityRecord(); const defaultEditorSettings = useMemo( () => { diff --git a/packages/editor/src/components/editor-canvas/index.js b/packages/editor/src/components/editor-canvas/index.js index 489694bb8adfd..f011f285644c0 100644 --- a/packages/editor/src/components/editor-canvas/index.js +++ b/packages/editor/src/components/editor-canvas/index.js @@ -110,7 +110,7 @@ function EditorCanvas( { if ( postTypeSlug === 'wp_block' ) { _wrapperBlockName = 'core/block'; - } else if ( ! _renderingMode === 'post-only' ) { + } else if ( _renderingMode === 'post-only' ) { _wrapperBlockName = 'core/post-content'; } diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 8d716a04346da..4c0f1078fde65 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -585,7 +585,7 @@ export function updateEditorSettings( settings ) { * - `post-only`: This mode extracts the post blocks from the template and renders only those. The idea is to allow the user to edit the post/page in isolation without the wrapping template. * - `template-locked`: This mode renders both the template and the post blocks but the template blocks are locked and can't be edited. The post blocks are editable. * - * @param {string} mode Mode (one of 'post-only', 'template-locked' or 'all'). + * @param {string} mode Mode (one of 'post-only' or 'template-locked'). */ export const setRenderingMode = ( mode ) => diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index 978a5c8697410..b2ae6b5224b14 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -266,7 +266,7 @@ export function editorSettings( state = EDITOR_SETTINGS_DEFAULTS, action ) { return state; } -export function renderingMode( state = 'all', action ) { +export function renderingMode( state = 'post-only', action ) { switch ( action.type ) { case 'SET_RENDERING_MODE': return action.mode; From aac86f7b69c016aae4ae341a0f191b119b6fff7d Mon Sep 17 00:00:00 2001 From: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com> Date: Mon, 12 Feb 2024 16:40:30 +0100 Subject: [PATCH 05/15] Block Bindings: lock editing of blocks by default (#58787) * Lock editing by default when bindings exist * Use default in post meta source * Set `lockEditing` to false in pattern overrides * Move default value to reducer * Use `_x` for sources translations * Add context to translations --- .../block-editor/src/hooks/use-bindings-attributes.js | 2 +- packages/block-editor/src/store/reducer.js | 2 +- packages/block-library/src/button/edit.js | 2 +- packages/block-library/src/image/edit.js | 2 +- packages/block-library/src/image/image.js | 6 +++--- packages/editor/src/bindings/index.js | 2 ++ packages/editor/src/bindings/pattern-overrides.js | 11 +++++++++++ packages/editor/src/bindings/post-meta.js | 5 ++--- 8 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 packages/editor/src/bindings/pattern-overrides.js diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js index a1873143c294a..75f337cff9795 100644 --- a/packages/block-editor/src/hooks/use-bindings-attributes.js +++ b/packages/block-editor/src/hooks/use-bindings-attributes.js @@ -46,7 +46,7 @@ const createEditFunctionWithBindingsAttribute = () => settings.source ); - if ( source ) { + if ( source && source.useSource ) { // Second argument (`updateMetaValue`) will be used to update the value in the future. const { placeholder, diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index d4755169c53e5..0be421b757bce 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -2057,7 +2057,7 @@ function blockBindingsSources( state = {}, action ) { [ action.sourceName ]: { label: action.sourceLabel, useSource: action.useSource, - lockAttributesEditing: action.lockAttributesEditing, + lockAttributesEditing: action.lockAttributesEditing ?? true, }, }; } diff --git a/packages/block-library/src/button/edit.js b/packages/block-library/src/button/edit.js index c3a2aff1bd0d9..e01898ca00dec 100644 --- a/packages/block-library/src/button/edit.js +++ b/packages/block-library/src/button/edit.js @@ -246,7 +246,7 @@ function ButtonEdit( props ) { lockUrlControls: !! metadata?.bindings?.url && getBlockBindingsSource( metadata?.bindings?.url?.source ) - ?.lockAttributesEditing === true, + ?.lockAttributesEditing, }; }, [ isSelected ] diff --git a/packages/block-library/src/image/edit.js b/packages/block-library/src/image/edit.js index 63b99460b386a..61d023e4e580a 100644 --- a/packages/block-library/src/image/edit.js +++ b/packages/block-library/src/image/edit.js @@ -349,7 +349,7 @@ export function ImageEdit( { lockUrlControls: !! metadata?.bindings?.url && getBlockBindingsSource( metadata?.bindings?.url?.source ) - ?.lockAttributesEditing === true, + ?.lockAttributesEditing, }; }, [ isSingleSelected ] diff --git a/packages/block-library/src/image/image.js b/packages/block-library/src/image/image.js index 8c911fad726ae..f551d8df007a8 100644 --- a/packages/block-library/src/image/image.js +++ b/packages/block-library/src/image/image.js @@ -427,7 +427,7 @@ export default function Image( { lockUrlControls: !! urlBinding && getBlockBindingsSource( urlBinding?.source ) - ?.lockAttributesEditing === true, + ?.lockAttributesEditing, lockHrefControls: // Disable editing the link of the URL if the image is inside a pattern instance. // This is a temporary solution until we support overriding the link on the frontend. @@ -435,11 +435,11 @@ export default function Image( { lockAltControls: !! altBinding && getBlockBindingsSource( altBinding?.source ) - ?.lockAttributesEditing === true, + ?.lockAttributesEditing, lockTitleControls: !! titleBinding && getBlockBindingsSource( titleBinding?.source ) - ?.lockAttributesEditing === true, + ?.lockAttributesEditing, }; }, [ clientId, isSingleSelected, metadata?.bindings ] diff --git a/packages/editor/src/bindings/index.js b/packages/editor/src/bindings/index.js index 8a883e8904a71..ff0516902e321 100644 --- a/packages/editor/src/bindings/index.js +++ b/packages/editor/src/bindings/index.js @@ -7,7 +7,9 @@ import { dispatch } from '@wordpress/data'; * Internal dependencies */ import { unlock } from '../lock-unlock'; +import patternOverrides from './pattern-overrides'; import postMeta from './post-meta'; const { registerBlockBindingsSource } = unlock( dispatch( blockEditorStore ) ); +registerBlockBindingsSource( patternOverrides ); registerBlockBindingsSource( postMeta ); diff --git a/packages/editor/src/bindings/pattern-overrides.js b/packages/editor/src/bindings/pattern-overrides.js new file mode 100644 index 0000000000000..5f7f475a649a3 --- /dev/null +++ b/packages/editor/src/bindings/pattern-overrides.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import { _x } from '@wordpress/i18n'; + +export default { + name: 'core/pattern-overrides', + label: _x( 'Pattern Overrides', 'block bindings source' ), + useSource: null, + lockAttributesEditing: false, +}; diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js index 091491b2ed00c..a9a00599b6803 100644 --- a/packages/editor/src/bindings/post-meta.js +++ b/packages/editor/src/bindings/post-meta.js @@ -3,7 +3,7 @@ */ import { useEntityProp } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { _x } from '@wordpress/i18n'; /** * Internal dependencies */ @@ -11,7 +11,7 @@ import { store as editorStore } from '../store'; export default { name: 'core/post-meta', - label: __( 'Post Meta' ), + label: _x( 'Post Meta', 'block bindings source' ), useSource( props, sourceAttributes ) { const { getCurrentPostType } = useSelect( editorStore ); const { context } = props; @@ -38,5 +38,4 @@ export default { useValue: [ metaValue, updateMetaValue ], }; }, - lockAttributesEditing: true, }; From 64ee423ef73013d7d7f6e81fbaa988461a4bd5f2 Mon Sep 17 00:00:00 2001 From: Aki Hamano <54422211+t-hamano@users.noreply.github.com> Date: Tue, 13 Feb 2024 01:13:51 +0900 Subject: [PATCH 06/15] Font Library: Show error message when no fonts found to install (#58914) Co-authored-by: t-hamano Co-authored-by: matiasbenedetto Co-authored-by: okmttdhr --- .../global-styles/font-library-modal/context.js | 9 --------- .../font-library-modal/tab-panel-layout.js | 10 +++++++++- .../global-styles/font-library-modal/upload-fonts.js | 6 ++++++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/context.js b/packages/edit-site/src/components/global-styles/font-library-modal/context.js index 2b9efd2ddccd6..04eabb149f5f4 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/context.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/context.js @@ -61,15 +61,6 @@ function FontLibraryProvider( { children } ) { setRefreshKey( Date.now() ); }; - // Reset notice on dismiss. - useEffect( () => { - if ( notice ) { - notice.onRemove = () => { - setNotice( null ); - }; - } - }, [ notice, setNotice ] ); - const { records: libraryPosts = [], isResolving: isResolvingLibrary, diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js b/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js index 7250e0a745ce5..c959a5373190e 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/tab-panel-layout.js @@ -1,6 +1,7 @@ /** * WordPress dependencies */ +import { useContext } from '@wordpress/element'; import { __experimentalText as Text, __experimentalHeading as Heading, @@ -14,6 +15,11 @@ import { import { chevronLeft } from '@wordpress/icons'; import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { FontLibraryContext } from './context'; + function TabPanelLayout( { title, description, @@ -22,6 +28,8 @@ function TabPanelLayout( { children, footer, } ) { + const { setNotice } = useContext( FontLibraryContext ); + return (
@@ -53,7 +61,7 @@ function TabPanelLayout( { setNotice( null ) } > { notice.message } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/upload-fonts.js b/packages/edit-site/src/components/global-styles/font-library-modal/upload-fonts.js index c956b0d55e691..a40534861ae96 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/upload-fonts.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/upload-fonts.js @@ -63,6 +63,12 @@ function UploadFonts() { } ); if ( allowedFiles.length > 0 ) { loadFiles( allowedFiles ); + } else { + setNotice( { + type: 'error', + message: __( 'No fonts found to install.' ), + } ); + setIsUploading( false ); } }; From c00ca0201cec639969b1124ffcc2b68fa1e50d37 Mon Sep 17 00:00:00 2001 From: Andrea Fercia Date: Mon, 12 Feb 2024 18:02:51 +0100 Subject: [PATCH 07/15] Clean up link control CSS. (#58934) --- .../block-editor/src/components/link-control/style.scss | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index 96820aee356d3..425df96ab31fa 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -340,13 +340,6 @@ $block-editor-link-control-number-of-actions: 1; } } -.block-editor-link-control__drawer { - display: flex; // allow for ordering. - order: 30; - flex-direction: column; - flex-basis: 100%; // occupy full width. -} - // Inner div required to avoid padding/margin // causing janky animation. .block-editor-link-control__drawer-inner { From a5c6aed30ec7913876f8f7ed239665f110cb8d50 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 12 Feb 2024 18:13:14 +0100 Subject: [PATCH 08/15] Editor: Remove inline toolbar preference (#58945) Co-authored-by: youknowriad Co-authored-by: draganescu --- .../src/components/observe-typing/index.js | 17 ++--- .../rich-text/format-toolbar-container.js | 49 +------------ .../src/components/rich-text/index.js | 1 - packages/edit-post/src/editor.js | 71 +++++++++---------- .../provider/use-block-editor-settings.js | 1 - 5 files changed, 41 insertions(+), 98 deletions(-) diff --git a/packages/block-editor/src/components/observe-typing/index.js b/packages/block-editor/src/components/observe-typing/index.js index 08764f5939a13..75afc4bbdf0f9 100644 --- a/packages/block-editor/src/components/observe-typing/index.js +++ b/packages/block-editor/src/components/observe-typing/index.js @@ -115,11 +115,10 @@ export function useMouseMoveTypingReset() { * field, presses ESC or TAB, or moves the mouse in the document. */ export function useTypingObserver() { - const { isTyping, hasInlineToolbar } = useSelect( ( select ) => { - const { isTyping: _isTyping, getSettings } = select( blockEditorStore ); + const { isTyping } = useSelect( ( select ) => { + const { isTyping: _isTyping } = select( blockEditorStore ); return { isTyping: _isTyping(), - hasInlineToolbar: getSettings().hasInlineToolbar, }; }, [] ); const { startTyping, stopTyping } = useDispatch( blockEditorStore ); @@ -183,12 +182,10 @@ export function useTypingObserver() { node.addEventListener( 'focus', stopTypingOnNonTextField ); node.addEventListener( 'keydown', stopTypingOnEscapeKey ); - if ( ! hasInlineToolbar ) { - ownerDocument.addEventListener( - 'selectionchange', - stopTypingOnSelectionUncollapse - ); - } + ownerDocument.addEventListener( + 'selectionchange', + stopTypingOnSelectionUncollapse + ); return () => { defaultView.clearTimeout( timerId ); @@ -245,7 +242,7 @@ export function useTypingObserver() { node.removeEventListener( 'keydown', startTypingInTextField ); }; }, - [ isTyping, hasInlineToolbar, startTyping, stopTyping ] + [ isTyping, startTyping, stopTyping ] ); return useMergeRefs( [ ref1, ref2 ] ); diff --git a/packages/block-editor/src/components/rich-text/format-toolbar-container.js b/packages/block-editor/src/components/rich-text/format-toolbar-container.js index ffefcd1e302a8..ae98cacaf82cc 100644 --- a/packages/block-editor/src/components/rich-text/format-toolbar-container.js +++ b/packages/block-editor/src/components/rich-text/format-toolbar-container.js @@ -3,13 +3,6 @@ */ import { __ } from '@wordpress/i18n'; import { Popover, ToolbarGroup } from '@wordpress/components'; -import { useSelect } from '@wordpress/data'; -import { - isCollapsed, - getActiveFormats, - useAnchor, - store as richTextStore, -} from '@wordpress/rich-text'; /** * Internal dependencies @@ -17,22 +10,6 @@ import { import BlockControls from '../block-controls'; import FormatToolbar from './format-toolbar'; import NavigableToolbar from '../navigable-toolbar'; -import { store as blockEditorStore } from '../../store'; - -function InlineSelectionToolbar( { editableContentElement, activeFormats } ) { - const lastFormat = activeFormats[ activeFormats.length - 1 ]; - const lastFormatType = lastFormat?.type; - const settings = useSelect( - ( select ) => select( richTextStore ).getFormatType( lastFormatType ), - [ lastFormatType ] - ); - const popoverAnchor = useAnchor( { - editableContentElement, - settings, - } ); - - return ; -} function InlineToolbar( { popoverAnchor } ) { return ( @@ -56,35 +33,11 @@ function InlineToolbar( { popoverAnchor } ) { ); } -const FormatToolbarContainer = ( { - inline, - editableContentElement, - value, -} ) => { - const hasInlineToolbar = useSelect( - ( select ) => select( blockEditorStore ).getSettings().hasInlineToolbar, - [] - ); - +const FormatToolbarContainer = ( { inline, editableContentElement } ) => { if ( inline ) { return ; } - if ( hasInlineToolbar ) { - const activeFormats = getActiveFormats( value ); - - if ( isCollapsed( value ) && ! activeFormats.length ) { - return null; - } - - return ( - - ); - } - // Render regular toolbar. return ( diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index f45cc618d4547..43a9fb1a31f1b 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -348,7 +348,6 @@ export function RichTextWrapper( ) } { - const { isFeatureActive, getEditedPostTemplate } = - select( editPostStore ); - const { getEntityRecord, getPostType, canUser } = - select( coreStore ); - const { getEditorSettings } = select( editorStore ); + const { post, preferredStyleVariations, template } = useSelect( + ( select ) => { + const { getEditedPostTemplate } = select( editPostStore ); + const { getEntityRecord, getPostType, canUser } = + select( coreStore ); + const { getEditorSettings } = select( editorStore ); - const postObject = getEntityRecord( - 'postType', - currentPost.postType, - currentPost.postId - ); + const postObject = getEntityRecord( + 'postType', + currentPost.postType, + currentPost.postId + ); - const supportsTemplateMode = - getEditorSettings().supportsTemplateMode; - const isViewable = - getPostType( currentPost.postType )?.viewable ?? false; - const canEditTemplate = canUser( 'create', 'templates' ); - return { - hasInlineToolbar: isFeatureActive( 'inlineToolbar' ), - preferredStyleVariations: select( preferencesStore ).get( - 'core/edit-post', - 'preferredStyleVariations' - ), - template: - supportsTemplateMode && - isViewable && - canEditTemplate && - currentPost.postType !== 'wp_template' - ? getEditedPostTemplate() - : null, - post: postObject, - }; - }, - [ currentPost.postType, currentPost.postId ] - ); + const supportsTemplateMode = + getEditorSettings().supportsTemplateMode; + const isViewable = + getPostType( currentPost.postType )?.viewable ?? false; + const canEditTemplate = canUser( 'create', 'templates' ); + return { + preferredStyleVariations: select( preferencesStore ).get( + 'core/edit-post', + 'preferredStyleVariations' + ), + template: + supportsTemplateMode && + isViewable && + canEditTemplate && + currentPost.postType !== 'wp_template' + ? getEditedPostTemplate() + : null, + post: postObject, + }; + }, + [ currentPost.postType, currentPost.postId ] + ); const { updatePreferredStyleVariations } = useDispatch( editPostStore ); @@ -100,11 +97,9 @@ function Editor( { value: preferredStyleVariations, onChange: updatePreferredStyleVariations, }, - hasInlineToolbar, } ), [ settings, - hasInlineToolbar, preferredStyleVariations, updatePreferredStyleVariations, onNavigateToEntityRecord, diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js index 164f925743522..5ae329f7897f8 100644 --- a/packages/editor/src/components/provider/use-block-editor-settings.js +++ b/packages/editor/src/components/provider/use-block-editor-settings.js @@ -52,7 +52,6 @@ const BLOCK_EDITOR_SETTINGS = [ 'gradients', 'generateAnchors', 'onNavigateToEntityRecord', - 'hasInlineToolbar', 'imageDefaultSize', 'imageDimensions', 'imageEditing', From cf18b3bd947be8b250cd2c18b0c496d6dbe85930 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 13 Feb 2024 04:15:06 +1100 Subject: [PATCH 09/15] Template revisions API: move from experimental to compat/6.4 (#58920) * Initial commit: Reverting https://github.com/WordPress/gutenberg/pull/51774 by moving the experimental code into compat/6.4 The changes to the templates controller were made in WordPress 6.4. See: https://github.com/WordPress/wordpress-develop/commit/1f51e1f4f6c81238fffed706ff165795ea34d522 * Linty --- ...tenberg-rest-templates-controller-6-4.php} | 6 ++--- lib/compat/wordpress-6.4/rest-api.php | 20 +++++++++++++++++ lib/experimental/rest-api.php | 22 ------------------- lib/load.php | 2 +- 4 files changed, 24 insertions(+), 26 deletions(-) rename lib/{experimental/class-gutenberg-rest-template-revision-count.php => compat/wordpress-6.4/class-gutenberg-rest-templates-controller-6-4.php} (91%) diff --git a/lib/experimental/class-gutenberg-rest-template-revision-count.php b/lib/compat/wordpress-6.4/class-gutenberg-rest-templates-controller-6-4.php similarity index 91% rename from lib/experimental/class-gutenberg-rest-template-revision-count.php rename to lib/compat/wordpress-6.4/class-gutenberg-rest-templates-controller-6-4.php index 17fb34e05ecfe..ec969519f9ac4 100644 --- a/lib/experimental/class-gutenberg-rest-template-revision-count.php +++ b/lib/compat/wordpress-6.4/class-gutenberg-rest-templates-controller-6-4.php @@ -1,19 +1,19 @@ register_routes(); } add_action( 'rest_api_init', 'gutenberg_register_rest_block_patterns_routes' ); + + +if ( ! function_exists( 'wp_api_template_revision_args' ) ) { + /** + * Hook in to the template and template part post types and decorate + * the rest endpoint with the revision count. + * + * @param array $args Current registered post type args. + * @param string $post_type Name of post type. + * + * @return array + */ + function wp_api_template_revision_args( $args, $post_type ) { + if ( 'wp_template' === $post_type || 'wp_template_part' === $post_type ) { + $args['rest_controller_class'] = 'Gutenberg_REST_Templates_Controller_6_4'; + } + return $args; + } +} +add_filter( 'register_post_type_args', 'wp_api_template_revision_args', 10, 2 ); diff --git a/lib/experimental/rest-api.php b/lib/experimental/rest-api.php index 7c6a9bf74d739..77f7d091d2655 100644 --- a/lib/experimental/rest-api.php +++ b/lib/experimental/rest-api.php @@ -101,25 +101,3 @@ function gutenberg_auto_draft_get_sample_permalink( $permalink, $id, $title, $na return $permalink; } add_filter( 'get_sample_permalink', 'gutenberg_auto_draft_get_sample_permalink', 10, 5 ); - -if ( ! function_exists( 'wp_api_template_revision_args' ) ) { - /** - * Hook in to the template and template part post types and decorate - * the rest endpoint with the revision count. - * - * When merging to core, this can be removed once Gutenberg_REST_Template_Revision_Count is - * merged with WP_REST_Template_Controller. - * - * @param array $args Current registered post type args. - * @param string $post_type Name of post type. - * - * @return array - */ - function wp_api_template_revision_args( $args, $post_type ) { - if ( 'wp_template' === $post_type || 'wp_template_part' === $post_type ) { - $args['rest_controller_class'] = 'Gutenberg_REST_Template_Revision_Count'; - } - return $args; - } -} -add_filter( 'register_post_type_args', 'wp_api_template_revision_args', 10, 2 ); diff --git a/lib/load.php b/lib/load.php index 47d41fb50b3b5..b111c8e0d7921 100644 --- a/lib/load.php +++ b/lib/load.php @@ -36,6 +36,7 @@ function gutenberg_is_experiment_enabled( $name ) { } // WordPress 6.4 compat. + require_once __DIR__ . '/compat/wordpress-6.4/class-gutenberg-rest-templates-controller-6-4.php'; require_once __DIR__ . '/compat/wordpress-6.4/class-gutenberg-rest-global-styles-revisions-controller-6-4.php'; require_once __DIR__ . '/compat/wordpress-6.4/class-gutenberg-rest-block-patterns-controller.php'; require_once __DIR__ . '/compat/wordpress-6.4/rest-api.php'; @@ -53,7 +54,6 @@ function gutenberg_is_experiment_enabled( $name ) { if ( ! class_exists( 'WP_Rest_Customizer_Nonces' ) ) { require_once __DIR__ . '/experimental/class-wp-rest-customizer-nonces.php'; } - require_once __DIR__ . '/experimental/class-gutenberg-rest-template-revision-count.php'; require_once __DIR__ . '/experimental/rest-api.php'; require_once __DIR__ . '/experimental/kses-allowed-html.php'; From c4ba5cf4c350c31e75f2eb7afe05b15681ea26e7 Mon Sep 17 00:00:00 2001 From: Alex Stine Date: Mon, 12 Feb 2024 14:17:29 -0500 Subject: [PATCH 10/15] Try double enter for details block. (#58903) Co-authored-by: alexstine Co-authored-by: andrewhayward Co-authored-by: carolinan Co-authored-by: talksina --- packages/block-library/src/details/block.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/block-library/src/details/block.json b/packages/block-library/src/details/block.json index 868307d6d22a1..a488ae1fa73a7 100644 --- a/packages/block-library/src/details/block.json +++ b/packages/block-library/src/details/block.json @@ -19,6 +19,7 @@ } }, "supports": { + "__experimentalOnEnter": true, "align": [ "wide", "full" ], "color": { "gradients": true, From 535252ede8a5ad48d425c68da64f3a44d0bd973a Mon Sep 17 00:00:00 2001 From: Mario Santos <34552881+SantosGuillamot@users.noreply.github.com> Date: Mon, 12 Feb 2024 20:46:45 +0100 Subject: [PATCH 11/15] Use `data_wp_context` helper in core blocks and remove `data-wp-interactive` object (#58943) * Update file block * Update image block * Update navigation block * Update query block * WIP: Update form block * Use boolean instead of string in `$open_by_default` variable * Don't use `data-wp-interactive` object in search block * Remove unnecessary quotes * Adapt query block unit test Co-authored-by: SantosGuillamot Co-authored-by: c4rl0sbr4v0 --- packages/block-library/src/file/index.php | 2 +- packages/block-library/src/image/index.php | 4 ++-- .../block-library/src/navigation/index.php | 12 +++++------ packages/block-library/src/query/index.php | 2 +- packages/block-library/src/search/index.php | 20 +++++++++++++------ phpunit/blocks/render-query-test.php | 2 +- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index b2e7f86dcc7a3..ba0343ae6b25c 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -53,7 +53,7 @@ static function ( $matches ) { $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag(); - $processor->set_attribute( 'data-wp-interactive', '{"namespace":"core/file"}' ); + $processor->set_attribute( 'data-wp-interactive', 'core/file' ); $processor->next_tag( 'object' ); $processor->set_attribute( 'data-wp-bind--hidden', '!state.hasPdfPreview' ); $processor->set_attribute( 'hidden', true ); diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index ab22133398c58..0b75bf95a7d4c 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -159,7 +159,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { $figure_class_names = $p->get_attribute( 'class' ); $figure_styles = $p->get_attribute( 'style' ); $p->add_class( 'wp-lightbox-container' ); - $p->set_attribute( 'data-wp-interactive', '{"namespace":"core/image"}' ); + $p->set_attribute( 'data-wp-interactive', 'core/image' ); $p->set_attribute( 'data-wp-context', wp_json_encode( @@ -240,7 +240,7 @@ function block_core_image_print_lightbox_overlay() { echo << array(), 'type' => 'overlay', 'roleAttribute' => '', 'ariaLabel' => __( 'Menu' ), - ), - JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP + ) ); $nav_element_directives = ' - data-wp-interactive=\'{"namespace":"core/navigation"}\' - data-wp-context=\'' . $nav_element_context . '\' - '; + data-wp-interactive="core/navigation"' + . $nav_element_context; /* * When the navigation's 'overlayMenu' attribute is set to 'always', JavaScript @@ -780,7 +778,7 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut ) ) ) { // Add directives to the parent `
  • `. - $tags->set_attribute( 'data-wp-interactive', '{ "namespace": "core/navigation" }' ); + $tags->set_attribute( 'data-wp-interactive', 'core/navigation' ); $tags->set_attribute( 'data-wp-context', '{ "submenuOpenedBy": {}, "type": "submenu" }' ); $tags->set_attribute( 'data-wp-watch', 'callbacks.initMenu' ); $tags->set_attribute( 'data-wp-on--focusout', 'actions.handleMenuFocusout' ); diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index 1a536dd3dcd98..b6c34eb71d070 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -49,7 +49,7 @@ function render_block_core_query( $attributes, $content, $block ) { $p = new WP_HTML_Tag_Processor( $content ); if ( $p->next_tag() ) { // Add the necessary directives. - $p->set_attribute( 'data-wp-interactive', '{"namespace":"core/query"}' ); + $p->set_attribute( 'data-wp-interactive', 'core/query' ); $p->set_attribute( 'data-wp-router-region', 'query-' . $attributes['queryId'] ); $p->set_attribute( 'data-wp-init', 'callbacks.setQueryRef' ); $p->set_attribute( 'data-wp-context', '{}' ); diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index 6623a9a8481bc..5688e2871a246 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -47,7 +47,7 @@ function render_block_core_search( $attributes ) { $border_color_classes = get_border_color_classes_for_block_core_search( $attributes ); // This variable is a constant and its value is always false at this moment. // It is defined this way because some values depend on it, in case it changes in the future. - $open_by_default = 'false'; + $open_by_default = false; $label_inner_html = empty( $attributes['label'] ) ? __( 'Search' ) : wp_kses_post( $attributes['label'] ); $label = new WP_HTML_Tag_Processor( sprintf( '', $inline_styles['label'], $label_inner_html ) ); @@ -179,12 +179,20 @@ function render_block_core_search( $attributes ) { if ( $is_expandable_searchfield ) { $aria_label_expanded = __( 'Submit Search' ); $aria_label_collapsed = __( 'Expand search field' ); + $form_context = data_wp_context( + array( + 'isSearchInputVisible' => $open_by_default, + 'inputId' => $input_id, + 'ariaLabelExpanded' => $aria_label_expanded, + 'ariaLabelCollapsed' => $aria_label_collapsed, + ) + ); $form_directives = ' - data-wp-interactive=\'{ "namespace": "core/search" }\' - data-wp-context=\'{ "isSearchInputVisible": ' . $open_by_default . ', "inputId": "' . $input_id . '", "ariaLabelExpanded": "' . $aria_label_expanded . '", "ariaLabelCollapsed": "' . $aria_label_collapsed . '" }\' - data-wp-class--wp-block-search__searchfield-hidden="!context.isSearchInputVisible" - data-wp-on--keydown="actions.handleSearchKeydown" - data-wp-on--focusout="actions.handleSearchFocusout" + data-wp-interactive=\'"core/search"\'' + . $form_context . + 'data-wp-class--wp-block-search__searchfield-hidden="!context.isSearchInputVisible" + data-wp-on--keydown="actions.handleSearchKeydown" + data-wp-on--focusout="actions.handleSearchFocusout" '; } diff --git a/phpunit/blocks/render-query-test.php b/phpunit/blocks/render-query-test.php index a121c490d747c..a68bd2c7adcba 100644 --- a/phpunit/blocks/render-query-test.php +++ b/phpunit/blocks/render-query-test.php @@ -86,7 +86,7 @@ public function test_rendering_query_with_enhanced_pagination() { $p->next_tag( array( 'class_name' => 'wp-block-query' ) ); $this->assertSame( '{}', $p->get_attribute( 'data-wp-context' ) ); $this->assertSame( 'query-0', $p->get_attribute( 'data-wp-router-region' ) ); - $this->assertSame( '{"namespace":"core/query"}', $p->get_attribute( 'data-wp-interactive' ) ); + $this->assertSame( 'core/query', $p->get_attribute( 'data-wp-interactive' ) ); $p->next_tag( array( 'class_name' => 'wp-block-post' ) ); $this->assertSame( 'post-template-item-' . self::$posts[1], $p->get_attribute( 'data-wp-key' ) ); From 7011e5aca983eb177c83ac6dddcff14337101908 Mon Sep 17 00:00:00 2001 From: Andrew Serong <14988353+andrewserong@users.noreply.github.com> Date: Tue, 13 Feb 2024 09:06:49 +1100 Subject: [PATCH 12/15] Docs: Clarify the status of the wp-block-styles theme support, and its intent (#58915) * Docs: Clarify the status of the wp-block-styles theme support, and its intent to be used in classic themes * Add heading for opinionated block styles * Switch to verb "opt in" Co-authored-by: Ramon --------- Co-authored-by: andrewserong Co-authored-by: ramonjd Co-authored-by: carolinan Co-authored-by: simison --- docs/how-to-guides/themes/theme-support.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/how-to-guides/themes/theme-support.md b/docs/how-to-guides/themes/theme-support.md index 4a952c4de657d..edf4b8e505c13 100644 --- a/docs/how-to-guides/themes/theme-support.md +++ b/docs/how-to-guides/themes/theme-support.md @@ -52,7 +52,9 @@ add_action( 'after_setup_theme', 'mytheme_setup_theme_supported_features' ); Core blocks include default structural styles. These are loaded in both the editor and the front end by default. An example of these styles is the CSS that powers the columns block. Without these rules, the block would result in a broken layout containing no columns at all. -The block editor allows themes to opt-in to slightly more opinionated styles for the front end. An example of these styles is the default color bar to the left of blockquotes. If you'd like to use these opinionated styles in your theme, add theme support for `wp-block-styles`: +### Opinionated block styles + +The block editor allows themes to opt in to slightly more opinionated styles for the front end. An example of these styles is the default color bar to the left of blockquotes. If you'd like to use these opinionated styles in a classic theme, add theme support for `wp-block-styles`: ```php add_theme_support( 'wp-block-styles' ); @@ -60,6 +62,8 @@ add_theme_support( 'wp-block-styles' ); You can see the CSS that will be included in the [block library theme file](https://github.com/WordPress/gutenberg/blob/trunk/packages/block-library/src/theme.scss). +For block themes or themes providing a `theme.json` file, it is not recommended to use this theme support. Instead, to ensure there is no styling conflict between global styles rules and block styles, add the desired block styles to the theme's `theme.json` file. + ### Wide Alignment: Some blocks such as the image block have the possibility to define a "wide" or "full" alignment by adding the corresponding classname to the block's wrapper ( `alignwide` or `alignfull` ). A theme can opt-in for this feature by calling: From 15d5fbce69921a65de14d7ee31300b03a5bc8cbf Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Mon, 12 Feb 2024 22:41:11 +0000 Subject: [PATCH 13/15] Update Changelog for 17.6.6 --- changelog.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/changelog.txt b/changelog.txt index 85c8b351d69a3..c11c4246a60bd 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,20 @@ == Changelog == += 17.6.6 = + +## Changelog + +### Bug Fixes + +- Script loader 6.4 compat: check for init hook completion ([58406](https://github.com/WordPress/gutenberg/pull/58406)) + +## Contributors + +The following contributors merged PRs in this release: + +@ramonjd + + = 17.7.0-rc.1 = From 60fc52d1e9bbaa43ec3cc337579b963c8d5ee474 Mon Sep 17 00:00:00 2001 From: Kai Hao Date: Tue, 13 Feb 2024 13:09:30 +0800 Subject: [PATCH 14/15] Migrate `change-detection` to Playwright (#58767) * Migrate change-detection to Playwright * Do not retry page.reload * Only resolve 'hasDialog' to true for the 'beforeunload' type * Update the initial block creation method to avoid flakiness * Fix route interception Co-authored-by: kevin940726 Co-authored-by: Mamaduka --- .../editor/various/change-detection.test.js | 417 ------------- .../editor/various/change-detection.spec.js | 561 ++++++++++++++++++ 2 files changed, 561 insertions(+), 417 deletions(-) delete mode 100644 packages/e2e-tests/specs/editor/various/change-detection.test.js create mode 100644 test/e2e/specs/editor/various/change-detection.spec.js diff --git a/packages/e2e-tests/specs/editor/various/change-detection.test.js b/packages/e2e-tests/specs/editor/various/change-detection.test.js deleted file mode 100644 index 0eb673671222f..0000000000000 --- a/packages/e2e-tests/specs/editor/various/change-detection.test.js +++ /dev/null @@ -1,417 +0,0 @@ -/** - * WordPress dependencies - */ -import { - clickBlockAppender, - createNewPost, - pressKeyWithModifier, - ensureSidebarOpened, - publishPost, - saveDraft, - openDocumentSettingsSidebar, - isCurrentURL, - openTypographyToolsPanelMenu, - canvas, -} from '@wordpress/e2e-test-utils'; - -describe( 'Change detection', () => { - let handleInterceptedRequest, hadInterceptedSave; - - beforeEach( async () => { - hadInterceptedSave = false; - - await createNewPost(); - } ); - - afterEach( async () => { - if ( handleInterceptedRequest ) { - await releaseSaveIntercept(); - } - } ); - - async function assertIsDirty( isDirty ) { - let hadDialog = false; - - function handleOnDialog() { - hadDialog = true; - } - - try { - page.on( 'dialog', handleOnDialog ); - await page.reload(); - - // Ensure whether it was expected that dialog was encountered. - expect( hadDialog ).toBe( isDirty ); - } catch ( error ) { - throw error; - } finally { - page.removeListener( 'dialog', handleOnDialog ); - } - } - - async function interceptSave() { - await page.setRequestInterception( true ); - - handleInterceptedRequest = ( interceptedRequest ) => { - if ( interceptedRequest.url().includes( '/wp/v2/posts' ) ) { - hadInterceptedSave = true; - } else { - interceptedRequest.continue(); - } - }; - page.on( 'request', handleInterceptedRequest ); - } - - async function releaseSaveIntercept() { - page.removeListener( 'request', handleInterceptedRequest ); - await page.setRequestInterception( false ); - hadInterceptedSave = false; - handleInterceptedRequest = null; - } - - it( 'Should not save on new unsaved post', async () => { - await interceptSave(); - - // Keyboard shortcut Ctrl+S save. - await pressKeyWithModifier( 'primary', 'S' ); - - expect( hadInterceptedSave ).toBe( false ); - } ); - - it( 'Should autosave post', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - // Force autosave to occur immediately. - await Promise.all( [ - page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave() - ), - page.waitForSelector( '.editor-post-saved-state.is-autosaving' ), - page.waitForSelector( '.editor-post-saved-state.is-saved' ), - ] ); - - // Autosave draft as same user should do full save, i.e. not dirty. - await assertIsDirty( false ); - } ); - - it( 'Should prompt to confirm unsaved changes for autosaved draft for non-content fields', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - // Toggle post as needing review (not persisted for autosave). - await ensureSidebarOpened(); - - const postPendingReviewButton = ( - await page.$x( "//label[contains(text(), 'Pending review')]" ) - )[ 0 ]; - await postPendingReviewButton.click( 'button' ); - - // Force autosave to occur immediately. - await Promise.all( [ - page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave() - ), - page.waitForSelector( '.editor-post-saved-state.is-autosaving' ), - page.waitForSelector( '.editor-post-saved-state.is-saved' ), - ] ); - - await assertIsDirty( true ); - } ); - - it( 'Should prompt to confirm unsaved changes for autosaved published post', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - await publishPost(); - - // Close publish panel. - await Promise.all( [ - page.waitForFunction( - () => ! document.querySelector( '.editor-post-publish-panel' ) - ), - page.click( '.editor-post-publish-panel__header button' ), - ] ); - - // Should be dirty after autosave change of published post. - await canvas().type( '.editor-post-title__input', '!' ); - - await Promise.all( [ - page.waitForSelector( - '.editor-post-publish-button[aria-disabled="true"]' - ), - page.waitForSelector( - '.editor-post-publish-button[aria-disabled="false"]' - ), - page.evaluate( () => - window.wp.data.dispatch( 'core/editor' ).autosave() - ), - ] ); - - await assertIsDirty( true ); - } ); - - it( 'Should not prompt to confirm unsaved changes for new post', async () => { - await assertIsDirty( false ); - } ); - - it( 'Should prompt to confirm unsaved changes for new post with initial edits', async () => { - await createNewPost( { - title: 'My New Post', - content: 'My content', - excerpt: 'My excerpt', - } ); - - await assertIsDirty( true ); - } ); - - it( 'Should prompt if property changed without save', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - await assertIsDirty( true ); - } ); - - it( 'Should prompt if content added without save', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'Paragraph' ); - - await assertIsDirty( true ); - } ); - - it( 'Should not prompt if changes saved', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - await saveDraft(); - - await assertIsDirty( false ); - } ); - - it( 'Should not prompt if changes saved right after typing', async () => { - await clickBlockAppender(); - await page.keyboard.type( 'Hello World' ); - - await saveDraft(); - - await assertIsDirty( false ); - } ); - - it( 'Should not save if all changes saved', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - await saveDraft(); - - await interceptSave(); - - // Keyboard shortcut Ctrl+S save. - await pressKeyWithModifier( 'primary', 'S' ); - - expect( hadInterceptedSave ).toBe( false ); - } ); - - it( 'Should prompt if save failed', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - await page.setOfflineMode( true ); - - await Promise.all( [ - // Keyboard shortcut Ctrl+S save. - pressKeyWithModifier( 'primary', 'S' ), - - // Ensure save update fails and presents button. - page.waitForXPath( - '//*[contains(@class, "components-notice") and contains(@class, "is-error")]/*[text()="Updating failed. You are probably offline."]' - ), - page.waitForSelector( '.editor-post-save-draft' ), - ] ); - - // Need to disable offline to allow reload. - await page.setOfflineMode( false ); - - await assertIsDirty( true ); - - expect( console ).toHaveErroredWith( - 'Failed to load resource: net::ERR_INTERNET_DISCONNECTED' - ); - } ); - - it( 'Should prompt if changes and save is in-flight', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - // Hold the posts request so we don't deal with race conditions of the - // save completing early. Other requests should be allowed to continue, - // for example the page reload test. - await interceptSave(); - - // Keyboard shortcut Ctrl+S save. - await pressKeyWithModifier( 'primary', 'S' ); - - await assertIsDirty( true ); - - await releaseSaveIntercept(); - } ); - - it( 'Should prompt if changes made while save is in-flight', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - // Hold the posts request so we don't deal with race conditions of the - // save completing early. Other requests should be allowed to continue, - // for example the page reload test. - await interceptSave(); - - // Keyboard shortcut Ctrl+S save. - await pressKeyWithModifier( 'primary', 'S' ); - - await canvas().type( '.editor-post-title__input', '!' ); - await page.waitForSelector( '.editor-post-save-draft' ); - - await releaseSaveIntercept(); - - await assertIsDirty( true ); - } ); - - it( 'Should prompt if property changes made while save is in-flight, and save completes', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - // Hold the posts request so we don't deal with race conditions of the - // save completing early. - await interceptSave(); - - // Keyboard shortcut Ctrl+S save. - await pressKeyWithModifier( 'primary', 'S' ); - - // Start this check immediately after save since dirtying the post will - // remove the "Saved" with the Save button. - const savedPromise = page.waitForSelector( - '.editor-post-saved-state.is-saved' - ); - - // Dirty post while save is in-flight. - await canvas().type( '.editor-post-title__input', '!' ); - - // Allow save to complete. Disabling interception flushes pending. - await Promise.all( [ savedPromise, releaseSaveIntercept() ] ); - - await assertIsDirty( true ); - } ); - - it( 'Should prompt if block revision is made while save is in-flight, and save completes', async () => { - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - // Hold the posts request so we don't deal with race conditions of the - // save completing early. - await interceptSave(); - - // Keyboard shortcut Ctrl+S save. - await pressKeyWithModifier( 'primary', 'S' ); - - // Start this check immediately after save since dirtying the post will - // remove the "Saved" with the Save button. - const savedPromise = page.waitForSelector( - '.editor-post-saved-state.is-saved' - ); - - await clickBlockAppender(); - await page.keyboard.type( 'Paragraph' ); - - // Allow save to complete. Disabling interception flushes pending. - await Promise.all( [ savedPromise, releaseSaveIntercept() ] ); - - await assertIsDirty( true ); - } ); - - it( 'should save posts without titles and persist and overwrite the auto draft title', async () => { - // Enter content. - await clickBlockAppender(); - await page.keyboard.type( 'Paragraph' ); - - // Save. - await saveDraft(); - - // Verify that the title is empty. - const title = await canvas().$eval( - '.editor-post-title__input', - // Trim padding non-breaking space. - ( element ) => element.textContent.trim() - ); - expect( title ).toBe( '' ); - - // Verify that the post is not dirty. - await assertIsDirty( false ); - } ); - - it( 'should not prompt to confirm unsaved changes when trashing an existing post', async () => { - // Enter title. - await canvas().type( '.editor-post-title__input', 'Hello World' ); - - // Save. - await saveDraft(); - const postId = await page.evaluate( () => - window.wp.data.select( 'core/editor' ).getCurrentPostId() - ); - - // Trash post. - await openDocumentSettingsSidebar(); - await page.click( '.editor-post-trash.components-button' ); - await page.click( '.components-confirm-dialog .is-primary' ); - - await Promise.all( [ - // Wait for "Saved" to confirm save complete. - await page.waitForSelector( '.editor-post-saved-state.is-saved' ), - - // Make sure redirection happens. - await page.waitForNavigation(), - ] ); - - expect( - isCurrentURL( - '/wp-admin/edit.php', - `post_type=post&ids=${ postId }` - ) - ).toBe( true ); - } ); - - it( 'consecutive edits to the same attribute should mark the post as dirty after a save', async () => { - // Open the sidebar block settings. - await openDocumentSettingsSidebar(); - - const blockInspectorTab = await page.waitForXPath( - '//button[@role="tab"][contains(text(), "Block")]' - ); - await blockInspectorTab.click(); - - // Insert a paragraph. - await clickBlockAppender(); - await page.keyboard.type( 'Hello, World!' ); - - // Save and wait till the post is clean. - await Promise.all( [ - page.waitForSelector( '.editor-post-saved-state.is-saved' ), - pressKeyWithModifier( 'primary', 'S' ), - ] ); - - // Change the paragraph's `drop cap`. - await canvas().click( '[data-type="core/paragraph"]' ); - - await openTypographyToolsPanelMenu(); - await page.click( 'button[aria-label="Show Drop cap"]' ); - - const [ dropCapToggle ] = await page.$x( - "//label[contains(text(), 'Drop cap')]" - ); - await dropCapToggle.click(); - await canvas().click( '[data-type="core/paragraph"]' ); - - // Check that the post is dirty. - await page.waitForSelector( '.editor-post-save-draft' ); - - // Save and wait till the post is clean. - await Promise.all( [ - page.waitForSelector( '.editor-post-saved-state.is-saved' ), - pressKeyWithModifier( 'primary', 'S' ), - ] ); - - // Change the paragraph's `drop cap` again. - await canvas().click( '[data-type="core/paragraph"]' ); - await dropCapToggle.click(); - - // Check that the post is dirty. - await page.waitForSelector( '.editor-post-save-draft' ); - } ); -} ); diff --git a/test/e2e/specs/editor/various/change-detection.spec.js b/test/e2e/specs/editor/various/change-detection.spec.js new file mode 100644 index 0000000000000..9fa20ba3c0c34 --- /dev/null +++ b/test/e2e/specs/editor/various/change-detection.spec.js @@ -0,0 +1,561 @@ +/** + * WordPress dependencies + */ +const { + test: base, + expect, +} = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * @typedef {import('@playwright/test').Page} Page + */ + +/** @type {ReturnType>} */ +const test = base.extend( { + changeDetectionUtils: async ( { page }, use ) => { + await use( new ChangeDetectionUtils( { page } ) ); + }, +} ); + +const POST_URLS = [ + '/wp/v2/posts', + `rest_route=${ encodeURIComponent( '/wp/v2/posts' ) }`, +]; + +test.describe( 'Change detection', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost(); + } ); + + test.afterEach( async ( { changeDetectionUtils } ) => { + await changeDetectionUtils.releaseSaveIntercept(); + } ); + + test( 'Should not save on new unsaved post', async ( { + pageUtils, + changeDetectionUtils, + } ) => { + const getHadInterceptedSave = + await changeDetectionUtils.interceptSave(); + + // Keyboard shortcut Ctrl+S save. + await pageUtils.pressKeys( 'primary+s' ); + + await expect.poll( getHadInterceptedSave ).toBe( false ); + } ); + + test( 'Should autosave post', async ( { + page, + editor, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + // Force autosave to occur immediately. + await Promise.all( [ + page.evaluate( () => + window.wp.data.dispatch( 'core/editor' ).autosave() + ), + expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'saved' } ) + ).toBeDisabled(), + ] ); + + // Autosave draft as same user should do full save, i.e. not dirty. + expect( await changeDetectionUtils.getIsDirty() ).toBe( false ); + } ); + + test( 'Should prompt to confirm unsaved changes for autosaved draft for non-content fields', async ( { + page, + editor, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + // Toggle post as needing review (not persisted for autosave). + await editor.openDocumentSettingsSidebar(); + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'checkbox', { name: 'Pending review' } ) + .setChecked( true ); + + // Force autosave to occur immediately. + await Promise.all( [ + page.evaluate( () => + window.wp.data.dispatch( 'core/editor' ).autosave() + ), + expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'saved' } ) + ).toBeDisabled(), + ] ); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'Should prompt to confirm unsaved changes for autosaved published post', async ( { + page, + editor, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + await editor.publishPost(); + + // Close publish panel. + await page + .getByRole( 'region', { name: 'Editor publish' } ) + .getByRole( 'button', { name: 'Close panel' } ) + .click(); + + const updateButton = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Update' } ); + await expect( updateButton ).toBeDisabled(); + + // Should be dirty after autosave change of published post. + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .type( '!' ); + + // Force autosave to occur immediately. + await page.evaluate( () => + window.wp.data.dispatch( 'core/editor' ).autosave() + ); + await expect( updateButton ).toBeEnabled(); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'Should not prompt to confirm unsaved changes for new post', async ( { + changeDetectionUtils, + } ) => { + expect( await changeDetectionUtils.getIsDirty() ).toBe( false ); + } ); + + test( 'Should prompt to confirm unsaved changes for new post with initial edits', async ( { + admin, + changeDetectionUtils, + } ) => { + await admin.createNewPost( { + title: 'My New Post', + content: 'My content', + excerpt: 'My excerpt', + } ); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'Should prompt if property changed without save', async ( { + editor, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'Should prompt if content added without save', async ( { + editor, + page, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'button', { name: 'Add default block' } ) + .click(); + await page.keyboard.type( 'Paragraph' ); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'Should not prompt if changes saved', async ( { + editor, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + await editor.saveDraft(); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( false ); + } ); + + test( 'Should not prompt if changes saved right after typing', async ( { + page, + editor, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'button', { name: 'Add default block' } ) + .click(); + await page.keyboard.type( 'Hello World' ); + + await editor.saveDraft(); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( false ); + } ); + + test( 'Should not save if all changes saved', async ( { + editor, + pageUtils, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + await editor.saveDraft(); + + const getHadInterceptedSave = + await changeDetectionUtils.interceptSave(); + + // Keyboard shortcut Ctrl+S save. + await pageUtils.pressKeys( 'primary+s' ); + + await expect.poll( getHadInterceptedSave ).toBe( false ); + } ); + + test( 'Should prompt if save failed', async ( { + page, + context, + editor, + pageUtils, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + await context.setOffline( true ); + + // Keyboard shortcut Ctrl+S save. + await pageUtils.pressKeys( 'primary+s' ); + // Ensure save update fails and presents button. + await expect( + page + .getByRole( 'region', { name: 'Editor content' } ) + .getByText( 'Updating failed. You are probably offline.' ) + ).toBeVisible(); + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save draft' } ) + ).toBeEnabled(); + + // Need to disable offline to allow reload. + await context.setOffline( false ); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'Should prompt if changes and save is in-flight', async ( { + editor, + pageUtils, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + // Hold the posts request so we don't deal with race conditions of the + // save completing early. Other requests should be allowed to continue, + // for example the page reload test. + await changeDetectionUtils.interceptSave(); + + // Keyboard shortcut Ctrl+S save. + await pageUtils.pressKeys( 'primary+s' ); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'Should prompt if changes made while save is in-flight', async ( { + editor, + pageUtils, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + // Hold the posts request so we don't deal with race conditions of the + // save completing early. Other requests should be allowed to continue, + // for example the page reload test. + await changeDetectionUtils.interceptSave(); + + // Keyboard shortcut Ctrl+S save. + await pageUtils.pressKeys( 'primary+s' ); + + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World!' ); + + await changeDetectionUtils.releaseSaveIntercept(); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'Should prompt if property changes made while save is in-flight, and save completes', async ( { + page, + editor, + pageUtils, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + // Hold the posts request so we don't deal with race conditions of the + // save completing early. + await changeDetectionUtils.interceptSave(); + + // Keyboard shortcut Ctrl+S save. + await pageUtils.pressKeys( 'primary+s' ); + + // Dirty post while save is in-flight. + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .type( '!' ); + + // Allow save to complete. Disabling interception flushes pending. + await Promise.all( [ + expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Saved' } ) + ).toBeDisabled(), + changeDetectionUtils.releaseSaveIntercept(), + ] ); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'Should prompt if block revision is made while save is in-flight, and save completes', async ( { + page, + editor, + pageUtils, + changeDetectionUtils, + } ) => { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + // Hold the posts request so we don't deal with race conditions of the + // save completing early. + await changeDetectionUtils.interceptSave(); + + // Keyboard shortcut Ctrl+S save. + await pageUtils.pressKeys( 'primary+s' ); + + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( 'Paragraph' ); + + // Allow save to complete. Disabling interception flushes pending. + await Promise.all( [ + expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Saved' } ) + ).toBeDisabled(), + changeDetectionUtils.releaseSaveIntercept(), + ] ); + + expect( await changeDetectionUtils.getIsDirty() ).toBe( true ); + } ); + + test( 'should save posts without titles and persist and overwrite the auto draft title', async ( { + page, + editor, + changeDetectionUtils, + } ) => { + // Enter content. + await editor.canvas + .getByRole( 'button', { name: 'Add default block' } ) + .click(); + await page.keyboard.type( 'Paragraph' ); + + // Save. + await editor.saveDraft(); + + // Verify that the title is empty. + await expect( + editor.canvas.getByRole( 'textbox', { name: 'Add title' } ) + ).toHaveText( '' ); + + // Verify that the post is not dirty. + expect( await changeDetectionUtils.getIsDirty() ).toBe( false ); + } ); + + test( 'should not prompt to confirm unsaved changes when trashing an existing post', async ( { + page, + editor, + } ) => { + // Enter title. + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( 'Hello World' ); + + // Save. + await editor.saveDraft(); + + // Trash post. + await editor.openDocumentSettingsSidebar(); + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Move to trash' } ) + .click(); + await page + .getByRole( 'dialog' ) + .getByRole( 'button', { name: 'OK' } ) + .click(); + + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'saved' } ) + ).toBeDisabled(); + + await expect( page ).toHaveURL( '/wp-admin/edit.php?post_type=post' ); + } ); + + test( 'consecutive edits to the same attribute should mark the post as dirty after a save', async ( { + page, + editor, + pageUtils, + } ) => { + // Open the sidebar block settings. + await editor.openDocumentSettingsSidebar(); + + // Insert a paragraph. + await editor.canvas + .getByRole( 'button', { name: 'Add default block' } ) + .click(); + await page.keyboard.type( 'Hello, World!' ); + + // Save and wait till the post is clean. + await Promise.all( [ + expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'saved' } ) + ).toBeDisabled(), + pageUtils.pressKeys( 'primary+s' ), + ] ); + + // Change the paragraph's `drop cap`. + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Typography options' } ) + .click(); + await page + .getByRole( 'menu', { name: 'Typography options' } ) + .getByRole( 'menuitemcheckbox', { name: 'Show drop cap' } ) + .click(); + + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'checkbox', { name: 'Drop cap' } ) + .setChecked( true ); + + // Check that the post is dirty. + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save draft' } ) + ).toBeEnabled(); + + // Save and wait till the post is clean. + await Promise.all( [ + expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'saved' } ) + ).toBeDisabled(), + pageUtils.pressKeys( 'primary+s' ), + ] ); + + // Change the paragraph's `drop cap` again. + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'checkbox', { name: 'Drop cap' } ) + .setChecked( false ); + + // Check that the post is dirty. + await expect( + page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: 'Save draft' } ) + ).toBeEnabled(); + } ); +} ); + +class ChangeDetectionUtils { + /** @type {Page} */ + #page; + /** @type {(() => void) | null} */ + #continueInterceptedSave = null; + + constructor( { page } ) { + this.#page = page; + } + + /** + * @return {Promise} Whether the post is dirty. + */ + getIsDirty = async () => { + return await test.step( + 'assert the post is dirty', + async () => { + const hasDialog = new Promise( ( resolve ) => { + this.#page.on( 'dialog', ( dialog ) => { + void dialog.accept(); + resolve( dialog.type() === 'beforeunload' ); + } ); + this.#page.on( 'load', () => resolve( false ) ); + } ); + await this.#page.reload( { waitUntil: 'commit' } ); // No need to wait for the full load. + return hasDialog; + }, + { box: true } + ); + }; + + interceptSave = async () => { + let hadInterceptedSave = false; + + const deferred = new Promise( ( res ) => { + this.#continueInterceptedSave = res; + } ); + + await this.#page.route( + ( url ) => + POST_URLS.some( ( postUrl ) => url.href.includes( postUrl ) ), + async ( route ) => { + hadInterceptedSave = true; + await deferred; + await route.continue(); + } + ); + + return () => hadInterceptedSave; + }; + + releaseSaveIntercept = async () => { + this.#continueInterceptedSave?.(); + await this.#page.unroute( ( url ) => + POST_URLS.some( ( postUrl ) => url.href.includes( postUrl ) ) + ); + this.#continueInterceptedSave = null; + }; +} From 1a63093959896e8db659838719f9e568beeecf63 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 13 Feb 2024 16:40:14 +1100 Subject: [PATCH 15/15] Style theme variations: add property extraction and merge utils (#58803) * First commit Pulling out the utils from https://github.com/WordPress/gutenberg/pull/56622 so we test separately * Adding failing tests :) * Adds working tests Removes extra functions - now internal to the hook * Adds more tests * Adds filterObjectByProperty tests * Update packages/edit-site/src/hooks/use-theme-style-variations/use-theme-style-variations-by-property.js Co-authored-by: Andrew Serong <14988353+andrewserong@users.noreply.github.com> * Merge map callbacks * More tests, adding a `null` check too * Update comment to reflect functionality * Update test to ensure we don't reference original object --------- Co-authored-by: ramonjd Co-authored-by: andrewserong Co-authored-by: scruffian --- .../push-changes-to-global-styles/index.js | 5 +- .../use-theme-style-variations-by-property.js | 964 ++++++++++++++++++ .../use-theme-style-variations-by-property.js | 92 ++ packages/edit-site/src/utils/clone-deep.js | 8 + 4 files changed, 1065 insertions(+), 4 deletions(-) create mode 100644 packages/edit-site/src/hooks/use-theme-style-variations/test/use-theme-style-variations-by-property.js create mode 100644 packages/edit-site/src/hooks/use-theme-style-variations/use-theme-style-variations-by-property.js create mode 100644 packages/edit-site/src/utils/clone-deep.js diff --git a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js index 4844ec6d03eb5..21f19201b7510 100644 --- a/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js +++ b/packages/edit-site/src/hooks/push-changes-to-global-styles/index.js @@ -26,6 +26,7 @@ import { store as coreStore } from '@wordpress/core-data'; */ import { useSupportedStyles } from '../../components/global-styles/hooks'; import { unlock } from '../../lock-unlock'; +import cloneDeep from '../../utils/clone-deep'; const { cleanEmptyObject, GlobalStylesContext } = unlock( blockEditorPrivateApis @@ -275,10 +276,6 @@ function setNestedValue( object, path, value ) { return object; } -function cloneDeep( object ) { - return ! object ? {} : JSON.parse( JSON.stringify( object ) ); -} - function PushChangesToGlobalStylesControl( { name, attributes, diff --git a/packages/edit-site/src/hooks/use-theme-style-variations/test/use-theme-style-variations-by-property.js b/packages/edit-site/src/hooks/use-theme-style-variations/test/use-theme-style-variations-by-property.js new file mode 100644 index 0000000000000..ced849f8fb121 --- /dev/null +++ b/packages/edit-site/src/hooks/use-theme-style-variations/test/use-theme-style-variations-by-property.js @@ -0,0 +1,964 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import useThemeStyleVariationsByProperty, { + filterObjectByProperty, +} from '../use-theme-style-variations-by-property'; + +describe( 'filterObjectByProperty', () => { + const noop = () => {}; + test.each( [ + { + object: { + foo: 'bar', + array: [ 1, 3, 4 ], + }, + property: 'array', + expected: { array: [ 1, 3, 4 ] }, + }, + { + object: { + foo: 'bar', + }, + property: 'does-not-exist', + expected: {}, + }, + { + object: { + foo: 'bar', + }, + property: false, + expected: {}, + }, + { + object: { + dig: { + deeper: { + null: null, + }, + }, + }, + property: 'null', + expected: { + dig: { + deeper: { + null: null, + }, + }, + }, + }, + { + object: { + function: noop, + }, + property: 'function', + expected: { + function: noop, + }, + }, + { + object: [], + property: 'something', + expected: {}, + }, + { + object: {}, + property: undefined, + expected: {}, + }, + { + object: { + 'nested-object': { + 'nested-object-foo': 'bar', + array: [ 1, 3, 4 ], + }, + }, + property: 'nested-object-foo', + expected: { + 'nested-object': { + 'nested-object-foo': 'bar', + }, + }, + }, + ] )( + 'should filter object by $property', + ( { expected, object, property } ) => { + const result = filterObjectByProperty( object, property ); + expect( result ).toEqual( expected ); + } + ); +} ); + +describe( 'useThemeStyleVariationsByProperty', () => { + const mockVariations = [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + color: { + duotone: [ + { + name: 'Dark grayscale', + colors: [ '#000000', '#7f7f7f' ], + slug: 'dark-grayscale', + }, + { + name: 'Grayscale', + colors: [ '#000000', '#ffffff' ], + slug: 'grayscale', + }, + { + name: 'Purple and yellow', + colors: [ '#8c00b7', '#fcff41' ], + slug: 'purple-yellow', + }, + ], + gradients: [ + { + name: 'Vivid cyan blue to vivid purple', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'vivid-cyan-blue-to-vivid-purple', + }, + { + name: 'Light green cyan to vivid green cyan', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'light-green-cyan-to-vivid-green-cyan', + }, + { + name: 'Luminous vivid amber to luminous vivid orange', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'luminous-vivid-amber-to-luminous-vivid-orange', + }, + ], + palette: [ + { + name: 'Vivid red', + slug: 'vivid-red', + color: '#cf2e2e', + }, + { + name: 'Luminous vivid orange', + slug: 'luminous-vivid-orange', + color: '#ff6900', + }, + { + name: 'Luminous vivid amber', + slug: 'luminous-vivid-amber', + color: '#fcb900', + }, + ], + }, + typography: { + fluid: true, + fontFamilies: { + theme: [ + { + name: 'Inter san-serif', + fontFamily: 'Inter san-serif', + slug: 'inter-san-serif', + fontFace: [ + { + src: 'inter-san-serif.woff2', + fontWeight: '400', + fontStyle: 'italic', + fontFamily: 'Inter san-serif', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: '13px', + }, + { + name: 'Medium', + slug: 'medium', + size: '20px', + }, + { + name: 'Large', + slug: 'large', + size: '36px', + }, + ], + }, + layout: { + wideSize: '1200px', + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + color: { + backgroundColor: 'red', + color: 'orange', + }, + elements: { + cite: { + color: { + text: 'white', + }, + }, + }, + blocks: { + 'core/quote': { + color: { + text: 'black', + background: 'white', + }, + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + { + title: 'Title 2', + description: 'Description 2', + settings: { + color: { + duotone: [ + { + name: 'Boom', + colors: [ '#000000', '#7f7f7f' ], + slug: 'boom', + }, + { + name: 'Gray to white', + colors: [ '#000000', '#ffffff' ], + slug: 'gray-to-white', + }, + { + name: 'Whatever to whatever', + colors: [ '#8c00b7', '#fcff41' ], + slug: 'whatever-to-whatever', + }, + ], + gradients: [ + { + name: 'Jam in the office', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'jam-in-the-office', + }, + { + name: 'Open source', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'open-source', + }, + { + name: 'Here to there', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'here-to-there', + }, + ], + palette: [ + { + name: 'Chunky Bacon', + slug: 'chunky-bacon', + color: '#cf2e2e', + }, + { + name: 'Burrito', + slug: 'burrito', + color: '#ff6900', + }, + { + name: 'Dinosaur', + slug: 'dinosaur', + color: '#fcb900', + }, + ], + }, + typography: { + fontSizes: [ + { + name: 'Smallish', + slug: 'smallish', + size: '15px', + }, + { + name: 'Mediumish', + slug: 'mediumish', + size: '22px', + }, + { + name: 'Largish', + slug: 'largish', + size: '44px', + }, + ], + }, + layout: { + contentSize: '300px', + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + color: { + backgroundColor: 'red', + text: 'orange', + }, + elements: { + link: { + typography: { + textDecoration: 'underline', + }, + }, + }, + blocks: { + 'core/paragraph': { + color: { + text: 'purple', + background: 'green', + }, + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + ]; + const mockBaseVariation = { + settings: { + typography: { + fontFamilies: { + custom: [ + { + name: 'ADLaM Display', + fontFamily: 'ADLaM Display, system-ui', + slug: 'adlam-display', + fontFace: [ + { + src: 'adlam.woff2', + fontWeight: '400', + fontStyle: 'normal', + fontFamily: 'ADLaM Display', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Base small', + slug: 'base-small', + size: '1px', + }, + { + name: 'Base medium', + slug: 'base-medium', + size: '2px', + }, + { + name: 'Base large', + slug: 'base-large', + size: '3px', + }, + ], + }, + color: { + palette: { + custom: [ + { + color: '#c42727', + name: 'Color 1', + slug: 'custom-color-1', + }, + { + color: '#3b0f0f', + name: 'Color 2', + slug: 'custom-color-2', + }, + ], + }, + }, + layout: { + wideSize: '1137px', + contentSize: '400px', + }, + }, + styles: { + typography: { + fontSize: '12px', + lineHeight: '1.5', + }, + color: { + backgroundColor: 'cheese', + color: 'lettuce', + }, + blocks: { + 'core/quote': { + color: { + text: 'hello', + background: 'dolly', + }, + typography: { + fontSize: '111111px', + }, + }, + 'core/group': { + typography: { + fontFamily: 'var:preset|font-family|system-sans-serif', + }, + }, + }, + }, + }; + + it( 'should return variations if property is falsy', () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: mockVariations, + property: '', + } ) + ); + + expect( result.current ).toEqual( mockVariations ); + } ); + + it( 'should return variations if variations is empty or falsy', () => { + const { result: emptyResult } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: [], + property: 'layout', + } ) + ); + + expect( emptyResult.current ).toEqual( [] ); + + const { result: falsyResult } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: null, + property: 'layout', + } ) + ); + + expect( falsyResult.current ).toEqual( null ); + } ); + + it( 'should return new, unreferenced object', () => { + const variations = [ + { + title: 'hey', + description: 'ho', + joe: { + where: { + you: 'going with that unit test in your hand', + }, + }, + }, + ]; + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations, + property: 'where', + } ) + ); + + expect( result.current ).toEqual( [ + { + title: 'hey', + description: 'ho', + joe: { + where: { + you: 'going with that unit test in your hand', + }, + }, + }, + ] ); + + expect( result.current[ 0 ].joe.where ).not.toBe( + variations[ 0 ].joe.where + ); + expect( result.current[ 0 ].joe ).not.toBe( variations[ 0 ].joe ); + } ); + + it( "should return the variation's typography properties", () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: mockVariations, + property: 'typography', + } ) + ); + + expect( result.current ).toEqual( [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + typography: { + fluid: true, + fontFamilies: { + theme: [ + { + name: 'Inter san-serif', + fontFamily: 'Inter san-serif', + slug: 'inter-san-serif', + fontFace: [ + { + src: 'inter-san-serif.woff2', + fontWeight: '400', + fontStyle: 'italic', + fontFamily: 'Inter san-serif', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: '13px', + }, + { + name: 'Medium', + slug: 'medium', + size: '20px', + }, + { + name: 'Large', + slug: 'large', + size: '36px', + }, + ], + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + blocks: { + 'core/quote': { + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + { + title: 'Title 2', + description: 'Description 2', + settings: { + typography: { + fontSizes: [ + { + name: 'Smallish', + slug: 'smallish', + size: '15px', + }, + { + name: 'Mediumish', + slug: 'mediumish', + size: '22px', + }, + { + name: 'Largish', + slug: 'largish', + size: '44px', + }, + ], + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + elements: { + link: { + typography: { + textDecoration: 'underline', + }, + }, + }, + blocks: { + 'core/paragraph': { + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + ] ); + } ); + + it( "should return the variation's color properties", () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: mockVariations, + property: 'color', + } ) + ); + + expect( result.current ).toEqual( [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + color: { + duotone: [ + { + name: 'Dark grayscale', + colors: [ '#000000', '#7f7f7f' ], + slug: 'dark-grayscale', + }, + { + name: 'Grayscale', + colors: [ '#000000', '#ffffff' ], + slug: 'grayscale', + }, + { + name: 'Purple and yellow', + colors: [ '#8c00b7', '#fcff41' ], + slug: 'purple-yellow', + }, + ], + gradients: [ + { + name: 'Vivid cyan blue to vivid purple', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'vivid-cyan-blue-to-vivid-purple', + }, + { + name: 'Light green cyan to vivid green cyan', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'light-green-cyan-to-vivid-green-cyan', + }, + { + name: 'Luminous vivid amber to luminous vivid orange', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'luminous-vivid-amber-to-luminous-vivid-orange', + }, + ], + palette: [ + { + name: 'Vivid red', + slug: 'vivid-red', + color: '#cf2e2e', + }, + { + name: 'Luminous vivid orange', + slug: 'luminous-vivid-orange', + color: '#ff6900', + }, + { + name: 'Luminous vivid amber', + slug: 'luminous-vivid-amber', + color: '#fcb900', + }, + ], + }, + }, + styles: { + color: { + backgroundColor: 'red', + color: 'orange', + }, + elements: { + cite: { + color: { + text: 'white', + }, + }, + }, + blocks: { + 'core/quote': { + color: { + text: 'black', + background: 'white', + }, + }, + }, + }, + }, + { + title: 'Title 2', + description: 'Description 2', + settings: { + color: { + duotone: [ + { + name: 'Boom', + colors: [ '#000000', '#7f7f7f' ], + slug: 'boom', + }, + { + name: 'Gray to white', + colors: [ '#000000', '#ffffff' ], + slug: 'gray-to-white', + }, + { + name: 'Whatever to whatever', + colors: [ '#8c00b7', '#fcff41' ], + slug: 'whatever-to-whatever', + }, + ], + gradients: [ + { + name: 'Jam in the office', + gradient: + 'linear-gradient(135deg,rgba(6,147,227,1) 0%,rgb(155,81,224) 100%)', + slug: 'jam-in-the-office', + }, + { + name: 'Open source', + gradient: + 'linear-gradient(135deg,rgb(122,220,180) 0%,rgb(0,208,130) 100%)', + slug: 'open-source', + }, + { + name: 'Here to there', + gradient: + 'linear-gradient(135deg,rgba(252,185,0,1) 0%,rgba(255,105,0,1) 100%)', + slug: 'here-to-there', + }, + ], + palette: [ + { + name: 'Chunky Bacon', + slug: 'chunky-bacon', + color: '#cf2e2e', + }, + { + name: 'Burrito', + slug: 'burrito', + color: '#ff6900', + }, + { + name: 'Dinosaur', + slug: 'dinosaur', + color: '#fcb900', + }, + ], + }, + }, + styles: { + color: { + backgroundColor: 'red', + text: 'orange', + }, + blocks: { + 'core/paragraph': { + color: { + text: 'purple', + background: 'green', + }, + }, + }, + }, + }, + ] ); + } ); + + it( 'should merge the user styles and settings with the supplied variation, but only for the specified property', () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: [ mockVariations[ 0 ] ], + property: 'typography', + baseVariation: mockBaseVariation, + } ) + ); + + expect( result.current ).toEqual( [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + typography: { + fluid: true, + fontFamilies: { + theme: [ + { + name: 'Inter san-serif', + fontFamily: 'Inter san-serif', + slug: 'inter-san-serif', + fontFace: [ + { + src: 'inter-san-serif.woff2', + fontWeight: '400', + fontStyle: 'italic', + fontFamily: 'Inter san-serif', + }, + ], + }, + ], + custom: [ + { + name: 'ADLaM Display', + fontFamily: 'ADLaM Display, system-ui', + slug: 'adlam-display', + fontFace: [ + { + src: 'adlam.woff2', + fontWeight: '400', + fontStyle: 'normal', + fontFamily: 'ADLaM Display', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: '13px', + }, + { + name: 'Medium', + slug: 'medium', + size: '20px', + }, + { + name: 'Large', + slug: 'large', + size: '36px', + }, + ], + }, + color: { + palette: { + custom: [ + { + color: '#c42727', + name: 'Color 1', + slug: 'custom-color-1', + }, + { + color: '#3b0f0f', + name: 'Color 2', + slug: 'custom-color-2', + }, + ], + }, + }, + layout: { + wideSize: '1137px', + contentSize: '400px', + }, + }, + styles: { + color: { + backgroundColor: 'cheese', + color: 'lettuce', + }, + typography: { + fontSize: '12px', + letterSpacing: '3px', + lineHeight: '1.5', + }, + blocks: { + 'core/quote': { + color: { + text: 'hello', + background: 'dolly', + }, + typography: { + fontSize: '20px', + }, + }, + 'core/group': { + typography: { + fontFamily: + 'var:preset|font-family|system-sans-serif', + }, + }, + }, + }, + }, + ] ); + } ); + + it( 'should filter the output and return only variations that match filter', () => { + const { result } = renderHook( () => + useThemeStyleVariationsByProperty( { + variations: mockVariations, + property: 'typography', + filter: ( variation ) => + !! variation?.settings?.typography?.fontFamilies?.theme + ?.length, + } ) + ); + expect( result.current ).toEqual( [ + { + title: 'Title 1', + description: 'Description 1', + settings: { + typography: { + fluid: true, + fontFamilies: { + theme: [ + { + name: 'Inter san-serif', + fontFamily: 'Inter san-serif', + slug: 'inter-san-serif', + fontFace: [ + { + src: 'inter-san-serif.woff2', + fontWeight: '400', + fontStyle: 'italic', + fontFamily: 'Inter san-serif', + }, + ], + }, + ], + }, + fontSizes: [ + { + name: 'Small', + slug: 'small', + size: '13px', + }, + { + name: 'Medium', + slug: 'medium', + size: '20px', + }, + { + name: 'Large', + slug: 'large', + size: '36px', + }, + ], + }, + }, + styles: { + typography: { + letterSpacing: '3px', + }, + blocks: { + 'core/quote': { + typography: { + fontSize: '20px', + }, + }, + }, + }, + }, + ] ); + } ); +} ); diff --git a/packages/edit-site/src/hooks/use-theme-style-variations/use-theme-style-variations-by-property.js b/packages/edit-site/src/hooks/use-theme-style-variations/use-theme-style-variations-by-property.js new file mode 100644 index 0000000000000..b9c1b40ec7c1d --- /dev/null +++ b/packages/edit-site/src/hooks/use-theme-style-variations/use-theme-style-variations-by-property.js @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { mergeBaseAndUserConfigs } from '../../components/global-styles/global-styles-provider'; +import cloneDeep from '../../utils/clone-deep'; + +/** + * Returns a new object, with properties specified in `property`, + * maintain the original object tree structure. + * The function is recursive, so it will perform a deep search for the given property. + * E.g., the function will return `{ a: { b: { c: { test: 1 } } } }` if the property is `test`. + * + * @param {Object} object The object to filter + * @param {Object} property The property to filter by + * @return {Object} The merged object. + */ +export const filterObjectByProperty = ( object, property ) => { + if ( ! object ) { + return {}; + } + + const newObject = {}; + Object.keys( object ).forEach( ( key ) => { + if ( key === property ) { + newObject[ key ] = object[ key ]; + } else if ( typeof object[ key ] === 'object' ) { + const newFilter = filterObjectByProperty( object[ key ], property ); + if ( Object.keys( newFilter ).length ) { + newObject[ key ] = newFilter; + } + } + } ); + return newObject; +}; + +/** + * Returns a new object with only the properties specified in `property`. + * + * @param {Object} props Object of hook args. + * @param {Object[]} props.variations The theme style variations to filter. + * @param {string} props.property The property to filter by. + * @param {Function} props.filter Optional. The filter function to apply to the variations. + * @param {Object} props.baseVariation Optional. Base or user settings to be updated with variation properties. + * @return {Object[]|*} The merged object. + */ +export default function useThemeStyleVariationsByProperty( { + variations, + property, + filter, + baseVariation, +} ) { + return useMemo( () => { + if ( ! property || ! variations || variations?.length === 0 ) { + return variations; + } + + const clonedBaseVariation = + typeof baseVariation === 'object' && + Object.keys( baseVariation ).length > 0 + ? cloneDeep( baseVariation ) + : null; + + let processedStyleVariations = variations.map( ( variation ) => { + let result = { + ...filterObjectByProperty( cloneDeep( variation ), property ), + title: variation?.title, + description: variation?.description, + }; + + if ( clonedBaseVariation ) { + /* + * Overwrites all baseVariation object `styleProperty` properties + * with the theme variation `styleProperty` properties. + */ + result = mergeBaseAndUserConfigs( clonedBaseVariation, result ); + } + return result; + } ); + + if ( 'function' === typeof filter ) { + processedStyleVariations = + processedStyleVariations.filter( filter ); + } + + return processedStyleVariations; + }, [ variations, property, baseVariation, filter ] ); +} diff --git a/packages/edit-site/src/utils/clone-deep.js b/packages/edit-site/src/utils/clone-deep.js new file mode 100644 index 0000000000000..149e1df2408ea --- /dev/null +++ b/packages/edit-site/src/utils/clone-deep.js @@ -0,0 +1,8 @@ +/** + * Makes a copy of an object without storing any references to the original object. + * @param {Object} object + * @return {Object} The cloned object. + */ +export default function cloneDeep( object ) { + return ! object ? {} : JSON.parse( JSON.stringify( object ) ); +}