diff --git a/backport-changelog/6.8/7903.md b/backport-changelog/6.8/7903.md new file mode 100644 index 00000000000000..cb20d8d2dd2b1b --- /dev/null +++ b/backport-changelog/6.8/7903.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7903 + +* https://github.com/WordPress/gutenberg/pull/67199 diff --git a/backport-changelog/6.8/7909.md b/backport-changelog/6.8/7909.md new file mode 100644 index 00000000000000..32a441ef296a2d --- /dev/null +++ b/backport-changelog/6.8/7909.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7909 + +* https://github.com/WordPress/gutenberg/pull/67330 diff --git a/changelog.txt b/changelog.txt index bf13c6b273e9b6..9e07615adf43f6 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,296 @@ == Changelog == += 19.7.0 = + +## Changelog + +### Enhancements + +- Add "show template" to preview dropdown. ([66514](https://github.com/WordPress/gutenberg/pull/66514)) +- Iframe: Always enable for block themes, in core too. ([66800](https://github.com/WordPress/gutenberg/pull/66800)) +- Media Utils: Add experimental `sideloadMedia`. ([66378](https://github.com/WordPress/gutenberg/pull/66378)) +- Post fields: Clean up. ([66941](https://github.com/WordPress/gutenberg/pull/66941)) +- Post fields: Extract `title` from `edit-site` to `fields` package. ([66940](https://github.com/WordPress/gutenberg/pull/66940)) +- Post fields: Move `comment_status` from edit-site to fields package. ([66934](https://github.com/WordPress/gutenberg/pull/66934)) +- Post fields: Move `date` fields from `edit-site` to `fields` package. ([66938](https://github.com/WordPress/gutenberg/pull/66938)) +- Post fields: Move `status` from `edit-site` to `fields`. ([66937](https://github.com/WordPress/gutenberg/pull/66937)) +- Relocate “View” external link to end of editor header controls. ([66785](https://github.com/WordPress/gutenberg/pull/66785)) + +#### Block Library +- Added toggle control to set any image as feature image if no feature image is set for post. ([65896](https://github.com/WordPress/gutenberg/pull/65896)) +- Improve cover z-index solution. ([66249](https://github.com/WordPress/gutenberg/pull/66249)) +- Post Content: Add border and spacing support. ([66366](https://github.com/WordPress/gutenberg/pull/66366)) +- Query Loop: Use templateSlug and postType for more context. ([65820](https://github.com/WordPress/gutenberg/pull/65820)) +- Update text case of "Starter Content". ([66954](https://github.com/WordPress/gutenberg/pull/66954)) +- [Details Block]: Adds anchor support in details block. ([66734](https://github.com/WordPress/gutenberg/pull/66734)) + +#### Components +- Guide: Use small size button for page controls. ([66607](https://github.com/WordPress/gutenberg/pull/66607)) +- MenuItem: Add 40px size prop on Button. ([66596](https://github.com/WordPress/gutenberg/pull/66596)) +- Notice: Add appropriate size props to Buttons. ([66593](https://github.com/WordPress/gutenberg/pull/66593)) +- PaletteEdit: Add appropriate size props to Buttons. ([66590](https://github.com/WordPress/gutenberg/pull/66590)) +- Popover: Add small size prop to close button. ([66587](https://github.com/WordPress/gutenberg/pull/66587)) + +#### Global Styles +- Global styles revisions: Move focus and active state to list item. ([66780](https://github.com/WordPress/gutenberg/pull/66780)) +- Site editor: Integrate global styles controls and style book preview into the styles panel. ([65619](https://github.com/WordPress/gutenberg/pull/65619)) + +#### DataViews +- DataViews Fields API: Default getValueFromId supports nested objects. ([66890](https://github.com/WordPress/gutenberg/pull/66890)) + +#### Block Editor +- Inserter: Add 'Starter Content' category to the inserter. ([66819](https://github.com/WordPress/gutenberg/pull/66819)) + +#### Zoom Out +- Enable zoom out mode for non-iframe editor. ([66789](https://github.com/WordPress/gutenberg/pull/66789)) + +#### Themes +- Theme JSON Resolver: Remove theme json merge in resolve_theme_file_uris. ([66662](https://github.com/WordPress/gutenberg/pull/66662)) + +#### Edit Mode +- Image block: Add support for "more" dropdown for additional tools in Write mode. ([66605](https://github.com/WordPress/gutenberg/pull/66605)) + +#### Style Book +- Add a landing section to stylebook tabs. ([66545](https://github.com/WordPress/gutenberg/pull/66545)) + +#### Media +- Media Library: Expose filters dropdown for individual images, such as with the Image block. ([65965](https://github.com/WordPress/gutenberg/pull/65965)) + + +### Bug Fixes + +- Block toolbar: Restrict visible child calculation to known blocks. ([66702](https://github.com/WordPress/gutenberg/pull/66702)) +- ComplementaryArea: Fix button position. ([66677](https://github.com/WordPress/gutenberg/pull/66677)) +- Fix Paragraph appender layout shift (building on 66061). ([66779](https://github.com/WordPress/gutenberg/pull/66779)) +- Fix: Set the `fit-content` width for images that are not `.svg`. ([66643](https://github.com/WordPress/gutenberg/pull/66643)) +- Preference modal: Avoid fetching all reusable blocks when the site editor loads. ([66621](https://github.com/WordPress/gutenberg/pull/66621)) +- Revert "Set image width to `fit-content` to solve aspect ratio problems in Firefox. (#66217)". ([66804](https://github.com/WordPress/gutenberg/pull/66804)) +- Safari: Fix site editor template error. ([66647](https://github.com/WordPress/gutenberg/pull/66647)) +- Safari: Prevent focus capturing caused by flex display. ([66402](https://github.com/WordPress/gutenberg/pull/66402)) +- Select Mode: Hide tool selector in the post editor and force design mode. ([66784](https://github.com/WordPress/gutenberg/pull/66784)) +- Shadow panel: Make the delete modal text translatable. ([66712](https://github.com/WordPress/gutenberg/pull/66712)) +- Site Editor: Fix template for page-on-front option. ([66739](https://github.com/WordPress/gutenberg/pull/66739)) +- WP Scripts: Make watch mode more resilient for developer errors. ([66752](https://github.com/WordPress/gutenberg/pull/66752)) +- getDefaultTemplateId: Ensure entity configuration is loaded. ([66650](https://github.com/WordPress/gutenberg/pull/66650)) +- Comments controller: fix issue where comments are allowed when closed (https://github.com/WordPress/gutenberg/pull/66976) + +#### Block Library +- Cover: Fix media library image selection. ([66782](https://github.com/WordPress/gutenberg/pull/66782)) +- Cover: Show DropZone only when dragging withing the block. ([66912](https://github.com/WordPress/gutenberg/pull/66912)) +- Media & Text: Set `.wp-block-media-text__media a` display to block. ([66915](https://github.com/WordPress/gutenberg/pull/66915)) +- Prevent duplicate post format taxonomy queries. ([66627](https://github.com/WordPress/gutenberg/pull/66627)) +- Query Loop: Check for postTypeFromContext before using it. ([66655](https://github.com/WordPress/gutenberg/pull/66655)) +- Query Loop: Remove postTypeFromContext. ([66681](https://github.com/WordPress/gutenberg/pull/66681)) + +#### Block Editor +- Appender: Fix initial position. ([66711](https://github.com/WordPress/gutenberg/pull/66711)) +- Appender: Fix outside canvas styles. ([66630](https://github.com/WordPress/gutenberg/pull/66630)) +- Block Inspector: Restore bottom margin for RadioControl. ([66688](https://github.com/WordPress/gutenberg/pull/66688)) +- Iframed editor: Fix relative wp-content URLs. ([66751](https://github.com/WordPress/gutenberg/pull/66751)) + +#### Global Styles +- Section Styles: Fix insecure properties removal for inner block types and elements. ([66896](https://github.com/WordPress/gutenberg/pull/66896)) +- Style book: Reduce margin selector specificity so that it doesn't override global block styles. ([66895](https://github.com/WordPress/gutenberg/pull/66895)) +- Theme JSON: Replace top-level background style objects on merge. ([66656](https://github.com/WordPress/gutenberg/pull/66656)) + +#### Components +- FormTokenField: Fix token styles. ([66640](https://github.com/WordPress/gutenberg/pull/66640)) +- Storybook: Fix DataViews action modals. ([66727](https://github.com/WordPress/gutenberg/pull/66727)) +- ToggleGroupControl: Fix active background for `zero` value. ([66855](https://github.com/WordPress/gutenberg/pull/66855)) + +#### Post Editor +- Disable device preview button in pattern/template part/navitation editor. ([65970](https://github.com/WordPress/gutenberg/pull/65970)) +- PostTaxonomiesFlatTermSelector: Abstract wrapper component. ([66625](https://github.com/WordPress/gutenberg/pull/66625)) +- VisualEditor: Always output has-global-padding classname when in post only mode. ([66626](https://github.com/WordPress/gutenberg/pull/66626)) + +#### DataViews +- Fix TypeError when duplicating uncategorized theme patterns. ([66889](https://github.com/WordPress/gutenberg/pull/66889)) +- Tweak primary field in patterns grid layout. ([66733](https://github.com/WordPress/gutenberg/pull/66733)) + +#### Meta Boxes +- Fix: Show Meta Boxes at the bottom of the screen regardless of the current rendering mode. ([66508](https://github.com/WordPress/gutenberg/pull/66508)) +- Hide metaboxes in Zoom Out. ([66886](https://github.com/WordPress/gutenberg/pull/66886)) + +#### Site Editor +- DataViews: Fix 'aria-label' for pattern preview element. ([66601](https://github.com/WordPress/gutenberg/pull/66601)) +- Site Hub: Fixed navigation redirect on mobile devices for classic themes. ([66867](https://github.com/WordPress/gutenberg/pull/66867)) + +#### Media +- Add `x-wav` mime type for wav files in Firefox. ([66850](https://github.com/WordPress/gutenberg/pull/66850)) +- Ensure HEIC files selectable from “Upload” button. ([66292](https://github.com/WordPress/gutenberg/pull/66292)) + +#### Patterns +- Fix uncategorized pattern browsing when pattern has no categories. ([66945](https://github.com/WordPress/gutenberg/pull/66945)) + +#### Interactivity API +- Fix property modification from inherited context two or more levels above. ([66872](https://github.com/WordPress/gutenberg/pull/66872)) + +#### Block API +- Process Block Type: Copy deprecation to a new object instead of mutating when stabilizing supports. ([66849](https://github.com/WordPress/gutenberg/pull/66849)) + +#### Design Tools +- Block Gap: Fix block spacing control for axial gap supported blocks. ([66783](https://github.com/WordPress/gutenberg/pull/66783)) + +#### Document Settings +- Editor: Restore the 'PluginPostStatusInfo' slot position. ([66665](https://github.com/WordPress/gutenberg/pull/66665)) + +#### Templates API +- Fix flash when clicking template name in the editor when a plugin registered template matches a default WP theme template. ([66359](https://github.com/WordPress/gutenberg/pull/66359)) + +#### Block bindings +- Fix unset array key warning in block-bindings.php. ([66337](https://github.com/WordPress/gutenberg/pull/66337)) + + +### Accessibility + +- Fix : Snackbar Notice Inconsistency. ([66405](https://github.com/WordPress/gutenberg/pull/66405)) +- Image: Add `aria-haspopup` prop write mode `more` tools menu items. ([66815](https://github.com/WordPress/gutenberg/pull/66815)) +- Site Icon Focus fix. ([66952](https://github.com/WordPress/gutenberg/pull/66952)) + +#### Components +- Popover: Fix missing label of the headerTitle Close button. ([66813](https://github.com/WordPress/gutenberg/pull/66813)) + +#### Post Editor +- Fix inconsistent sidebars close buttons sizes. ([66756](https://github.com/WordPress/gutenberg/pull/66756)) + +#### Block Library +- Remove unnecessary tooltip from Video block Text tracks button. ([66716](https://github.com/WordPress/gutenberg/pull/66716)) + +#### Block Editor +- Speak 'Block moved up/down' after using keyboard actions to move up/down. ([64966](https://github.com/WordPress/gutenberg/pull/64966)) + +#### Patterns +- Block Patterns List: Fix visual title and tooltip inconsistencies. ([64815](https://github.com/WordPress/gutenberg/pull/64815)) + + +### Performance + +- Inline Commenting: Avoid querying comments on editor load. ([66670](https://github.com/WordPress/gutenberg/pull/66670)) +- Patterns: Receive intermediate responses while unbound request is resolving. ([66713](https://github.com/WordPress/gutenberg/pull/66713)) +- Perf metrics: Update select and other metrics to use non-empty paragraphs. ([66762](https://github.com/WordPress/gutenberg/pull/66762)) +- Site Editor: Preload settings requests. ([66488](https://github.com/WordPress/gutenberg/pull/66488)) +- Site Editor: Speed up load by preloading home and front-page templates. ([66579](https://github.com/WordPress/gutenberg/pull/66579)) +- Site editor: Preload post if needed. ([66631](https://github.com/WordPress/gutenberg/pull/66631)) + +#### Global Styles +- Preload user global styles based on user caps. ([66541](https://github.com/WordPress/gutenberg/pull/66541)) + + +### Experiments + +- Add `isVisible` option to fields within DataForm. ([65826](https://github.com/WordPress/gutenberg/pull/65826)) +- DataViews: Implement `isItemClickable` and `onClickItem` props. ([66365](https://github.com/WordPress/gutenberg/pull/66365)) + +#### DataViews +- Quick Edit - Slug Field: Improve slug preview. ([66559](https://github.com/WordPress/gutenberg/pull/66559)) +- QuickEdit: Add password field data to the pages quick edit. ([66567](https://github.com/WordPress/gutenberg/pull/66567)) + + +### Documentation + +- Add 6.6.2 to Version in WordPress. ([66870](https://github.com/WordPress/gutenberg/pull/66870)) +- Add missing properties for DataViews/DataForm components. ([66749](https://github.com/WordPress/gutenberg/pull/66749)) +- Add section about the Fields API. ([66761](https://github.com/WordPress/gutenberg/pull/66761)) +- Block Bindings: Documentation API reference. ([66251](https://github.com/WordPress/gutenberg/pull/66251)) +- Docs: Include a note about supported licenses in WordPress packages. ([66562](https://github.com/WordPress/gutenberg/pull/66562)) +- Document `filterSortAndPaginate` & `isItemValid` utilities. ([66738](https://github.com/WordPress/gutenberg/pull/66738)) +- Feat: Storybook: Improve component organisation - Navigation Category - Issue #66275. ([66658](https://github.com/WordPress/gutenberg/pull/66658)) +- Feat: Storybook: Improve component organisation - Overlays Category - Issue #66275. ([66657](https://github.com/WordPress/gutenberg/pull/66657)) +- Feat: Storybook: Improve component organisation - Selection & Input Category - Issue #66275. ([66660](https://github.com/WordPress/gutenberg/pull/66660)) +- Feat: Storybook: Improve component organisation - Typography - Issue #66275. ([66633](https://github.com/WordPress/gutenberg/pull/66633)) +- Improve readability of DataViews documentation. ([66766](https://github.com/WordPress/gutenberg/pull/66766)) +- Move documentation for filter operators to proper place. ([66743](https://github.com/WordPress/gutenberg/pull/66743)) +- Reorganize to bootstrap DataForm API section. ([66729](https://github.com/WordPress/gutenberg/pull/66729)) +- Storybook: Improve component organisation - Actions. ([66680](https://github.com/WordPress/gutenberg/pull/66680)) +- Storybook: Log `warning()` when in dev mode. ([66568](https://github.com/WordPress/gutenberg/pull/66568)) +- Update Commands documentation with the existing contexts. ([66860](https://github.com/WordPress/gutenberg/pull/66860)) + + +### Code Quality + +- BlockPatternsList: Use the Async component. ([66744](https://github.com/WordPress/gutenberg/pull/66744)) +- Core Commands: Fix add new post URL assignment. ([66830](https://github.com/WordPress/gutenberg/pull/66830)) +- Inline Commenting: Optimize store selector and misc changes. ([66592](https://github.com/WordPress/gutenberg/pull/66592)) +- Remove unnecessary boolean assignments. ([66857](https://github.com/WordPress/gutenberg/pull/66857)) +- TypeScript: Fix and improve types for private-apis. ([66667](https://github.com/WordPress/gutenberg/pull/66667)) + +#### Block Editor +- Fix 'useSelect' dependencies for the 'RichText' component. ([66964](https://github.com/WordPress/gutenberg/pull/66964)) +- Fix ESLint warning for 'useBlockTypesState' hook. ([66757](https://github.com/WordPress/gutenberg/pull/66757)) +- Fix React Compiler error for 'BlockProps' util component. ([66809](https://github.com/WordPress/gutenberg/pull/66809)) +- Optimize `getVisibleElementBounds` in scrollable cases. ([66546](https://github.com/WordPress/gutenberg/pull/66546)) +- Revert: Fix unable to remove empty blocks on merge (#65262) + alternative. ([66564](https://github.com/WordPress/gutenberg/pull/66564)) +- URLInput: Fix incorrect classname for suggestions. ([66714](https://github.com/WordPress/gutenberg/pull/66714)) + +#### Site Editor +- Avoid using edited entity state in site editor loading hook. ([66924](https://github.com/WordPress/gutenberg/pull/66924)) +- Avoid using edited post selectors in welcome guide. ([66926](https://github.com/WordPress/gutenberg/pull/66926)) +- Edit Site: Refactor to remove usage of edited entity state. ([66922](https://github.com/WordPress/gutenberg/pull/66922)) +- Edit Site: Remove leftover 'priority-queue' dependency. ([66773](https://github.com/WordPress/gutenberg/pull/66773)) +- Remove useEditedEntityRecord hook. ([66955](https://github.com/WordPress/gutenberg/pull/66955)) + +#### Components +- Fix React Compiler error for 'useScrollRectIntoView'. ([66498](https://github.com/WordPress/gutenberg/pull/66498)) +- Panel: Add 40px size prop to Button. ([66589](https://github.com/WordPress/gutenberg/pull/66589)) +- Radio: Deprecate 36px default size. ([66572](https://github.com/WordPress/gutenberg/pull/66572)) +- Snackbar: Use `link` variant for action Button. ([66560](https://github.com/WordPress/gutenberg/pull/66560)) + +#### Data Layer +- Convert the emitter module in data package to TS. ([66669](https://github.com/WordPress/gutenberg/pull/66669)) +- Data: Rename useSelect internals to fix React Compiler violations. ([66807](https://github.com/WordPress/gutenberg/pull/66807)) +- Data: Upgrade Redux to v5.0.1. ([66966](https://github.com/WordPress/gutenberg/pull/66966)) + +#### Post Editor +- ESLint: Fix React Compiler violations in various commands. ([66787](https://github.com/WordPress/gutenberg/pull/66787)) +- Fix TS types for editor package. ([66754](https://github.com/WordPress/gutenberg/pull/66754)) + +#### Zoom Out +- Zoom-out: Move default background to the iframe component. ([66284](https://github.com/WordPress/gutenberg/pull/66284)) + +#### Design Tools +- Typography: Stabilize typography block supports within block processing. ([63401](https://github.com/WordPress/gutenberg/pull/63401)) + + +### Tools + +#### Testing +- Media: Check for `wav` mime type using isset. ([66947](https://github.com/WordPress/gutenberg/pull/66947)) + +#### Build Tooling +- Enforce the same order of fields in `package.json` files. ([66239](https://github.com/WordPress/gutenberg/pull/66239)) +- Introduce React Scanner for component usage stats. ([65463](https://github.com/WordPress/gutenberg/pull/65463)) + + +### Various + +- Style engine: Wrap array_merge in conditionals to prevent unnecessary merging. ([66661](https://github.com/WordPress/gutenberg/pull/66661)) + +#### Block Library +- Update placeholder text for blocks that support drag and drop. ([66842](https://github.com/WordPress/gutenberg/pull/66842)) +- update: Add Media to Add media in cover block. ([66835](https://github.com/WordPress/gutenberg/pull/66835)) + + +## First-time contributors + +The following PRs were merged by first-time contributors: + +- @benharri: Fix unset array key warning in block-bindings.php. ([66337](https://github.com/WordPress/gutenberg/pull/66337)) +- @benniledl: Add 6.6.2 to Version in WordPress. ([66870](https://github.com/WordPress/gutenberg/pull/66870)) +- @Infinite-Null: Media & Text: Set `.wp-block-media-text__media a` display to block. ([66915](https://github.com/WordPress/gutenberg/pull/66915)) +- @karthick-murugan: Site Icon Focus fix. ([66952](https://github.com/WordPress/gutenberg/pull/66952)) +- @rinkalpagdar: Post Content: Add border and spacing support. ([66366](https://github.com/WordPress/gutenberg/pull/66366)) +- @yogeshbhutkar: Site Hub: Fixed navigation redirect on mobile devices for classic themes. ([66867](https://github.com/WordPress/gutenberg/pull/66867)) + + +## Contributors + +The following contributors merged PRs in this release: + +@aaronrobertshaw @adamsilverstein @afercia @Aljullu @amitraj2203 @andrewserong @benharri @benniledl @carolinan @cbravobernal @DAreRodz @dcalhoun @ellatrix @fabiankaegy @gigitux @gziolo @hbhalodia @Infinite-Null @jasmussen @jorgefilipecosta @jsnajdr @juanfra @karthick-murugan @kevin940726 @louwie17 @Mamaduka @manzoorwanijk @matiasbenedetto @mikachan @mirka @n2erjo00 @ntsekouras @oandregal @ramonjd @renatho @rinkalpagdar @Soean @stokesman @swissspidy @t-hamano @tellthemachines @tyxla @up1512001 @Vrishabhsk @yogeshbhutkar @youknowriad + + + + = 19.8.0-rc.1 = diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index 474207aa20460f..199c29cd67dd2e 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -359,7 +359,7 @@ _Parameters_ - _state_ `State`: State tree - _kind_ `string`: Entity kind. - _name_ `string`: Entity name. -- _key_ `EntityRecordKey`: Record's key +- _key_ `EntityRecordKey`: Optional record's key. If requesting a global record (e.g. site settings), the key can be omitted. If requesting a specific item, the key must always be included. - _query_ `GetRecordsHttpQuery`: Optional query. If requesting specific fields, fields must always include the ID. For valid query parameters see the [Reference](https://developer.wordpress.org/rest-api/reference/) in the REST API Handbook and select the entity kind. Then see the arguments available "Retrieve a [Entity kind]". _Returns_ diff --git a/lib/compat/wordpress-6.8/class-gutenberg-rest-user-controller.php b/lib/compat/wordpress-6.8/class-gutenberg-rest-user-controller.php new file mode 100644 index 00000000000000..c1ecb8c86660cd --- /dev/null +++ b/lib/compat/wordpress-6.8/class-gutenberg-rest-user-controller.php @@ -0,0 +1,62 @@ + array(), + 'description' => __( 'Array of column names to be searched.' ), + 'type' => 'array', + 'items' => array( + 'enum' => array( 'email', 'name', 'id', 'username', 'slug' ), + 'type' => 'string', + ), + ); + + return $query_params; +} + +add_filter( 'rest_user_collection_params', 'gutenberg_add_search_columns_param', 10, 1 ); + +/** + * Modify user query based on search_columns parameter + * + * @param array $prepared_args Array of arguments for WP_User_Query. + * @param WP_REST_Request $request The REST API request. + * @return array Modified arguments + */ +function gutenberg_modify_user_query_args( $prepared_args, $request ) { + if ( $request->get_param( 'search' ) && $request->get_param( 'search_columns' ) ) { + $search_columns = $request->get_param( 'search_columns' ); + + // Validate search columns + $valid_columns = isset( $prepared_args['search_columns'] ) + ? $prepared_args['search_columns'] + : array( 'ID', 'user_login', 'user_nicename', 'user_email', 'user_url', 'display_name' ); + $search_columns_mapping = array( + 'id' => 'ID', + 'username' => 'user_login', + 'slug' => 'user_nicename', + 'email' => 'user_email', + 'name' => 'display_name', + ); + $search_columns = array_map( + static function ( $column ) use ( $search_columns_mapping ) { + return $search_columns_mapping[ $column ]; + }, + $search_columns + ); + $search_columns = array_intersect( $search_columns, $valid_columns ); + + if ( ! empty( $search_columns ) ) { + $prepared_args['search_columns'] = $search_columns; + } + } + + return $prepared_args; +} +add_filter( 'rest_user_query', 'gutenberg_modify_user_query_args', 10, 2 ); diff --git a/lib/compat/wordpress-6.8/site-editor.php b/lib/compat/wordpress-6.8/site-editor.php new file mode 100644 index 00000000000000..cde108830b1d2c --- /dev/null +++ b/lib/compat/wordpress-6.8/site-editor.php @@ -0,0 +1,124 @@ + '/wp_navigation/' . $_REQUEST['postId'] ), remove_query_arg( array( 'postType', 'postId' ) ) ); + } + + if ( isset( $_REQUEST['postType'] ) && 'wp_navigation' === $_REQUEST['postType'] && empty( $_REQUEST['postId'] ) ) { + return add_query_arg( array( 'p' => '/navigation' ), remove_query_arg( 'postType' ) ); + } + + if ( isset( $_REQUEST['path'] ) && '/wp_global_styles' === $_REQUEST['path'] ) { + return add_query_arg( array( 'p' => '/styles' ), remove_query_arg( 'path' ) ); + } + + if ( isset( $_REQUEST['postType'] ) && 'page' === $_REQUEST['postType'] && ( empty( $_REQUEST['canvas'] ) || empty( $_REQUEST['postId'] ) ) ) { + return add_query_arg( array( 'p' => '/page' ), remove_query_arg( 'postType' ) ); + } + + if ( isset( $_REQUEST['postType'] ) && 'page' === $_REQUEST['postType'] && ! empty( $_REQUEST['postId'] ) ) { + return add_query_arg( array( 'p' => '/page/' . $_REQUEST['postId'] ), remove_query_arg( array( 'postType', 'postId' ) ) ); + } + + if ( isset( $_REQUEST['postType'] ) && 'wp_template' === $_REQUEST['postType'] && ( empty( $_REQUEST['canvas'] ) || empty( $_REQUEST['postId'] ) ) ) { + return add_query_arg( array( 'p' => '/template' ), remove_query_arg( 'postType' ) ); + } + + if ( isset( $_REQUEST['postType'] ) && 'wp_template' === $_REQUEST['postType'] && ! empty( $_REQUEST['postId'] ) ) { + return add_query_arg( array( 'p' => '/wp_template/' . $_REQUEST['postId'] ), remove_query_arg( array( 'postType', 'postId' ) ) ); + } + + if ( isset( $_REQUEST['postType'] ) && 'wp_block' === $_REQUEST['postType'] && ( empty( $_REQUEST['canvas'] ) || empty( $_REQUEST['postId'] ) ) ) { + return add_query_arg( array( 'p' => '/pattern' ), remove_query_arg( 'postType' ) ); + } + + if ( isset( $_REQUEST['postType'] ) && 'wp_block' === $_REQUEST['postType'] && ! empty( $_REQUEST['postId'] ) ) { + return add_query_arg( array( 'p' => '/wp_block/' . $_REQUEST['postId'] ), remove_query_arg( array( 'postType', 'postId' ) ) ); + } + + if ( isset( $_REQUEST['postType'] ) && 'wp_template_part' === $_REQUEST['postType'] && ( empty( $_REQUEST['canvas'] ) || empty( $_REQUEST['postId'] ) ) ) { + return add_query_arg( array( 'p' => '/pattern' ) ); + } + + if ( isset( $_REQUEST['postType'] ) && 'wp_template_part' === $_REQUEST['postType'] && ! empty( $_REQUEST['postId'] ) ) { + return add_query_arg( array( 'p' => '/wp_template_part/' . $_REQUEST['postId'] ), remove_query_arg( array( 'postType', 'postId' ) ) ); + } + + // The following redirects are for backward compatibility with the old site editor URLs. + if ( isset( $_REQUEST['path'] ) && '/wp_template_part/all' === $_REQUEST['path'] ) { + return add_query_arg( + array( + 'p' => '/pattern', + 'postType' => 'wp_template_part', + ), + remove_query_arg( 'path' ) + ); + } + + if ( isset( $_REQUEST['path'] ) && '/page' === $_REQUEST['path'] ) { + return add_query_arg( array( 'p' => '/page' ), remove_query_arg( 'path' ) ); + } + + if ( isset( $_REQUEST['path'] ) && '/wp_template' === $_REQUEST['path'] ) { + return add_query_arg( array( 'p' => '/template' ), remove_query_arg( 'path' ) ); + } + + if ( isset( $_REQUEST['path'] ) && '/patterns' === $_REQUEST['path'] ) { + return add_query_arg( array( 'p' => '/pattern' ), remove_query_arg( 'path' ) ); + } + + if ( isset( $_REQUEST['path'] ) && '/navigation' === $_REQUEST['path'] ) { + return add_query_arg( array( 'p' => '/navigation' ), remove_query_arg( 'path' ) ); + } + + return add_query_arg( array( 'p' => '/' ) ); +} + +function gutenberg_redirect_site_editor_deprecated_urls() { + $redirection = gutenberg_get_site_editor_redirection(); + if ( false !== $redirection ) { + wp_redirect( $redirection, 301 ); + exit; + } +} +add_action( 'admin_init', 'gutenberg_redirect_site_editor_deprecated_urls' ); + +/** + * Filter the `wp_die_handler` to allow access to the Site Editor's new pages page + * for Classic themes. + * + * site-editor.php's access is forbidden for hybrid/classic themes and only allowed with some very special query args (some very special pages like template parts...). + * The only way to disable this protection since we're changing the urls in Gutenberg is to override the wp_die_handler. + * + * @param callable $default_handler The default handler. + * @return callable The default handler or a custom handler. + */ +function gutenberg_styles_wp_die_handler( $default_handler ) { + if ( ! wp_is_block_theme() && str_contains( $_SERVER['REQUEST_URI'], 'site-editor.php' ) && isset( $_GET['p'] ) ) { + return '__return_false'; + } + return $default_handler; +} +add_filter( 'wp_die_handler', 'gutenberg_styles_wp_die_handler' ); diff --git a/lib/experimental/posts/load.php b/lib/experimental/posts/load.php index 7321392b11a25d..699534f1886f52 100644 --- a/lib/experimental/posts/load.php +++ b/lib/experimental/posts/load.php @@ -69,18 +69,6 @@ function gutenberg_posts_dashboard() { echo '
'; } -/** - * Redirects to the new posts dashboard page and adds the postType query arg. - */ -function gutenberg_add_post_type_arg() { - global $pagenow; - if ( 'admin.php' === $pagenow && isset( $_GET['page'] ) && 'gutenberg-posts-dashboard' === $_GET['page'] && empty( $_GET['postType'] ) ) { - wp_redirect( admin_url( '/admin.php?page=gutenberg-posts-dashboard&postType=post' ) ); - exit; - } -} -add_action( 'admin_init', 'gutenberg_add_post_type_arg' ); - /** * Replaces the default posts menu item with the new posts dashboard. */ diff --git a/lib/load.php b/lib/load.php index 85d1c7e3292b50..26af78f3173c53 100644 --- a/lib/load.php +++ b/lib/load.php @@ -98,6 +98,8 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.8/blocks.php'; require __DIR__ . '/compat/wordpress-6.8/functions.php'; require __DIR__ . '/compat/wordpress-6.8/post.php'; +require __DIR__ . '/compat/wordpress-6.8/site-editor.php'; +require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-user-controller.php'; // Experimental features. require __DIR__ . '/experimental/block-editor-settings-mobile.php'; diff --git a/package-lock.json b/package-lock.json index ccf779f2d67eab..80a64c6f7a04ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44492,6 +44492,12 @@ "node": ">=10.0.0" } }, + "node_modules/route-recognizer": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/route-recognizer/-/route-recognizer-0.3.4.tgz", + "integrity": "sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==", + "license": "MIT" + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -53957,7 +53963,6 @@ "@wordpress/core-data": "*", "@wordpress/data": "*", "@wordpress/dom": "*", - "@wordpress/editor": "*", "@wordpress/element": "*", "@wordpress/hooks": "*", "@wordpress/i18n": "*", @@ -54386,7 +54391,6 @@ "@wordpress/data": "*", "@wordpress/deprecated": "*", "@wordpress/dom": "*", - "@wordpress/editor": "*", "@wordpress/element": "*", "@wordpress/hooks": "*", "@wordpress/i18n": "*", @@ -55563,10 +55567,12 @@ "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", + "@wordpress/compose": "*", "@wordpress/element": "*", "@wordpress/private-apis": "*", "@wordpress/url": "*", - "history": "^5.3.0" + "history": "^5.3.0", + "route-recognizer": "^0.3.4" }, "engines": { "node": ">=18.12.0", diff --git a/packages/block-editor/src/components/alignment-control/stories/aliginment-toolbar.story.js b/packages/block-editor/src/components/alignment-control/stories/aliginment-toolbar.story.js new file mode 100644 index 00000000000000..f2191220d6bb4c --- /dev/null +++ b/packages/block-editor/src/components/alignment-control/stories/aliginment-toolbar.story.js @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { AlignmentToolbar } from '..'; + +/** + * The `AlignmentToolbar` component renders a dropdown menu that displays alignment options for the selected block in `Toolbar`. + */ +const meta = { + title: 'BlockEditor/AlignmentToolbar', + component: AlignmentToolbar, + argTypes: { + value: { + control: { type: null }, + defaultValue: 'undefined', + description: 'The current value of the alignment setting.', + }, + onChange: { + action: 'onChange', + control: { type: null }, + description: + "A callback function invoked when the toolbar's alignment value is changed via an interaction with any of the toolbar's buttons. Called with the new alignment value (ie: `left`, `center`, `right`, `undefined`) as the only argument.", + }, + }, +}; +export default meta; + +export const Default = { + render: function Template( { onChange, ...args } ) { + const [ value, setValue ] = useState(); + return ( + { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + value={ value } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/alignment-control/stories/index.story.js b/packages/block-editor/src/components/alignment-control/stories/index.story.js new file mode 100644 index 00000000000000..85c92f7e0665a4 --- /dev/null +++ b/packages/block-editor/src/components/alignment-control/stories/index.story.js @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { AlignmentControl } from '../'; + +/** + * The `AlignmentControl` component renders a dropdown menu that displays alignment options for the selected block. + * + * This component is mostly used for blocks that display text, such as Heading, Paragraph, Post Author, Post Comments, Verse, Quote, Post Title, etc... And the available alignment options are `left`, `center` or `right` alignment. + * + * If you want to use the alignment control in a toolbar, you should use the `AlignmentToolbar` component instead. + */ +const meta = { + title: 'BlockEditor/AlignmentControl', + component: AlignmentControl, + argTypes: { + value: { + control: { type: null }, + defaultValue: 'undefined', + description: 'The current value of the alignment setting.', + }, + onChange: { + action: 'onChange', + control: { type: null }, + description: + "A callback function invoked when the toolbar's alignment value is changed via an interaction with any of the toolbar's buttons. Called with the new alignment value (ie: `left`, `center`, `right`, `undefined`) as the only argument.", + }, + }, +}; +export default meta; + +export const Default = { + render: function Template( { onChange, ...args } ) { + const [ value, setValue ] = useState(); + return ( + { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + value={ value } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/block-patterns-list/index.js b/packages/block-editor/src/components/block-patterns-list/index.js index 8128e89418f45a..0c7e54c3c62b24 100644 --- a/packages/block-editor/src/components/block-patterns-list/index.js +++ b/packages/block-editor/src/components/block-patterns-list/index.js @@ -41,6 +41,7 @@ function BlockPattern( { onHover, showTitlesAsTooltip, category, + isSelected, } ) { const [ isDragging, setIsDragging ] = useState( false ); const { blocks, viewportWidth } = pattern; @@ -114,6 +115,7 @@ function BlockPattern( { pattern.type === INSERTER_PATTERN_TYPES.user && ! pattern.syncStatus, + 'is-selected': isSelected, } ) } /> @@ -192,6 +194,7 @@ function BlockPatternsList( ref ) { const [ activeCompositeId, setActiveCompositeId ] = useState( undefined ); + const [ activePattern, setActivePattern ] = useState( null ); // State to track active pattern useEffect( () => { // Reset the active composite item whenever the available patterns change, @@ -201,6 +204,11 @@ function BlockPatternsList( setActiveCompositeId( firstCompositeItemId ); }, [ blockPatterns ] ); + const handleClickPattern = ( pattern, blocks ) => { + setActivePattern( pattern.name ); + onClickPattern( pattern, blocks ); + }; + return ( ) ) } { pagingProps && } diff --git a/packages/block-editor/src/components/block-patterns-list/style.scss b/packages/block-editor/src/components/block-patterns-list/style.scss index c46bb49b9a9016..8b1b0b54c9b1a0 100644 --- a/packages/block-editor/src/components/block-patterns-list/style.scss +++ b/packages/block-editor/src/components/block-patterns-list/style.scss @@ -44,19 +44,29 @@ outline: $border-width solid rgba($black, 0.1); outline-offset: -$border-width; border-radius: $radius-medium; + + transition: outline 0.1s linear; + @include reduce-motion("transition"); } } - &:hover:not(:focus) .block-editor-block-preview__container::after { + // Selected + &.is-selected .block-editor-block-preview__container::after { + outline-color: $gray-900; + outline-width: var(--wp-admin-border-width-focus); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); + } + + // Hover state + &:hover .block-editor-block-preview__container::after { outline-color: rgba($black, 0.3); } - &:focus .block-editor-block-preview__container::after { + // Focused state + &[data-focus-visible] .block-editor-block-preview__container::after { outline-color: var(--wp-admin-theme-color); outline-width: var(--wp-admin-border-width-focus); - outline-offset: calc((-1 * var(--wp-admin-border-width-focus))); - transition: outline 0.1s linear; - @include reduce-motion("transition"); + outline-offset: calc(-1 * var(--wp-admin-border-width-focus)); } .block-editor-patterns__pattern-details:not(:empty) { @@ -68,6 +78,7 @@ .block-editor-patterns__pattern-icon-wrapper { min-width: 24px; height: 24px; + .block-editor-patterns__pattern-icon { fill: var(--wp-block-synced-color); } diff --git a/packages/block-editor/src/components/block-settings-menu-controls/index.js b/packages/block-editor/src/components/block-settings-menu-controls/index.js index 4ebce4172e9b37..b0755be4c26297 100644 --- a/packages/block-editor/src/components/block-settings-menu-controls/index.js +++ b/packages/block-editor/src/components/block-settings-menu-controls/index.js @@ -55,7 +55,8 @@ const BlockSettingsMenuControlsSlot = ( { fillProps, clientIds = null } ) => { const convertToGroupButtonProps = useConvertToGroupButtonProps( selectedClientIds ); const { isGroupable, isUngroupable } = convertToGroupButtonProps; - const showConvertToGroupButton = isGroupable || isUngroupable; + const showConvertToGroupButton = + ( isGroupable || isUngroupable ) && ! isContentOnly; return ( { const { @@ -74,6 +76,7 @@ export function BlockSettingsDropdown( { getBlockAttributes, getOpenedBlockSettingsMenu, getBlockEditingMode, + isZoomOut: _isZoomOut, } = unlock( select( blockEditorStore ) ); const { getActiveBlockVariation } = select( blocksStore ); @@ -98,10 +101,12 @@ export function BlockSettingsDropdown( { openedBlockSettingsMenu: getOpenedBlockSettingsMenu(), isContentOnly: getBlockEditingMode( firstBlockClientId ) === 'contentOnly', + isZoomOut: _isZoomOut(), }; }, [ firstBlockClientId ] ); + const { getBlockOrder, getSelectedBlockClientIds } = useSelect( blockEditorStore ); @@ -248,7 +253,7 @@ export function BlockSettingsDropdown( { clientId={ firstBlockClientId } /> ) } - { ! isContentOnly && ( + { ( ! isContentOnly || isZoomOut ) && ( { + const actualImplementation = jest.requireActual( '@wordpress/blocks' ); return { + ...actualImplementation, isReusableBlock( { title } ) { return title === 'Reusable Block'; }, diff --git a/packages/block-editor/src/components/inserter-draggable-blocks/index.js b/packages/block-editor/src/components/inserter-draggable-blocks/index.js index 0e1aaadc72e67b..ebef6304937aa7 100644 --- a/packages/block-editor/src/components/inserter-draggable-blocks/index.js +++ b/packages/block-editor/src/components/inserter-draggable-blocks/index.js @@ -2,12 +2,9 @@ * WordPress dependencies */ import { Draggable } from '@wordpress/components'; -import { - createBlock, - serialize, - store as blocksStore, -} from '@wordpress/blocks'; +import { createBlock, store as blocksStore } from '@wordpress/blocks'; import { useDispatch, useSelect } from '@wordpress/data'; +import { useMemo } from '@wordpress/element'; /** * Internal dependencies @@ -24,20 +21,6 @@ const InserterDraggableBlocks = ( { children, pattern, } ) => { - const transferData = { - type: 'inserter', - blocks, - }; - - const blocksContainMedia = - blocks.filter( - ( block ) => - ( block.name === 'core/image' || - block.name === 'core/audio' || - block.name === 'core/video' ) && - ( block.attributes.url || block.attributes.src ) - ).length > 0; - const blockTypeIcon = useSelect( ( select ) => { const { getBlockType } = select( blocksStore ); @@ -52,6 +35,13 @@ const InserterDraggableBlocks = ( { useDispatch( blockEditorStore ) ); + const patternBlock = useMemo( () => { + return pattern?.type === INSERTER_PATTERN_TYPES.user && + pattern?.syncStatus !== 'unsynced' + ? [ createBlock( 'core/block', { ref: pattern.id } ) ] + : undefined; + }, [ pattern?.type, pattern?.syncStatus, pattern?.id ] ); + if ( ! isEnabled ) { return children( { draggable: false, @@ -60,21 +50,21 @@ const InserterDraggableBlocks = ( { } ); } + const draggableBlocks = patternBlock ?? blocks; return ( { startDragging(); - const parsedBlocks = - pattern?.type === INSERTER_PATTERN_TYPES.user && - pattern?.syncStatus !== 'unsynced' - ? [ createBlock( 'core/block', { ref: pattern.id } ) ] - : blocks; - event.dataTransfer.setData( - blocksContainMedia ? 'default' : 'text/html', - serialize( parsedBlocks ) - ); + for ( const block of draggableBlocks ) { + const type = `wp-block:${ block.name }`; + // This will fill in the dataTransfer.types array so that + // the drop zone can check if the draggable is eligible. + // Unfortuantely, on drag start, we don't have access to the + // actual data, only the data keys/types. + event.dataTransfer.items.add( '', type ); + } } } onDragEnd={ () => { stopDragging(); diff --git a/packages/block-editor/src/components/media-placeholder/index.js b/packages/block-editor/src/components/media-placeholder/index.js index e7b6c836468f02..0cbc6c8c26203f 100644 --- a/packages/block-editor/src/components/media-placeholder/index.js +++ b/packages/block-editor/src/components/media-placeholder/index.js @@ -19,7 +19,6 @@ import { __ } from '@wordpress/i18n'; import { useState, useEffect } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { keyboardReturn } from '@wordpress/icons'; -import { pasteHandler } from '@wordpress/blocks'; import deprecated from '@wordpress/deprecated'; /** @@ -29,6 +28,7 @@ import MediaUpload from '../media-upload'; import MediaUploadCheck from '../media-upload/check'; import URLPopover from '../url-popover'; import { store as blockEditorStore } from '../../store'; +import { parseDropEvent } from '../use-on-block-drop'; const noop = () => {}; @@ -229,30 +229,15 @@ export function MediaPlaceholder( { } ); }; - async function handleBlocksDrop( blocks ) { - if ( ! blocks || ! Array.isArray( blocks ) ) { - return; - } + async function handleBlocksDrop( event ) { + const { blocks } = parseDropEvent( event ); - function recursivelyFindMediaFromBlocks( _blocks ) { - return _blocks.flatMap( ( block ) => - ( block.name === 'core/image' || - block.name === 'core/audio' || - block.name === 'core/video' ) && - ( block.attributes.url || block.attributes.src ) - ? [ block ] - : recursivelyFindMediaFromBlocks( block.innerBlocks ) - ); - } - - const mediaBlocks = recursivelyFindMediaFromBlocks( blocks ); - - if ( ! mediaBlocks.length ) { + if ( ! blocks?.length ) { return; } const uploadedMediaList = await Promise.all( - mediaBlocks.map( ( block ) => { + blocks.map( ( block ) => { const blockType = block.name.split( '/' )[ 1 ]; if ( block.attributes.id ) { block.attributes.type = blockType; @@ -292,13 +277,6 @@ export function MediaPlaceholder( { } } - async function onDrop( event ) { - const blocks = pasteHandler( { - HTML: event.dataTransfer?.getData( 'default' ), - } ); - return await handleBlocksDrop( blocks ); - } - const onUpload = ( event ) => { onFilesUpload( event.target.files ); }; @@ -385,7 +363,26 @@ export function MediaPlaceholder( { return null; } - return ; + return ( + { + const prefix = 'wp-block:core/'; + const types = []; + for ( const type of dataTransfer.types ) { + if ( type.startsWith( prefix ) ) { + types.push( type.slice( prefix.length ) ); + } + } + return ( + types.every( ( type ) => + allowedTypes.includes( type ) + ) && ( multiple ? true : types.length === 1 ) + ); + } } + /> + ); }; const renderCancelLink = () => { diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 5a5ce7a801594b..9779ae1300fb57 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -109,16 +109,16 @@ function getEnabledClientIdsTreeUnmemoized( state, rootClientId ) { * * @return {Object[]} Tree of block objects with only clientID and innerBlocks set. */ -export const getEnabledClientIdsTree = createRegistrySelector( ( select ) => - createSelector( getEnabledClientIdsTreeUnmemoized, ( state ) => [ +export const getEnabledClientIdsTree = createSelector( + getEnabledClientIdsTreeUnmemoized, + ( state ) => [ state.blocks.order, + state.derivedBlockEditingModes, + state.derivedNavModeBlockEditingModes, state.blockEditingModes, state.settings.templateLock, state.blockListSettings, - select( STORE_NAME ).__unstableGetEditorMode( state ), - state.zoomLevel, - getSectionRootClientId( state ), - ] ) + ] ); /** diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 2f0fa70d616fd9..edae9c392c37de 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -9,12 +9,20 @@ import fastDeepEqual from 'fast-deep-equal/es6'; import { pipe } from '@wordpress/compose'; import { combineReducers, select } from '@wordpress/data'; import deprecated from '@wordpress/deprecated'; -import { store as blocksStore } from '@wordpress/blocks'; +import { + store as blocksStore, + privateApis as blocksPrivateApis, +} from '@wordpress/blocks'; + /** * Internal dependencies */ import { PREFERENCES_DEFAULTS, SETTINGS_DEFAULTS } from './defaults'; import { insertAt, moveTo } from './array'; +import { sectionRootClientIdKey } from './private-keys'; +import { unlock } from '../lock-unlock'; + +const { isContentBlock } = unlock( blocksPrivateApis ); const identity = ( x ) => x; @@ -2131,6 +2139,632 @@ const combinedReducers = combineReducers( { zoomLevel, } ); +/** + * Retrieves a block's tree structure, handling both controlled and uncontrolled inner blocks. + * + * @param {Object} state The current state object. + * @param {string} clientId The client ID of the block to retrieve. + * + * @return {Object|undefined} The block tree object, or undefined if not found. For controlled blocks, + * returns a merged tree with controlled inner blocks. + */ +function getBlockTreeBlock( state, clientId ) { + if ( clientId === '' ) { + const rootBlock = state.blocks.tree.get( clientId ); + + if ( ! rootBlock ) { + return; + } + + // Patch the root block to have a clientId property. + // TODO - consider updating the blocks reducer so that the root block has this property. + return { + clientId: '', + ...rootBlock, + }; + } + + if ( ! state.blocks.controlledInnerBlocks[ clientId ] ) { + return state.blocks.tree.get( clientId ); + } + + const controlledTree = state.blocks.tree.get( `controlled||${ clientId }` ); + const regularTree = state.blocks.tree.get( clientId ); + + return { + ...regularTree, + innerBlocks: controlledTree?.innerBlocks, + }; +} + +/** + * Recursively traverses through a block tree of a given block and executes a callback for each block. + * + * @param {Object} state The store state. + * @param {string} clientId The clientId of the block to start traversing from. + * @param {Function} callback Function to execute for each block encountered during traversal. + * The callback receives the current block as its argument. + */ +function traverseBlockTree( state, clientId, callback ) { + const parentTree = getBlockTreeBlock( state, clientId ); + if ( ! parentTree ) { + return; + } + + callback( parentTree ); + + if ( ! parentTree?.innerBlocks?.length ) { + return; + } + + for ( const block of parentTree?.innerBlocks ) { + traverseBlockTree( state, block.clientId, callback ); + } +} + +/** + * Checks if a block has a parent in a list of client IDs, and if so returns the client ID of the parent. + * + * @param {Object} state The current state object. + * @param {string} clientId The client ID of the block to search the parents of. + * @param {Array} clientIds The client IDs of the blocks to check. + * + * @return {string|undefined} The client ID of the parent block if found, undefined otherwise. + */ +function findParentInClientIdsList( state, clientId, clientIds ) { + let parent = state.blocks.parents.get( clientId ); + while ( parent ) { + if ( clientIds.includes( parent ) ) { + return parent; + } + parent = state.blocks.parents.get( parent ); + } +} + +/** + * Checks if a block has any bindings in its metadata attributes. + * + * @param {Object} block The block object to check for bindings. + * @return {boolean} True if the block has bindings, false otherwise. + */ +function hasBindings( block ) { + return ( + block?.attributes?.metadata?.bindings && + Object.keys( block?.attributes?.metadata?.bindings ).length + ); +} + +/** + * Computes and returns derived block editing modes for a given block tree. + * + * This function calculates the editing modes for each block in the tree, taking into account + * various factors such as zoom level, navigation mode, sections, and synced patterns. + * + * @param {Object} state The current state object. + * @param {boolean} isNavMode Whether the navigation mode is active. + * @param {string} treeClientId The client ID of the root block for the tree. Defaults to an empty string. + * @return {Map} A Map containing the derived block editing modes, keyed by block client ID. + */ +function getDerivedBlockEditingModesForTree( + state, + isNavMode = false, + treeClientId = '' +) { + const isZoomedOut = + state?.zoomLevel < 100 || state?.zoomLevel === 'auto-scaled'; + const derivedBlockEditingModes = new Map(); + + // When there are sections, the majority of blocks are disabled, + // so the default block editing mode is set to disabled. + const sectionRootClientId = state.settings?.[ sectionRootClientIdKey ]; + const sectionClientIds = state.blocks.order.get( sectionRootClientId ); + const syncedPatternClientIds = Object.keys( + state.blocks.controlledInnerBlocks + ).filter( + ( clientId ) => + state.blocks.byClientId?.get( clientId )?.name === 'core/block' + ); + + traverseBlockTree( state, treeClientId, ( block ) => { + const { clientId, name: blockName } = block; + if ( isZoomedOut || isNavMode ) { + // If the root block is the section root set its editing mode to contentOnly. + if ( clientId === sectionRootClientId ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + // There are no sections, so everything else is disabled. + if ( ! sectionClientIds?.length ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + if ( sectionClientIds.includes( clientId ) ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + // If zoomed out, all blocks that aren't sections or the section root are + // disabled. + // If the tree root is not in a section, set its editing mode to disabled. + if ( + isZoomedOut || + ! findParentInClientIdsList( state, clientId, sectionClientIds ) + ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + // Handle synced pattern content so the inner blocks of a synced pattern are + // properly disabled. + if ( syncedPatternClientIds.length ) { + const parentPatternClientId = findParentInClientIdsList( + state, + clientId, + syncedPatternClientIds + ); + + if ( parentPatternClientId ) { + // This is a pattern nested in another pattern, it should be disabled. + if ( + findParentInClientIdsList( + state, + parentPatternClientId, + syncedPatternClientIds + ) + ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + if ( hasBindings( block ) ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + // Synced pattern content without a binding isn't editable + // from the instance, the user has to edit the pattern source, + // so return 'disabled'. + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + } + + if ( blockName && isContentBlock( blockName ) ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + if ( syncedPatternClientIds.length ) { + // Synced pattern blocks (core/block). + if ( syncedPatternClientIds.includes( clientId ) ) { + // This is a pattern nested in another pattern, it should be disabled. + if ( + findParentInClientIdsList( + state, + clientId, + syncedPatternClientIds + ) + ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + // Else do nothing, use the default block editing mode. + return; + } + + // Inner blocks of synced patterns. + const parentPatternClientId = findParentInClientIdsList( + state, + clientId, + syncedPatternClientIds + ); + if ( parentPatternClientId ) { + // This is a pattern nested in another pattern, it should be disabled. + if ( + findParentInClientIdsList( + state, + parentPatternClientId, + syncedPatternClientIds + ) + ) { + derivedBlockEditingModes.set( clientId, 'disabled' ); + return; + } + + if ( hasBindings( block ) ) { + derivedBlockEditingModes.set( clientId, 'contentOnly' ); + return; + } + + // Synced pattern content without a binding isn't editable + // from the instance, the user has to edit the pattern source, + // so return 'disabled'. + derivedBlockEditingModes.set( clientId, 'disabled' ); + } + } + } ); + + return derivedBlockEditingModes; +} + +/** + * Updates the derived block editing modes based on added and removed blocks. + * + * This function handles the updating of block editing modes when blocks are added, + * removed, or moved within the editor. + * + * It only returns a value when modifications are made to the block editing modes. + * + * @param {Object} options The options for updating derived block editing modes. + * @param {Object} options.prevState The previous state object. + * @param {Object} options.nextState The next state object. + * @param {Array} [options.addedBlocks] An array of blocks that were added. + * @param {Array} [options.removedClientIds] An array of client IDs of blocks that were removed. + * @param {boolean} [options.isNavMode] Whether the navigation mode is active. + * @return {Map|undefined} The updated derived block editing modes, or undefined if no changes were made. + */ +function getDerivedBlockEditingModesUpdates( { + prevState, + nextState, + addedBlocks, + removedClientIds, + isNavMode = false, +} ) { + const prevDerivedBlockEditingModes = isNavMode + ? prevState.derivedNavModeBlockEditingModes + : prevState.derivedBlockEditingModes; + let nextDerivedBlockEditingModes; + + // Perform removals before additions to handle cases like the `MOVE_BLOCKS_TO_POSITION` action. + // That action removes a set of clientIds, but adds the same blocks back in a different location. + // If removals were performed after additions, those moved clientIds would be removed incorrectly. + removedClientIds?.forEach( ( clientId ) => { + // The actions only receive parent block IDs for removal. + // Recurse through the block tree to ensure all blocks are removed. + // Specifically use the previous state, before the blocks were removed. + traverseBlockTree( prevState, clientId, ( block ) => { + if ( prevDerivedBlockEditingModes.has( block.clientId ) ) { + if ( ! nextDerivedBlockEditingModes ) { + nextDerivedBlockEditingModes = new Map( + prevDerivedBlockEditingModes + ); + } + nextDerivedBlockEditingModes.delete( block.clientId ); + } + } ); + } ); + + addedBlocks?.forEach( ( addedBlock ) => { + traverseBlockTree( nextState, addedBlock.clientId, ( block ) => { + const updates = getDerivedBlockEditingModesForTree( + nextState, + isNavMode, + block.clientId + ); + + if ( updates.size ) { + if ( ! nextDerivedBlockEditingModes ) { + nextDerivedBlockEditingModes = new Map( [ + ...( prevDerivedBlockEditingModes?.size + ? prevDerivedBlockEditingModes + : [] ), + ...updates, + ] ); + } else { + nextDerivedBlockEditingModes = new Map( [ + ...( nextDerivedBlockEditingModes?.size + ? nextDerivedBlockEditingModes + : [] ), + ...updates, + ] ); + } + } + } ); + } ); + + return nextDerivedBlockEditingModes; +} + +/** + * Higher-order reducer that adds derived block editing modes to the state. + * + * This function wraps a reducer and enhances it to handle actions that affect + * block editing modes. It updates the derivedBlockEditingModes in the state + * based on various actions such as adding, removing, or moving blocks, or changing + * the editor mode. + * + * @param {Function} reducer The original reducer function to be wrapped. + * @return {Function} A new reducer function that includes derived block editing modes handling. + */ +export function withDerivedBlockEditingModes( reducer ) { + return ( state, action ) => { + const nextState = reducer( state, action ); + + // An exception is needed here to still recompute the block editing modes when + // the editor mode changes since the editor mode isn't stored within the + // block editor state and changing it won't trigger an altered new state. + if ( action.type !== 'SET_EDITOR_MODE' && nextState === state ) { + return state; + } + + switch ( action.type ) { + case 'REMOVE_BLOCKS': { + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + removedClientIds: action.clientIds, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + removedClientIds: action.clientIds, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'RECEIVE_BLOCKS': + case 'INSERT_BLOCKS': { + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'SET_HAS_CONTROLLED_INNER_BLOCKS': { + const updatedBlock = nextState.blocks.tree.get( + action.clientId + ); + // The block might have been removed. + if ( ! updatedBlock ) { + break; + } + + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: [ updatedBlock ], + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: [ updatedBlock ], + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'REPLACE_BLOCKS': { + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + removedClientIds: action.clientIds, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + removedClientIds: action.clientIds, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'REPLACE_INNER_BLOCKS': { + // Get the clientIds of the blocks that are being replaced + // from the old state, before they were removed. + const removedClientIds = state.blocks.order.get( + action.rootClientId + ); + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + removedClientIds, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks: action.blocks, + removedClientIds, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'MOVE_BLOCKS_TO_POSITION': { + const addedBlocks = action.clientIds.map( ( clientId ) => { + return nextState.blocks.byClientId.get( clientId ); + } ); + const nextDerivedBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks, + removedClientIds: action.clientIds, + isNavMode: false, + } ); + const nextDerivedNavModeBlockEditingModes = + getDerivedBlockEditingModesUpdates( { + prevState: state, + nextState, + addedBlocks, + removedClientIds: action.clientIds, + isNavMode: true, + } ); + + if ( + nextDerivedBlockEditingModes || + nextDerivedNavModeBlockEditingModes + ) { + return { + ...nextState, + derivedBlockEditingModes: + nextDerivedBlockEditingModes ?? + state.derivedBlockEditingModes, + derivedNavModeBlockEditingModes: + nextDerivedNavModeBlockEditingModes ?? + state.derivedNavModeBlockEditingModes, + }; + } + break; + } + case 'UPDATE_SETTINGS': { + // Recompute the entire tree if the section root changes. + if ( + state?.settings?.[ sectionRootClientIdKey ] !== + nextState?.settings?.[ sectionRootClientIdKey ] + ) { + return { + ...nextState, + derivedBlockEditingModes: + getDerivedBlockEditingModesForTree( + nextState, + false /* Nav mode off */ + ), + derivedNavModeBlockEditingModes: + getDerivedBlockEditingModesForTree( + nextState, + true /* Nav mode on */ + ), + }; + } + break; + } + case 'RESET_BLOCKS': + case 'SET_EDITOR_MODE': + case 'RESET_ZOOM_LEVEL': + case 'SET_ZOOM_LEVEL': { + // Recompute the entire tree if the editor mode or zoom level changes, + // or if all the blocks are reset. + return { + ...nextState, + derivedBlockEditingModes: + getDerivedBlockEditingModesForTree( + nextState, + false /* Nav mode off */ + ), + derivedNavModeBlockEditingModes: + getDerivedBlockEditingModesForTree( + nextState, + true /* Nav mode on */ + ), + }; + } + } + + // If there's no change, the derivedBlockEditingModes from the previous + // state need to be preserved. + nextState.derivedBlockEditingModes = + state?.derivedBlockEditingModes ?? new Map(); + nextState.derivedNavModeBlockEditingModes = + state?.derivedNavModeBlockEditingModes ?? new Map(); + + return nextState; + }; +} + function withAutomaticChangeReset( reducer ) { return ( state, action ) => { const nextState = reducer( state, action ); @@ -2184,4 +2818,7 @@ function withAutomaticChangeReset( reducer ) { }; } -export default withAutomaticChangeReset( combinedReducers ); +export default pipe( + withDerivedBlockEditingModes, + withAutomaticChangeReset +)( combinedReducers ); diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index ac1d178f43de7c..75c43770f7e175 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -3000,14 +3000,6 @@ export function __unstableIsWithinBlockOverlay( state, clientId ) { return false; } -function isWithinBlock( state, clientId, parentClientId ) { - let parent = state.blocks.parents.get( clientId ); - while ( !! parent && parent !== parentClientId ) { - parent = state.blocks.parents.get( parent ); - } - return parent === parentClientId; -} - /** * @typedef {import('../components/block-editing-mode').BlockEditingMode} BlockEditingMode */ @@ -3049,68 +3041,28 @@ export const getBlockEditingMode = createRegistrySelector( clientId = ''; } - // In zoom-out mode, override the behavior set by - // __unstableSetBlockEditingMode to only allow editing the top-level - // sections. - if ( isZoomOut( state ) ) { - const sectionRootClientId = getSectionRootClientId( state ); - - if ( clientId === '' /* ROOT_CONTAINER_CLIENT_ID */ ) { - return sectionRootClientId ? 'disabled' : 'contentOnly'; - } - if ( clientId === sectionRootClientId ) { - return 'contentOnly'; - } - const sectionsClientIds = getBlockOrder( - state, - sectionRootClientId - ); - - // Sections are always contentOnly. - if ( sectionsClientIds?.includes( clientId ) ) { - return 'contentOnly'; - } - - return 'disabled'; + const isNavMode = + select( preferencesStore )?.get( 'core', 'editorTool' ) === + 'navigation'; + + // If the editor is currently not in navigation mode, check if the clientId + // has an editing mode set in the regular derived map. + // There may be an editing mode set here for synced patterns or in zoomed out + // mode. + if ( + ! isNavMode && + state.derivedBlockEditingModes?.has( clientId ) + ) { + return state.derivedBlockEditingModes.get( clientId ); } - const editorMode = __unstableGetEditorMode( state ); - if ( editorMode === 'navigation' ) { - const sectionRootClientId = getSectionRootClientId( state ); - - // The root section is "default mode" - if ( clientId === sectionRootClientId ) { - return 'default'; - } - - // Sections should always be contentOnly in navigation mode. - const sectionsClientIds = getBlockOrder( - state, - sectionRootClientId - ); - if ( sectionsClientIds.includes( clientId ) ) { - return 'contentOnly'; - } - - // Blocks outside sections should be disabled. - const isWithinSectionRoot = isWithinBlock( - state, - clientId, - sectionRootClientId - ); - if ( ! isWithinSectionRoot ) { - return 'disabled'; - } - - // The rest of the blocks depend on whether they are content blocks or not. - // This "flattens" the sections tree. - const name = getBlockName( state, clientId ); - const { hasContentRoleAttribute } = unlock( - select( blocksStore ) - ); - const isContent = hasContentRoleAttribute( name ); - - return isContent ? 'contentOnly' : 'disabled'; + // If the editor *is* in navigation mode, the block editing mode states + // are stored in the derivedNavModeBlockEditingModes map. + if ( + isNavMode && + state.derivedNavModeBlockEditingModes?.has( clientId ) + ) { + return state.derivedNavModeBlockEditingModes.get( clientId ); } // In normal mode, consider that an explicitely set editing mode takes over. @@ -3120,7 +3072,7 @@ export const getBlockEditingMode = createRegistrySelector( } // In normal mode, top level is default mode. - if ( ! clientId ) { + if ( clientId === '' ) { return 'default'; } diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index fb1d736e175af0..268d463f227d4d 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -129,6 +129,7 @@ describe( 'private selectors', () => { getBlockEditingMode.registry = { select: jest.fn( () => ( { hasContentRoleAttribute, + get, } ) ), }; __unstableGetEditorMode.registry = { diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index c99d639ba8a09e..dd1665d6736ada 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -10,7 +10,10 @@ import { registerBlockType, unregisterBlockType, createBlock, + privateApis, } from '@wordpress/blocks'; +import { combineReducers, select } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies @@ -38,8 +41,30 @@ import { blockEditingModes, openedBlockSettingsMenu, expandedBlock, + zoomLevel, + withDerivedBlockEditingModes, } from '../reducer'; +import { unlock } from '../../lock-unlock'; +import { sectionRootClientIdKey } from '.././private-keys'; + +const { isContentBlock } = unlock( privateApis ); + +jest.mock( '@wordpress/data/src/select', () => { + const actualSelect = jest.requireActual( '@wordpress/data/src/select' ); + + return { + select: jest.fn( ( ...args ) => actualSelect.select( ...args ) ), + }; +} ); + +jest.mock( '@wordpress/blocks/src/api/utils', () => { + return { + ...jest.requireActual( '@wordpress/blocks/src/api/utils' ), + isContentBlock: jest.fn(), + }; +} ); + const noop = () => {}; describe( 'state', () => { @@ -3544,4 +3569,828 @@ describe( 'state', () => { expect( state ).toBe( null ); } ); } ); + + describe( 'withDerivedBlockEditingModes', () => { + const testReducer = withDerivedBlockEditingModes( + combineReducers( { + blocks, + settings, + zoomLevel, + } ) + ); + + function dispatchActions( actions, reducer, initialState = {} ) { + return actions.reduce( ( _state, action ) => { + return reducer( _state, action ); + }, initialState ); + } + + beforeEach( () => { + isContentBlock.mockImplementation( + ( blockName ) => blockName === 'core/paragraph' + ); + } ); + + afterAll( () => { + isContentBlock.mockRestore(); + } ); + + describe( 'edit mode', () => { + let initialState; + beforeAll( () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + + initialState = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: '', + }, + }, + { + type: 'RESET_BLOCKS', + blocks: [ + { + name: 'core/group', + clientId: 'group-1', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-2', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-2', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + }, + ], + testReducer + ); + } ); + + afterAll( () => { + select.mockRestore(); + } ); + + it( 'returns no block editing modes when zoomed out / navigation mode are not active and there are no synced patterns', () => { + expect( initialState.derivedBlockEditingModes ).toEqual( + new Map() + ); + } ); + } ); + + describe( 'synced patterns', () => { + let initialState; + beforeAll( () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + + // Simulates how the editor typically inserts controlled blocks, + // - first the pattern is inserted with no inner blocks. + // - next the pattern is marked as a controlled block. + // - finally, once the inner blocks of the pattern are received, they're inserted. + // This process is repeated for the two patterns in this test. + initialState = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: '', + }, + }, + { + type: 'RESET_BLOCKS', + blocks: [ + { + name: 'core/group', + clientId: 'group-1', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-2', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-2', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + }, + { + type: 'INSERT_BLOCKS', + rootClientId: '', + blocks: [ + { + name: 'core/block', + clientId: 'root-pattern', + attributes: {}, + innerBlocks: [], + }, + ], + }, + { + type: 'SET_HAS_CONTROLLED_INNER_BLOCKS', + clientId: 'root-pattern', + hasControlledInnerBlocks: true, + }, + { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'root-pattern', + blocks: [ + { + name: 'core/block', + clientId: 'nested-pattern', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/paragraph', + clientId: 'pattern-paragraph', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'pattern-group', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: + 'pattern-paragraph-with-overrides', + attributes: { + metadata: { + bindings: { + __default: + 'core/pattern-overrides', + }, + }, + }, + innerBlocks: [], + }, + ], + }, + ], + }, + { + type: 'SET_HAS_CONTROLLED_INNER_BLOCKS', + clientId: 'nested-pattern', + hasControlledInnerBlocks: true, + }, + { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'nested-pattern', + blocks: [ + { + name: 'core/paragraph', + clientId: 'nested-paragraph', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'nested-group', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: + 'nested-paragraph-with-overrides', + attributes: { + metadata: { + bindings: { + __default: + 'core/pattern-overrides', + }, + }, + }, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + testReducer, + initialState + ); + } ); + + afterAll( () => { + select.mockRestore(); + } ); + + it( 'returns the expected block editing modes for synced patterns', () => { + // Only the parent pattern and its own children that have bindings + // are in contentOnly mode. All other blocks are disabled. + expect( initialState.derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + 'pattern-paragraph': 'disabled', + 'pattern-group': 'disabled', + 'pattern-paragraph-with-overrides': 'contentOnly', + 'nested-pattern': 'disabled', + 'nested-paragraph': 'disabled', + 'nested-group': 'disabled', + 'nested-paragraph-with-overrides': 'disabled', + } ) + ) + ); + } ); + + it( 'removes block editing modes when synced patterns are removed', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'REMOVE_BLOCKS', + clientIds: [ 'root-pattern' ], + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( new Map() ); + } ); + + it( 'returns the expected block editing modes for synced patterns when switching to navigation mode', () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'navigation' ), + }; + } + return select( storeName ); + } ); + + const { + derivedBlockEditingModes, + derivedNavModeBlockEditingModes, + } = dispatchActions( + [ + { + type: 'SET_EDITOR_MODE', + mode: 'navigation', + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + 'pattern-paragraph': 'disabled', + 'pattern-group': 'disabled', + 'pattern-paragraph-with-overrides': 'contentOnly', // Pattern child with bindings. + 'nested-pattern': 'disabled', + 'nested-paragraph': 'disabled', + 'nested-group': 'disabled', + 'nested-paragraph-with-overrides': 'disabled', + } ) + ) + ); + + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section. + 'paragraph-1': 'contentOnly', // Content block in section. + 'group-2': 'disabled', + 'paragraph-2': 'contentOnly', // Content block in section. + 'root-pattern': 'contentOnly', // Section. + 'pattern-paragraph': 'disabled', + 'pattern-group': 'disabled', + 'pattern-paragraph-with-overrides': 'contentOnly', // Pattern child with bindings. + 'nested-pattern': 'disabled', + 'nested-paragraph': 'disabled', + 'nested-group': 'disabled', + 'nested-paragraph-with-overrides': 'disabled', + } ) + ) + ); + + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + } ); + + it( 'returns the expected block editing modes for synced patterns when switching to zoomed out mode', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'SET_ZOOM_LEVEL', + zoom: 'auto-scaled', + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section. + 'paragraph-1': 'disabled', + 'group-2': 'disabled', + 'paragraph-2': 'disabled', + 'root-pattern': 'contentOnly', // Pattern and section. + 'pattern-paragraph': 'disabled', + 'pattern-group': 'disabled', + 'pattern-paragraph-with-overrides': 'disabled', + 'nested-pattern': 'disabled', + 'nested-paragraph': 'disabled', + 'nested-group': 'disabled', + 'nested-paragraph-with-overrides': 'disabled', + } ) + ) + ); + } ); + } ); + + describe( 'navigation mode', () => { + let initialState; + + beforeAll( () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'navigation' ), + }; + } + return select( storeName ); + } ); + + initialState = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: '', + }, + }, + { + type: 'RESET_BLOCKS', + blocks: [ + { + name: 'core/group', + clientId: 'group-1', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-2', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-2', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + }, + ], + testReducer + ); + } ); + + afterAll( () => { + select.mockRestore(); + } ); + + it( 'returns the expected block editing modes', () => { + expect( initialState.derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'contentOnly', // Content block in section. + 'group-2': 'disabled', // Non-content block in section. + 'paragraph-2': 'contentOnly', // Content block in section. + } ) + ) + ); + } ); + + it( 'removes block editing modes when blocks are removed', () => { + const { derivedNavModeBlockEditingModes } = dispatchActions( + [ + { + type: 'REMOVE_BLOCKS', + clientIds: [ 'group-2' ], + }, + ], + testReducer, + initialState + ); + + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', + 'group-1': 'contentOnly', + 'paragraph-1': 'contentOnly', + } ) + ) + ); + } ); + + it( 'updates block editing modes when new blocks are inserted', () => { + const { derivedNavModeBlockEditingModes } = dispatchActions( + [ + { + type: 'INSERT_BLOCKS', + rootClientId: '', + blocks: [ + { + name: 'core/group', + clientId: 'group-3', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-3', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-4', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + testReducer, + initialState + ); + + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'contentOnly', // Content block in section. + 'group-2': 'disabled', // Non-content block in section. + 'paragraph-2': 'contentOnly', // Content block in section. + 'group-3': 'contentOnly', // New section block. + 'paragraph-3': 'contentOnly', // New content block in section. + 'group-4': 'disabled', // Non-content block in section. + } ) + ) + ); + } ); + + it( 'updates block editing modes when blocks are moved to a new position', () => { + const { derivedNavModeBlockEditingModes } = dispatchActions( + [ + { + type: 'MOVE_BLOCKS_TO_POSITION', + clientIds: [ 'group-2' ], + fromRootClientId: 'group-1', + toRootClientId: '', + }, + ], + testReducer, + initialState + ); + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'contentOnly', // Content block in section. + 'group-2': 'contentOnly', // New section block. + 'paragraph-2': 'contentOnly', // Still a content block in a section. + } ) + ) + ); + } ); + + it( 'handles changes to the section root', () => { + const { derivedNavModeBlockEditingModes } = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: 'group-1', + }, + }, + ], + testReducer, + initialState + ); + + expect( derivedNavModeBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'disabled', + 'group-1': 'contentOnly', + 'paragraph-1': 'contentOnly', + 'group-2': 'contentOnly', + 'paragraph-2': 'contentOnly', + } ) + ) + ); + } ); + } ); + + describe( 'zoom out mode', () => { + let initialState; + + beforeAll( () => { + initialState = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: '', + }, + }, + { + type: 'SET_ZOOM_LEVEL', + zoom: 'auto-scaled', + }, + { + type: 'RESET_BLOCKS', + blocks: [ + { + name: 'core/group', + clientId: 'group-1', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-2', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-2', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + }, + ], + testReducer + ); + } ); + + it( 'returns the expected block editing modes', () => { + expect( initialState.derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'disabled', + 'group-2': 'disabled', + 'paragraph-2': 'disabled', + } ) + ) + ); + } ); + + it( 'overrides navigation mode', () => { + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'navigation' ), + }; + } + return select( storeName ); + } ); + + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'SET_EDITOR_MODE', + mode: 'navigation', + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'disabled', + 'group-2': 'disabled', + 'paragraph-2': 'disabled', + } ) + ) + ); + + select.mockImplementation( ( storeName ) => { + if ( storeName === preferencesStore ) { + return { + get: jest.fn( () => 'edit' ), + }; + } + return select( storeName ); + } ); + } ); + + it( 'removes block editing modes when blocks are removed', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'REMOVE_BLOCKS', + clientIds: [ 'group-2' ], + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', + 'group-1': 'contentOnly', + 'paragraph-1': 'disabled', + } ) + ) + ); + } ); + + it( 'updates block editing modes when new blocks are inserted', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'INSERT_BLOCKS', + rootClientId: '', + blocks: [ + { + name: 'core/group', + clientId: 'group-3', + attributes: {}, + innerBlocks: [ + { + name: 'core/paragraph', + clientId: 'paragraph-3', + attributes: {}, + innerBlocks: [], + }, + { + name: 'core/group', + clientId: 'group-4', + attributes: {}, + innerBlocks: [], + }, + ], + }, + ], + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'disabled', + 'group-2': 'disabled', + 'paragraph-2': 'disabled', + 'group-3': 'contentOnly', // New section block. + 'paragraph-3': 'disabled', + 'group-4': 'disabled', + } ) + ) + ); + } ); + + it( 'updates block editing modes when blocks are moved to a new position', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'MOVE_BLOCKS_TO_POSITION', + clientIds: [ 'group-2' ], + fromRootClientId: 'group-1', + toRootClientId: '', + }, + ], + testReducer, + initialState + ); + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'contentOnly', // Section root. + 'group-1': 'contentOnly', // Section block. + 'paragraph-1': 'disabled', + 'group-2': 'contentOnly', // New section block. + 'paragraph-2': 'disabled', + } ) + ) + ); + } ); + + it( 'handles changes to the section root', () => { + const { derivedBlockEditingModes } = dispatchActions( + [ + { + type: 'UPDATE_SETTINGS', + settings: { + [ sectionRootClientIdKey ]: 'group-1', + }, + }, + ], + testReducer, + initialState + ); + + expect( derivedBlockEditingModes ).toEqual( + new Map( + Object.entries( { + '': 'disabled', + 'group-1': 'contentOnly', // New section root. + 'paragraph-1': 'contentOnly', // Section block. + 'group-2': 'contentOnly', // Section block. + 'paragraph-2': 'disabled', + } ) + ) + ); + } ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 7c0361449c5fca..7692bd6bf2cbb6 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -9,14 +9,12 @@ import { import { RawHTML } from '@wordpress/element'; import { symbol } from '@wordpress/icons'; import { select, dispatch } from '@wordpress/data'; -import { store as preferencesStore } from '@wordpress/preferences'; /** * Internal dependencies */ import * as selectors from '../selectors'; import { store } from '../'; -import { sectionRootClientIdKey } from '../private-keys'; import { lock } from '../../lock-unlock'; const { @@ -4469,29 +4467,19 @@ describe( 'getBlockEditingMode', () => { blockEditingModes: new Map( [] ), }; - const navigationModeStateWithRootSection = { - ...baseState, - settings: { - [ sectionRootClientIdKey ]: 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337', // The group is the "main" container - }, - }; - const hasContentRoleAttribute = jest.fn( () => false ); + const get = jest.fn( () => 'edit' ); - const fauxPrivateAPIs = {}; + const mockedSelectors = { get }; - lock( fauxPrivateAPIs, { + lock( mockedSelectors, { hasContentRoleAttribute, } ); getBlockEditingMode.registry = { - select: jest.fn( () => fauxPrivateAPIs ), + select: jest.fn( () => mockedSelectors ), }; - afterEach( () => { - dispatch( preferencesStore ).set( 'core', 'editorTool', undefined ); - } ); - it( 'should return default by default', () => { expect( getBlockEditingMode( @@ -4614,98 +4602,4 @@ describe( 'getBlockEditingMode', () => { getBlockEditingMode( state, 'b3247f75-fd94-4fef-97f9-5bfd162cc416' ) ).toBe( 'contentOnly' ); } ); - - describe( 'navigation mode', () => { - const writeModeExperiment = window.__experimentalEditorWriteMode; - beforeAll( () => { - window.__experimentalEditorWriteMode = true; - } ); - afterAll( () => { - window.__experimentalEditorWriteMode = writeModeExperiment; - } ); - it( 'in navigation mode, the root section container is default', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - 'ef45d5fd-5234-4fd5-ac4f-c3736c7f9337' - ) - ).toBe( 'default' ); - } ); - - it( 'in navigation mode, anything outside the section container is disabled', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - '6cf70164-9097-4460-bcbf-200560546988' - ) - ).toBe( 'disabled' ); - } ); - - it( 'in navigation mode, sections are contentOnly', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - 'b26fc763-417d-4f01-b81c-2ec61e14a972' - ) - ).toBe( 'contentOnly' ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - '9b9c5c3f-2e46-4f02-9e14-9fe9515b958f' - ) - ).toBe( 'contentOnly' ); - } ); - - it( 'in navigation mode, blocks with content attributes within sections are contentOnly', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - hasContentRoleAttribute.mockReturnValueOnce( true ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - 'b3247f75-fd94-4fef-97f9-5bfd162cc416' - ) - ).toBe( 'contentOnly' ); - - hasContentRoleAttribute.mockReturnValueOnce( true ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - 'e178812d-ce5e-48c7-a945-8ae4ffcbbb7c' - ) - ).toBe( 'contentOnly' ); - } ); - - it( 'in navigation mode, blocks without content attributes within sections are disabled', () => { - dispatch( preferencesStore ).set( - 'core', - 'editorTool', - 'navigation' - ); - expect( - getBlockEditingMode( - navigationModeStateWithRootSection, - '9b9c5c3f-2e46-4f02-9e14-9fed515b958s' - ) - ).toBe( 'disabled' ); - } ); - } ); } ); diff --git a/packages/block-library/src/block/edit.js b/packages/block-library/src/block/edit.js index 104b07157cba74..3d4d07e52b386a 100644 --- a/packages/block-library/src/block/edit.js +++ b/packages/block-library/src/block/edit.js @@ -7,7 +7,7 @@ import clsx from 'clsx'; * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; -import { useRef, useMemo, useEffect } from '@wordpress/element'; +import { useRef, useMemo } from '@wordpress/element'; import { useEntityRecord, store as coreStore, @@ -37,12 +37,10 @@ import { getBlockBindingsSource } from '@wordpress/blocks'; /** * Internal dependencies */ -import { name as patternBlockName } from './index'; import { unlock } from '../lock-unlock'; const { useLayoutClasses } = unlock( blockEditorPrivateApis ); -const { isOverridableBlock, hasOverridableBlocks } = - unlock( patternsPrivateApis ); +const { hasOverridableBlocks } = unlock( patternsPrivateApis ); const fullAlignments = [ 'full', 'wide', 'left', 'right' ]; @@ -75,22 +73,6 @@ const useInferredLayout = ( blocks, parentLayout ) => { }, [ blocks, parentLayout ] ); }; -function setBlockEditMode( setEditMode, blocks, mode ) { - blocks.forEach( ( block ) => { - const editMode = - mode || - ( isOverridableBlock( block ) ? 'contentOnly' : 'disabled' ); - setEditMode( block.clientId, editMode ); - - setBlockEditMode( - setEditMode, - block.innerBlocks, - // Disable editing for nested patterns. - block.name === patternBlockName ? 'disabled' : mode - ); - } ); -} - function RecursionWarning() { const blockProps = useBlockProps(); return ( @@ -171,7 +153,6 @@ function ReusableBlockEdit( { name, attributes: { ref, content }, __unstableParentLayout: parentLayout, - clientId: patternClientId, setAttributes, } ) { const { record, hasResolved } = useEntityRecord( @@ -184,49 +165,24 @@ function ReusableBlockEdit( { } ); const isMissing = hasResolved && ! record; - const { setBlockEditingMode, __unstableMarkLastChangeAsPersistent } = + const { __unstableMarkLastChangeAsPersistent } = useDispatch( blockEditorStore ); - const { - innerBlocks, - onNavigateToEntityRecord, - editingMode, - hasPatternOverridesSource, - } = useSelect( + const { onNavigateToEntityRecord, hasPatternOverridesSource } = useSelect( ( select ) => { - const { getBlocks, getSettings, getBlockEditingMode } = - select( blockEditorStore ); + const { getSettings } = select( blockEditorStore ); // For editing link to the site editor if the theme and user permissions support it. return { - innerBlocks: getBlocks( patternClientId ), onNavigateToEntityRecord: getSettings().onNavigateToEntityRecord, - editingMode: getBlockEditingMode( patternClientId ), hasPatternOverridesSource: !! getBlockBindingsSource( 'core/pattern-overrides' ), }; }, - [ patternClientId ] + [] ); - // Sync the editing mode of the pattern block with the inner blocks. - useEffect( () => { - setBlockEditMode( - setBlockEditingMode, - innerBlocks, - // Disable editing if the pattern itself is disabled. - editingMode === 'disabled' || ! hasPatternOverridesSource - ? 'disabled' - : undefined - ); - }, [ - editingMode, - innerBlocks, - setBlockEditingMode, - hasPatternOverridesSource, - ] ); - const canOverrideBlocks = useMemo( () => hasPatternOverridesSource && hasOverridableBlocks( blocks ), [ hasPatternOverridesSource, blocks ] @@ -244,7 +200,6 @@ function ReusableBlockEdit( { } ); const innerBlocksProps = useInnerBlocksProps( blockProps, { - templateLock: 'all', layout, value: blocks, onInput: NOOP, diff --git a/packages/block-library/src/comments-pagination/editor.scss b/packages/block-library/src/comments-pagination/editor.scss index a875c9e0ee21ce..3cd99c632ee833 100644 --- a/packages/block-library/src/comments-pagination/editor.scss +++ b/packages/block-library/src/comments-pagination/editor.scss @@ -26,6 +26,7 @@ $pagination-margin: 0.5em; margin-right: $pagination-margin; margin-bottom: $pagination-margin; + font-size: inherit; &:last-child { /*rtl:ignore*/ margin-right: 0; diff --git a/packages/block-library/src/comments-pagination/style.scss b/packages/block-library/src/comments-pagination/style.scss index c6b5d9a0a29e91..2fb6e3dd2d48f4 100644 --- a/packages/block-library/src/comments-pagination/style.scss +++ b/packages/block-library/src/comments-pagination/style.scss @@ -8,6 +8,7 @@ $pagination-margin: 0.5em; margin-right: $pagination-margin; margin-bottom: $pagination-margin; + font-size: inherit; &:last-child { /*rtl:ignore*/ margin-right: 0; diff --git a/packages/block-library/src/missing/test/edit.native.js b/packages/block-library/src/missing/test/edit.native.js index 47d0da572b7c88..eba1169ae643b7 100644 --- a/packages/block-library/src/missing/test/edit.native.js +++ b/packages/block-library/src/missing/test/edit.native.js @@ -10,7 +10,6 @@ import { Text } from 'react-native'; import { BottomSheet, Icon } from '@wordpress/components'; import { help, plugins } from '@wordpress/icons'; import { storeConfig } from '@wordpress/block-editor'; -jest.mock( '@wordpress/blocks' ); jest.mock( '@wordpress/block-editor/src/store/selectors' ); /** diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index ae3b9620a33584..68b23aceeced65 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -1436,20 +1436,6 @@ function block_core_navigation_get_most_recently_published_navigation() { return null; } -/** - * Accepts the serialized markup of a block and its inner blocks, and returns serialized markup of the inner blocks. - * - * @since 6.5.0 - * - * @param string $serialized_block The serialized markup of a block and its inner blocks. - * @return string - */ -function block_core_navigation_remove_serialized_parent_block( $serialized_block ) { - $start = strpos( $serialized_block, '-->' ) + strlen( '-->' ); - $end = strrpos( $serialized_block, ' +

Pattern Overrides

+ + +

Post Meta Binding

+ + +

No Overrides or Binding

+ + `; + + const { id } = await requestUtils.createBlock( { + title: 'Pattern', + content, + status: 'publish', + } ); + + await admin.visitSiteEditor( { + postId: 'emptytheme//index', + postType: 'wp_template', + canvas: 'edit', + } ); + + await editor.setContent( '' ); + + await editor.insertBlock( { + name: 'core/block', + attributes: { ref: id }, + } ); + + const patternBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Pattern', + } ); + const paragraphs = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + const blockWithOverrides = paragraphs.filter( { + hasText: 'Pattern Overrides', + } ); + const blockWithBindings = paragraphs.filter( { + hasText: 'Post Meta Binding', + } ); + const blockWithoutOverridesOrBindings = paragraphs.filter( { + hasText: 'No Overrides or Binding', + } ); + + await test.step( 'Zoomed in / Design mode', async () => { + await editor.switchEditorTool( 'Design' ); + // In zoomed in and design mode the pattern block and child blocks + // with bindings are editable. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithOverrides ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed in / Write mode - pattern as a section', async () => { + await editor.switchEditorTool( 'Write' ); + // The pattern block is still editable as a section. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + // Child blocks of the pattern with bindings are editable. + await expect( blockWithOverrides ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed out / Write mode - pattern as a section', async () => { + await page.getByLabel( 'Zoom Out' ).click(); + // In zoomed out only the pattern block is editable, as in this scenario it's a section. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithOverrides ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed out / Design mode - pattern as a section', async () => { + await editor.switchEditorTool( 'Design' ); + // In zoomed out only the pattern block is editable, as in this scenario it's a section. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithOverrides ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + // Zoom out and group the pattern. + await page.getByLabel( 'Zoom Out' ).click(); + await editor.selectBlocks( patternBlock ); + await editor.clickBlockOptionsMenuItem( 'Group' ); + + await test.step( 'Zoomed in / Write mode - pattern nested in a section', async () => { + await editor.switchEditorTool( 'Write' ); + // The pattern block is not inert as it has editable content, but it shouldn't be selectable. + // TODO: find a way to test that the block is not selectable. + await expect( patternBlock ).not.toHaveAttribute( + 'inert', + 'true' + ); + // Child blocks of the pattern are editable as normal. + await expect( blockWithOverrides ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).not.toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed out / Write mode - pattern nested in a section', async () => { + await page.getByLabel( 'Zoom Out' ).click(); + // None of the pattern is editable in zoomed out when nested in a section. + await expect( patternBlock ).toHaveAttribute( 'inert', 'true' ); + await expect( blockWithOverrides ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + + await test.step( 'Zoomed out / Design mode - pattern nested in a section', async () => { + await editor.switchEditorTool( 'Design' ); + // None of the pattern is editable in zoomed out when nested in a section. + await expect( patternBlock ).toHaveAttribute( 'inert', 'true' ); + await expect( blockWithOverrides ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithBindings ).toHaveAttribute( + 'inert', + 'true' + ); + await expect( blockWithoutOverridesOrBindings ).toHaveAttribute( + 'inert', + 'true' + ); + } ); + } ); + + test( 'disables editing of nested patterns', async ( { + page, + admin, + requestUtils, + editor, + } ) => { + const paragraphName = 'Editable paragraph'; + const headingName = 'Editable heading'; + const innerPattern = await requestUtils.createBlock( { + title: 'Inner Pattern', + content: ` +

Inner paragraph

+ `, + status: 'publish', + } ); + const outerPattern = await requestUtils.createBlock( { + title: 'Outer Pattern', + content: ` +

Outer heading

+ + `, + status: 'publish', + } ); + + await admin.createNewPost(); + + await editor.insertBlock( { + name: 'core/block', + attributes: { ref: outerPattern.id }, + } ); + + // Make an edit to the outer pattern heading. + await editor.canvas + .getByRole( 'document', { name: 'Block: Heading' } ) + .fill( 'Outer heading (edited)' ); + + const postId = await editor.publishPost(); + + // Check the pattern has the correct attributes. + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/block', + attributes: { + ref: outerPattern.id, + content: { + [ headingName ]: { + content: 'Outer heading (edited)', + }, + }, + }, + innerBlocks: [], + }, + ] ); + // Check it renders correctly. + const headingBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Heading', + } ); + const paragraphBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ); + await expect( headingBlock ).toHaveText( 'Outer heading (edited)' ); + await expect( headingBlock ).not.toHaveAttribute( 'inert', 'true' ); + await expect( paragraphBlock ).toHaveText( + 'Inner paragraph (edited)' + ); + await expect( paragraphBlock ).toHaveAttribute( 'inert', 'true' ); + + // Edit the outer pattern. + await editor.selectBlocks( + editor.canvas + .getByRole( 'document', { name: 'Block: Pattern' } ) + .first() + ); + await editor.showBlockToolbar(); + await page + .getByRole( 'toolbar', { name: 'Block tools' } ) + .getByRole( 'button', { name: 'Edit original' } ) + .click(); + + // The inner paragraph should be editable in the pattern focus mode. + await editor.selectBlocks( + editor.canvas + .getByRole( 'document', { name: 'Block: Pattern' } ) + .first() + ); + await expect( + editor.canvas.getByRole( 'document', { + name: 'Block: Paragraph', + } ), + 'The inner paragraph should be editable' + ).not.toHaveAttribute( 'inert', 'true' ); + + // Visit the post on the frontend. + await page.goto( `/?p=${ postId }` ); + + await expect( + page.getByRole( 'heading', { level: 2 } ) + ).toHaveText( 'Outer heading (edited)' ); + await expect( + page.getByText( 'Inner paragraph (edited)' ) + ).toBeVisible(); + } ); + } ); + test( 'retains override values when converting a pattern block to regular blocks', async ( { page, admin, @@ -425,107 +744,6 @@ test.describe( 'Pattern Overrides', () => { await expect( buttonLink ).toHaveAttribute( 'rel', /^\s*nofollow\s*$/ ); } ); - test( 'disables editing of nested patterns', async ( { - page, - admin, - requestUtils, - editor, - } ) => { - const paragraphName = 'Editable paragraph'; - const headingName = 'Editable heading'; - const innerPattern = await requestUtils.createBlock( { - title: 'Inner Pattern', - content: ` -

Inner paragraph

-`, - status: 'publish', - } ); - const outerPattern = await requestUtils.createBlock( { - title: 'Outer Pattern', - content: ` -

Outer heading

- -`, - status: 'publish', - } ); - - await admin.createNewPost(); - - await editor.insertBlock( { - name: 'core/block', - attributes: { ref: outerPattern.id }, - } ); - - // Make an edit to the outer pattern heading. - await editor.canvas - .getByRole( 'document', { name: 'Block: Heading' } ) - .fill( 'Outer heading (edited)' ); - - const postId = await editor.publishPost(); - - // Check the pattern has the correct attributes. - await expect.poll( editor.getBlocks ).toMatchObject( [ - { - name: 'core/block', - attributes: { - ref: outerPattern.id, - content: { - [ headingName ]: { - content: 'Outer heading (edited)', - }, - }, - }, - innerBlocks: [], - }, - ] ); - // Check it renders correctly. - const headingBlock = editor.canvas.getByRole( 'document', { - name: 'Block: Heading', - } ); - const paragraphBlock = editor.canvas.getByRole( 'document', { - name: 'Block: Paragraph', - } ); - await expect( headingBlock ).toHaveText( 'Outer heading (edited)' ); - await expect( headingBlock ).not.toHaveAttribute( 'inert', 'true' ); - await expect( paragraphBlock ).toHaveText( 'Inner paragraph (edited)' ); - await expect( paragraphBlock ).toHaveAttribute( 'inert', 'true' ); - - // Edit the outer pattern. - await editor.selectBlocks( - editor.canvas - .getByRole( 'document', { name: 'Block: Pattern' } ) - .first() - ); - await editor.showBlockToolbar(); - await page - .getByRole( 'toolbar', { name: 'Block tools' } ) - .getByRole( 'button', { name: 'Edit original' } ) - .click(); - - // The inner paragraph should be editable in the pattern focus mode. - await editor.selectBlocks( - editor.canvas - .getByRole( 'document', { name: 'Block: Pattern' } ) - .first() - ); - await expect( - editor.canvas.getByRole( 'document', { - name: 'Block: Paragraph', - } ), - 'The inner paragraph should be editable' - ).not.toHaveAttribute( 'inert', 'true' ); - - // Visit the post on the frontend. - await page.goto( `/?p=${ postId }` ); - - await expect( page.getByRole( 'heading', { level: 2 } ) ).toHaveText( - 'Outer heading (edited)' - ); - await expect( - page.getByText( 'Inner paragraph (edited)' ) - ).toBeVisible(); - } ); - test( 'resets overrides after clicking the reset button', async ( { page, admin, @@ -993,7 +1211,11 @@ test.describe( 'Pattern Overrides', () => { page.getByRole( 'button', { name: 'Dismiss this notice' } ) ).toBeVisible(); - patternId = new URL( page.url() ).searchParams.get( 'postId' ); + patternId = await page.evaluate( () => { + return window.wp.data + .select( 'core/editor' ) + .getCurrentPostId(); + } ); } ); await test.step( 'create a post and insert the pattern with synced values', async () => { diff --git a/test/e2e/specs/site-editor/browser-history.spec.js b/test/e2e/specs/site-editor/browser-history.spec.js index eaafb3aad1b3fd..a2326d10e3cc51 100644 --- a/test/e2e/specs/site-editor/browser-history.spec.js +++ b/test/e2e/specs/site-editor/browser-history.spec.js @@ -21,13 +21,13 @@ test.describe( 'Site editor browser history', () => { await page.click( 'role=button[name="Templates"]' ); await page.getByRole( 'link', { name: 'Index' } ).click(); await expect( page ).toHaveURL( - '/wp-admin/site-editor.php?postId=emptytheme%2F%2Findex&postType=wp_template&canvas=edit' + '/wp-admin/site-editor.php?p=%2Fwp_template%2Femptytheme%2F%2Findex&canvas=edit' ); // Navigate back to the template list await page.goBack(); await expect( page ).toHaveURL( - '/wp-admin/site-editor.php?postType=wp_template' + '/wp-admin/site-editor.php?p=%2Ftemplate' ); // Navigate back to the dashboard diff --git a/test/e2e/specs/site-editor/command-center.spec.js b/test/e2e/specs/site-editor/command-center.spec.js index 19318081aa171b..197a01c43c8b46 100644 --- a/test/e2e/specs/site-editor/command-center.spec.js +++ b/test/e2e/specs/site-editor/command-center.spec.js @@ -28,7 +28,7 @@ test.describe( 'Site editor command palette', () => { await page.keyboard.type( 'new page' ); await page.getByRole( 'option', { name: 'Add new page' } ).click(); await expect( page ).toHaveURL( - /\/wp-admin\/site-editor.php\?postId=(\d+)&postType=page&canvas=edit/ + /\/wp-admin\/site-editor.php\?p=%2Fpage%2F(\d+)&canvas=edit/ ); await expect( editor.canvas diff --git a/test/e2e/specs/site-editor/homepage-settings.spec.js b/test/e2e/specs/site-editor/homepage-settings.spec.js new file mode 100644 index 00000000000000..d53130af23ac8b --- /dev/null +++ b/test/e2e/specs/site-editor/homepage-settings.spec.js @@ -0,0 +1,72 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.describe( 'Homepage Settings via Editor', () => { + test.beforeAll( async ( { requestUtils } ) => { + await Promise.all( [ requestUtils.activateTheme( 'emptytheme' ) ] ); + await requestUtils.createPage( { + title: 'Homepage', + status: 'publish', + } ); + } ); + + test.beforeEach( async ( { admin, page } ) => { + await admin.visitSiteEditor(); + await page.getByRole( 'button', { name: 'Pages' } ).click(); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await Promise.all( [ + requestUtils.deleteAllPages(), + requestUtils.updateSiteSettings( { + show_on_front: 'posts', + page_on_front: 0, + page_for_posts: 0, + } ), + ] ); + } ); + + test( 'should show "Set as homepage" action on pages with `publish` status', async ( { + page, + } ) => { + const samplePage = page + .getByRole( 'gridcell' ) + .getByLabel( 'Homepage' ); + const samplePageRow = page + .getByRole( 'row' ) + .filter( { has: samplePage } ); + await samplePageRow.hover(); + await samplePageRow + .getByRole( 'button', { + name: 'Actions', + } ) + .click(); + await expect( + page.getByRole( 'menuitem', { name: 'Set as homepage' } ) + ).toBeVisible(); + } ); + + test( 'should not show "Set as homepage" action on current homepage', async ( { + page, + } ) => { + const samplePage = page + .getByRole( 'gridcell' ) + .getByLabel( 'Homepage' ); + const samplePageRow = page + .getByRole( 'row' ) + .filter( { has: samplePage } ); + await samplePageRow.click(); + await samplePageRow + .getByRole( 'button', { + name: 'Actions', + } ) + .click(); + await page.getByRole( 'menuitem', { name: 'Set as homepage' } ).click(); + await page.getByRole( 'button', { name: 'Set homepage' } ).click(); + await expect( + page.getByRole( 'menuitem', { name: 'Set as homepage' } ) + ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/site-editor/hybrid-theme.spec.js b/test/e2e/specs/site-editor/hybrid-theme.spec.js index b568aaf4445b5c..042cb1042cac22 100644 --- a/test/e2e/specs/site-editor/hybrid-theme.spec.js +++ b/test/e2e/specs/site-editor/hybrid-theme.spec.js @@ -33,7 +33,7 @@ test.describe( 'Hybrid theme', () => { ); await expect( page ).toHaveURL( - '/wp-admin/site-editor.php?postType=wp_template_part' + '/wp-admin/site-editor.php?p=%2Fpattern&postType=wp_template_part' ); await expect( 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 f26fb8e13b8c3c..a0cc0af5463aed 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 @@ -44,7 +44,7 @@ test.describe( 'Site editor url navigation', () => { .click(); await page.getByRole( 'option', { name: 'Demo' } ).click(); await expect( page ).toHaveURL( - '/wp-admin/site-editor.php?postId=emptytheme%2F%2Fsingle-post-demo&postType=wp_template&canvas=edit' + '/wp-admin/site-editor.php?p=%2Fwp_template%2Femptytheme%2F%2Fsingle-post-demo&canvas=edit' ); } ); @@ -63,7 +63,7 @@ test.describe( 'Site editor url navigation', () => { await page.type( 'role=dialog >> role=textbox[name="Name"i]', 'Demo' ); await page.keyboard.press( 'Enter' ); await expect( page ).toHaveURL( - '/wp-admin/site-editor.php?postId=emptytheme%2F%2Fdemo&postType=wp_template_part&canvas=edit' + '/wp-admin/site-editor.php?p=%2Fwp_template_part%2Femptytheme%2F%2Fdemo&canvas=edit' ); } );