From a5dd6626f453de6c7bfa37cbc213543fee3d4961 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 21 Jun 2023 20:13:52 +1000 Subject: [PATCH] Site Editor: Add Library for Template Parts & Patterns Management (#51078) * Add placeholder Library page * Add placeholder Library nav screen * Use placeholder library page and nav screen * Add dodgy placeholder display for patterns or template parts * Temporarily add category type and name to query params * Try displaying patterns and template parts * Highlight active category nav item * Add placeholder dropdown for deleting patterns * Try making pattern previews clickable * Add placeholder create pattern modal and action * Add category select to create pattern modal * Remove the library url params when existing library * Fix pattern styles not loading on fresh reload of Library page (#51114) * Hook up template part preview to existing edit page This also adds a temporary hack to the sidebar-navigation-screen so the back button from the existing template part editing will return to the previously selected category in the library. * Fix template part pattern display in library * Try retrieving user created patterns from wp_block CPT * Update now user created patterns have category arrays * Update user created patterns based of new meta wp_block property * Remove new template part screen as it was covered by pattern screen * Allow user created patterns to be clicked on * Fix typos * Fix classnames * Hook up reusable block editing * Try adding delete for user patterns * Add snackbar notices for user-created pattern deletion * Hook up pattern creation modal * Make new patterns return to correct category * Try updating the Library to use pattern category taxonomy * Fix theme pattern display in Grid * Fix selected pattern category detection * Use invalidateResolution to clear taxonomy cache Not sure if this will be better than manual counts as we'll likely still need to get userPatterns back to collect an Uncategorized group. * Include uncategorized reusable block patterns in Library * Make pattern category selection option and add sync toggle * Separate display of synced/unsynced patterns * Add placeholder search control. * Update to use new Page components added along with Table * Fix private apis imports * style changes to library * copy change * svg colour * add template part and pattern dialog * fix template part modal close * Try filtering of patterns in library * search input patterns style * responsive pattern grid * Use only default block pattern categories The removal of the pattern category taxonomy changes will be done once that related PR has been updated. * Fix Library routing in mobile (#51558) * Remove obselete TODO comments * Use extracted useDebouncedInput hook * Fix clicking browser's back button in edit mode from the Library (#51566) * Rename Your Patterns to Custom patterns * Remove arrow functions for internal Grid components * Fix linting error * Add dependencies to package-lock.json * Update core pattern filtering * Consolidate and reuse constants * Clean up comments and unused styles * Rename manage all link for custom patterns * Remove `wp_block` property from post meta for patterns * Update conversion of reusable block to pattern after sync_status moved * Rename setReusableBlock to setEditedEntity * Improve wording * Add aria description for pattern action menu * Refactor filtering out duplicate patterns by name * Add focus style for patterns * Add empty pattern placeholder * Fix tests and converter flow * Tweak focused placeholder styles * Make the action menu unfocusable and use a keyboard shortcut for deletion * Add confirm step for deleting * Add a more descriptive aria-description * Ensure pattern lists are announced with the correct number of items * Improve labelling of pattern lists --------- Co-authored-by: Glen Davies Co-authored-by: Daniel Richards Co-authored-by: Saxon Fletcher Co-authored-by: Kai Hao Co-authored-by: Kai Hao --- .../data/data-core-edit-site.md | 13 + package-lock.json | 32 +-- packages/edit-site/package.json | 4 +- .../src/components/add-new-pattern/index.js | 94 +++++++ .../add-custom-template-modal-content.js | 14 +- .../src/components/add-new-template/index.js | 3 - .../add-new-template/new-template-part.js | 109 -------- .../components/create-pattern-modal/index.js | 134 ++++++++++ .../create-pattern-modal/style.scss | 3 + .../create-template-part-modal/index.js | 66 ++++- .../edit-site/src/components/editor/index.js | 17 +- .../edit-site/src/components/layout/index.js | 27 +- .../src/components/page-library/grid-item.js | 179 +++++++++++++ .../src/components/page-library/grid.js | 39 +++ .../src/components/page-library/index.js | 110 ++------ .../components/page-library/no-patterns.js | 12 + .../components/page-library/patterns-list.js | 113 +++++++++ .../components/page-library/search-items.js | 171 +++++++++++++ .../src/components/page-library/style.scss | 105 ++++++++ .../page-library/use-library-settings.js | 51 ++++ .../components/page-library/use-patterns.js | 238 ++++++++++++++++++ .../src/components/page-library/utils.js | 21 ++ .../src/components/page-main/index.js | 2 +- .../edit-site/src/components/page/index.js | 18 +- .../sidebar-navigation-item/index.js | 3 + .../sidebar-navigation-item/style.scss | 4 + .../category-item.js | 43 ++++ .../index.js | 136 ++++++++++ .../style.scss | 7 + .../use-default-pattern-categories.js | 32 +++ .../use-pattern-categories.js | 64 +++++ .../use-template-part-areas.js | 33 +++ .../use-theme-patterns.js | 44 ++++ .../sidebar-navigation-screen-main/index.js | 2 +- .../index.js | 116 +++++++++ .../index.js | 167 ------------ ...template-part-navigation-menu-list-item.js | 28 --- .../template-part-navigation-menu-list.js | 21 -- .../template-part-navigation-menu.js | 30 --- .../template-part-navigation-menus.js | 33 --- .../sidebar-navigation-screen/index.js | 24 +- .../edit-site/src/components/sidebar/index.js | 16 +- .../use-init-edited-entity-from-url.js | 13 +- .../use-sync-path-with-url.js | 10 + .../convert-to-template-part.js | 29 +-- packages/edit-site/src/store/actions.js | 16 ++ packages/edit-site/src/style.scss | 3 + .../edit-site/src/utils/get-is-list-page.js | 20 +- .../src/utils/use-debounced-input.js | 17 ++ .../site-editor-url-navigation.spec.js | 6 +- .../site-editor/template-parts-mode.spec.js | 51 ---- 51 files changed, 1914 insertions(+), 629 deletions(-) create mode 100644 packages/edit-site/src/components/add-new-pattern/index.js delete mode 100644 packages/edit-site/src/components/add-new-template/new-template-part.js create mode 100644 packages/edit-site/src/components/create-pattern-modal/index.js create mode 100644 packages/edit-site/src/components/create-pattern-modal/style.scss create mode 100644 packages/edit-site/src/components/page-library/grid-item.js create mode 100644 packages/edit-site/src/components/page-library/grid.js create mode 100644 packages/edit-site/src/components/page-library/no-patterns.js create mode 100644 packages/edit-site/src/components/page-library/patterns-list.js create mode 100644 packages/edit-site/src/components/page-library/search-items.js create mode 100644 packages/edit-site/src/components/page-library/style.scss create mode 100644 packages/edit-site/src/components/page-library/use-library-settings.js create mode 100644 packages/edit-site/src/components/page-library/use-patterns.js create mode 100644 packages/edit-site/src/components/page-library/utils.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-library/category-item.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-library/index.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-library/style.scss create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-library/use-default-pattern-categories.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-library/use-pattern-categories.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-library/use-template-part-areas.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-library/use-theme-patterns.js create mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-pattern/index.js delete mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-template-part/index.js delete mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu-list-item.js delete mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu-list.js delete mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu.js delete mode 100644 packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menus.js create mode 100644 packages/edit-site/src/utils/use-debounced-input.js delete mode 100644 test/e2e/specs/site-editor/template-parts-mode.spec.js diff --git a/docs/reference-guides/data/data-core-edit-site.md b/docs/reference-guides/data/data-core-edit-site.md index a2610c2647c65..83e9b09dfd7d8 100644 --- a/docs/reference-guides/data/data-core-edit-site.md +++ b/docs/reference-guides/data/data-core-edit-site.md @@ -268,6 +268,19 @@ _Parameters_ - _options_ `[Object]`: - _options.allowUndo_ `[boolean]`: Whether to allow the user to undo reverting the template. Default true. +### setEditedEntity + +Action that sets an edited entity. + +_Parameters_ + +- _postType_ `string`: The entity's post type. +- _postId_ `string`: The entity's ID. + +_Returns_ + +- `Object`: Action object. + ### setEditedPostContext Set's the current block editor context. diff --git a/package-lock.json b/package-lock.json index dde0ee8a66967..52fb775cbe3f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17808,6 +17808,7 @@ "@wordpress/viewport": "file:packages/viewport", "@wordpress/widgets": "file:packages/widgets", "@wordpress/wordcount": "file:packages/wordcount", + "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.9.2", "deepmerge": "^4.3.0", @@ -17817,7 +17818,8 @@ "lodash": "^4.17.21", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", - "rememo": "^4.0.2" + "rememo": "^4.0.2", + "remove-accents": "^0.4.2" } }, "@wordpress/edit-widgets": { @@ -25902,7 +25904,7 @@ "array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "integrity": "sha1-nlKHYrSpBmrRY6aWKjZEGOlibs4=", "dev": true }, "array-includes": { @@ -30865,7 +30867,7 @@ "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, "cssesc": { @@ -31111,7 +31113,7 @@ "debuglog": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz", - "integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==", + "integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=", "dev": true }, "decache": { @@ -35952,7 +35954,7 @@ "git-remote-origin-url": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/git-remote-origin-url/-/git-remote-origin-url-2.0.0.tgz", - "integrity": "sha512-eU+GGrZgccNJcsDH5LkXR3PB9M958hxc7sbA8DFJjrv9j4L2P/eZfKhM+QD6wyzpiv+b1BpK0XrYCxkovtjSLw==", + "integrity": "sha1-UoJlna4hBxRaERJhEq0yFuxfpl8=", "dev": true, "requires": { "gitconfiglocal": "^1.0.0", @@ -35999,7 +36001,7 @@ "gitconfiglocal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz", - "integrity": "sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==", + "integrity": "sha1-QdBF84UaXqiPA/JMocYXgRRGS5s=", "dev": true, "requires": { "ini": "^1.3.2" @@ -37247,7 +37249,7 @@ "humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "integrity": "sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=", "dev": true, "requires": { "ms": "^2.0.0" @@ -38263,7 +38265,7 @@ "is-text-path": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-1.0.1.tgz", - "integrity": "sha512-xFuJpne9oFz5qDaodwmmG08e3CawH/2ZV8Qqza1Ko7Sk8POWbkRdwIoAWVhqvq0XeUzANEhKo2n0IXUGBm7A/w==", + "integrity": "sha1-Thqg+1G/vLPpJogAE5cgLBd1tm4=", "dev": true, "requires": { "text-extensions": "^1.0.0" @@ -40036,7 +40038,7 @@ "jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=", "dev": true }, "jsprim": { @@ -41142,7 +41144,7 @@ "lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", - "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==", + "integrity": "sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=", "dev": true }, "lodash.isplainobject": { @@ -41422,7 +41424,7 @@ "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", - "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", + "integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==", "dev": true }, "macos-release": { @@ -48645,7 +48647,7 @@ "promzard": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/promzard/-/promzard-0.3.0.tgz", - "integrity": "sha512-JZeYqd7UAcHCwI+sTOeUDYkvEU+1bQ7iE0UT1MgB/tERkAPkesW46MrpIySzODi+owTjZtiF8Ay5j9m60KmMBw==", + "integrity": "sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=", "dev": true, "requires": { "read": "1" @@ -48679,7 +48681,7 @@ "proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=", "dev": true }, "protocols": { @@ -50296,7 +50298,7 @@ "read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "integrity": "sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=", "dev": true, "requires": { "mute-stream": "~0.0.4" @@ -55686,7 +55688,7 @@ "temp-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==", + "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=", "dev": true }, "terminal-link": { diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 381f29a82e95f..ab9b8229634ba 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -64,6 +64,7 @@ "@wordpress/viewport": "file:../viewport", "@wordpress/widgets": "file:../widgets", "@wordpress/wordcount": "file:../wordcount", + "change-case": "^4.1.2", "classnames": "^2.3.1", "colord": "^2.9.2", "deepmerge": "^4.3.0", @@ -73,7 +74,8 @@ "lodash": "^4.17.21", "memize": "^2.1.0", "react-autosize-textarea": "^7.1.0", - "rememo": "^4.0.2" + "rememo": "^4.0.2", + "remove-accents": "^0.4.2" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/edit-site/src/components/add-new-pattern/index.js b/packages/edit-site/src/components/add-new-pattern/index.js new file mode 100644 index 0000000000000..54b6f48488aeb --- /dev/null +++ b/packages/edit-site/src/components/add-new-pattern/index.js @@ -0,0 +1,94 @@ +/** + * WordPress dependencies + */ +import { DropdownMenu } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { plus, header, file } from '@wordpress/icons'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; + +/** + * Internal dependencies + */ +import CreatePatternModal from '../create-pattern-modal'; +import CreateTemplatePartModal from '../create-template-part-modal'; +import { unlock } from '../../lock-unlock'; +import SidebarButton from '../sidebar-button'; + +const { useHistory } = unlock( routerPrivateApis ); + +export default function AddNewPattern() { + const history = useHistory(); + const [ showPatternModal, setShowPatternModal ] = useState( false ); + const [ showTemplatePartModal, setShowTemplatePartModal ] = + useState( false ); + + function handleCreatePattern( { pattern, categoryId } ) { + setShowPatternModal( false ); + + history.push( { + postId: pattern.id, + postType: 'wp_block', + categoryType: 'wp_block', + categoryId, + canvas: 'edit', + } ); + } + + function handleCreateTemplatePart( templatePart ) { + setShowTemplatePartModal( false ); + + // Navigate to the created template part editor. + history.push( { + postId: templatePart.id, + postType: 'wp_template_part', + canvas: 'edit', + } ); + } + + function handleError() { + setShowPatternModal( false ); + setShowTemplatePartModal( false ); + } + + return ( + <> + setShowTemplatePartModal( true ), + title: 'Create a template part', + }, + { + icon: file, + onClick: () => setShowPatternModal( true ), + title: 'Create a pattern', + }, + ] } + icon={ + + } + label="Create a pattern." + /> + { showPatternModal && ( + setShowPatternModal( false ) } + onCreate={ handleCreatePattern } + onError={ handleError } + /> + ) } + { showTemplatePartModal && ( + setShowTemplatePartModal( false ) } + blocks={ [] } + onCreate={ handleCreateTemplatePart } + onError={ handleError } + /> + ) } + + ); +} diff --git a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js index 5636ec16e1ac1..80a20939dec25 100644 --- a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js +++ b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js @@ -15,13 +15,13 @@ import { __unstableUseCompositeState as useCompositeState, __unstableCompositeItem as CompositeItem, } from '@wordpress/components'; -import { useDebounce } from '@wordpress/compose'; import { useEntityRecords } from '@wordpress/core-data'; import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies */ +import useDebouncedInput from '../../utils/use-debounced-input'; import { mapToIHasNameAndId } from './utils'; const EMPTY_ARRAY = []; @@ -73,18 +73,6 @@ function SuggestionListItem( { ); } -function useDebouncedInput() { - const [ input, setInput ] = useState( '' ); - const [ debounced, setter ] = useState( '' ); - const setDebounced = useDebounce( setter, 250 ); - useEffect( () => { - if ( debounced !== input ) { - setDebounced( input ); - } - }, [ debounced, input ] ); - return [ input, setInput, debounced ]; -} - function useSearchSuggestions( entityForSuggestions, search ) { const { config } = entityForSuggestions; const query = useMemo( diff --git a/packages/edit-site/src/components/add-new-template/index.js b/packages/edit-site/src/components/add-new-template/index.js index c3ba06f065bb9..ffdf1b8937690 100644 --- a/packages/edit-site/src/components/add-new-template/index.js +++ b/packages/edit-site/src/components/add-new-template/index.js @@ -8,7 +8,6 @@ import { store as coreStore } from '@wordpress/core-data'; * Internal dependencies */ import NewTemplate from './new-template'; -import NewTemplatePart from './new-template-part'; export default function AddNewTemplate( { templateType = 'wp_template', @@ -25,8 +24,6 @@ export default function AddNewTemplate( { if ( templateType === 'wp_template' ) { return ; - } else if ( templateType === 'wp_template_part' ) { - return ; } return null; diff --git a/packages/edit-site/src/components/add-new-template/new-template-part.js b/packages/edit-site/src/components/add-new-template/new-template-part.js deleted file mode 100644 index b43cfd3150f2a..0000000000000 --- a/packages/edit-site/src/components/add-new-template/new-template-part.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * WordPress dependencies - */ -import { useState } from '@wordpress/element'; -import { useDispatch } from '@wordpress/data'; -import { Button } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { store as noticesStore } from '@wordpress/notices'; -import { store as coreStore } from '@wordpress/core-data'; -import { plus } from '@wordpress/icons'; -import { privateApis as routerPrivateApis } from '@wordpress/router'; - -/** - * Internal dependencies - */ -import CreateTemplatePartModal from '../create-template-part-modal'; -import { - useExistingTemplateParts, - getUniqueTemplatePartTitle, - getCleanTemplatePartSlug, -} from '../../utils/template-part-create'; -import { unlock } from '../../lock-unlock'; - -const { useHistory } = unlock( routerPrivateApis ); - -export default function NewTemplatePart( { - postType, - showIcon = true, - toggleProps, -} ) { - const history = useHistory(); - const [ isModalOpen, setIsModalOpen ] = useState( false ); - const { createErrorNotice } = useDispatch( noticesStore ); - const { saveEntityRecord } = useDispatch( coreStore ); - const existingTemplateParts = useExistingTemplateParts(); - - async function createTemplatePart( { title, area } ) { - if ( ! title ) { - createErrorNotice( __( 'Title is not defined.' ), { - type: 'snackbar', - } ); - return; - } - - try { - const uniqueTitle = getUniqueTemplatePartTitle( - title, - existingTemplateParts - ); - const cleanSlug = getCleanTemplatePartSlug( uniqueTitle ); - - const templatePart = await saveEntityRecord( - 'postType', - 'wp_template_part', - { - slug: cleanSlug, - title: uniqueTitle, - content: '', - area, - }, - { throwOnError: true } - ); - - setIsModalOpen( false ); - - // Navigate to the created template part editor. - history.push( { - postId: templatePart.id, - postType: 'wp_template_part', - canvas: 'edit', - } ); - - // TODO: Add a success notice? - } catch ( error ) { - const errorMessage = - error.message && error.code !== 'unknown_error' - ? error.message - : __( - 'An error occurred while creating the template part.' - ); - - createErrorNotice( errorMessage, { type: 'snackbar' } ); - - setIsModalOpen( false ); - } - } - const { as: Toggle = Button, ...restToggleProps } = toggleProps ?? {}; - - return ( - <> - { - setIsModalOpen( true ); - } } - icon={ showIcon ? plus : null } - label={ postType.labels.add_new } - > - { showIcon ? null : postType.labels.add_new } - - { isModalOpen && ( - setIsModalOpen( false ) } - onCreate={ createTemplatePart } - /> - ) } - - ); -} diff --git a/packages/edit-site/src/components/create-pattern-modal/index.js b/packages/edit-site/src/components/create-pattern-modal/index.js new file mode 100644 index 0000000000000..d57e424ef7318 --- /dev/null +++ b/packages/edit-site/src/components/create-pattern-modal/index.js @@ -0,0 +1,134 @@ +/** + * WordPress dependencies + */ +import { + TextControl, + Button, + Modal, + ToggleControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; +import { useState } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; +import { useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { SYNC_TYPES, USER_PATTERN_CATEGORY } from '../page-library/utils'; + +export default function CreatePatternModal( { + closeModal, + onCreate, + onError, +} ) { + const [ name, setName ] = useState( '' ); + const [ syncType, setSyncType ] = useState( SYNC_TYPES.full ); + const [ isSubmitting, setIsSubmitting ] = useState( false ); + + const onSyncChange = () => { + setSyncType( + syncType === SYNC_TYPES.full ? SYNC_TYPES.unsynced : SYNC_TYPES.full + ); + }; + + const { createErrorNotice } = useDispatch( noticesStore ); + const { saveEntityRecord } = useDispatch( coreStore ); + + async function createPattern() { + if ( ! name ) { + createErrorNotice( __( 'Please enter a pattern name.' ), { + type: 'snackbar', + } ); + return; + } + + try { + const pattern = await saveEntityRecord( + 'postType', + 'wp_block', + { + title: name || __( 'Untitled Pattern' ), + content: '', + status: 'publish', + meta: { sync_status: syncType }, + }, + { throwOnError: true } + ); + + onCreate( { pattern, categoryId: USER_PATTERN_CATEGORY } ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while creating the pattern.' ); + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + onError(); + } + } + + return ( + +

{ __( 'Turn this block into a pattern to reuse later' ) }

+ +
{ + event.preventDefault(); + if ( ! name ) { + return; + } + setIsSubmitting( true ); + await createPattern(); + } } + > + + + + + + + + +
+
+ ); +} diff --git a/packages/edit-site/src/components/create-pattern-modal/style.scss b/packages/edit-site/src/components/create-pattern-modal/style.scss new file mode 100644 index 0000000000000..f3dcd5fff37c8 --- /dev/null +++ b/packages/edit-site/src/components/create-pattern-modal/style.scss @@ -0,0 +1,3 @@ +.edit-site-create-pattern-modal__input input { + height: 40px; +} diff --git a/packages/edit-site/src/components/create-template-part-modal/index.js b/packages/edit-site/src/components/create-template-part-modal/index.js index f91ec2c8c8b2d..7deeeae23ab56 100644 --- a/packages/edit-site/src/components/create-template-part-modal/index.js +++ b/packages/edit-site/src/components/create-template-part-modal/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useSelect } from '@wordpress/data'; +import { useSelect, useDispatch } from '@wordpress/data'; import { Icon, BaseControl, @@ -20,14 +20,31 @@ import { __ } from '@wordpress/i18n'; import { useState } from '@wordpress/element'; import { useInstanceId } from '@wordpress/compose'; import { store as editorStore } from '@wordpress/editor'; +import { store as noticesStore } from '@wordpress/notices'; +import { store as coreStore } from '@wordpress/core-data'; import { check } from '@wordpress/icons'; +import { serialize } from '@wordpress/blocks'; /** * Internal dependencies */ import { TEMPLATE_PART_AREA_GENERAL } from '../../store/constants'; +import { + useExistingTemplateParts, + getUniqueTemplatePartTitle, + getCleanTemplatePartSlug, +} from '../../utils/template-part-create'; + +export default function CreateTemplatePartModal( { + closeModal, + blocks = [], + onCreate, + onError, +} ) { + const { createErrorNotice } = useDispatch( noticesStore ); + const { saveEntityRecord } = useDispatch( coreStore ); + const existingTemplateParts = useExistingTemplateParts(); -export default function CreateTemplatePartModal( { closeModal, onCreate } ) { const [ title, setTitle ] = useState( '' ); const [ area, setArea ] = useState( TEMPLATE_PART_AREA_GENERAL ); const [ isSubmitting, setIsSubmitting ] = useState( false ); @@ -39,6 +56,49 @@ export default function CreateTemplatePartModal( { closeModal, onCreate } ) { [] ); + async function createTemplatePart() { + if ( ! title ) { + createErrorNotice( __( 'Please enter a title.' ), { + type: 'snackbar', + } ); + return; + } + + try { + const uniqueTitle = getUniqueTemplatePartTitle( + title, + existingTemplateParts + ); + const cleanSlug = getCleanTemplatePartSlug( uniqueTitle ); + + const templatePart = await saveEntityRecord( + 'postType', + 'wp_template_part', + { + slug: cleanSlug, + title: uniqueTitle, + content: serialize( blocks ), + area, + }, + { throwOnError: true } + ); + await onCreate( templatePart ); + + // TODO: Add a success notice? + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( + 'An error occurred while creating the template part.' + ); + + createErrorNotice( errorMessage, { type: 'snackbar' } ); + + onError?.(); + } + } + return ( diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js index 9436b79e78674..7045461b7ceae 100644 --- a/packages/edit-site/src/components/editor/index.js +++ b/packages/edit-site/src/components/editor/index.js @@ -54,6 +54,12 @@ const interfaceLabels = { footer: __( 'Editor footer' ), }; +const typeLabels = { + wp_template: __( 'Template Part' ), + wp_template_part: __( 'Template Part' ), + wp_block: __( 'Pattern' ), +}; + export default function Editor( { isLoading } ) { const { record: editedPost, @@ -114,7 +120,7 @@ export default function Editor( { isLoading } ) { const isViewMode = canvasMode === 'view'; const isEditMode = canvasMode === 'edit'; const showVisualEditor = isViewMode || editorMode === 'visual'; - const shouldShowBlockBreakcrumbs = + const shouldShowBlockBreadcrumbs = showBlockBreadcrumbs && isEditMode && showVisualEditor && @@ -144,10 +150,7 @@ export default function Editor( { isLoading } ) { let title; if ( hasLoadedPost ) { - const type = - editedPostType === 'wp_template' - ? __( 'Template' ) - : __( 'Template Part' ); + const type = typeLabels[ editedPostType ] ?? __( 'Template' ); title = sprintf( // translators: A breadcrumb trail in browser tab. %1$s: title of template being edited, %2$s: type of template (Template or Template Part). __( '%1$s ‹ %2$s ‹ Editor' ), @@ -157,7 +160,7 @@ export default function Editor( { isLoading } ) { } // Only announce the title once the editor is ready to prevent "Replace" - // action in from double-announcing. + // action in from double-announcing. useTitle( hasLoadedPost && title ); return ( @@ -227,7 +230,7 @@ export default function Editor( { isLoading } ) { ) } footer={ - shouldShowBlockBreakcrumbs && ( + shouldShowBlockBreadcrumbs && ( { @@ -91,7 +92,6 @@ export default function Layout() { next: nextShortcut, } ); const disableMotion = useReducedMotion(); - const isMobileViewport = useViewportMatch( 'medium', '<' ); const showSidebar = ( isMobileViewport && ! isListPage ) || ( ! isMobileViewport && ( canvasMode === 'view' || ! isEditorPage ) ); @@ -171,20 +171,31 @@ export default function Layout() {
- { showSidebar && ( + { - ) } + } diff --git a/packages/edit-site/src/components/page-library/grid-item.js b/packages/edit-site/src/components/page-library/grid-item.js new file mode 100644 index 0000000000000..673057a12f43b --- /dev/null +++ b/packages/edit-site/src/components/page-library/grid-item.js @@ -0,0 +1,179 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { BlockPreview } from '@wordpress/block-editor'; +import { + __experimentalConfirmDialog as ConfirmDialog, + DropdownMenu, + MenuGroup, + MenuItem, + __experimentalHeading as Heading, + __experimentalHStack as HStack, + __unstableCompositeItem as CompositeItem, +} from '@wordpress/components'; +import { useInstanceId } from '@wordpress/compose'; +import { useDispatch } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, moreHorizontal } from '@wordpress/icons'; +import { store as noticesStore } from '@wordpress/notices'; +import { store as reusableBlocksStore } from '@wordpress/reusable-blocks'; +import { DELETE, BACKSPACE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import { PATTERNS, USER_PATTERNS } from './utils'; +import { useLink } from '../routes/link'; + +export default function GridItem( { categoryId, composite, icon, item } ) { + const instanceId = useInstanceId( GridItem ); + const descriptionId = `edit-site-library__pattern-description-${ instanceId }`; + const [ isDeleteDialogOpen, setIsDeleteDialogOpen ] = useState( false ); + + const { __experimentalDeleteReusableBlock } = + useDispatch( reusableBlocksStore ); + const { createErrorNotice, createSuccessNotice } = + useDispatch( noticesStore ); + + const { onClick } = useLink( { + postType: item.type, + postId: item.type === USER_PATTERNS ? item.id : item.name, + categoryId, + categoryType: item.type, + canvas: 'edit', + } ); + + const isEmpty = ! item.blocks?.length; + const patternClassNames = classnames( 'edit-site-library__pattern', { + 'is-placeholder': isEmpty, + } ); + const previewClassNames = classnames( 'edit-site-library__preview', { + 'is-inactive': item.type === PATTERNS, + } ); + + const deletePattern = async () => { + try { + await __experimentalDeleteReusableBlock( item.id ); + createSuccessNotice( __( 'Pattern successfully deleted.' ), { + type: 'snackbar', + } ); + } catch ( error ) { + const errorMessage = + error.message && error.code !== 'unknown_error' + ? error.message + : __( 'An error occurred while deleting the pattern.' ); + createErrorNotice( errorMessage, { type: 'snackbar' } ); + } + }; + + let ariaDescription; + if ( item.type === USER_PATTERNS ) { + // User patterns don't have descriptions, but can be edited and deleted, so include some help text. + ariaDescription = __( + 'Press Enter to edit, or Delete to delete the pattern.' + ); + } else if ( item.description ) { + ariaDescription = item.description; + } + + return ( + <> +
+ { + if ( + DELETE === event.keyCode || + BACKSPACE === event.keyCode + ) { + setIsDeleteDialogOpen( true ); + } + } } + > + { isEmpty && __( 'Empty pattern' ) } + { ! isEmpty && } + + { ariaDescription && ( + + ) } + +
+ { isDeleteDialogOpen && ( + setIsDeleteDialogOpen( false ) } + > + { __( 'Are you sure you want to delete this pattern?' ) } + + ) } + + ); +} diff --git a/packages/edit-site/src/components/page-library/grid.js b/packages/edit-site/src/components/page-library/grid.js new file mode 100644 index 0000000000000..cbd1f7ee724d2 --- /dev/null +++ b/packages/edit-site/src/components/page-library/grid.js @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ +import { + __unstableComposite as Composite, + __unstableUseCompositeState as useCompositeState, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import GridItem from './grid-item'; + +export default function Grid( { categoryId, label, icon, items } ) { + const composite = useCompositeState( { orientation: 'vertical' } ); + + if ( ! items?.length ) { + return null; + } + + return ( + + { items.map( ( item ) => ( + + ) ) } + + ); +} diff --git a/packages/edit-site/src/components/page-library/index.js b/packages/edit-site/src/components/page-library/index.js index b2bd1a0e932f8..2bc8e1ce9354c 100644 --- a/packages/edit-site/src/components/page-library/index.js +++ b/packages/edit-site/src/components/page-library/index.js @@ -1,104 +1,34 @@ /** * WordPress dependencies */ -import { - VisuallyHidden, - __experimentalHeading as Heading, - __experimentalVStack as VStack, -} from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; -import { useSelect } from '@wordpress/data'; -import { store as coreStore, useEntityRecords } from '@wordpress/core-data'; -import { decodeEntities } from '@wordpress/html-entities'; +import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; +import { getQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ +import { DEFAULT_CATEGORY, DEFAULT_TYPE } from './utils'; import Page from '../page'; -import Table from '../table'; -import Link from '../routes/link'; -import AddedBy from '../list/added-by'; -import TemplateActions from '../template-actions'; -import AddNewTemplate from '../add-new-template'; -import { store as editSiteStore } from '../../store'; +import PatternsList from './patterns-list'; +import useLibrarySettings from './use-library-settings'; +import { unlock } from '../../lock-unlock'; -export default function PageTemplates() { - const { records: templateParts } = useEntityRecords( - 'postType', - 'wp_template_part', - { - per_page: -1, - } - ); - - const { canCreate } = useSelect( ( select ) => { - const { supportsTemplatePartsMode } = - select( editSiteStore ).getSettings(); - return { - postType: select( coreStore ).getPostType( 'wp_template_part' ), - canCreate: ! supportsTemplatePartsMode, - }; - } ); +const { ExperimentalBlockEditorProvider } = unlock( blockEditorPrivateApis ); - const columns = [ - { - header: __( 'Template Part' ), - cell: ( templatePart ) => ( - - - - { decodeEntities( - templatePart.title?.rendered || - templatePart.slug - ) } - - - - ), - maxWidth: 400, - }, - { - header: __( 'Added by' ), - cell: ( templatePart ) => ( - - ), - }, - { - header: { __( 'Actions' ) }, - cell: ( templatePart ) => ( - - ), - }, - ]; +export default function PageLibrary() { + const { categoryType, categoryId } = getQueryArgs( window.location.href ); + const type = categoryType || DEFAULT_TYPE; + const category = categoryId || DEFAULT_CATEGORY; + const settings = useLibrarySettings(); + // Wrap everything in a block editor provider. + // This ensures 'styles' that are needed for the previews are synced + // from the site editor store to the block editor store. return ( - - ) - } - > - { templateParts && ( - - ) } - + + + + + ); } diff --git a/packages/edit-site/src/components/page-library/no-patterns.js b/packages/edit-site/src/components/page-library/no-patterns.js new file mode 100644 index 0000000000000..27d9380aab661 --- /dev/null +++ b/packages/edit-site/src/components/page-library/no-patterns.js @@ -0,0 +1,12 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +export default function NoPatterns() { + return ( +
+ { __( 'No patterns found.' ) } +
+ ); +} diff --git a/packages/edit-site/src/components/page-library/patterns-list.js b/packages/edit-site/src/components/page-library/patterns-list.js new file mode 100644 index 0000000000000..dfbcd1ca2a966 --- /dev/null +++ b/packages/edit-site/src/components/page-library/patterns-list.js @@ -0,0 +1,113 @@ +/** + * WordPress dependencies + */ + +import { + SearchControl, + __experimentalHeading as Heading, + __experimentalText as Text, + __experimentalVStack as VStack, + Flex, + FlexBlock, +} from '@wordpress/components'; +import { __, isRTL } from '@wordpress/i18n'; +import { symbol, chevronLeft, chevronRight } from '@wordpress/icons'; +import { privateApis as routerPrivateApis } from '@wordpress/router'; +import { useViewportMatch } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import Grid from './grid'; +import NoPatterns from './no-patterns'; +import usePatterns from './use-patterns'; +import SidebarButton from '../sidebar-button'; +import useDebouncedInput from '../../utils/use-debounced-input'; +import { unlock } from '../../lock-unlock'; + +const { useLocation, useHistory } = unlock( routerPrivateApis ); + +export default function PatternsList( { categoryId, type } ) { + const location = useLocation(); + const history = useHistory(); + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const [ filterValue, setFilterValue, delayedFilterValue ] = + useDebouncedInput( '' ); + + const [ patterns, isResolving ] = usePatterns( + type, + categoryId, + delayedFilterValue + ); + + const { syncedPatterns, unsyncedPatterns } = patterns; + const hasPatterns = !! syncedPatterns.length || !! unsyncedPatterns.length; + + return ( + + + { isMobileViewport && ( + { + // Go back in history if we came from the library page. + // Otherwise push a stack onto the history. + if ( location.state?.backPath === '/library' ) { + history.back(); + } else { + history.push( { path: '/library' } ); + } + } } + /> + ) } + + setFilterValue( value ) } + placeholder={ __( 'Search patterns' ) } + value={ filterValue } + __nextHasNoMarginBottom + /> + + + { isResolving && __( 'Loading' ) } + { ! isResolving && !! syncedPatterns.length && ( + <> + + { __( 'Synced' ) } + + { __( + 'Patterns that are kept in sync across your site' + ) } + + + + + ) } + { ! isResolving && !! unsyncedPatterns.length && ( + <> + + { __( 'Standard' ) } + + { __( + 'Patterns that can be changed freely without affecting your site' + ) } + + + + + ) } + { ! isResolving && ! hasPatterns && } + + ); +} diff --git a/packages/edit-site/src/components/page-library/search-items.js b/packages/edit-site/src/components/page-library/search-items.js new file mode 100644 index 0000000000000..9026e7f39f4bf --- /dev/null +++ b/packages/edit-site/src/components/page-library/search-items.js @@ -0,0 +1,171 @@ +/** + * External dependencies + */ +import removeAccents from 'remove-accents'; +import { noCase } from 'change-case'; + +// Default search helpers. +const defaultGetName = ( item ) => item.name || ''; +const defaultGetTitle = ( item ) => item.title; +const defaultGetDescription = ( item ) => item.description || ''; +const defaultGetKeywords = ( item ) => item.keywords || []; +const defaultHasCategory = () => false; + +/** + * Extracts words from an input string. + * + * @param {string} input The input string. + * + * @return {Array} Words, extracted from the input string. + */ +function extractWords( input = '' ) { + return noCase( input, { + splitRegexp: [ + /([\p{Ll}\p{Lo}\p{N}])([\p{Lu}\p{Lt}])/gu, // One lowercase or digit, followed by one uppercase. + /([\p{Lu}\p{Lt}])([\p{Lu}\p{Lt}][\p{Ll}\p{Lo}])/gu, // One uppercase followed by one uppercase and one lowercase. + ], + stripRegexp: /(\p{C}|\p{P}|\p{S})+/giu, // Anything that's not a punctuation, symbol or control/format character. + } ) + .split( ' ' ) + .filter( Boolean ); +} + +/** + * Sanitizes the search input string. + * + * @param {string} input The search input to normalize. + * + * @return {string} The normalized search input. + */ +function normalizeSearchInput( input = '' ) { + // Disregard diacritics. + // Input: "média" + input = removeAccents( input ); + + // Accommodate leading slash, matching autocomplete expectations. + // Input: "/media" + input = input.replace( /^\//, '' ); + + // Lowercase. + // Input: "MEDIA" + input = input.toLowerCase(); + + return input; +} + +/** + * Converts the search term into a list of normalized terms. + * + * @param {string} input The search term to normalize. + * + * @return {string[]} The normalized list of search terms. + */ +export const getNormalizedSearchTerms = ( input = '' ) => { + return extractWords( normalizeSearchInput( input ) ); +}; + +const removeMatchingTerms = ( unmatchedTerms, unprocessedTerms ) => { + return unmatchedTerms.filter( + ( term ) => + ! getNormalizedSearchTerms( unprocessedTerms ).some( + ( unprocessedTerm ) => unprocessedTerm.includes( term ) + ) + ); +}; + +/** + * Filters an item list given a search term. + * + * @param {Array} items Item list + * @param {string} searchInput Search input. + * @param {Object} config Search Config. + * + * @return {Array} Filtered item list. + */ +export const searchItems = ( items = [], searchInput = '', config = {} ) => { + const normalizedSearchTerms = getNormalizedSearchTerms( searchInput ); + const onlyFilterByCategory = ! normalizedSearchTerms.length; + const searchRankConfig = { ...config, onlyFilterByCategory }; + + // If we aren't filtering on search terms, matching on category is satisfactory. + // If we are, then we need more than a category match. + const threshold = onlyFilterByCategory ? 0 : 1; + + const rankedItems = items + .map( ( item ) => { + return [ + item, + getItemSearchRank( item, searchInput, searchRankConfig ), + ]; + } ) + .filter( ( [ , rank ] ) => rank > threshold ); + + // If we didn't have terms to search on, there's no point sorting. + if ( normalizedSearchTerms.length === 0 ) { + return rankedItems.map( ( [ item ] ) => item ); + } + + rankedItems.sort( ( [ , rank1 ], [ , rank2 ] ) => rank2 - rank1 ); + return rankedItems.map( ( [ item ] ) => item ); +}; + +/** + * Get the search rank for a given item and a specific search term. + * The better the match, the higher the rank. + * If the rank equals 0, it should be excluded from the results. + * + * @param {Object} item Item to filter. + * @param {string} searchTerm Search term. + * @param {Object} config Search Config. + * + * @return {number} Search Rank. + */ +function getItemSearchRank( item, searchTerm, config ) { + const { + categoryId, + getName = defaultGetName, + getTitle = defaultGetTitle, + getDescription = defaultGetDescription, + getKeywords = defaultGetKeywords, + hasCategory = defaultHasCategory, + onlyFilterByCategory, + } = config; + + let rank = hasCategory( item, categoryId ) ? 1 : 0; + + // If an item doesn't belong to the current category or we don't have + // search terms to filter by, return the initial rank value. + if ( ! rank || onlyFilterByCategory ) { + return rank; + } + + const name = getName( item ); + const title = getTitle( item ); + const description = getDescription( item ); + const keywords = getKeywords( item ); + + const normalizedSearchInput = normalizeSearchInput( searchTerm ); + const normalizedTitle = normalizeSearchInput( title ); + + // Prefers exact matches + // Then prefers if the beginning of the title matches the search term + // name, keywords, description matches come later. + if ( normalizedSearchInput === normalizedTitle ) { + rank += 30; + } else if ( normalizedTitle.startsWith( normalizedSearchInput ) ) { + rank += 20; + } else { + const terms = [ name, title, description, ...keywords ].join( ' ' ); + const normalizedSearchTerms = extractWords( normalizedSearchInput ); + const unmatchedTerms = removeMatchingTerms( + normalizedSearchTerms, + terms + ); + + if ( unmatchedTerms.length === 0 ) { + rank += 10; + } + } + + return rank; +} diff --git a/packages/edit-site/src/components/page-library/style.scss b/packages/edit-site/src/components/page-library/style.scss new file mode 100644 index 0000000000000..e663fcdc24841 --- /dev/null +++ b/packages/edit-site/src/components/page-library/style.scss @@ -0,0 +1,105 @@ +.edit-site-library { + background: rgba(0, 0, 0, 0.05); + margin: $header-height 0 0; + .components-text { + color: $gray-600; + } + + .components-heading { + color: $white; + } + + @include break-medium { + margin: 0; + } +} + +.edit-site-library__grid { + column-gap: $grid-unit-30; + @include break-large() { + column-count: 2; + } + + // Small top padding required to avoid cutting off the visible outline + // when hovering items. + padding-top: $border-width-focus-fallback; + margin-bottom: $grid-unit-40; + + .edit-site-library__pattern { + break-inside: avoid-column; + display: flex; + flex-direction: column; + margin-bottom: $grid-unit-60; + + .edit-site-library__preview { + border-radius: $radius-block-ui; + cursor: pointer; + overflow: hidden; + + &:focus { + box-shadow: inset 0 0 0 2px $white, 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + } + + &.is-inactive { + cursor: default; + } + } + + .edit-site-library__footer, + .edit-site-library__button { + color: $gray-600; + } + + &.is-placeholder .edit-site-library__preview { + min-height: $grid-unit-80; + color: $gray-600; + border: 1px dashed $gray-800; + display: flex; + align-items: center; + justify-content: center; + + &:focus { + box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color); + } + } + } + + .edit-site-library__preview { + flex: 1; + margin-bottom: $grid-unit-20; + } +} + +// The increased specificity here is to overcome component styles +// without relying on internal component class names. +.edit-site-library__search { + &#{&} input[type="search"] { + background: $gray-800; + color: $gray-100; + + &:focus { + background: $gray-800; + } + } + + svg { + fill: $gray-600; + } +} + +.edit-site-library__pattern-title { + color: $gray-600; + + svg { + border-radius: $grid-unit-05; + background: var(--wp-block-synced-color); + fill: $white; + } +} + +.edit-site-library__no-results { + color: $gray-600; +} diff --git a/packages/edit-site/src/components/page-library/use-library-settings.js b/packages/edit-site/src/components/page-library/use-library-settings.js new file mode 100644 index 0000000000000..a0f1bfcdae393 --- /dev/null +++ b/packages/edit-site/src/components/page-library/use-library-settings.js @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; +import { filterOutDuplicatesByName } from './utils'; + +export default function useLibrarySettings() { + const storedSettings = useSelect( ( select ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + return getSettings(); + }, [] ); + + const settingsBlockPatterns = + storedSettings.__experimentalAdditionalBlockPatterns ?? // WP 6.0 + storedSettings.__experimentalBlockPatterns; // WP 5.9 + + const restBlockPatterns = useSelect( + ( select ) => select( coreStore ).getBlockPatterns(), + [] + ); + + const blockPatterns = useMemo( + () => + [ + ...( settingsBlockPatterns || [] ), + ...( restBlockPatterns || [] ), + ].filter( filterOutDuplicatesByName ), + [ settingsBlockPatterns, restBlockPatterns ] + ); + + const settings = useMemo( () => { + const { __experimentalAdditionalBlockPatterns, ...restStoredSettings } = + storedSettings; + + return { + ...restStoredSettings, + __experimentalBlockPatterns: blockPatterns, + __unstableIsPreviewMode: true, + }; + }, [ storedSettings, blockPatterns ] ); + + return settings; +} diff --git a/packages/edit-site/src/components/page-library/use-patterns.js b/packages/edit-site/src/components/page-library/use-patterns.js new file mode 100644 index 0000000000000..9034dc43421fc --- /dev/null +++ b/packages/edit-site/src/components/page-library/use-patterns.js @@ -0,0 +1,238 @@ +/** + * WordPress dependencies + */ +import { parse } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + CORE_PATTERN_SOURCES, + PATTERNS, + SYNC_TYPES, + TEMPLATE_PARTS, + USER_PATTERNS, + USER_PATTERN_CATEGORY, + filterOutDuplicatesByName, +} from './utils'; +import { unlock } from '../../lock-unlock'; +import { searchItems } from './search-items'; +import { store as editSiteStore } from '../../store'; + +const EMPTY_PATTERN_LIST = []; + +const createTemplatePartId = ( theme, slug ) => + theme && slug ? theme + '//' + slug : null; + +const templatePartToPattern = ( templatePart ) => ( { + blocks: parse( templatePart.content.raw ), + categories: [ templatePart.area ], + description: templatePart.description || '', + keywords: templatePart.keywords || [], + name: createTemplatePartId( templatePart.theme, templatePart.slug ), + title: templatePart.title.rendered, + type: templatePart.type, + templatePart, +} ); + +const useTemplatePartsAsPatterns = ( + categoryId, + postType = TEMPLATE_PARTS, + filterValue = '' +) => { + const { templateParts, isResolving } = useSelect( + ( select ) => { + if ( postType !== TEMPLATE_PARTS ) { + return { + templateParts: EMPTY_PATTERN_LIST, + isResolving: false, + }; + } + + const { getEntityRecords, isResolving: _isResolving } = + select( coreStore ); + const query = { per_page: -1 }; + const rawTemplateParts = getEntityRecords( + 'postType', + postType, + query + ); + const partsAsPatterns = rawTemplateParts?.map( ( templatePart ) => + templatePartToPattern( templatePart ) + ); + + return { + templateParts: partsAsPatterns, + isResolving: _isResolving( 'getEntityRecords', [ + 'postType', + 'wp_template_part', + query, + ] ), + }; + }, + [ postType ] + ); + + const filteredTemplateParts = useMemo( () => { + if ( ! templateParts ) { + return EMPTY_PATTERN_LIST; + } + + return searchItems( templateParts, filterValue, { + categoryId, + hasCategory: ( item, area ) => item.templatePart.area === area, + } ); + }, [ templateParts, filterValue, categoryId ] ); + + return { templateParts: filteredTemplateParts, isResolving }; +}; + +const useThemePatterns = ( + categoryId, + postType = PATTERNS, + filterValue = '' +) => { + const blockPatterns = useSelect( ( select ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + const settings = getSettings(); + return ( + settings.__experimentalAdditionalBlockPatterns ?? + settings.__experimentalBlockPatterns + ); + } ); + + const restBlockPatterns = useSelect( ( select ) => + select( coreStore ).getBlockPatterns() + ); + + const patterns = useMemo( + () => + [ ...( blockPatterns || [] ), ...( restBlockPatterns || [] ) ] + .filter( + ( pattern ) => + ! CORE_PATTERN_SOURCES.includes( pattern.source ) + ) + .filter( filterOutDuplicatesByName ) + .map( ( pattern ) => ( { + ...pattern, + keywords: pattern.keywords || [], + type: 'pattern', + blocks: parse( pattern.content ), + } ) ), + [ blockPatterns, restBlockPatterns ] + ); + + const filteredPatterns = useMemo( () => { + if ( postType !== PATTERNS ) { + return EMPTY_PATTERN_LIST; + } + + return searchItems( patterns, filterValue, { + categoryId, + hasCategory: ( item, currentCategory ) => + item.categories?.includes( currentCategory ), + } ); + }, [ patterns, filterValue, categoryId, postType ] ); + + return filteredPatterns; +}; + +const reusableBlockToPattern = ( reusableBlock ) => ( { + blocks: parse( reusableBlock.content.raw ), + categories: reusableBlock.wp_pattern, + id: reusableBlock.id, + name: reusableBlock.slug, + syncStatus: reusableBlock.meta?.sync_status, + title: reusableBlock.title.raw, + type: reusableBlock.type, + reusableBlock, +} ); + +const useUserPatterns = ( + categoryId, + categoryType = PATTERNS, + filterValue = '' +) => { + const postType = categoryType === PATTERNS ? USER_PATTERNS : categoryType; + const unfilteredPatterns = useSelect( + ( select ) => { + if ( + postType !== USER_PATTERNS || + categoryId !== USER_PATTERN_CATEGORY + ) { + return EMPTY_PATTERN_LIST; + } + + const { getEntityRecords } = select( coreStore ); + const records = getEntityRecords( 'postType', postType, { + per_page: -1, + } ); + + if ( ! records ) { + return EMPTY_PATTERN_LIST; + } + + return records.map( ( record ) => + reusableBlockToPattern( record ) + ); + }, + [ postType, categoryId ] + ); + + const filteredPatterns = useMemo( () => { + if ( ! unfilteredPatterns.length ) { + return EMPTY_PATTERN_LIST; + } + + return searchItems( unfilteredPatterns, filterValue, { + // We exit user pattern retrieval early if we aren't in the + // catch-all category for user created patterns, so it has + // to be in the category. + hasCategory: () => true, + } ); + }, [ unfilteredPatterns, filterValue ] ); + + const patterns = { syncedPatterns: [], unsyncedPatterns: [] }; + + filteredPatterns.forEach( ( pattern ) => { + if ( pattern.syncStatus === SYNC_TYPES.full ) { + patterns.syncedPatterns.push( pattern ); + } else { + patterns.unsyncedPatterns.push( pattern ); + } + } ); + + return patterns; +}; + +export const usePatterns = ( categoryType, categoryId, filterValue ) => { + const blockPatterns = useThemePatterns( + categoryId, + categoryType, + filterValue + ); + + const { syncedPatterns = [], unsyncedPatterns = [] } = useUserPatterns( + categoryId, + categoryType, + filterValue + ); + + const { templateParts, isResolving } = useTemplatePartsAsPatterns( + categoryId, + categoryType, + filterValue + ); + + const patterns = { + syncedPatterns: [ ...templateParts, ...syncedPatterns ], + unsyncedPatterns: [ ...blockPatterns, ...unsyncedPatterns ], + }; + + return [ patterns, isResolving ]; +}; + +export default usePatterns; diff --git a/packages/edit-site/src/components/page-library/utils.js b/packages/edit-site/src/components/page-library/utils.js new file mode 100644 index 0000000000000..a9f1d7a658483 --- /dev/null +++ b/packages/edit-site/src/components/page-library/utils.js @@ -0,0 +1,21 @@ +export const DEFAULT_CATEGORY = 'header'; +export const DEFAULT_TYPE = 'wp_template_part'; +export const PATTERNS = 'pattern'; +export const TEMPLATE_PARTS = 'wp_template_part'; +export const USER_PATTERNS = 'wp_block'; +export const USER_PATTERN_CATEGORY = 'custom-patterns'; + +export const CORE_PATTERN_SOURCES = [ + 'core', + 'pattern-directory/core', + 'pattern-directory/featured', + 'pattern-directory/theme', +]; + +export const SYNC_TYPES = { + full: 'fully', + unsynced: 'unsynced', +}; + +export const filterOutDuplicatesByName = ( currentItem, index, items ) => + index === items.findIndex( ( item ) => currentItem.name === item.name ); diff --git a/packages/edit-site/src/components/page-main/index.js b/packages/edit-site/src/components/page-main/index.js index 03fd0072cc367..ebcf35dfb0c1f 100644 --- a/packages/edit-site/src/components/page-main/index.js +++ b/packages/edit-site/src/components/page-main/index.js @@ -19,7 +19,7 @@ export default function PageMain() { if ( path === '/wp_template/all' ) { return ; - } else if ( path === '/wp_template_part/all' ) { + } else if ( path === '/library' ) { return ; } diff --git a/packages/edit-site/src/components/page/index.js b/packages/edit-site/src/components/page/index.js index 6836456c8eb9b..c0c48ff65b056 100644 --- a/packages/edit-site/src/components/page/index.js +++ b/packages/edit-site/src/components/page/index.js @@ -1,12 +1,24 @@ /** - * Internal dependencies + * External dependencies */ +import classnames from 'classnames'; +/** + * Internal dependencies + */ import Header from './header'; -export default function Page( { title, subTitle, actions, children } ) { +export default function Page( { + title, + subTitle, + actions, + children, + className, +} ) { + const classes = classnames( 'edit-site-page', className ); + return ( -
+
{ title && (
) } + { ! withChevron && suffix } ); diff --git a/packages/edit-site/src/components/sidebar-navigation-item/style.scss b/packages/edit-site/src/components/sidebar-navigation-item/style.scss index ce5b278daaebe..054ecdac1b5b1 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss @@ -31,6 +31,10 @@ outline: none; } } + + &.with-suffix { + padding-right: $grid-unit-20; + } } .edit-site-sidebar-navigation-screen__content .block-editor-list-view-block-select-button { diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-library/category-item.js b/packages/edit-site/src/components/sidebar-navigation-screen-library/category-item.js new file mode 100644 index 0000000000000..288f92d8ef5dd --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-library/category-item.js @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import SidebarNavigationItem from '../sidebar-navigation-item'; +import { useLink } from '../routes/link'; + +export default function CategoryItem( { + count, + icon, + id, + isActive, + label, + type, +} ) { + const linkInfo = useLink( + { + path: '/library', + categoryType: type, + categoryId: id, + }, + { + // Keep a record of where we came from in state so we can + // use the browser's back button to go back to the library. + // See the implementation of the back button in patterns-list. + backPath: '/library', + } + ); + + if ( ! count ) { + return; + } + + return ( + { count } } + className={ isActive ? 'is-active-category' : undefined } + > + { label } + + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-library/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-library/index.js new file mode 100644 index 0000000000000..d3cfcbece6be0 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-library/index.js @@ -0,0 +1,136 @@ +/** + * WordPress dependencies + */ +import { + __experimentalItemGroup as ItemGroup, + __experimentalItem as Item, +} from '@wordpress/components'; +import { useViewportMatch } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { getTemplatePartIcon } from '@wordpress/editor'; +import { __ } from '@wordpress/i18n'; +import { getQueryArgs } from '@wordpress/url'; +import { file } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import AddNewPattern from '../add-new-pattern'; +import SidebarNavigationItem from '../sidebar-navigation-item'; +import SidebarNavigationScreen from '../sidebar-navigation-screen'; +import CategoryItem from './category-item'; +import { DEFAULT_CATEGORY, DEFAULT_TYPE } from '../page-library/utils'; +import { store as editSiteStore } from '../../store'; +import usePatternCategories from './use-pattern-categories'; +import useTemplatePartAreas from './use-template-part-areas'; + +const templatePartAreaLabels = { + header: __( 'Headers' ), + footer: __( 'Footers' ), + sidebar: __( 'Sidebar' ), + uncategorized: __( 'Uncategorized' ), +}; + +export default function SidebarNavigationScreenLibrary() { + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const { categoryType, categoryId } = getQueryArgs( window.location.href ); + const currentCategory = categoryId || DEFAULT_CATEGORY; + const currentType = categoryType || DEFAULT_TYPE; + + const { templatePartAreas, hasTemplateParts, isLoading } = + useTemplatePartAreas(); + const { patternCategories, hasPatterns } = usePatternCategories(); + + const isTemplatePartsMode = useSelect( ( select ) => { + const settings = select( editSiteStore ).getSettings(); + return !! settings.supportsTemplatePartsMode; + }, [] ); + + return ( + } + footer={ + + { ! isMobileViewport && ( + + { __( 'Manage all custom patterns' ) } + + ) } + + } + content={ + <> + { isLoading && __( 'Loading library' ) } + { ! isLoading && ( + <> + { ! hasTemplateParts && ! hasPatterns && ( + + + { __( + 'No template parts or patterns found' + ) } + + + ) } + { hasTemplateParts && ( + + { Object.entries( templatePartAreas ).map( + ( [ area, parts ] ) => ( + + ) + ) } + + ) } + { hasPatterns && ( + + { patternCategories.map( ( category ) => ( + + ) ) } + + ) } + + ) } + + } + /> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-library/style.scss b/packages/edit-site/src/components/sidebar-navigation-screen-library/style.scss new file mode 100644 index 0000000000000..302faa17bc738 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-library/style.scss @@ -0,0 +1,7 @@ +.edit-site-sidebar-navigation-screen-library__group { + margin-bottom: $grid-unit-30; +} + +.edit-site-sidebar-navigation-item.is-active-category { + background: $gray-800; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-library/use-default-pattern-categories.js b/packages/edit-site/src/components/sidebar-navigation-screen-library/use-default-pattern-categories.js new file mode 100644 index 0000000000000..014d0e2e65b0c --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-library/use-default-pattern-categories.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; + +export default function useDefaultPatternCategories() { + const blockPatternCategories = useSelect( ( select ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + const settings = getSettings(); + + return ( + settings.__experimentalAdditionalBlockPatternCategories ?? + settings.__experimentalBlockPatternCategories + ); + } ); + + const restBlockPatternCategories = useSelect( ( select ) => + select( coreStore ).getBlockPatternCategories() + ); + + return [ + ...( blockPatternCategories || [] ), + ...( restBlockPatternCategories || [] ), + ]; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-library/use-pattern-categories.js b/packages/edit-site/src/components/sidebar-navigation-screen-library/use-pattern-categories.js new file mode 100644 index 0000000000000..a787f8c04c639 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-library/use-pattern-categories.js @@ -0,0 +1,64 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import useDefaultPatternCategories from './use-default-pattern-categories'; +import useThemePatterns from './use-theme-patterns'; + +export default function usePatternCategories() { + const defaultCategories = useDefaultPatternCategories(); + const themePatterns = useThemePatterns(); + const userPatterns = useSelect( ( select ) => + select( coreStore ).getEntityRecords( 'postType', 'wp_block', { + per_page: -1, + } ) + ); + + const patternCategories = useMemo( () => { + const categoryMap = {}; + const categoriesWithCounts = []; + + // Create a map for easier counting of patterns in categories. + defaultCategories.forEach( ( category ) => { + if ( ! categoryMap[ category.name ] ) { + categoryMap[ category.name ] = { ...category, count: 0 }; + } + } ); + + // Update the category counts to reflect theme registered patterns. + themePatterns.forEach( ( pattern ) => { + pattern.categories?.forEach( ( category ) => { + if ( categoryMap[ category ] ) { + categoryMap[ category ].count += 1; + } + } ); + } ); + + // Filter categories so we only have those containing patterns. + defaultCategories.forEach( ( category ) => { + if ( categoryMap[ category.name ].count ) { + categoriesWithCounts.push( categoryMap[ category.name ] ); + } + } ); + + // Add "Your Patterns" category for user patterns if there are any. + if ( userPatterns?.length ) { + categoriesWithCounts.push( { + count: userPatterns.length || 0, + name: 'custom-patterns', + label: __( 'Custom patterns' ), + } ); + } + + return categoriesWithCounts; + }, [ defaultCategories, themePatterns, userPatterns ] ); + + return { patternCategories, hasPatterns: !! patternCategories.length }; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-library/use-template-part-areas.js b/packages/edit-site/src/components/sidebar-navigation-screen-library/use-template-part-areas.js new file mode 100644 index 0000000000000..aa258344d132d --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-library/use-template-part-areas.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { useEntityRecords } from '@wordpress/core-data'; + +const getTemplatePartAreas = ( items ) => { + const allItems = items || []; + + const groupedByArea = allItems.reduce( + ( accumulator, item ) => { + const key = accumulator[ item.area ] ? item.area : 'uncategorized'; + accumulator[ key ].push( item ); + return accumulator; + }, + { header: [], footer: [], sidebar: [], uncategorized: [] } + ); + + return groupedByArea; +}; + +export default function useTemplatePartAreas() { + const { records: templateParts, isResolving: isLoading } = useEntityRecords( + 'postType', + 'wp_template_part', + { per_page: -1 } + ); + + return { + hasTemplateParts: templateParts ? !! templateParts.length : false, + isLoading, + templatePartAreas: getTemplatePartAreas( templateParts ), + }; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-library/use-theme-patterns.js b/packages/edit-site/src/components/sidebar-navigation-screen-library/use-theme-patterns.js new file mode 100644 index 0000000000000..d0534eca2846e --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-library/use-theme-patterns.js @@ -0,0 +1,44 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + CORE_PATTERN_SOURCES, + filterOutDuplicatesByName, +} from '../page-library/utils'; +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; + +export default function useThemePatterns() { + const blockPatterns = useSelect( ( select ) => { + const { getSettings } = unlock( select( editSiteStore ) ); + + return ( + getSettings().__experimentalAdditionalBlockPatterns ?? + getSettings().__experimentalBlockPatterns + ); + } ); + + const restBlockPatterns = useSelect( ( select ) => + select( coreStore ).getBlockPatterns() + ); + + const patterns = useMemo( + () => + [ ...( blockPatterns || [] ), ...( restBlockPatterns || [] ) ] + .filter( + ( pattern ) => + ! CORE_PATTERN_SOURCES.includes( pattern.source ) + ) + .filter( filterOutDuplicatesByName ), + [ blockPatterns, restBlockPatterns ] + ); + + return patterns; +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js index 5c89c9f72a23b..6e7810abe81c0 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-main/index.js @@ -75,7 +75,7 @@ export default function SidebarNavigationScreenMain() { diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-pattern/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/index.js new file mode 100644 index 0000000000000..149f420ff6fb9 --- /dev/null +++ b/packages/edit-site/src/components/sidebar-navigation-screen-pattern/index.js @@ -0,0 +1,116 @@ +/** + * WordPress dependencies + */ +import { __, sprintf, _x } from '@wordpress/i18n'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { pencil } from '@wordpress/icons'; +import { + __experimentalUseNavigator as useNavigator, + Icon, +} from '@wordpress/components'; +import { store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import SidebarNavigationScreen from '../sidebar-navigation-screen'; +import useEditedEntityRecord from '../use-edited-entity-record'; +import useInitEditedEntityFromURL from '../sync-state-with-url/use-init-edited-entity-from-url'; +import { unlock } from '../../lock-unlock'; +import { store as editSiteStore } from '../../store'; +import SidebarButton from '../sidebar-button'; +import { useAddedBy } from '../list/added-by'; + +function usePatternTitleAndDescription( postType, postId ) { + const { getDescription, getTitle, record } = useEditedEntityRecord( + postType, + postId + ); + const currentTheme = useSelect( + ( select ) => select( coreStore ).getCurrentTheme(), + [] + ); + const addedBy = useAddedBy( postType, postId ); + const isAddedByActiveTheme = + addedBy.type === 'theme' && record.theme === currentTheme?.stylesheet; + const title = getTitle(); + let descriptionText = getDescription(); + + if ( ! descriptionText && addedBy.text ) { + descriptionText = sprintf( + // translators: %s: pattern title e.g: "Header". + __( 'This is your %s pattern.' ), + getTitle() + ); + } + + if ( ! descriptionText && postType === 'wp_block' && record?.title ) { + descriptionText = sprintf( + // translators: %s: user created pattern title e.g. "Footer". + __( 'This is your %s pattern.' ), + record.title + ); + } + + const description = ( + <> + { descriptionText } + + { addedBy.text && ! isAddedByActiveTheme && ( + + + + { addedBy.imageUrl ? ( + + ) : ( + + ) } + + { addedBy.text } + + + { addedBy.isCustomized && ( + + { _x( '(Customized)', 'pattern' ) } + + ) } + + ) } + + ); + + return { title, description }; +} + +export default function SidebarNavigationScreenPattern() { + const { params } = useNavigator(); + const { postType, postId } = params; + const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); + + useInitEditedEntityFromURL(); + + const { title, description } = usePatternTitleAndDescription( + postType, + postId + ); + + return ( + setCanvasMode( 'edit' ) } + label={ __( 'Edit' ) } + icon={ pencil } + /> + } + backPath={ '/library' } + description={ description } + /> + ); +} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/index.js b/packages/edit-site/src/components/sidebar-navigation-screen-template-part/index.js deleted file mode 100644 index 664c708792dc9..0000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/index.js +++ /dev/null @@ -1,167 +0,0 @@ -/** - * WordPress dependencies - */ -import { __, sprintf, _x } from '@wordpress/i18n'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { pencil } from '@wordpress/icons'; -import { - __experimentalUseNavigator as useNavigator, - Icon, -} from '@wordpress/components'; -import { store as coreStore } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import SidebarNavigationScreen from '../sidebar-navigation-screen'; -import useEditedEntityRecord from '../use-edited-entity-record'; -import { unlock } from '../../lock-unlock'; -import { store as editSiteStore } from '../../store'; -import SidebarButton from '../sidebar-button'; -import { useAddedBy } from '../list/added-by'; -import SidebarNavigationScreenDetailsFooter from '../sidebar-navigation-screen-details-footer'; -import TemplatePartNavigationMenus from './template-part-navigation-menus'; - -function useTemplateDetails( postType, postId ) { - const { getDescription, getTitle, record } = useEditedEntityRecord( - postType, - postId - ); - - const currentTheme = useSelect( - ( select ) => select( coreStore ).getCurrentTheme(), - [] - ); - const addedBy = useAddedBy( postType, postId ); - const isAddedByActiveTheme = - addedBy.type === 'theme' && record.theme === currentTheme?.stylesheet; - const title = getTitle(); - let descriptionText = getDescription(); - - if ( ! descriptionText && addedBy.text ) { - descriptionText = sprintf( - // translators: %s: template part title e.g: "Header". - __( 'This is your %s template part.' ), - getTitle() - ); - } - - const description = ( - <> - { descriptionText } - - { addedBy.text && ! isAddedByActiveTheme && ( - - - - { addedBy.imageUrl ? ( - - ) : ( - - ) } - - { addedBy.text } - - - { addedBy.isCustomized && ( - - { _x( '(Customized)', 'template part' ) } - - ) } - - ) } - - ); - - const footer = !! record?.modified ? ( - - ) : null; - - return { title, description, footer }; -} - -export default function SidebarNavigationScreenTemplatePart() { - const { params } = useNavigator(); - const { postType, postId } = params; - const { setCanvasMode } = unlock( useDispatch( editSiteStore ) ); - - const { record } = useEditedEntityRecord( postType, postId ); - - const { title, description, footer } = useTemplateDetails( - postType, - postId - ); - - const navigationBlocks = getBlocksOfTypeFromBlocks( - 'core/navigation', - record?.blocks - ); - - // Get a list of the navigation menu ids from the navigation blocks' - // ref attribute. - const navigationMenuIds = navigationBlocks?.map( ( block ) => { - return block.attributes.ref; - } ); - - return ( - setCanvasMode( 'edit' ) } - label={ __( 'Edit' ) } - icon={ pencil } - /> - } - description={ description } - content={ - - } - footer={ footer } - /> - ); -} - -/** - * Retrieves a list of specific blocks from a given tree of blocks. - * - * @param {string} targetBlock the name of the block to find. - * @param {Array} blocks a list of blocks from the template part entity. - * @return {Array} a list of any navigation blocks found in the blocks. - */ -function getBlocksOfTypeFromBlocks( targetBlock, blocks ) { - if ( ! targetBlock || ! blocks?.length ) return []; - - const findInBlocks = ( _blocks ) => { - if ( ! _blocks ) { - return []; - } - - const navigationBlocks = []; - - for ( const block of _blocks ) { - if ( block.name === targetBlock ) { - navigationBlocks.push( block ); - } - - if ( block?.innerBlocks ) { - const innerNavigationBlocks = findInBlocks( block.innerBlocks ); - - if ( innerNavigationBlocks.length ) { - navigationBlocks.push( ...innerNavigationBlocks ); - } - } - } - - return navigationBlocks; - }; - - return findInBlocks( blocks ); -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu-list-item.js b/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu-list-item.js deleted file mode 100644 index b685c766107a3..0000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu-list-item.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * WordPress dependencies - */ -import { useEntityProp } from '@wordpress/core-data'; -import { __ } from '@wordpress/i18n'; - -/** - * Internal dependencies - */ -import SidebarNavigationItem from '../sidebar-navigation-item'; -import { useLink } from '../routes/link'; - -export default function TemplatePartNavigationMenuListItem( { id } ) { - const [ title ] = useEntityProp( 'postType', 'wp_navigation', 'title', id ); - - const linkInfo = useLink( { - postId: id, - postType: 'wp_navigation', - } ); - - if ( ! id ) return null; - - return ( - - { title || __( '(no title)' ) } - - ); -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu-list.js b/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu-list.js deleted file mode 100644 index 4171b1e782575..0000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu-list.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * WordPress dependencies - */ -import { __experimentalItemGroup as ItemGroup } from '@wordpress/components'; -/** - * Internal dependencies - */ -import TemplatePartNavigationMenuListItem from './template-part-navigation-menu-list-item'; - -export default function TemplatePartNavigationMenuList( { menus } ) { - return ( - - { menus.map( ( menuId ) => ( - - ) ) } - - ); -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu.js b/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu.js deleted file mode 100644 index f451c17e00adb..0000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menu.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { __experimentalHeading as Heading } from '@wordpress/components'; -import { useEntityProp } from '@wordpress/core-data'; - -/** - * Internal dependencies - */ -import NavigationMenuEditor from '../sidebar-navigation-screen-navigation-menu/navigation-menu-editor'; - -export default function TemplatePartNavigationMenu( { id } ) { - const [ title ] = useEntityProp( 'postType', 'wp_navigation', 'title', id ); - - if ( ! id ) return null; - - return ( - <> - - { title?.rendered || __( 'Navigation' ) } - - - - ); -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menus.js b/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menus.js deleted file mode 100644 index 418b1d4423b20..0000000000000 --- a/packages/edit-site/src/components/sidebar-navigation-screen-template-part/template-part-navigation-menus.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { __experimentalHeading as Heading } from '@wordpress/components'; -/** - * Internal dependencies - */ -import TemplatePartNavigationMenu from './template-part-navigation-menu'; -import TemplatePartNavigationMenuList from './template-part-navigation-menu-list'; - -export default function TemplatePartNavigationMenus( { menus } ) { - if ( ! menus.length ) return null; - - // if there is a single menu then render TemplatePartNavigationMenu - if ( menus.length === 1 ) { - return ; - } - - // if there are multiple menus then render TemplatePartNavigationMenuList - return ( - <> - - { __( 'Navigation' ) } - - - - ); -} diff --git a/packages/edit-site/src/components/sidebar-navigation-screen/index.js b/packages/edit-site/src/components/sidebar-navigation-screen/index.js index d62d95d626a87..c60757673a221 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen/index.js @@ -3,9 +3,10 @@ */ import { __experimentalHStack as HStack, - __experimentalVStack as VStack, - __experimentalNavigatorToParentButton as NavigatorToParentButton, __experimentalHeading as Heading, + __experimentalNavigatorToParentButton as NavigatorToParentButton, + __experimentalUseNavigator as useNavigator, + __experimentalVStack as VStack, } from '@wordpress/components'; import { isRTL, __, sprintf } from '@wordpress/i18n'; import { chevronRight, chevronLeft } from '@wordpress/icons'; @@ -31,6 +32,7 @@ export default function SidebarNavigationScreen( { content, footer, description, + backPath, } ) { const { dashboardLink } = useSelect( ( select ) => { const { getSettings } = unlock( select( editSiteStore ) ); @@ -39,7 +41,9 @@ export default function SidebarNavigationScreen( { }; }, [] ); const { getTheme } = useSelect( coreStore ); + const { goTo } = useNavigator(); const theme = getTheme( currentlyPreviewingTheme() ); + const icon = isRTL() ? chevronRight : chevronLeft; return ( <> @@ -53,15 +57,23 @@ export default function SidebarNavigationScreen( { alignment="flex-start" className="edit-site-sidebar-navigation-screen__title-icon" > - { ! isRoot ? ( + { ! isRoot && ! backPath && ( - ) : ( + ) } + { ! isRoot && backPath && ( + goTo( backPath, { isBack: true } ) } + icon={ icon } + label={ __( 'Back' ) } + /> + ) } + { isRoot && ( - + - + + + + + + + - - - ); } diff --git a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js index 32413a943bb9a..19518f650c0be 100644 --- a/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js +++ b/packages/edit-site/src/components/sync-state-with-url/use-init-edited-entity-from-url.js @@ -31,8 +31,13 @@ export default function useInitEditedEntityFromURL() { }; }, [] ); - const { setTemplate, setTemplatePart, setPage, setNavigationMenu } = - useDispatch( editSiteStore ); + const { + setEditedEntity, + setTemplate, + setTemplatePart, + setPage, + setNavigationMenu, + } = useDispatch( editSiteStore ); useEffect( () => { if ( postType && postId ) { @@ -46,6 +51,9 @@ export default function useInitEditedEntityFromURL() { case 'wp_navigation': setNavigationMenu( postId ); break; + case 'wp_block': + setEditedEntity( postType, postId ); + break; default: setPage( { context: { postType, postId }, @@ -71,6 +79,7 @@ export default function useInitEditedEntityFromURL() { postType, homepageId, isRequestingSite, + setEditedEntity, setPage, setTemplate, setTemplatePart, diff --git a/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js b/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js index 93a6db650b6dc..ee9fa0fffb801 100644 --- a/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js +++ b/packages/edit-site/src/components/sync-state-with-url/use-sync-path-with-url.js @@ -18,6 +18,7 @@ export function getPathFromURL( urlParams ) { // Compute the navigator path based on the URL params. if ( urlParams?.postType && urlParams?.postId ) { switch ( urlParams.postType ) { + case 'wp_block': case 'wp_template': case 'wp_template_part': case 'page': @@ -86,10 +87,19 @@ export default function useSyncPathWithURL() { postId: navigatorParams?.postId, path: undefined, } ); + } else if ( navigatorLocation.path === '/library' ) { + updateUrlParams( { + postType: undefined, + postId: undefined, + canvas: undefined, + path: navigatorLocation.path, + } ); } else { updateUrlParams( { postType: undefined, postId: undefined, + categoryType: undefined, + categoryId: undefined, path: navigatorLocation.path === '/' ? undefined diff --git a/packages/edit-site/src/components/template-part-converter/convert-to-template-part.js b/packages/edit-site/src/components/template-part-converter/convert-to-template-part.js index a671faf756cd4..d7571a0dcd628 100644 --- a/packages/edit-site/src/components/template-part-converter/convert-to-template-part.js +++ b/packages/edit-site/src/components/template-part-converter/convert-to-template-part.js @@ -4,10 +4,9 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { MenuItem } from '@wordpress/components'; -import { createBlock, serialize } from '@wordpress/blocks'; +import { createBlock } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; import { useState } from '@wordpress/element'; -import { store as coreStore } from '@wordpress/core-data'; import { store as noticesStore } from '@wordpress/notices'; import { symbolFilled } from '@wordpress/icons'; @@ -16,18 +15,11 @@ import { symbolFilled } from '@wordpress/icons'; */ import CreateTemplatePartModal from '../create-template-part-modal'; import { store as editSiteStore } from '../../store'; -import { - useExistingTemplateParts, - getUniqueTemplatePartTitle, - getCleanTemplatePartSlug, -} from '../../utils/template-part-create'; export default function ConvertToTemplatePart( { clientIds, blocks } ) { const [ isModalOpen, setIsModalOpen ] = useState( false ); const { replaceBlocks } = useDispatch( blockEditorStore ); - const { saveEntityRecord } = useDispatch( coreStore ); const { createSuccessNotice } = useDispatch( noticesStore ); - const existingTemplateParts = useExistingTemplateParts(); const { canCreate } = useSelect( ( select ) => { const { supportsTemplatePartsMode } = @@ -41,23 +33,7 @@ export default function ConvertToTemplatePart( { clientIds, blocks } ) { return null; } - const onConvert = async ( { title, area } ) => { - const uniqueTitle = getUniqueTemplatePartTitle( - title, - existingTemplateParts - ); - const cleanSlug = getCleanTemplatePartSlug( uniqueTitle ); - - const templatePart = await saveEntityRecord( - 'postType', - 'wp_template_part', - { - slug: cleanSlug, - title: uniqueTitle, - content: serialize( blocks ), - area, - } - ); + const onConvert = async ( templatePart ) => { replaceBlocks( clientIds, createBlock( 'core/template-part', { @@ -88,6 +64,7 @@ export default function ConvertToTemplatePart( { clientIds, blocks } ) { closeModal={ () => { setIsModalOpen( false ); } } + blocks={ blocks } onCreate={ onConvert } /> ) } diff --git a/packages/edit-site/src/store/actions.js b/packages/edit-site/src/store/actions.js index eba997f1a6f68..8da362ca01f17 100644 --- a/packages/edit-site/src/store/actions.js +++ b/packages/edit-site/src/store/actions.js @@ -191,6 +191,22 @@ export function setNavigationMenu( navigationMenuId ) { }; } +/** + * Action that sets an edited entity. + * + * @param {string} postType The entity's post type. + * @param {string} postId The entity's ID. + * + * @return {Object} Action object. + */ +export function setEditedEntity( postType, postId ) { + return { + type: 'SET_EDITED_POST', + postType, + id: postId, + }; +} + /** * @deprecated */ diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss index 5f434bb84f8f7..4c45f3c39dcaf 100644 --- a/packages/edit-site/src/style.scss +++ b/packages/edit-site/src/style.scss @@ -10,6 +10,7 @@ @import "./components/header-edit-mode/document-actions/style.scss"; @import "./components/list/style.scss"; @import "./components/page/style.scss"; +@import "./components/page-library/style.scss"; @import "./components/table/style.scss"; @import "./components/sidebar-edit-mode/style.scss"; @import "./components/sidebar-edit-mode/page-panels/style.scss"; @@ -17,6 +18,7 @@ @import "./components/sidebar-edit-mode/sidebar-card/style.scss"; @import "./components/sidebar-edit-mode/template-panel/style.scss"; @import "./components/editor/style.scss"; +@import "./components/create-pattern-modal/style.scss"; @import "./components/create-template-part-modal/style.scss"; @import "./components/secondary-sidebar/style.scss"; @import "./components/welcome-guide/style.scss"; @@ -31,6 +33,7 @@ @import "./components/sidebar-navigation-screen/style.scss"; @import "./components/sidebar-navigation-screen-details-footer/style.scss"; @import "./components/sidebar-navigation-screen-global-styles/style.scss"; +@import "./components/sidebar-navigation-screen-library/style.scss"; @import "./components/sidebar-navigation-screen-navigation-menu/style.scss"; @import "./components/sidebar-navigation-screen-page/style.scss"; @import "components/sidebar-navigation-screen-details-panel/style.scss"; diff --git a/packages/edit-site/src/utils/get-is-list-page.js b/packages/edit-site/src/utils/get-is-list-page.js index 3e7049985cc92..963287308ac9a 100644 --- a/packages/edit-site/src/utils/get-is-list-page.js +++ b/packages/edit-site/src/utils/get-is-list-page.js @@ -1,11 +1,23 @@ /** * Returns if the params match the list page route. * - * @param {Object} params The url params. - * @param {string} params.path The current path. + * @param {Object} params The url params. + * @param {string} params.path The current path. + * @param {string} [params.categoryType] The current category type. + * @param {string} [params.categoryId] The current category id. + * @param {boolean} isMobileViewport Is mobile viewport. * * @return {boolean} Is list page or not. */ -export default function getIsListPage( { path } ) { - return path === '/wp_template/all' || path === '/wp_template_part/all'; +export default function getIsListPage( + { path, categoryType, categoryId }, + isMobileViewport +) { + return ( + path === '/wp_template/all' || + ( path === '/library' && + // Don't treat "/library" without categoryType and categoryId as a list page + // in mobile because the sidebar covers the whole page. + ( ! isMobileViewport || ( !! categoryType && !! categoryId ) ) ) + ); } diff --git a/packages/edit-site/src/utils/use-debounced-input.js b/packages/edit-site/src/utils/use-debounced-input.js new file mode 100644 index 0000000000000..55d0ce989293e --- /dev/null +++ b/packages/edit-site/src/utils/use-debounced-input.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { useDebounce } from '@wordpress/compose'; + +export default function useDebouncedInput( defaultValue = '' ) { + const [ input, setInput ] = useState( defaultValue ); + const [ debounced, setter ] = useState( defaultValue ); + const setDebounced = useDebounce( setter, 250 ); + useEffect( () => { + if ( debounced !== input ) { + setDebounced( input ); + } + }, [ debounced, input ] ); + return [ input, setInput, debounced ]; +} diff --git a/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js b/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js index 5bd1fbd29d52b..ee9a0dd99eb48 100644 --- a/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js +++ b/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js @@ -63,7 +63,11 @@ test.describe( 'Site editor url navigation', () => { } ) => { await admin.visitSiteEditor(); await page.click( 'role=button[name="Library"i]' ); - await page.click( 'role=button[name="Add New"i]' ); + await page.click( 'role=button[name="Create a pattern"i]' ); + await page + .getByRole( 'menu', { name: 'Create a pattern' } ) + .getByRole( 'menuitem', { name: 'Create a template part' } ) + .click(); // Fill in a name in the dialog that pops up. await page.type( 'role=dialog >> role=textbox[name="Name"i]', diff --git a/test/e2e/specs/site-editor/template-parts-mode.spec.js b/test/e2e/specs/site-editor/template-parts-mode.spec.js deleted file mode 100644 index a8945794ae6a8..0000000000000 --- a/test/e2e/specs/site-editor/template-parts-mode.spec.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * WordPress dependencies - */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); - -test.describe( 'Template Parts for Classic themes', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'emptyhybrid' ); - } ); - - test.afterAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - } ); - - test( 'can access template parts list page', async ( { admin, page } ) => { - await admin.visitAdminPage( - 'site-editor.php', - 'postType=wp_template_part&path=/wp_template_part/all' - ); - - await expect( - page.getByRole( 'table' ).getByRole( 'link', { name: 'header' } ) - ).toBeVisible(); - } ); - - test( 'can view a template part', async ( { admin, editor, page } ) => { - await admin.visitAdminPage( - 'site-editor.php', - 'postType=wp_template_part&path=/wp_template_part/all' - ); - - const templatePart = page - .getByRole( 'table' ) - .getByRole( 'link', { name: 'header' } ); - - await expect( templatePart ).toBeVisible(); - await templatePart.click(); - - await expect( - page.getByRole( 'region', { name: 'Editor content' } ) - ).toBeVisible(); - - await editor.canvas.click( 'body' ); - - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Site Title', - } ) - ).toBeVisible(); - } ); -} );