diff --git a/.eslintrc.js b/.eslintrc.js index d0c22090b93e87..e5f42eea656b90 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -137,6 +137,11 @@ const restrictedSyntax = [ message: 'Avoid truthy checks on length property rendering, as zero length is rendered verbatim.', }, + { + selector: + 'CallExpression[callee.name=/^(__|_x|_n|_nx)$/] > Literal[value=/^toggle\\b/i]', + message: "Avoid using the verb 'Toggle' in translatable strings", + }, ]; /** `no-restricted-syntax` rules for components. */ diff --git a/.github/workflows/sync-assets-to-plugin-repo.yml b/.github/workflows/sync-assets-to-plugin-repo.yml new file mode 100644 index 00000000000000..c841b3ffc79579 --- /dev/null +++ b/.github/workflows/sync-assets-to-plugin-repo.yml @@ -0,0 +1,48 @@ +name: Sync Gutenberg plugin assets to WordPress.org plugin repo + +on: + push: + branches: + - trunk + paths: + - assets/** + +jobs: + sync-assets: + name: Sync assets to WordPress.org plugin repo + runs-on: ubuntu-latest + environment: wp.org plugin + env: + PLUGIN_REPO_URL: 'https://plugins.svn.wordpress.org/gutenberg' + SVN_USERNAME: ${{ secrets.SVN_USERNAME }} + SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} + + steps: + - name: Check out Gutenberg assets folder from WP.org plugin repo + run: | + svn checkout "$PLUGIN_REPO_URL/assets" \ + --username "$SVN_USERNAME" --password "$SVN_PASSWORD" + + - name: Delete everything + run: find assets -type f -not -path 'assets/.svn/*' -delete + + - name: Checkout assets from current release + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + sparse-checkout: | + assets + show-progress: ${{ runner.debug == '1' && 'true' || 'false' }} + path: git + + - name: Copy files from git checkout to svn working copy + run: cp -R git/assets/* assets + + - name: Commit the updated assets + working-directory: ./assets + run: | + svn st | awk '/^?/ {print $2}' | xargs -r svn add + svn st | awk '/^!/ {print $2}' | xargs -r svn rm + svn commit . \ + -m "Sync assets for commit $GITHUB_SHA" \ + --no-auth-cache --non-interactive --username "$SVN_USERNAME" --password "$SVN_PASSWORD" \ + --config-option=servers:global:http-timeout=600 diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 00000000000000..e437ec744d3807 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,7 @@ +## Gutenberg Plugin Assets + +The contents of this directory are synced from the [`assets/` directory in the Gutenberg repository on GitHub](https://github.com/WordPress/gutenberg/tree/trunk/assets) to the [`assets/` directory of the Gutenberg WordPress.org plugin repository](https://plugins.trac.wordpress.org/browser/gutenberg/assets). **Any changes committed directly to the plugin repository on WordPress.org will be overwritten.** + +The sync is performed by a [GitHub Actions workflow](https://github.com/WordPress/gutenberg/actions/workflows/sync-assets-to-plugin-repo.yml) that is triggered whenever a file in this directory is changed. + +Since that workflow requires access to WP.org plugin repository credentials, it needs to be approved manually by a member of the Gutenberg Core team. If you don't have the necessary permissions, please ask someone in [#core-editor](https://wordpress.slack.com/archives/C02QB2JS7). diff --git a/assets/banner-1544x500.jpg b/assets/banner-1544x500.jpg new file mode 100644 index 00000000000000..12e7192dd4285e Binary files /dev/null and b/assets/banner-1544x500.jpg differ diff --git a/assets/banner-772x250.jpg b/assets/banner-772x250.jpg new file mode 100644 index 00000000000000..316f7741071cbe Binary files /dev/null and b/assets/banner-772x250.jpg differ diff --git a/assets/icon-128x128.jpg b/assets/icon-128x128.jpg new file mode 100644 index 00000000000000..051af8504a919b Binary files /dev/null and b/assets/icon-128x128.jpg differ diff --git a/assets/icon-256x256.jpg b/assets/icon-256x256.jpg new file mode 100644 index 00000000000000..b7497f61652b7b Binary files /dev/null and b/assets/icon-256x256.jpg differ diff --git a/backport-changelog/6.8/8014.md b/backport-changelog/6.8/8014.md new file mode 100644 index 00000000000000..3ff171d5fb367e --- /dev/null +++ b/backport-changelog/6.8/8014.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/8014 + +* https://github.com/WordPress/gutenberg/pull/66479 diff --git a/backport-changelog/6.8/8015.md b/backport-changelog/6.8/8015.md new file mode 100644 index 00000000000000..214705518a0e72 --- /dev/null +++ b/backport-changelog/6.8/8015.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/8015 + +* https://github.com/WordPress/gutenberg/pull/68058 diff --git a/bin/api-docs/gen-components-docs/markdown/props.mjs b/bin/api-docs/gen-components-docs/markdown/props.mjs index 9d019c4240f008..aaa73041217528 100644 --- a/bin/api-docs/gen-components-docs/markdown/props.mjs +++ b/bin/api-docs/gen-components-docs/markdown/props.mjs @@ -48,4 +48,3 @@ export function generateMarkdownPropsJson( props, { headingLevel = 2 } = {} ) { return [ { [ `h${ headingLevel }` ]: 'Props' }, ...propsJson ]; } - diff --git a/bin/plugin/lib/utils.js b/bin/plugin/lib/utils.js index 4f57269d60c772..f4ef86c96ff081 100644 --- a/bin/plugin/lib/utils.js +++ b/bin/plugin/lib/utils.js @@ -2,7 +2,7 @@ * External dependencies */ // @ts-ignore -const inquirer = require( 'inquirer' ); +const { confirm } = require( '@inquirer/prompts' ); const fs = require( 'fs' ); const childProcess = require( 'child_process' ); const { v4: uuid } = require( 'uuid' ); @@ -97,14 +97,19 @@ async function askForConfirmation( isDefault = true, abortMessage = 'Aborting.' ) { - const { isReady } = await inquirer.prompt( [ - { - type: 'confirm', - name: 'isReady', + let isReady = false; + try { + isReady = await confirm( { default: isDefault, message, - }, - ] ); + } ); + } catch ( error ) { + if ( error instanceof Error && error.name === 'ExitPromptError' ) { + console.log( 'Cancelled.' ); + process.exit( 1 ); + } + throw error; + } if ( ! isReady ) { log( formats.error( '\n' + abortMessage ) ); diff --git a/changelog.txt b/changelog.txt index 8e7c1d84d7c7da..665265aef64d46 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,6 @@ == Changelog == -= 19.9.0-rc.1 = += 19.9.0 = ## Changelog @@ -35,8 +35,8 @@ - Navigation: Enable all non-interactive formats. ([67585](https://github.com/WordPress/gutenberg/pull/67585)) - Query block: Move patterns modal to dropdown on block toolbar. ([66993](https://github.com/WordPress/gutenberg/pull/66993)) - Separator block: Allow divs to be used as separators. ([67530](https://github.com/WordPress/gutenberg/pull/67530)) -- [ New Block ] Add Query Total block for displaying total query results or ranges. ([67629](https://github.com/WordPress/gutenberg/pull/67629)) -- [Block Library]: Update the relationship of `No results` block to `ancestor`. ([48348](https://github.com/WordPress/gutenberg/pull/48348)) +- New Block: Add Query Total block for displaying total query results or ranges. ([67629](https://github.com/WordPress/gutenberg/pull/67629)) +- Block Library: Update the relationship of `No results` block to `ancestor`. ([48348](https://github.com/WordPress/gutenberg/pull/48348)) #### DataViews - Add header to the quick edit when bulk editing. ([67390](https://github.com/WordPress/gutenberg/pull/67390)) @@ -335,6 +335,7 @@ - DataViews build-wp: Don't bundle the date package. ([67612](https://github.com/WordPress/gutenberg/pull/67612)) - Keycodes: Improve tree shaking by annotating exports as pure. ([67615](https://github.com/WordPress/gutenberg/pull/67615)) - Upgrade TypeScript to 5.7 and fix types. ([67461](https://github.com/WordPress/gutenberg/pull/67461)) +- Combine the release steps to ensure that releases are tagged. ([65591](https://github.com/WordPress/gutenberg/pull/65591)) #### Testing - e2e-test-utils-playwright: Increase timeout of site-editor selector. ([66672](https://github.com/WordPress/gutenberg/pull/66672)) @@ -381,7 +382,9 @@ The following PRs were merged by first-time contributors: The following contributors merged PRs in this release: -@aaronrobertshaw @afercia @akasunil @alexflorisca @annezazu @benazeer-ben @ciampo @creador-dev @creativecoder @DAreRodz @dcalhoun @dknauss @draganescu @ellatrix @fabiankaegy @getdave @gigitux @gvgvgvijayan @gziolo @hbhalodia @im3dabasia @imrraaj @jameskoster @jeryj @jorgefilipecosta @jsnajdr @juanfra @louwie17 @Mamaduka @manzoorwanijk @matiasbenedetto @Mayank-Tripathi32 @mcsf @michalczaplinski @miminari @mirka @ntsekouras @oandregal @ockham @prajapatisagar @ramonjd @sabernhardt @SantosGuillamot @sarthaknagoshe2002 @sgomes @shail-mehta @stokesman @subodhr258 @Sukhendu2002 @t-hamano @talldan @tellthemachines @tyxla @viralsampat-multidots @wwdes @yogeshbhutkar @youknowriad +@aaronrobertshaw @afercia @akasunil @alexflorisca @annezazu @benazeer-ben @ciampo @creador-dev @creativecoder @DAreRodz @dcalhoun @dd32 @dknauss @draganescu @ellatrix @fabiankaegy @getdave @gigitux @gvgvgvijayan @gziolo @hbhalodia @im3dabasia @imrraaj @jameskoster @jeryj @jorgefilipecosta @jsnajdr @juanfra @louwie17 @Mamaduka @manzoorwanijk @matiasbenedetto @Mayank-Tripathi32 @mcsf @michalczaplinski @miminari @mirka @ntsekouras @oandregal @ockham @prajapatisagar @ramonjd @sabernhardt @SantosGuillamot @sarthaknagoshe2002 @sgomes @shail-mehta @stokesman @subodhr258 @Sukhendu2002 @t-hamano @talldan @tellthemachines @tyxla @viralsampat-multidots @wwdes @yogeshbhutkar @youknowriad + + = 19.8.0 = diff --git a/docs/how-to-guides/themes/global-settings-and-styles.md b/docs/how-to-guides/themes/global-settings-and-styles.md index f71bd67bfaf2ec..205a3ee862ce6b 100644 --- a/docs/how-to-guides/themes/global-settings-and-styles.md +++ b/docs/how-to-guides/themes/global-settings-and-styles.md @@ -1053,16 +1053,16 @@ Pseudo selectors `:hover`, `:focus`, `:visited`, `:active`, `:link`, `:any-link` #### Variations -A block can have a "style variation", as defined per the [block.json specification](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/#styles-optional). Theme authors can define the style attributes for an existing style variation using the theme.json file. Styles for unregistered style variations will be ignored. +A block can have a "style variation," as defined in the [block.json specification](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/#styles-optional). Theme authors can define the style attributes for an existing style variation using the `theme.json` file. Styles for unregistered style variations will be ignored. -Note that variations are a "block concept", they only exist bound to blocks. The `theme.json` specification respects that distinction by only allowing `variations` at the block-level but not at the top-level. It's also worth highlighting that only variations defined in the `block.json` file of the block are considered "registered": so far, the style variations added via `register_block_style` or in the client are ignored, see [this issue](https://github.com/WordPress/gutenberg/issues/49602) for more information. +Note that variations are a "block concept"—they only exist when bound to blocks. The `theme.json` specification respects this distinction by only allowing `variations` at the block level, not the top level. It’s also worth highlighting that only variations defined in the `block.json` file of the block or via `register_block_style` on the server are considered "registered" for `theme.json` styling purposes. For example, this is how to provide styles for the existing `plain` variation for the `core/quote` block: ```json { "version": 3, - "styles":{ + "styles": { "blocks": { "core/quote": { "variations": { @@ -1078,7 +1078,7 @@ For example, this is how to provide styles for the existing `plain` variation fo } ``` -The resulting CSS output is this: +The resulting CSS output is: ```css .wp-block-quote.is-style-plain { @@ -1086,6 +1086,99 @@ The resulting CSS output is this: } ``` +It is also possible for multiple block types to share the same variation styles. There are two recommended ways to define such shared styles: + +1. `theme.json` partial files +2. programmatically, using `register_block_style` + +##### Variation Theme.json Partials + +Like theme style variation partials, those for block style variations reside within a theme's `/styles` directory. However, they are differentiated from theme style variations by the introduction of a top-level property called `blockTypes`. The `blockTypes` property is an array of block types for which the block style variation has been registered. + +Additionally, a `slug` property is available to provide consistency between the different sources that may define block style variations and to decouple the `slug` from the translatable `title` property. + +The following is an example of a `theme.json` partial that defines styles for the "Variation A" block style for the Group, Columns, and Media & Text block types: + +```json +{ + "$schema": "https://schemas.wp.org/trunk/theme.json", + "version": 3, + "title": "Variation A", + "slug": "variation-a", + "blockTypes": [ "core/group", "core/columns", "core/media-text" ], + "styles": { + "color": { + "background": "#eed8d3", + "text": "#201819" + }, + "elements": { + "heading": { + "color": { + "text": "#201819" + } + } + }, + "blocks": { + "core/group": { + "color": { + "background": "#825f58", + "text": "#eed8d3" + }, + "elements": { + "heading": { + "color": { + "text": "#eed8d3" + } + } + } + } + } + } +} +``` + +##### Programmatically Registering Variation Styles + +As an alternative to `theme.json` partials, you can register variation styles at the same time as registering the variation itself through `register_block_style`. This is done by registering the block style for an array of block types while also passing a "style object" within the `style_data` option. + +The example below registers a "Green" variation for the Group and Columns blocks. Note that the style object passed via `style_data` follows the same shape as the `styles` property of a `theme.json` partial. + +```php +register_block_style( + array( 'core/group', 'core/columns' ), + array( + 'name' => 'green', + 'label' => __( 'Green' ), + 'style_data' => array( + 'color' => array( + 'background' => '#4f6f52', + 'text' => '#d2e3c8', + ), + 'blocks' => array( + 'core/group' => array( + 'color' => array( + 'background' => '#739072', + 'text' => '#e3eedd', + ), + ), + ), + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => '#ead196', + ), + ':hover' => array( + 'color' => array( + 'text' => '#ebd9b4', + ), + ), + ), + ), + ), + ) +); +``` + ### customTemplates
Supported in WordPress from version 5.9.
diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md index 437f7be20f7705..bca05d57610934 100644 --- a/docs/reference-guides/data/data-core-block-editor.md +++ b/docs/reference-guides/data/data-core-block-editor.md @@ -190,7 +190,7 @@ _Parameters_ _Returns_ -- `Object?`: Block attributes. +- `?Object`: Block attributes. ### getBlockCount @@ -448,7 +448,7 @@ Determines the items that appear in the available block transforms list. Each item object contains what's necessary to display a menu item in the transform list and handle its selection. -The 'frecency' property is a heuristic () that combines block usage frequenty and recency. +The 'frecency' property is a heuristic () that combines block usage frequency and recency. Items are returned ordered descendingly by their 'frecency'. @@ -521,7 +521,7 @@ _Properties_ - _name_ `string`: The type of block. - _attributes_ `?Object`: Attributes to pass to the newly created block. -- _attributesToCopy_ `?Array`: Attributes to be copied from adjecent blocks when inserted. +- _attributesToCopy_ `?Array`: Attributes to be copied from adjacent blocks when inserted. ### getDraggedBlockClientIds @@ -580,7 +580,7 @@ Determines the items that appear in the inserter. Includes both static items (e. Each item object contains what's necessary to display a button in the inserter and handle its selection. -The 'frecency' property is a heuristic () that combines block usage frequenty and recency. +The 'frecency' property is a heuristic () that combines block usage frequency and recency. Items are returned ordered descendingly by their 'utility' and 'frecency'. diff --git a/docs/reference-guides/data/data-core-blocks.md b/docs/reference-guides/data/data-core-blocks.md index 158b7f92529122..04292135aca51b 100644 --- a/docs/reference-guides/data/data-core-blocks.md +++ b/docs/reference-guides/data/data-core-blocks.md @@ -172,7 +172,7 @@ _Parameters_ _Returns_ -- `Object?`: Block Type. +- `?Object`: Block Type. ### getBlockTypes diff --git a/docs/reference-guides/data/data-core-edit-post.md b/docs/reference-guides/data/data-core-edit-post.md index 06fe5fc30420ae..c316a9266af98a 100644 --- a/docs/reference-guides/data/data-core-edit-post.md +++ b/docs/reference-guides/data/data-core-edit-post.md @@ -65,7 +65,7 @@ Retrieves the template of the currently edited post. _Returns_ -- `Object?`: Post Template. +- `?Object`: Post Template. ### getEditorMode diff --git a/docs/reference-guides/data/data-core-rich-text.md b/docs/reference-guides/data/data-core-rich-text.md index 55220b3ca9c5d9..8c213ee9c69ec4 100644 --- a/docs/reference-guides/data/data-core-rich-text.md +++ b/docs/reference-guides/data/data-core-rich-text.md @@ -46,7 +46,7 @@ _Parameters_ _Returns_ -- `Object?`: Format type. +- `?Object`: Format type. ### getFormatTypeForBareElement diff --git a/gutenberg.php b/gutenberg.php index 92f935669fc46e..29cd0f63b40779 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 19.9.0-rc.1 + * Version: 19.9.0 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/compat/wordpress-6.8/blocks.php b/lib/compat/wordpress-6.8/blocks.php index 8e176e58c8d7f5..dc8747c04aec2f 100644 --- a/lib/compat/wordpress-6.8/blocks.php +++ b/lib/compat/wordpress-6.8/blocks.php @@ -170,7 +170,7 @@ function gutenberg_apply_block_hooks_to_post_content( $content ) { * @return WP_REST_Response The response object. */ function gutenberg_insert_hooked_blocks_into_rest_response( $response, $post ) { - if ( empty( $response->data['content']['raw'] ) || empty( $response->data['content']['rendered'] ) ) { + if ( empty( $response->data['content']['raw'] ) ) { return $response; } @@ -185,6 +185,8 @@ function gutenberg_insert_hooked_blocks_into_rest_response( $response, $post ) { if ( 'wp_navigation' === $post->post_type ) { $wrapper_block_type = 'core/navigation'; + } elseif ( 'wp_block' === $post->post_type ) { + $wrapper_block_type = 'core/block'; } else { $wrapper_block_type = 'core/post-content'; } @@ -206,6 +208,11 @@ function gutenberg_insert_hooked_blocks_into_rest_response( $response, $post ) { $response->data['content']['raw'] = $content; + // If the rendered content was previously empty, we leave it like that. + if ( empty( $response->data['content']['rendered'] ) ) { + return $response; + } + // No need to inject hooked blocks twice. $priority = has_filter( 'the_content', 'apply_block_hooks_to_content' ); if ( false !== $priority ) { @@ -224,6 +231,7 @@ function gutenberg_insert_hooked_blocks_into_rest_response( $response, $post ) { } add_filter( 'rest_prepare_page', 'gutenberg_insert_hooked_blocks_into_rest_response', 10, 2 ); add_filter( 'rest_prepare_post', 'gutenberg_insert_hooked_blocks_into_rest_response', 10, 2 ); +add_filter( 'rest_prepare_wp_block', 'gutenberg_insert_hooked_blocks_into_rest_response', 10, 2 ); /** * Updates the wp_postmeta with the list of ignored hooked blocks @@ -272,6 +280,8 @@ function gutenberg_update_ignored_hooked_blocks_postmeta( $post ) { if ( 'wp_navigation' === $post->post_type ) { $wrapper_block_type = 'core/navigation'; + } elseif ( 'wp_block' === $post->post_type ) { + $wrapper_block_type = 'core/block'; } else { $wrapper_block_type = 'core/post-content'; } @@ -311,3 +321,4 @@ function gutenberg_update_ignored_hooked_blocks_postmeta( $post ) { } add_filter( 'rest_pre_insert_page', 'gutenberg_update_ignored_hooked_blocks_postmeta' ); add_filter( 'rest_pre_insert_post', 'gutenberg_update_ignored_hooked_blocks_postmeta' ); +add_filter( 'rest_pre_insert_wp_block', 'gutenberg_update_ignored_hooked_blocks_postmeta' ); diff --git a/lib/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php b/lib/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php new file mode 100644 index 00000000000000..f61002f435a760 --- /dev/null +++ b/lib/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php @@ -0,0 +1,205 @@ + 'id=>parent', + 'posts_per_page' => -1, + ) + ); + $query = new WP_Query( $new_args ); + $posts = $query->posts; + $result = self::sort( $posts ); + + self::$post_ids = $result['post_ids']; + self::$levels = $result['levels']; + } + + /** + * Check if the request is eligible for hierarchical sorting. + * + * @param array $request The request data. + * + * @return bool Return true if the request is eligible for hierarchical sorting. + */ + public static function is_eligible( $request ) { + if ( ! isset( $request['orderby_hierarchy'] ) || true !== $request['orderby_hierarchy'] ) { + return false; + } + + return true; + } + + public static function get_ancestor( $post_id ) { + return get_post( $post_id )->post_parent ?? 0; + } + + /** + * Sort posts by hierarchy. + * + * Takes an array of posts and sorts them based on their parent-child relationships. + * It also tracks the level depth of each post in the hierarchy. + * + * Example input: + * ``` + * [ + * ['ID' => 4, 'post_parent' => 2], + * ['ID' => 2, 'post_parent' => 0], + * ['ID' => 3, 'post_parent' => 2], + * ] + * ``` + * + * Example output: + * ``` + * [ + * 'post_ids' => [2, 4, 3], + * 'levels' => [0, 1, 1] + * ] + * ``` + * + * @param array $posts Array of post objects containing ID and post_parent properties. + * + * @return array { + * Sorted post IDs and their hierarchical levels + * + * @type array $post_ids Array of post IDs + * @type array $levels Array of levels for the corresponding post ID in the same index + * } + */ + public static function sort( $posts ) { + /* + * Arrange pages in two arrays: + * + * - $top_level: posts whose parent is 0 + * - $children: post ID as the key and an array of children post IDs as the value. + * Example: $children[10][] contains all sub-pages whose parent is 10. + * + * Additionally, keep track of the levels of each post in $levels. + * Example: $levels[10] = 0 means the post ID is a top-level page. + * + */ + $top_level = array(); + $children = array(); + foreach ( $posts as $post ) { + if ( empty( $post->post_parent ) ) { + $top_level[] = $post->ID; + } else { + $children[ $post->post_parent ][] = $post->ID; + } + } + + $ids = array(); + $levels = array(); + self::add_hierarchical_ids( $ids, $levels, 0, $top_level, $children ); + + // Process remaining children. + if ( ! empty( $children ) ) { + foreach ( $children as $parent_id => $child_ids ) { + $level = 0; + $ancestor = $parent_id; + while ( 0 !== $ancestor ) { + ++$level; + $ancestor = self::get_ancestor( $ancestor ); + } + self::add_hierarchical_ids( $ids, $levels, $level, $child_ids, $children ); + } + } + + return array( + 'post_ids' => $ids, + 'levels' => $levels, + ); + } + + private static function add_hierarchical_ids( &$ids, &$levels, $level, $to_process, $children ) { + foreach ( $to_process as $id ) { + if ( in_array( $id, $ids, true ) ) { + continue; + } + $ids[] = $id; + $levels[ $id ] = $level; + + if ( isset( $children[ $id ] ) ) { + self::add_hierarchical_ids( $ids, $levels, $level + 1, $children[ $id ], $children ); + unset( $children[ $id ] ); + } + } + } + + public static function get_post_ids() { + return self::$post_ids; + } + + public static function get_levels() { + return self::$levels; + } +} + +add_filter( + 'rest_page_collection_params', + function ( $params ) { + $params['orderby_hierarchy'] = array( + 'description' => 'Sort pages by hierarchy.', + 'type' => 'boolean', + 'default' => false, + ); + return $params; + } +); + +add_filter( + 'rest_page_query', + function ( $args, $request ) { + if ( ! Gutenberg_Hierarchical_Sort::is_eligible( $request ) ) { + return $args; + } + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $hs->run( $args ); + + // Reconfigure the args to display only the ids in the list. + $args['post__in'] = $hs->get_post_ids(); + $args['orderby'] = 'post__in'; + + return $args; + }, + 10, + 2 +); + +add_filter( + 'rest_prepare_page', + function ( $response, $post, $request ) { + if ( ! Gutenberg_Hierarchical_Sort::is_eligible( $request ) ) { + return $response; + } + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $response->data['level'] = $hs->get_levels()[ $post->ID ]; + + return $response; + }, + 10, + 3 +); diff --git a/lib/load.php b/lib/load.php index 26af78f3173c53..371f9c54e5fc4a 100644 --- a/lib/load.php +++ b/lib/load.php @@ -45,6 +45,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.8/block-comments.php'; require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-comment-controller-6-8.php'; require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-rest-post-types-controller-6-8.php'; + require __DIR__ . '/compat/wordpress-6.8/class-gutenberg-hierarchical-sort.php'; require __DIR__ . '/compat/wordpress-6.8/rest-api.php'; // Plugin specific code. diff --git a/package-lock.json b/package-lock.json index e2063a35c7d0a0..bd901baca24a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "19.9.0-rc.1", + "version": "19.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "19.9.0-rc.1", + "version": "19.9.0", "hasInstallScript": true, "license": "GPL-2.0-or-later", "workspaces": [ @@ -25,6 +25,7 @@ "@emotion/jest": "11.7.1", "@emotion/native": "11.0.0", "@geometricpanda/storybook-addon-badges": "2.0.5", + "@inquirer/prompts": "7.2.0", "@octokit/rest": "16.26.0", "@octokit/types": "6.34.0", "@octokit/webhooks-types": "5.8.0", @@ -104,7 +105,6 @@ "filenamify": "4.2.0", "glob": "7.1.2", "husky": "7.0.0", - "inquirer": "7.1.0", "jest": "29.6.2", "jest-environment-jsdom": "^29.6.2", "jest-jasmine2": "29.6.2", @@ -5265,6 +5265,264 @@ "node": ">=6.9.0" } }, + "node_modules/@inquirer/checkbox": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.0.3.tgz", + "integrity": "sha512-CEt9B4e8zFOGtc/LYeQx5m8nfqQeG/4oNNv0PUvXGG0mys+wR/WbJ3B4KfSQ4Fcr3AQfpiuFOi3fVvmPfvNbxw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.0.tgz", + "integrity": "sha512-osaBbIMEqVFjTX5exoqPXs6PilWQdjaLhGtMDXMXg/yxkHXNq43GlxGyTA35lK2HpzUgDN+Cjh/2AmqCN0QJpw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.1.tgz", + "integrity": "sha512-rmZVXy9iZvO3ZStEe/ayuuwIJ23LSF13aPMlLMTQARX6lGUBDHGV8UB5i9MRrfy0+mZwt5/9bdy8llszSD3NQA==", + "license": "MIT", + "dependencies": { + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.0.tgz", + "integrity": "sha512-Z3LeGsD3WlItDqLxTPciZDbGtm0wrz7iJGS/uUxSiQxef33ZrBq7LhsXg30P7xrWz1kZX4iGzxxj5SKZmJ8W+w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.3.tgz", + "integrity": "sha512-MDszqW4HYBpVMmAoy/FA9laLrgo899UAga0itEjsYrBthKieDZNc0e16gdn7N3cQ0DSf/6zsTBZMuDYDQU4ktg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.8.tgz", + "integrity": "sha512-tKd+jsmhq21AP1LhexC0pPwsCxEhGgAkg28byjJAd+xhmIs8LUX8JbUc3vBf3PhLxWiB5EvyBE5X7JSPAqMAqg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.0.tgz", + "integrity": "sha512-16B8A9hY741yGXzd8UJ9R8su/fuuyO2e+idd7oVLYjP23wKJ6ILRIIHcnXe8/6AoYgwRS2zp4PNsW/u/iZ24yg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.3.tgz", + "integrity": "sha512-HA/W4YV+5deKCehIutfGBzNxWH1nhvUC67O4fC9ufSijn72yrYnRmzvC61dwFvlXIG1fQaYWi+cqNE9PaB9n6Q==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.3.tgz", + "integrity": "sha512-3qWjk6hS0iabG9xx0U1plwQLDBc/HA/hWzLFFatADpR6XfE62LqPr9GpFXBkLU0KQUaIXZ996bNG+2yUvocH8w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.2.0.tgz", + "integrity": "sha512-ZXYZ5oGVrb+hCzcglPeVerJ5SFwennmDOPfXq1WyeZIrPGySLbl4W6GaSsBFvu3WII36AOK5yB8RMIEEkBjf8w==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.0.3", + "@inquirer/confirm": "^5.1.0", + "@inquirer/editor": "^4.2.0", + "@inquirer/expand": "^4.0.3", + "@inquirer/input": "^4.1.0", + "@inquirer/number": "^3.0.3", + "@inquirer/password": "^4.0.3", + "@inquirer/rawlist": "^4.0.3", + "@inquirer/search": "^3.0.3", + "@inquirer/select": "^4.0.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.3.tgz", + "integrity": "sha512-5MhinSzfmOiZlRoPezfbJdfVCZikZs38ja3IOoWe7H1dxL0l3Z2jAUgbBldeyhhOkELdGvPlBfQaNbeLslib1w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/type": "^3.0.1", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/search": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.3.tgz", + "integrity": "sha512-mQTCbdNolTGvGGVCJSI6afDwiSGTV+fMLPEIMDJgIV6L/s3+RYRpxt6t0DYnqMQmemnZ/Zq0vTIRwoHT1RgcTg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/select": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.0.3.tgz", + "integrity": "sha512-OZfKDtDE8+J54JYAFTUGZwvKNfC7W/gFCjDkcsO7HnTH/wljsZo9y/FJquOxMy++DY0+9l9o/MOZ8s5s1j5wmw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.1.1", + "@inquirer/figures": "^1.0.8", + "@inquirer/type": "^3.0.1", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.1.tgz", + "integrity": "sha512-+ksJMIy92sOAiAccGpcKZUc3bYO07cADnscIxHBknEm3uNts3movSmBofc1908BNy5edKscxYeAdaX1NXkHS6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -19349,9 +19607,13 @@ } }, "node_modules/cli-width": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", - "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } }, "node_modules/client-zip": { "version": "2.4.5", @@ -24408,9 +24670,10 @@ } }, "node_modules/external-editor": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.0.3.tgz", - "integrity": "sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "license": "MIT", "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -27503,145 +27766,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/inquirer": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.1.0.tgz", - "integrity": "sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg==", - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^3.0.0", - "cli-cursor": "^3.1.0", - "cli-width": "^2.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.15", - "mute-stream": "0.0.8", - "run-async": "^2.4.0", - "rxjs": "^6.5.3", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/inquirer/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/inquirer/node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/inquirer/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inquirer/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/inquirer/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/internal-slot": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", @@ -41349,6 +41473,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -41427,6 +41552,7 @@ "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dev": true, "dependencies": { "tslib": "^1.9.0" }, @@ -41437,7 +41563,8 @@ "node_modules/rxjs/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/sade": { "version": "1.8.1", @@ -48655,6 +48782,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zip-stream": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz", @@ -50691,6 +50830,7 @@ "version": "4.57.0", "license": "GPL-2.0-or-later", "dependencies": { + "@inquirer/prompts": "^7.2.0", "@wordpress/lazy-import": "*", "chalk": "^4.0.0", "change-case": "^4.1.2", @@ -50698,7 +50838,6 @@ "commander": "^9.2.0", "execa": "^4.0.2", "fast-glob": "^3.2.7", - "inquirer": "^7.1.0", "make-dir": "^3.0.0", "mustache": "^4.0.0", "npm-package-arg": "^8.1.5", @@ -51098,6 +51237,7 @@ "@wordpress/icons": "*", "@wordpress/keyboard-shortcuts": "*", "@wordpress/keycodes": "*", + "@wordpress/media-utils": "5.14.0", "@wordpress/notices": "*", "@wordpress/patterns": "*", "@wordpress/plugins": "*", @@ -51256,12 +51396,12 @@ "version": "10.14.0", "license": "GPL-2.0-or-later", "dependencies": { + "@inquirer/prompts": "^7.2.0", "chalk": "^4.0.0", "copy-dir": "^1.3.0", "docker-compose": "^0.24.3", "extract-zip": "^1.6.7", "got": "^11.8.5", - "inquirer": "^7.1.0", "js-yaml": "^3.13.1", "ora": "^4.0.2", "rimraf": "^5.0.10", diff --git a/package.json b/package.json index 07d725c4a3edcb..792546d28582fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "19.9.0-rc.1", + "version": "19.9.0", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", @@ -34,6 +34,7 @@ "@emotion/jest": "11.7.1", "@emotion/native": "11.0.0", "@geometricpanda/storybook-addon-badges": "2.0.5", + "@inquirer/prompts": "7.2.0", "@octokit/rest": "16.26.0", "@octokit/types": "6.34.0", "@octokit/webhooks-types": "5.8.0", @@ -113,7 +114,6 @@ "filenamify": "4.2.0", "glob": "7.1.2", "husky": "7.0.0", - "inquirer": "7.1.0", "jest": "29.6.2", "jest-environment-jsdom": "^29.6.2", "jest-jasmine2": "29.6.2", diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 13dffce114f59a..8fe2c5f1179dcd 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -713,10 +713,50 @@ Undocumented declaration. ### PlainText +Render an auto-growing textarea allow users to fill any textual content. + _Related_ - +_Usage_ + +```jsx +import { registerBlockType } from '@wordpress/blocks'; +import { PlainText } from '@wordpress/block-editor'; + +registerBlockType( 'my-plugin/example-block', { + // ... + + attributes: { + content: { + type: 'string', + }, + }, + + edit( { className, attributes, setAttributes } ) { + return ( + setAttributes( { content } ) } + /> + ); + }, +} ); +``` + +_Parameters_ + +- _props_ `Object`: Component props. +- _props.value_ `string`: String value of the textarea. +- _props.onChange_ `Function`: Function called when the text value changes. +- _props.ref_ `[Object]`: The component forwards the `ref` property to the `TextareaAutosize` component. + +_Returns_ + +- `Element`: Plain text component + ### privateApis Private @wordpress/block-editor APIs. diff --git a/packages/block-editor/src/components/block-alignment-matrix-control/README.md b/packages/block-editor/src/components/block-alignment-matrix-control/README.md index dfb38e15964124..b4267d68fe1fdc 100644 --- a/packages/block-editor/src/components/block-alignment-matrix-control/README.md +++ b/packages/block-editor/src/components/block-alignment-matrix-control/README.md @@ -41,13 +41,36 @@ const controls = ( /> </BlockControls> </> -} +); ``` ### Props -| Name | Type | Default | Description | -| ---------- | ---------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `label` | `string` | `Change matrix alignment` | concise description of tool's functionality. | -| `onChange` | `function` | `noop` | the function to execute upon a user's change of the matrix state | -| `value` | `string` | `center` | describes the content alignment location and can be `top`, `right`, `bottom`, `left`, `topRight`, `bottomRight`, `bottomLeft`, `topLeft` | +### `label` + +- **Type:** `string` +- **Default:** `'Change matrix alignment'` + +Label for the control. + +### `onChange` + +- **Type:** `Function` +- **Default:** `noop` + +Function to execute upon a user's change of the matrix state. + +### `value` + +- **Type:** `string` +- **Default:** `'center'` +- **Options:** `'center'`, `'center center'`, `'center left'`, `'center right'`, `'top center'`, `'top left'`, `'top right'`, `'bottom center'`, `'bottom left'`, `'bottom right'` + +Content alignment location. + +### `isDisabled` + +- **Type:** `boolean` +- **Default:** `false` + +Whether the control should be disabled. \ No newline at end of file diff --git a/packages/block-editor/src/components/block-alignment-matrix-control/index.js b/packages/block-editor/src/components/block-alignment-matrix-control/index.js index cdec41dfc7b978..fef7b424fdc947 100644 --- a/packages/block-editor/src/components/block-alignment-matrix-control/index.js +++ b/packages/block-editor/src/components/block-alignment-matrix-control/index.js @@ -11,6 +11,37 @@ import { const noop = () => {}; +/** + * The alignment matrix control allows users to quickly adjust inner block alignment. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/block-alignment-matrix-control/README.md + * + * @example + * ```jsx + * function Example() { + * return ( + * <BlockControls> + * <BlockAlignmentMatrixControl + * label={ __( 'Change content position' ) } + * value="center" + * onChange={ ( nextPosition ) => + * setAttributes( { contentPosition: nextPosition } ) + * } + * /> + * </BlockControls> + * ); + * } + * ``` + * + * @param {Object} props Component props. + * @param {string} props.label Label for the control. Defaults to 'Change matrix alignment'. + * @param {Function} props.onChange Function to execute upon change of matrix state. + * @param {string} props.value Content alignment location. One of: 'center', 'center center', + * 'center left', 'center right', 'top center', 'top left', + * 'top right', 'bottom center', 'bottom left', 'bottom right'. + * @param {boolean} props.isDisabled Whether the control should be disabled. + * @return {Element} The BlockAlignmentMatrixControl component. + */ function BlockAlignmentMatrixControl( props ) { const { label = __( 'Change matrix alignment' ), diff --git a/packages/block-editor/src/components/block-alignment-matrix-control/stories/index.story.js b/packages/block-editor/src/components/block-alignment-matrix-control/stories/index.story.js new file mode 100644 index 00000000000000..c2e1d27ea55b9f --- /dev/null +++ b/packages/block-editor/src/components/block-alignment-matrix-control/stories/index.story.js @@ -0,0 +1,78 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import BlockAlignmentMatrixControl from '../'; + +const meta = { + title: 'BlockEditor/BlockAlignmentMatrixControl', + component: BlockAlignmentMatrixControl, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: + 'Renders a control for selecting block alignment using a matrix of alignment options.', + }, + }, + }, + argTypes: { + label: { + control: 'text', + table: { + type: { summary: 'string' }, + defaultValue: { summary: "'Change matrix alignment'" }, + }, + description: 'Label for the control.', + }, + onChange: { + action: 'onChange', + control: { type: null }, + table: { + type: { summary: 'function' }, + defaultValue: { summary: '() => {}' }, + }, + description: + "Function to execute upon a user's change of the matrix state.", + }, + isDisabled: { + control: 'boolean', + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, + }, + description: 'Whether the control should be disabled.', + }, + value: { + control: { type: null }, + table: { + type: { summary: 'string' }, + defaultValue: { summary: "'center'" }, + }, + description: 'Content alignment location.', + }, + }, +}; + +export default meta; + +export const Default = { + render: function Template( { onChange, ...args } ) { + const [ value, setValue ] = useState(); + + return ( + <BlockAlignmentMatrixControl + { ...args } + value={ value } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/block-card/index.js b/packages/block-editor/src/components/block-card/index.js index cdf52ee7df0311..988dcfb2216b2a 100644 --- a/packages/block-editor/src/components/block-card/index.js +++ b/packages/block-editor/src/components/block-card/index.js @@ -14,9 +14,8 @@ import { privateApis as componentsPrivateApis, } from '@wordpress/components'; import { chevronLeft, chevronRight } from '@wordpress/icons'; -import { __, _x, isRTL, sprintf } from '@wordpress/i18n'; +import { __, isRTL } from '@wordpress/i18n'; import { useSelect, useDispatch } from '@wordpress/data'; -import { createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies @@ -71,25 +70,10 @@ function BlockCard( { title, icon, description, blockType, className, name } ) { <BlockIcon icon={ icon } showColors /> <VStack spacing={ 1 }> <h2 className="block-editor-block-card__title"> - { name?.length - ? createInterpolateElement( - sprintf( - // translators: 1: Custom block name. 2: Block title. - _x( - '<span>%1$s</span> <badge>%2$s</badge>', - 'block label' - ), - name, - title - ), - { - span: ( - <span className="block-editor-block-card__name" /> - ), - badge: <Badge />, - } - ) - : title } + <span className="block-editor-block-card__name"> + { !! name?.length ? name : title } + </span> + { !! name?.length && <Badge>{ title }</Badge> } </h2> { description && ( <Text className="block-editor-block-card__description"> diff --git a/packages/block-editor/src/components/block-card/stories/index.story.js b/packages/block-editor/src/components/block-card/stories/index.story.js new file mode 100644 index 00000000000000..0fe68e2032d394 --- /dev/null +++ b/packages/block-editor/src/components/block-card/stories/index.story.js @@ -0,0 +1,79 @@ +/** + * WordPress dependencies + */ +import { box, button, cog, paragraph } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import BlockCard from '../'; + +const meta = { + title: 'BlockEditor/BlockCard', + component: BlockCard, + parameters: { + docs: { + description: { + component: + 'The `BlockCard` component allows to display a "card" which contains the title of a block, its icon and its description.', + }, + canvas: { sourceState: 'shown' }, + }, + }, + argTypes: { + title: { + control: 'text', + description: 'The title of the block.', + table: { + type: { summary: 'string' }, + }, + }, + description: { + control: 'text', + description: 'A description of the block functionality.', + table: { + type: { summary: 'string' }, + }, + }, + icon: { + control: 'select', + options: [ 'paragraph', 'cog', 'box', 'button' ], + mapping: { + paragraph, + cog, + box, + button, + }, + description: + 'The icon of the block. This can be any of [WordPress Dashicons](https://developer.wordpress.org/resource/dashicons/), or a custom `svg` element.', + table: { + type: { summary: 'string | object' }, + }, + }, + name: { + control: 'text', + description: 'Optional custom name for the block.', + table: { + type: { summary: 'string' }, + }, + }, + className: { + control: 'text', + description: 'Additional CSS class names.', + table: { + type: { summary: 'string' }, + }, + }, + }, +}; + +export default meta; + +export const Default = { + args: { + title: 'Paragraph', + icon: paragraph, + description: 'This is a paragraph block description.', + name: 'Paragraph Block', + }, +}; diff --git a/packages/block-editor/src/components/block-card/style.scss b/packages/block-editor/src/components/block-card/style.scss index b02310fb630f4f..a5cb675597908b 100644 --- a/packages/block-editor/src/components/block-card/style.scss +++ b/packages/block-editor/src/components/block-card/style.scss @@ -16,10 +16,13 @@ font-size: $default-font-size; line-height: $default-line-height; margin: 0; - padding: 3px 0; // This makes the title as high as the icon. } } +.block-editor-block-card__name { + padding: 3px 0; // This makes the title as high as the icon. +} + .block-editor-block-card .block-editor-block-icon { flex: 0 0 $button-size-small; margin-left: 0; diff --git a/packages/block-editor/src/components/block-list/index.js b/packages/block-editor/src/components/block-list/index.js index 2d91108ccb4123..bcf6783a10d1c3 100644 --- a/packages/block-editor/src/components/block-list/index.js +++ b/packages/block-editor/src/components/block-list/index.js @@ -12,11 +12,7 @@ import { useDispatch, useRegistry, } from '@wordpress/data'; -import { - useViewportMatch, - useMergeRefs, - useDebounce, -} from '@wordpress/compose'; +import { useMergeRefs, useDebounce } from '@wordpress/compose'; import { createContext, useMemo, @@ -46,7 +42,6 @@ export const IntersectionObserver = createContext(); const pendingBlockVisibilityUpdatesPerRegistry = new WeakMap(); function Root( { className, ...settings } ) { - const isLargeViewport = useViewportMatch( 'medium' ); const { isOutlineMode, isFocusMode, temporarilyEditingAsBlocks } = useSelect( ( select ) => { const { getSettings, getTemporarilyEditingAsBlocks, isTyping } = @@ -105,7 +100,7 @@ function Root( { className, ...settings } ) { ] ), className: clsx( 'is-root-container', className, { 'is-outline-mode': isOutlineMode, - 'is-focus-mode': isFocusMode && isLargeViewport, + 'is-focus-mode': isFocusMode, } ), }, settings diff --git a/packages/block-editor/src/components/block-styles/utils.js b/packages/block-editor/src/components/block-styles/utils.js index 511e78da83da60..e4483ec4e695f8 100644 --- a/packages/block-editor/src/components/block-styles/utils.js +++ b/packages/block-editor/src/components/block-styles/utils.js @@ -10,7 +10,7 @@ import { _x } from '@wordpress/i18n'; * @param {Array} styles Block styles. * @param {string} className Class name * - * @return {Object?} The active style. + * @return {?Object} The active style. */ export function getActiveStyle( styles, className ) { for ( const style of new TokenList( className ).values() ) { @@ -34,7 +34,7 @@ export function getActiveStyle( styles, className ) { * Replaces the active style in the block's className. * * @param {string} className Class name. - * @param {Object?} activeStyle The replaced style. + * @param {?Object} activeStyle The replaced style. * @param {Object} newStyle The replacing style. * * @return {string} The updated className. @@ -83,7 +83,7 @@ export function getRenderedStyles( styles ) { * * @param {Array} styles Block styles. * - * @return {Object?} The default style object, if found. + * @return {?Object} The default style object, if found. */ export function getDefaultStyle( styles ) { return styles?.find( ( style ) => style.isDefault ); diff --git a/packages/block-editor/src/components/block-title/stories/index.story.js b/packages/block-editor/src/components/block-title/stories/index.story.js new file mode 100644 index 00000000000000..dc66fc721e5158 --- /dev/null +++ b/packages/block-editor/src/components/block-title/stories/index.story.js @@ -0,0 +1,76 @@ +/** + * WordPress dependencies + */ +import { registerCoreBlocks } from '@wordpress/block-library'; +import { createBlock } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { ExperimentalBlockEditorProvider } from '../../provider'; +import BlockTitle from '../'; + +// Register core blocks for the story environment +registerCoreBlocks(); + +// Sample blocks for testing +const blocks = [ createBlock( 'core/paragraph' ) ]; + +const meta = { + title: 'BlockEditor/BlockTitle', + component: BlockTitle, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: + "Renders the block's configured title as a string, or empty if the title cannot be determined.", + }, + }, + }, + decorators: [ + ( Story ) => ( + <ExperimentalBlockEditorProvider value={ blocks }> + <Story /> + </ExperimentalBlockEditorProvider> + ), + ], + argTypes: { + clientId: { + control: { type: null }, + description: 'Client ID of block.', + table: { + type: { + summary: 'string', + }, + }, + }, + maximumLength: { + control: { type: 'number' }, + description: + 'The maximum length that the block title string may be before truncated.', + table: { + type: { + summary: 'number', + }, + }, + }, + context: { + control: { type: 'text' }, + description: 'The context to pass to `getBlockLabel`.', + table: { + type: { + summary: 'string', + }, + }, + }, + }, +}; + +export default meta; + +export const Default = { + args: { + clientId: blocks[ 0 ].clientId, + }, +}; diff --git a/packages/block-editor/src/components/button-block-appender/index.js b/packages/block-editor/src/components/button-block-appender/index.js index 53b15e2fd2cfdd..4cde8c26d75638 100644 --- a/packages/block-editor/src/components/button-block-appender/index.js +++ b/packages/block-editor/src/components/button-block-appender/index.js @@ -7,7 +7,7 @@ import clsx from 'clsx'; * WordPress dependencies */ import { Button } from '@wordpress/components'; -import { forwardRef, useRef } from '@wordpress/element'; +import { forwardRef } from '@wordpress/element'; import { _x, sprintf } from '@wordpress/i18n'; import { Icon, plus } from '@wordpress/icons'; import deprecated from '@wordpress/deprecated'; @@ -16,15 +16,11 @@ import deprecated from '@wordpress/deprecated'; * Internal dependencies */ import Inserter from '../inserter'; -import { useMergeRefs } from '@wordpress/compose'; function ButtonBlockAppender( { rootClientId, className, onFocus, tabIndex, onSelect }, ref ) { - const inserterButtonRef = useRef(); - - const mergedInserterButtonRef = useMergeRefs( [ inserterButtonRef, ref ] ); return ( <Inserter position="bottom center" @@ -34,7 +30,6 @@ function ButtonBlockAppender( if ( onSelect && typeof onSelect === 'function' ) { onSelect( ...args ); } - inserterButtonRef.current?.focus(); } } renderToggle={ ( { onToggle, @@ -61,7 +56,7 @@ function ButtonBlockAppender( return ( <Button __next40pxDefaultSize - ref={ mergedInserterButtonRef } + ref={ ref } onFocus={ onFocus } tabIndex={ tabIndex } className={ clsx( diff --git a/packages/block-editor/src/components/date-format-picker/index.js b/packages/block-editor/src/components/date-format-picker/index.js index 719390a1d6f903..6854ee74a0162b 100644 --- a/packages/block-editor/src/components/date-format-picker/index.js +++ b/packages/block-editor/src/components/date-format-picker/index.js @@ -40,7 +40,11 @@ export default function DateFormatPicker( { onChange, } ) { return ( - <fieldset className="block-editor-date-format-picker"> + <VStack + as="fieldset" + spacing={ 4 } + className="block-editor-date-format-picker" + > <VisuallyHidden as="legend">{ __( 'Date format' ) }</VisuallyHidden> <ToggleControl __nextHasNoMarginBottom @@ -57,7 +61,7 @@ export default function DateFormatPicker( { { format && ( <NonDefaultControls format={ format } onChange={ onChange } /> ) } - </fieldset> + </VStack> ); } diff --git a/packages/block-editor/src/components/date-format-picker/stories/index.story.js b/packages/block-editor/src/components/date-format-picker/stories/index.story.js new file mode 100644 index 00000000000000..12d7e071054949 --- /dev/null +++ b/packages/block-editor/src/components/date-format-picker/stories/index.story.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import DateFormatPicker from '../'; + +export default { + title: 'BlockEditor/DateFormatPicker', + component: DateFormatPicker, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: + 'The `DateFormatPicker` component enables users to configure their preferred *date format*. This determines how dates are displayed.', + }, + }, + }, + argTypes: { + defaultFormat: { + control: 'text', + description: + 'The date format that will be used if the user selects "Default".', + table: { + type: { summary: 'string' }, + }, + }, + format: { + control: { type: null }, + description: + 'The selected date format. If `null`, _Default_ is selected.', + table: { + type: { summary: 'string | null' }, + }, + }, + onChange: { + action: 'onChange', + control: { type: null }, + description: + 'Called when a selection is made. If `null`, _Default_ is selected.', + table: { + type: { summary: 'function' }, + }, + }, + }, +}; + +export const Default = { + args: { + defaultFormat: 'M j, Y', + }, + render: function Template( { onChange, ...args } ) { + const [ format, setFormat ] = useState(); + return ( + <DateFormatPicker + { ...args } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setFormat( ...changeArgs ); + } } + format={ format } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/date-format-picker/style.scss b/packages/block-editor/src/components/date-format-picker/style.scss index 748e43bb8db94a..55f844a9ac887b 100644 --- a/packages/block-editor/src/components/date-format-picker/style.scss +++ b/packages/block-editor/src/components/date-format-picker/style.scss @@ -1,5 +1,7 @@ .block-editor-date-format-picker { - margin-bottom: $grid-unit-20; + margin: 0 0 $grid-unit-20; + padding: 0; + border: none; } .block-editor-date-format-picker__custom-format-select-control__custom-option { diff --git a/packages/block-editor/src/components/global-styles/typography-utils.js b/packages/block-editor/src/components/global-styles/typography-utils.js index 4b7c90ae4f222c..2f4d2b4424a6fb 100644 --- a/packages/block-editor/src/components/global-styles/typography-utils.js +++ b/packages/block-editor/src/components/global-styles/typography-utils.js @@ -45,7 +45,7 @@ import { getFontStylesAndWeights } from '../../utils/get-font-styles-and-weights * @param {Preset} preset * @param {Object} settings * @param {boolean|TypographySettings} settings.typography.fluid Whether fluid typography is enabled, and, optionally, fluid font size options. - * @param {Object?} settings.typography.layout Layout options. + * @param {?Object} settings.typography.layout Layout options. * * @return {string|*} A font-size value or the value of preset.size. */ diff --git a/packages/block-editor/src/components/global-styles/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/use-global-styles-output.js index fabc65d143d1aa..7bdc95d222142d 100644 --- a/packages/block-editor/src/components/global-styles/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/use-global-styles-output.js @@ -624,7 +624,7 @@ function pickStyleKeys( treeToPickFrom ) { // clone the style objects so that `getFeatureDeclarations` can remove consumed keys from it const clonedEntries = pickedEntries.map( ( [ key, style ] ) => [ key, - structuredClone( style ), + JSON.parse( JSON.stringify( style ) ), ] ); return Object.fromEntries( clonedEntries ); } diff --git a/packages/block-editor/src/components/inserter/category-tabs/index.js b/packages/block-editor/src/components/inserter/category-tabs/index.js index ff0a130f1a8271..2f70ea58f2532a 100644 --- a/packages/block-editor/src/components/inserter/category-tabs/index.js +++ b/packages/block-editor/src/components/inserter/category-tabs/index.js @@ -64,7 +64,6 @@ function CategoryTabs( { <Tabs.Tab key={ category.name } tabId={ category.name } - aria-label={ category.label } aria-current={ category === selectedCategory ? 'true' : undefined } diff --git a/packages/block-editor/src/components/plain-text/README.md b/packages/block-editor/src/components/plain-text/README.md index aa15758118afdc..1e0a7888ed1e4d 100644 --- a/packages/block-editor/src/components/plain-text/README.md +++ b/packages/block-editor/src/components/plain-text/README.md @@ -6,11 +6,11 @@ Render an auto-growing textarea allow users to fill any textual content. ### `value: string` -_Required._ String value of the textarea +_Required._ String value of the textarea. ### `onChange( value: string ): Function` -_Required._ Called when the value changes. +_Required._ Function called when the text value changes. You can also pass any extra prop to the textarea rendered by this component. diff --git a/packages/block-editor/src/components/plain-text/index.js b/packages/block-editor/src/components/plain-text/index.js index 4bd6681f4eb079..d28aabebf7a140 100644 --- a/packages/block-editor/src/components/plain-text/index.js +++ b/packages/block-editor/src/components/plain-text/index.js @@ -15,7 +15,41 @@ import { forwardRef } from '@wordpress/element'; import EditableText from '../editable-text'; /** + * Render an auto-growing textarea allow users to fill any textual content. + * * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/block-editor/src/components/plain-text/README.md + * + * @example + * ```jsx + * import { registerBlockType } from '@wordpress/blocks'; + * import { PlainText } from '@wordpress/block-editor'; + * + * registerBlockType( 'my-plugin/example-block', { + * // ... + * + * attributes: { + * content: { + * type: 'string', + * }, + * }, + * + * edit( { className, attributes, setAttributes } ) { + * return ( + * <PlainText + * className={ className } + * value={ attributes.content } + * onChange={ ( content ) => setAttributes( { content } ) } + * /> + * ); + * }, + * } ); + * ```` + * + * @param {Object} props Component props. + * @param {string} props.value String value of the textarea. + * @param {Function} props.onChange Function called when the text value changes. + * @param {Object} [props.ref] The component forwards the `ref` property to the `TextareaAutosize` component. + * @return {Element} Plain text component */ const PlainText = forwardRef( ( { __experimentalVersion, ...props }, ref ) => { if ( __experimentalVersion === 2 ) { diff --git a/packages/block-editor/src/components/plain-text/stories/index.story.js b/packages/block-editor/src/components/plain-text/stories/index.story.js new file mode 100644 index 00000000000000..d1a6253c0870a7 --- /dev/null +++ b/packages/block-editor/src/components/plain-text/stories/index.story.js @@ -0,0 +1,75 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import PlainText from '..'; + +const meta = { + title: 'BlockEditor/PlainText', + component: PlainText, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: + 'PlainText renders an auto-growing textarea that allows users to enter any textual content.', + }, + }, + }, + argTypes: { + value: { + control: { + type: null, + }, + table: { + type: { + summary: 'string', + }, + }, + description: 'String value of the textarea.', + }, + onChange: { + action: 'onChange', + control: { + type: null, + }, + table: { + type: { + summary: 'function', + }, + }, + description: 'Function called when the text value changes.', + }, + className: { + control: 'text', + table: { + type: { + summary: 'string', + }, + }, + description: 'Additional class name for the PlainText.', + }, + }, +}; + +export default meta; + +export const Default = { + render: function Template( { onChange, ...args } ) { + const [ value, setValue ] = useState(); + return ( + <PlainText + { ...args } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + value={ value } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/provider/use-media-upload-settings.js b/packages/block-editor/src/components/provider/use-media-upload-settings.js index 486066c7aa7303..40390a77e746ef 100644 --- a/packages/block-editor/src/components/provider/use-media-upload-settings.js +++ b/packages/block-editor/src/components/provider/use-media-upload-settings.js @@ -10,7 +10,7 @@ import { useMemo } from '@wordpress/element'; * * @return {Object} Media upload settings. */ -function useMediaUploadSettings( settings ) { +function useMediaUploadSettings( settings = {} ) { return useMemo( () => ( { mediaUpload: settings.mediaUpload, diff --git a/packages/block-editor/src/components/responsive-block-control/index.js b/packages/block-editor/src/components/responsive-block-control/index.js index 148ba9600f0032..388e7ec543693a 100644 --- a/packages/block-editor/src/components/responsive-block-control/index.js +++ b/packages/block-editor/src/components/responsive-block-control/index.js @@ -57,7 +57,7 @@ function ResponsiveBlockControl( props ) { ); const toggleHelpText = __( - 'Toggle between using the same value for all screen sizes or using a unique value per screen size.' + 'Choose whether to use the same value for all screen sizes or a unique value for each screen size.' ); const defaultControl = renderDefaultControl( diff --git a/packages/block-editor/src/components/text-decoration-control/README.md b/packages/block-editor/src/components/text-decoration-control/README.md index a606140baa330e..87fb6e89bd5712 100644 --- a/packages/block-editor/src/components/text-decoration-control/README.md +++ b/packages/block-editor/src/components/text-decoration-control/README.md @@ -28,7 +28,6 @@ Then, you can use the component in your block editor UI: ### `value` - **Type:** `String` -- **Default:** `none` - **Options:** `none`, `underline`, `line-through` The current value of the Text Decoration setting. You may only choose from the `Options` listed above. diff --git a/packages/block-editor/src/components/text-decoration-control/stories/index.story.js b/packages/block-editor/src/components/text-decoration-control/stories/index.story.js index 2212b484185cde..d139b30a2bb4b5 100644 --- a/packages/block-editor/src/components/text-decoration-control/stories/index.story.js +++ b/packages/block-editor/src/components/text-decoration-control/stories/index.story.js @@ -8,26 +8,61 @@ import { useState } from '@wordpress/element'; */ import TextDecorationControl from '../'; -export default { +const meta = { title: 'BlockEditor/TextDecorationControl', component: TextDecorationControl, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: 'Control to facilitate text decoration selections.', + }, + }, + }, argTypes: { - onChange: { action: 'onChange' }, + value: { + control: { type: null }, + description: 'Currently selected text decoration.', + table: { + type: { + summary: 'string', + }, + }, + }, + onChange: { + action: 'onChange', + control: { type: null }, + description: 'Handles change in text decoration selection.', + table: { + type: { + summary: 'function', + }, + }, + }, + className: { + control: 'text', + description: 'Additional class name to apply.', + table: { + type: { summary: 'string' }, + }, + }, }, }; -const Template = ( { onChange, ...args } ) => { - const [ value, setValue ] = useState(); - return ( - <TextDecorationControl - { ...args } - onChange={ ( ...changeArgs ) => { - onChange( ...changeArgs ); - setValue( ...changeArgs ); - } } - value={ value } - /> - ); -}; +export default meta; -export const Default = Template.bind( {} ); +export const Default = { + render: function Template( { onChange, ...args } ) { + const [ value, setValue ] = useState(); + return ( + <TextDecorationControl + { ...args } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + value={ value } + /> + ); + }, +}; diff --git a/packages/block-editor/src/components/text-transform-control/README.md b/packages/block-editor/src/components/text-transform-control/README.md index 2d40cc16ba86f8..3ed8f1da8cd6e9 100644 --- a/packages/block-editor/src/components/text-transform-control/README.md +++ b/packages/block-editor/src/components/text-transform-control/README.md @@ -1,7 +1,7 @@ # TextTransformControl The `TextTransformControl` component is responsible for rendering a control element that allows users to select and apply text transformation options to blocks or elements in the Gutenberg editor. It provides an intuitive interface for changing the text appearance by applying different transformations such as `none`, `uppercase`, `lowercase`, `capitalize`. - + ![TextTransformConrol Element in Inspector Control](https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/assets/text-transform-component.png?raw=true) ## Development guidelines @@ -28,7 +28,6 @@ const MyTextTransformControlComponent = () => ( ### `value` - **Type:** `String` -- **Default:** `none` - **Options:** `none`, `uppercase`, `lowercase`, `capitalize` The current value of the Text Transform setting. You may only choose from the `Options` listed above. @@ -37,4 +36,4 @@ The current value of the Text Transform setting. You may only choose from the `O - **Type:** `Function` -A callback function invoked when the Text Transform value is changed via an interaction with any of the buttons. Called with the Text Transform value (`none`, `uppercase`, `lowercase`, `capitalize`) as the only argument. \ No newline at end of file +A callback function invoked when the Text Transform value is changed via an interaction with any of the buttons. Called with the Text Transform value (`none`, `uppercase`, `lowercase`, `capitalize`) as the only argument. diff --git a/packages/block-editor/src/components/text-transform-control/stories/index.story.js b/packages/block-editor/src/components/text-transform-control/stories/index.story.js index 96dd8ed479dc4e..77dc550368da19 100644 --- a/packages/block-editor/src/components/text-transform-control/stories/index.story.js +++ b/packages/block-editor/src/components/text-transform-control/stories/index.story.js @@ -8,26 +8,63 @@ import { useState } from '@wordpress/element'; */ import TextTransformControl from '../'; -export default { +const meta = { title: 'BlockEditor/TextTransformControl', component: TextTransformControl, + parameters: { + docs: { + canvas: { sourceState: 'shown' }, + description: { + component: + 'Control to facilitate text transformation selections.', + }, + }, + }, argTypes: { - onChange: { action: 'onChange' }, + onChange: { + action: 'onChange', + control: { + type: null, + }, + description: 'Handles change in text transform selection.', + table: { + type: { + summary: 'function', + }, + }, + }, + className: { + control: { type: 'text' }, + description: 'Class name to add to the control.', + table: { + type: { summary: 'string' }, + }, + }, + value: { + control: { type: null }, + description: 'Currently selected text transform.', + table: { + type: { summary: 'string' }, + }, + }, }, }; -const Template = ( { onChange, ...args } ) => { - const [ value, setValue ] = useState(); - return ( - <TextTransformControl - { ...args } - onChange={ ( ...changeArgs ) => { - onChange( ...changeArgs ); - setValue( ...changeArgs ); - } } - value={ value } - /> - ); -}; +export default meta; + +export const Default = { + render: function Template( { onChange, ...args } ) { + const [ value, setValue ] = useState(); -export const Default = Template.bind( {} ); + return ( + <TextTransformControl + { ...args } + onChange={ ( ...changeArgs ) => { + onChange( ...changeArgs ); + setValue( ...changeArgs ); + } } + value={ value } + /> + ); + }, +}; diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js index 3755aecbcb9d0b..6268ff31b29890 100644 --- a/packages/block-editor/src/hooks/background.js +++ b/packages/block-editor/src/hooks/background.js @@ -177,6 +177,11 @@ export function BackgroundImagePanel( { }, }; + const defaultControls = getBlockSupport( name, [ + BACKGROUND_SUPPORT_KEY, + 'defaultControls', + ] ); + return ( <StylesBackgroundPanel inheritedValue={ inheritedValue } @@ -185,6 +190,7 @@ export function BackgroundImagePanel( { defaultValues={ BACKGROUND_BLOCK_DEFAULT_VALUES } settings={ updatedSettings } onChange={ onChange } + defaultControls={ defaultControls } value={ style } /> ); diff --git a/packages/block-editor/src/hooks/gap.js b/packages/block-editor/src/hooks/gap.js index 887325e6409dde..c5c4bbb7130350 100644 --- a/packages/block-editor/src/hooks/gap.js +++ b/packages/block-editor/src/hooks/gap.js @@ -8,7 +8,7 @@ import { getSpacingPresetCssVar } from '../components/spacing-sizes-control/util * The string check is for backwards compatibility before Gutenberg supported * split gap values (row and column) and the value was a string n + unit. * - * @param {string? | Object?} blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. + * @param {?string | ?Object} blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. * @return {Object|null} A value to pass to the BoxControl component. */ export function getGapBoxControlValueFromStyle( blockGapValue ) { @@ -26,7 +26,7 @@ export function getGapBoxControlValueFromStyle( blockGapValue ) { /** * Returns a CSS value for the `gap` property from a given blockGap style. * - * @param {string? | Object?} blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. + * @param {?string | ?Object} blockGapValue A block gap string or axial object value, e.g., '10px' or { top: '10px', left: '10px'}. * @param {?string} defaultValue A default gap value. * @return {string|null} The concatenated gap value (row and column). */ diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 5be2b1b3fd40a8..db2acd01665b60 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -245,7 +245,7 @@ export function omitStyle( style, paths, preserveReference = false ) { let newStyle = style; if ( ! preserveReference ) { - newStyle = structuredClone( style ); + newStyle = JSON.parse( JSON.stringify( style ) ); } if ( ! Array.isArray( paths ) ) { diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index ed9e859f028a98..7f8ddc7a4be31c 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -128,7 +128,7 @@ export function isBlockValid( state, clientId ) { * @param {Object} state Editor state. * @param {string} clientId Block client ID. * - * @return {Object?} Block attributes. + * @return {?Object} Block attributes. */ export function getBlockAttributes( state, clientId ) { const block = state.blocks.byClientId.get( clientId ); @@ -1992,7 +1992,7 @@ const getItemFromVariation = ( state, item ) => ( variation ) => { * Returns the calculated frecency. * * 'frecency' is a heuristic (https://en.wikipedia.org/wiki/Frecency) - * that combines block usage frequenty and recency. + * that combines block usage frequency and recency. * * @param {number} time When the last insert occurred as a UNIX epoch * @param {number} count The number of inserts that have occurred. @@ -2080,7 +2080,7 @@ const buildBlockTypeItem = * inserter and handle its selection. * * The 'frecency' property is a heuristic (https://en.wikipedia.org/wiki/Frecency) - * that combines block usage frequenty and recency. + * that combines block usage frequency and recency. * * Items are returned ordered descendingly by their 'utility' and 'frecency'. * @@ -2236,7 +2236,7 @@ export const getInserterItems = createRegistrySelector( ( select ) => * transform list and handle its selection. * * The 'frecency' property is a heuristic (https://en.wikipedia.org/wiki/Frecency) - * that combines block usage frequenty and recency. + * that combines block usage frequency and recency. * * Items are returned ordered descendingly by their 'frecency'. * @@ -2400,7 +2400,7 @@ export const __experimentalGetAllowedBlocks = createSelector( * @typedef {Object} WPDirectInsertBlock * @property {string} name The type of block. * @property {?Object} attributes Attributes to pass to the newly created block. - * @property {?Array<string>} attributesToCopy Attributes to be copied from adjecent blocks when inserted. + * @property {?Array<string>} attributesToCopy Attributes to be copied from adjacent blocks when inserted. */ export function getDirectInsertBlock( state, rootClientId = null ) { if ( ! rootClientId ) { diff --git a/packages/block-library/src/block/index.php b/packages/block-library/src/block/index.php index 8beef975fad6f3..e8075115cabda4 100644 --- a/packages/block-library/src/block/index.php +++ b/packages/block-library/src/block/index.php @@ -87,6 +87,26 @@ function render_block_core_block( $attributes ) { add_filter( 'render_block_context', $filter_block_context, 1 ); } + $ignored_hooked_blocks = get_post_meta( $attributes['ref'], '_wp_ignored_hooked_blocks', true ); + if ( ! empty( $ignored_hooked_blocks ) ) { + $ignored_hooked_blocks = json_decode( $ignored_hooked_blocks, true ); + $attributes['metadata'] = array( + 'ignoredHookedBlocks' => $ignored_hooked_blocks, + ); + } + + // Wrap in "Block" block so the Block Hooks algorithm can insert blocks + // that are hooked as first or last child of `core/block`. + $content = get_comment_delimited_block_content( + 'core/block', + $attributes, + $content + ); + // Apply Block Hooks. + $content = apply_block_hooks_to_content( $content, $reusable_block ); + // Remove block wrapper. + $content = remove_serialized_parent_block( $content ); + $content = do_blocks( $content ); unset( $seen_refs[ $attributes['ref'] ] ); diff --git a/packages/block-library/src/latest-posts/block.json b/packages/block-library/src/latest-posts/block.json index bb8c2d24962f3f..58b1c6da81ca33 100644 --- a/packages/block-library/src/latest-posts/block.json +++ b/packages/block-library/src/latest-posts/block.json @@ -111,6 +111,18 @@ "fontSize": true } }, + "__experimentalBorder": { + "radius": true, + "color": true, + "width": true, + "style": true, + "__experimentalDefaultControls": { + "radius": true, + "color": true, + "width": true, + "style": true + } + }, "interactivity": { "clientNavigation": true } diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 39073b848d3ca8..5966739aa61a61 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -9,7 +9,8 @@ import clsx from 'clsx'; import { createBlock } from '@wordpress/blocks'; import { useSelect, useDispatch } from '@wordpress/data'; import { - PanelBody, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, TextControl, TextareaControl, ToolbarButton, @@ -161,71 +162,110 @@ function getMissingText( type ) { function Controls( { attributes, setAttributes, setIsLabelFieldFocused } ) { const { label, url, description, title, rel } = attributes; return ( - <PanelBody title={ __( 'Settings' ) }> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ label ? stripHTML( label ) : '' } - onChange={ ( labelValue ) => { - setAttributes( { label: labelValue } ); - } } + <ToolsPanel label={ __( 'Settings' ) }> + <ToolsPanelItem + hasValue={ () => !! label } label={ __( 'Text' ) } - autoComplete="off" - onFocus={ () => setIsLabelFieldFocused( true ) } - onBlur={ () => setIsLabelFieldFocused( false ) } - /> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ url ? safeDecodeURI( url ) : '' } - onChange={ ( urlValue ) => { - updateAttributes( - { url: urlValue }, - setAttributes, - attributes - ); - } } + onDeselect={ () => setAttributes( { label: '' } ) } + isShownByDefault + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + label={ __( 'Text' ) } + value={ label ? stripHTML( label ) : '' } + onChange={ ( labelValue ) => { + setAttributes( { label: labelValue } ); + } } + autoComplete="off" + onFocus={ () => setIsLabelFieldFocused( true ) } + onBlur={ () => setIsLabelFieldFocused( false ) } + /> + </ToolsPanelItem> + + <ToolsPanelItem + hasValue={ () => !! url } label={ __( 'Link' ) } - autoComplete="off" - /> - <TextareaControl - __nextHasNoMarginBottom - value={ description || '' } - onChange={ ( descriptionValue ) => { - setAttributes( { description: descriptionValue } ); - } } + onDeselect={ () => setAttributes( { url: '' } ) } + isShownByDefault + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + label={ __( 'Link' ) } + value={ url ? safeDecodeURI( url ) : '' } + onChange={ ( urlValue ) => { + updateAttributes( + { url: urlValue }, + setAttributes, + attributes + ); + } } + autoComplete="off" + /> + </ToolsPanelItem> + + <ToolsPanelItem + hasValue={ () => !! description } label={ __( 'Description' ) } - help={ __( - 'The description will be displayed in the menu if the current theme supports it.' - ) } - /> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ title || '' } - onChange={ ( titleValue ) => { - setAttributes( { title: titleValue } ); - } } + onDeselect={ () => setAttributes( { description: '' } ) } + isShownByDefault + > + <TextareaControl + __nextHasNoMarginBottom + label={ __( 'Description' ) } + value={ description || '' } + onChange={ ( descriptionValue ) => { + setAttributes( { description: descriptionValue } ); + } } + help={ __( + 'The description will be displayed in the menu if the current theme supports it.' + ) } + /> + </ToolsPanelItem> + + <ToolsPanelItem + hasValue={ () => !! title } label={ __( 'Title attribute' ) } - autoComplete="off" - help={ __( - 'Additional information to help clarify the purpose of the link.' - ) } - /> - <TextControl - __nextHasNoMarginBottom - __next40pxDefaultSize - value={ rel || '' } - onChange={ ( relValue ) => { - setAttributes( { rel: relValue } ); - } } + onDeselect={ () => setAttributes( { title: '' } ) } + isShownByDefault + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + label={ __( 'Title attribute' ) } + value={ title || '' } + onChange={ ( titleValue ) => { + setAttributes( { title: titleValue } ); + } } + autoComplete="off" + help={ __( + 'Additional information to help clarify the purpose of the link.' + ) } + /> + </ToolsPanelItem> + + <ToolsPanelItem + hasValue={ () => !! rel } label={ __( 'Rel attribute' ) } - autoComplete="off" - help={ __( - 'The relationship of the linked URL as space-separated link types.' - ) } - /> - </PanelBody> + onDeselect={ () => setAttributes( { rel: '' } ) } + isShownByDefault + > + <TextControl + __nextHasNoMarginBottom + __next40pxDefaultSize + label={ __( 'Rel attribute' ) } + value={ rel || '' } + onChange={ ( relValue ) => { + setAttributes( { rel: relValue } ); + } } + autoComplete="off" + help={ __( + 'The relationship of the linked URL as space-separated link types.' + ) } + /> + </ToolsPanelItem> + </ToolsPanel> ); } diff --git a/packages/block-library/src/page-list/edit.js b/packages/block-library/src/page-list/edit.js index 00d96e9ba307bd..ef927ecceccf2d 100644 --- a/packages/block-library/src/page-list/edit.js +++ b/packages/block-library/src/page-list/edit.js @@ -362,6 +362,7 @@ export default function PageListEdit( { <ToolsPanelItem label={ __( 'Edit Menu' ) } isShownByDefault + hasValue={ () => false } > <div> <p>{ convertDescription }</p> diff --git a/packages/block-library/src/post-author-name/edit.js b/packages/block-library/src/post-author-name/edit.js index b4afb9a9799498..2b4bb0709356b0 100644 --- a/packages/block-library/src/post-author-name/edit.js +++ b/packages/block-library/src/post-author-name/edit.js @@ -13,7 +13,7 @@ import { useBlockProps, } from '@wordpress/block-editor'; import { useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; import { PanelBody, ToggleControl } from '@wordpress/components'; @@ -22,9 +22,10 @@ function PostAuthorNameEdit( { attributes: { textAlign, isLink, linkTarget }, setAttributes, } ) { - const { authorName } = useSelect( + const { authorName, supportsAuthor } = useSelect( ( select ) => { - const { getEditedEntityRecord, getUser } = select( coreStore ); + const { getEditedEntityRecord, getUser, getPostType } = + select( coreStore ); const _authorId = getEditedEntityRecord( 'postType', postType, @@ -33,6 +34,8 @@ function PostAuthorNameEdit( { return { authorName: _authorId ? getUser( _authorId ) : null, + supportsAuthor: + getPostType( postType )?.supports?.author ?? false, }; }, [ postType, postId ] @@ -90,7 +93,17 @@ function PostAuthorNameEdit( { ) } </PanelBody> </InspectorControls> - <div { ...blockProps }> { displayAuthor } </div> + <div { ...blockProps }> + { supportsAuthor + ? displayAuthor + : sprintf( + // translators: %s: Name of the post type e.g: "post". + __( + 'This post type (%s) does not support the author.' + ), + postType + ) } + </div> </> ); } diff --git a/packages/block-library/src/post-author-name/index.php b/packages/block-library/src/post-author-name/index.php index effc83962a3547..243d78ca70129e 100644 --- a/packages/block-library/src/post-author-name/index.php +++ b/packages/block-library/src/post-author-name/index.php @@ -26,6 +26,10 @@ function render_block_core_post_author_name( $attributes, $content, $block ) { return ''; } + if ( ! post_type_supports( $block->context['postType'], 'author' ) ) { + return ''; + } + $author_name = get_the_author_meta( 'display_name', $author_id ); if ( isset( $attributes['isLink'] ) && $attributes['isLink'] ) { $author_name = sprintf( '<a href="%1$s" target="%2$s" class="wp-block-post-author-name__link">%3$s</a>', get_author_posts_url( $author_id ), esc_attr( $attributes['linkTarget'] ), $author_name ); diff --git a/packages/block-library/src/post-author/edit.js b/packages/block-library/src/post-author/edit.js index 6186b0d052e8aa..dd2b3aa617548d 100644 --- a/packages/block-library/src/post-author/edit.js +++ b/packages/block-library/src/post-author/edit.js @@ -21,7 +21,7 @@ import { __experimentalVStack as VStack, } from '@wordpress/components'; import { useSelect, useDispatch } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { store as coreStore } from '@wordpress/core-data'; const minimumUsersForCombobox = 25; @@ -38,9 +38,9 @@ function PostAuthorEdit( { setAttributes, } ) { const isDescendentOfQueryLoop = Number.isFinite( queryId ); - const { authorId, authorDetails, authors } = useSelect( + const { authorId, authorDetails, authors, supportsAuthor } = useSelect( ( select ) => { - const { getEditedEntityRecord, getUser, getUsers } = + const { getEditedEntityRecord, getUser, getUsers, getPostType } = select( coreStore ); const _authorId = getEditedEntityRecord( 'postType', @@ -52,6 +52,8 @@ function PostAuthorEdit( { authorId: _authorId, authorDetails: _authorId ? getUser( _authorId ) : null, authors: getUsers( AUTHORS_QUERY ), + supportsAuthor: + getPostType( postType )?.supports?.author ?? false, }; }, [ postType, postId ] @@ -97,6 +99,18 @@ function PostAuthorEdit( { const showAuthorControl = !! postId && ! isDescendentOfQueryLoop && authorOptions.length > 0; + if ( ! supportsAuthor ) { + return ( + <div { ...blockProps }> + { sprintf( + // translators: %s: Name of the post type e.g: "post". + __( 'This post type (%s) does not support the author.' ), + postType + ) } + </div> + ); + } + return ( <> <InspectorControls> diff --git a/packages/block-library/src/post-author/index.php b/packages/block-library/src/post-author/index.php index faf894d997d732..2d01de508b94af 100644 --- a/packages/block-library/src/post-author/index.php +++ b/packages/block-library/src/post-author/index.php @@ -26,6 +26,10 @@ function render_block_core_post_author( $attributes, $content, $block ) { return ''; } + if ( ! post_type_supports( $block->context['postType'], 'author' ) ) { + return ''; + } + $avatar = ! empty( $attributes['avatarSize'] ) ? get_avatar( $author_id, $attributes['avatarSize'] diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index 1592f0a10cbff5..20b59109874d9e 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -19,14 +19,14 @@ function render_block_core_query_pagination_previous( $attributes, $content, $block ) { $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; - - $wrapper_attributes = get_block_wrapper_attributes(); - $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; - $default_label = __( 'Previous Page' ); - $label_text = isset( $attributes['label'] ) && ! empty( $attributes['label'] ) ? esc_html( $attributes['label'] ) : $default_label; - $label = $show_label ? $label_text : ''; - $pagination_arrow = get_query_pagination_arrow( $block, false ); + $wrapper_attributes = get_block_wrapper_attributes(); + $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; + $default_label = __( 'Previous Page' ); + $label_text = isset( $attributes['label'] ) && ! empty( $attributes['label'] ) ? esc_html( $attributes['label'] ) : $default_label; + $label = $show_label ? $label_text : ''; + $pagination_arrow = get_query_pagination_arrow( $block, false ); if ( ! $label ) { $wrapper_attributes .= ' aria-label="' . $label_text . '"'; } @@ -44,13 +44,20 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl add_filter( 'previous_posts_link_attributes', $filter_link_attributes ); $content = get_previous_posts_link( $label ); remove_filter( 'previous_posts_link_attributes', $filter_link_attributes ); - } elseif ( 1 !== $page ) { - $content = sprintf( - '<a href="%1$s" %2$s>%3$s</a>', - esc_url( add_query_arg( $page_key, $page - 1 ) ), - $wrapper_attributes, - $label - ); + } else { + $block_query = new WP_Query( build_query_vars_from_query_block( $block, $page ) ); + $block_max_pages = $block_query->max_num_pages; + $total = ! $max_page || $max_page > $block_max_pages ? $block_max_pages : $max_page; + wp_reset_postdata(); + + if ( 1 < $page && $page <= $total ) { + $content = sprintf( + '<a href="%1$s" %2$s>%3$s</a>', + esc_url( add_query_arg( $page_key, $page - 1 ) ), + $wrapper_attributes, + $label + ); + } } if ( $enhanced_pagination && isset( $content ) ) { diff --git a/packages/block-library/src/query-pagination/query-pagination-label-control.js b/packages/block-library/src/query-pagination/query-pagination-label-control.js index 9ff80a663adeb5..16766c19bef086 100644 --- a/packages/block-library/src/query-pagination/query-pagination-label-control.js +++ b/packages/block-library/src/query-pagination/query-pagination-label-control.js @@ -9,9 +9,7 @@ export function QueryPaginationLabelControl( { value, onChange } ) { <ToggleControl __nextHasNoMarginBottom label={ __( 'Show label text' ) } - help={ __( - 'Toggle off to hide the label text, e.g. "Next Page".' - ) } + help={ __( 'Make label text visible, e.g. "Next Page".' ) } onChange={ onChange } checked={ value === true } /> diff --git a/packages/block-library/src/query-total/index.php b/packages/block-library/src/query-total/index.php index 5a8ab76b5d1ef4..c78d0498f634f1 100644 --- a/packages/block-library/src/query-total/index.php +++ b/packages/block-library/src/query-total/index.php @@ -43,16 +43,16 @@ function render_block_core_query_total( $attributes, $content, $block ) { $range_text = sprintf( /* translators: 1: Start index of posts, 2: Total number of posts */ __( 'Displaying %1$s of %2$s' ), - '<strong>' . $start . '</strong>', - '<strong>' . $max_rows . '</strong>' + $start, + $max_rows ); } else { $range_text = sprintf( /* translators: 1: Start index of posts, 2: End index of posts, 3: Total number of posts */ __( 'Displaying %1$s – %2$s of %3$s' ), - '<strong>' . $start . '</strong>', - '<strong>' . $end . '</strong>', - '<strong>' . $max_rows . '</strong>' + $start, + $end, + $max_rows ); } @@ -61,10 +61,11 @@ function render_block_core_query_total( $attributes, $content, $block ) { case 'total-results': default: - $output = sprintf( - '<p><strong>%d</strong> %s</p>', - $max_rows, - _n( 'result found', 'results found', $max_rows ) + // translators: %d: number of results. + $total_text = sprintf( _n( '%d result found', '%d results found', $max_rows ), $max_rows ); + $output = sprintf( + '<p>%s</p>', + $total_text ); break; } diff --git a/packages/block-library/src/site-title/edit.js b/packages/block-library/src/site-title/edit.js index 644629a96fe4e1..44b29173e06b03 100644 --- a/packages/block-library/src/site-title/edit.js +++ b/packages/block-library/src/site-title/edit.js @@ -25,6 +25,11 @@ import { import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; import { decodeEntities } from '@wordpress/html-entities'; +/** + * Internal dependencies + */ +import { useToolsPanelDropdownMenuProps } from '../utils/hooks'; + export default function SiteTitleEdit( { attributes, setAttributes, @@ -47,6 +52,7 @@ export default function SiteTitleEdit( { }; }, [] ); const { editEntityRecord } = useDispatch( coreStore ); + const dropdownMenuProps = useToolsPanelDropdownMenuProps(); function setTitle( newTitle ) { editEntityRecord( 'root', 'site', undefined, { @@ -121,6 +127,7 @@ export default function SiteTitleEdit( { linkTarget: '_self', } ); } } + dropdownMenuProps={ dropdownMenuProps } > <ToolsPanelItem hasValue={ () => isLink !== false } diff --git a/packages/block-library/src/spacer/controls.js b/packages/block-library/src/spacer/controls.js index 1e899e15aff0de..fde06d3ee8c339 100644 --- a/packages/block-library/src/spacer/controls.js +++ b/packages/block-library/src/spacer/controls.js @@ -10,10 +10,11 @@ import { privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { - PanelBody, __experimentalUseCustomUnits as useCustomUnits, __experimentalUnitControl as UnitControl, __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue, + __experimentalToolsPanel as ToolsPanel, + __experimentalToolsPanelItem as ToolsPanelItem, } from '@wordpress/components'; import { useInstanceId } from '@wordpress/compose'; import { View } from '@wordpress/primitives'; @@ -94,28 +95,54 @@ export default function SpacerControls( { } ) { return ( <InspectorControls> - <PanelBody title={ __( 'Settings' ) }> + <ToolsPanel + label={ __( 'Settings' ) } + resetAll={ () => { + setAttributes( { + width: undefined, + height: '100px', + } ); + } } + > { orientation === 'horizontal' && ( - <DimensionInput + <ToolsPanelItem label={ __( 'Width' ) } - value={ width } - onChange={ ( nextWidth ) => - setAttributes( { width: nextWidth } ) + isShownByDefault + hasValue={ () => width !== undefined } + onDeselect={ () => + setAttributes( { width: undefined } ) } - isResizing={ isResizing } - /> + > + <DimensionInput + label={ __( 'Width' ) } + value={ width } + onChange={ ( nextWidth ) => + setAttributes( { width: nextWidth } ) + } + isResizing={ isResizing } + /> + </ToolsPanelItem> ) } { orientation !== 'horizontal' && ( - <DimensionInput + <ToolsPanelItem label={ __( 'Height' ) } - value={ height } - onChange={ ( nextHeight ) => - setAttributes( { height: nextHeight } ) + isShownByDefault + hasValue={ () => height !== '100px' } + onDeselect={ () => + setAttributes( { height: '100px' } ) } - isResizing={ isResizing } - /> + > + <DimensionInput + label={ __( 'Height' ) } + value={ height } + onChange={ ( nextHeight ) => + setAttributes( { height: nextHeight } ) + } + isResizing={ isResizing } + /> + </ToolsPanelItem> ) } - </PanelBody> + </ToolsPanel> </InspectorControls> ); } diff --git a/packages/block-library/src/table-of-contents/edit.js b/packages/block-library/src/table-of-contents/edit.js index c95b89200cb88c..394ff2666067d4 100644 --- a/packages/block-library/src/table-of-contents/edit.js +++ b/packages/block-library/src/table-of-contents/edit.js @@ -122,7 +122,7 @@ export default function TableOfContentsEdit( { 'Only including headings from the current page (if the post is paginated).' ) : __( - 'Toggle to only include headings from the current page (if the post is paginated).' + 'Include headings from all pages (if the post is paginated).' ) } /> diff --git a/packages/blocks/src/store/selectors.js b/packages/blocks/src/store/selectors.js index c4589ce8232f66..e20938fd84e2b4 100644 --- a/packages/blocks/src/store/selectors.js +++ b/packages/blocks/src/store/selectors.js @@ -102,7 +102,7 @@ export const getBlockTypes = createSelector( * }; * ``` * - * @return {Object?} Block Type. + * @return {?Object} Block Type. */ export function getBlockType( state, name ) { return state.blockTypes[ name ]; diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 7b5ec64bd44ca5..db8b0f749217f4 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -11,6 +11,8 @@ - `TreeSelect`: Deprecate 36px default size ([#67855](https://github.com/WordPress/gutenberg/pull/67855)). - `SelectControl`: Deprecate 36px default size ([#66898](https://github.com/WordPress/gutenberg/pull/66898)). - `InputControl`: Deprecate 36px default size ([#66897](https://github.com/WordPress/gutenberg/pull/66897)). +- `RadioGroup`: Log deprecation warning ([#68067](https://github.com/WordPress/gutenberg/pull/68067)). +- Soft deprecate `ButtonGroup` component. Use `ToggleGroupControl` instead ([#65429](https://github.com/WordPress/gutenberg/pull/65429)). ### Bug Fixes diff --git a/packages/components/src/badge/stories/index.story.tsx b/packages/components/src/badge/stories/index.story.tsx index aaa4bfb3c08f60..7f827d3bfabf5a 100644 --- a/packages/components/src/badge/stories/index.story.tsx +++ b/packages/components/src/badge/stories/index.story.tsx @@ -11,6 +11,7 @@ import Badge from '..'; const meta = { component: Badge, title: 'Components/Containers/Badge', + id: 'components-badge', tags: [ 'status-private' ], } satisfies Meta< typeof Badge >; diff --git a/packages/components/src/button-group/README.md b/packages/components/src/button-group/README.md index 5c0179d6877af9..579103dc70e062 100644 --- a/packages/components/src/button-group/README.md +++ b/packages/components/src/button-group/README.md @@ -1,5 +1,9 @@ # ButtonGroup +<div class="callout callout-alert"> + This component is deprecated. Use `ToggleGroupControl` instead. +</div> + ButtonGroup can be used to group any related buttons together. To emphasize related buttons, a group should share a common container. ![ButtonGroup component](https://wordpress.org/gutenberg/files/2018/12/s_96EC471FE9C9D91A996770229947AAB54A03351BDE98F444FD3C1BF0CED365EA_1541792995815_ButtonGroup.png) diff --git a/packages/components/src/button-group/index.tsx b/packages/components/src/button-group/index.tsx index fb2659c2a0d7de..e073b0c3b359b8 100644 --- a/packages/components/src/button-group/index.tsx +++ b/packages/components/src/button-group/index.tsx @@ -8,6 +8,7 @@ import type { ForwardedRef } from 'react'; * WordPress dependencies */ import { forwardRef } from '@wordpress/element'; +import deprecated from '@wordpress/deprecated'; /** * Internal dependencies @@ -19,9 +20,16 @@ function UnforwardedButtonGroup( props: WordPressComponentProps< ButtonGroupProps, 'div', false >, ref: ForwardedRef< HTMLDivElement > ) { - const { className, ...restProps } = props; + const { className, __shouldNotWarnDeprecated, ...restProps } = props; const classes = clsx( 'components-button-group', className ); + if ( ! __shouldNotWarnDeprecated ) { + deprecated( 'wp.components.ButtonGroup', { + since: '6.8', + alternative: 'wp.components.__experimentalToggleGroupControl', + } ); + } + return ( <div ref={ ref } role="group" className={ classes } { ...restProps } /> ); @@ -31,6 +39,8 @@ function UnforwardedButtonGroup( * ButtonGroup can be used to group any related buttons together. To emphasize * related buttons, a group should share a common container. * + * @deprecated Use `ToggleGroupControl` instead. + * * ```jsx * import { Button, ButtonGroup } from '@wordpress/components'; * diff --git a/packages/components/src/button-group/stories/index.story.tsx b/packages/components/src/button-group/stories/index.story.tsx index 4b5ab3d5dfdb6b..a2df76004d4385 100644 --- a/packages/components/src/button-group/stories/index.story.tsx +++ b/packages/components/src/button-group/stories/index.story.tsx @@ -9,8 +9,15 @@ import type { Meta, StoryObj } from '@storybook/react'; import ButtonGroup from '..'; import Button from '../../button'; +/** + * ButtonGroup can be used to group any related buttons together. + * To emphasize related buttons, a group should share a common container. + * + * This component is deprecated. Use `ToggleGroupControl` instead. + */ const meta: Meta< typeof ButtonGroup > = { - title: 'Components/ButtonGroup', + title: 'Components (Deprecated)/ButtonGroup', + id: 'components-buttongroup', component: ButtonGroup, argTypes: { children: { control: false }, diff --git a/packages/components/src/button-group/types.ts b/packages/components/src/button-group/types.ts index 0bc162d5cf1c74..57388c7b5fc095 100644 --- a/packages/components/src/button-group/types.ts +++ b/packages/components/src/button-group/types.ts @@ -8,4 +8,11 @@ export type ButtonGroupProps = { * The children elements. */ children: ReactNode; + /** + * Do not throw a warning for component deprecation. + * For internal components of other components that already throw the warning. + * + * @ignore + */ + __shouldNotWarnDeprecated?: boolean; }; diff --git a/packages/components/src/radio-group/index.tsx b/packages/components/src/radio-group/index.tsx index e59775c00a8023..589d20ffdaae5b 100644 --- a/packages/components/src/radio-group/index.tsx +++ b/packages/components/src/radio-group/index.tsx @@ -6,6 +6,7 @@ import * as Ariakit from '@ariakit/react'; /** * WordPress dependencies */ +import deprecated from '@wordpress/deprecated'; import { useMemo, forwardRef } from '@wordpress/element'; import { isRTL } from '@wordpress/i18n'; @@ -46,11 +47,21 @@ function UnforwardedRadioGroup( [ radioStore, disabled ] ); + deprecated( 'wp.components.__experimentalRadioGroup', { + alternative: + 'wp.components.RadioControl or wp.components.__experimentalToggleGroupControl', + since: '6.8', + } ); + return ( <RadioGroupContext.Provider value={ contextValue }> <Ariakit.RadioGroup store={ radioStore } - render={ <ButtonGroup>{ children }</ButtonGroup> } + render={ + <ButtonGroup __shouldNotWarnDeprecated> + { children } + </ButtonGroup> + } aria-label={ label } ref={ ref } { ...props } diff --git a/packages/compose/src/hooks/use-focus-return/index.js b/packages/compose/src/hooks/use-focus-return/index.js index 2cd93b279cd318..36dc7560669652 100644 --- a/packages/compose/src/hooks/use-focus-return/index.js +++ b/packages/compose/src/hooks/use-focus-return/index.js @@ -48,7 +48,13 @@ function useFocusReturn( onFocusReturn ) { return; } - focusedBeforeMount.current = node.ownerDocument.activeElement; + const activeDocument = + node.ownerDocument.activeElement instanceof + window.HTMLIFrameElement + ? node.ownerDocument.activeElement.contentDocument + : node.ownerDocument; + + focusedBeforeMount.current = activeDocument?.activeElement ?? null; } else if ( focusedBeforeMount.current ) { const isFocused = ref.current?.contains( ref.current?.ownerDocument.activeElement diff --git a/packages/core-data/src/fetch/__experimental-fetch-url-data.js b/packages/core-data/src/fetch/__experimental-fetch-url-data.js index effb0566691dfe..003cc0ebf74ebb 100644 --- a/packages/core-data/src/fetch/__experimental-fetch-url-data.js +++ b/packages/core-data/src/fetch/__experimental-fetch-url-data.js @@ -29,7 +29,7 @@ const CACHE = new Map(); * * @async * @param {string} url the URL to request details from. - * @param {Object?} options any options to pass to the underlying fetch. + * @param {?Object} options any options to pass to the underlying fetch. * @example * ```js * import { __experimentalFetchUrlData as fetchUrlData } from '@wordpress/core-data'; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index a35403c0493460..4f101035b10130 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -226,7 +226,7 @@ export const getEditedEntityRecord = forwardResolver( 'getEntityRecord' ); * * @param {string} kind Entity kind. * @param {string} name Entity name. - * @param {Object?} query Query Object. If requesting specific fields, fields + * @param {?Object} query Query Object. If requesting specific fields, fields * must always include the ID. */ export const getEntityRecords = diff --git a/packages/create-block/CHANGELOG.md b/packages/create-block/CHANGELOG.md index 73522a9be0726d..e109e36ccbd798 100644 --- a/packages/create-block/CHANGELOG.md +++ b/packages/create-block/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Enhancement + +- Add support for custom `textdomain` property for the scaffolded block ([#57197](https://github.com/WordPress/gutenberg/pull/57197)). + +### Internal + +- Refactored the code to use new API introduced together with `@inquirer/prompts` instead of legacy `inquirer` package ([#67877](https://github.com/WordPress/gutenberg/pull/67877)). + ## 4.57.0 (2024-12-11) ### Internal diff --git a/packages/create-block/lib/check-system-requirements.js b/packages/create-block/lib/check-system-requirements.js index 4a88d167d437c7..152931bc191410 100644 --- a/packages/create-block/lib/check-system-requirements.js +++ b/packages/create-block/lib/check-system-requirements.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const inquirer = require( 'inquirer' ); +const { confirm } = require( '@inquirer/prompts' ); const checkSync = require( 'check-node-version' ); const tools = require( 'check-node-version/tools' ); const { promisify } = require( 'util' ); @@ -34,14 +34,10 @@ async function checkSystemRequirements( engines ) { log.error( 'The program may not complete correctly if you continue.' ); log.info( '' ); - const { yesContinue } = await inquirer.prompt( [ - { - type: 'confirm', - name: 'yesContinue', - message: 'Are you sure you want to continue anyway?', - default: false, - }, - ] ); + const yesContinue = await confirm( { + message: 'Are you sure you want to continue anyway?', + default: false, + } ); if ( ! yesContinue ) { log.error( 'Cancelled.' ); diff --git a/packages/create-block/lib/index.js b/packages/create-block/lib/index.js index da08bcd4ab1dc7..ccc2e91b106e20 100644 --- a/packages/create-block/lib/index.js +++ b/packages/create-block/lib/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const inquirer = require( 'inquirer' ); +const { confirm, select } = require( '@inquirer/prompts' ); const { capitalCase } = require( 'change-case' ); const program = require( 'commander' ); @@ -14,9 +14,9 @@ const log = require( './log' ); const { engines, version } = require( '../package.json' ); const scaffold = require( './scaffold' ); const { - getPluginTemplate, getDefaultValues, - getPrompts, + getProjectTemplate, + runPrompts, } = require( './templates' ); const commandName = `wp-create-block`; @@ -79,11 +79,13 @@ program targetDir, } ) => { - await checkSystemRequirements( engines ); try { - const pluginTemplate = await getPluginTemplate( templateName ); + await checkSystemRequirements( engines ); + + const projectTemplate = + await getProjectTemplate( templateName ); const availableVariants = Object.keys( - pluginTemplate.variants + projectTemplate.variants ); if ( variant && ! availableVariants.includes( variant ) ) { if ( ! availableVariants.length ) { @@ -113,7 +115,7 @@ program if ( slug ) { const defaultValues = getDefaultValues( - pluginTemplate, + projectTemplate, variant ); const answers = { @@ -123,7 +125,7 @@ program title: capitalCase( slug ), ...optionsValues, }; - await scaffold( pluginTemplate, answers ); + await scaffold( projectTemplate, answers ); } else { log.info( '' ); log.info( @@ -133,25 +135,22 @@ program ); if ( ! variant && availableVariants.length > 1 ) { - const result = await inquirer.prompt( { - type: 'list', - name: 'variant', + variant = await select( { message: 'The template variant to use for this block:', - choices: availableVariants, + choices: availableVariants.map( ( value ) => ( { + value, + } ) ), } ); - variant = result.variant; } const defaultValues = getDefaultValues( - pluginTemplate, + projectTemplate, variant ); - const filterOptionsProvided = ( { name } ) => - ! Object.keys( optionsValues ).includes( name ); - const blockPrompts = getPrompts( - pluginTemplate, + const blockAnswers = await runPrompts( + projectTemplate, [ 'slug', 'namespace', @@ -159,45 +158,36 @@ program 'description', 'dashicon', 'category', - ], - variant - ).filter( filterOptionsProvided ); - const blockAnswers = await inquirer.prompt( blockPrompts ); - - const pluginAnswers = plugin - ? await inquirer - .prompt( { - type: 'confirm', - name: 'configurePlugin', - message: - 'Do you want to customize the WordPress plugin?', - default: false, - } ) - .then( async ( { configurePlugin } ) => { - if ( ! configurePlugin ) { - return {}; - } + ! plugin && 'textdomain', + ].filter( Boolean ), + variant, + optionsValues + ); - const pluginPrompts = getPrompts( - pluginTemplate, - [ - 'pluginURI', - 'version', - 'author', - 'license', - 'licenseURI', - 'domainPath', - 'updateURI', - ], - variant - ).filter( filterOptionsProvided ); - const result = - await inquirer.prompt( pluginPrompts ); - return result; - } ) - : {}; + const pluginAnswers = + plugin && + ( await confirm( { + message: + 'Do you want to customize the WordPress plugin?', + default: false, + } ) ) + ? await runPrompts( + projectTemplate, + [ + 'pluginURI', + 'version', + 'author', + 'license', + 'licenseURI', + 'domainPath', + 'updateURI', + ], + variant, + optionsValues + ) + : {}; - await scaffold( pluginTemplate, { + await scaffold( projectTemplate, { ...defaultValues, ...optionsValues, variant, @@ -209,6 +199,9 @@ program if ( error instanceof CLIError ) { log.error( error.message ); process.exit( 1 ); + } else if ( error.name === 'ExitPromptError' ) { + log.info( 'Cancelled.' ); + process.exit( 1 ); } else { throw error; } diff --git a/packages/create-block/lib/prompts.js b/packages/create-block/lib/prompts.js index 12da9f892b80e6..88bdaf22635d36 100644 --- a/packages/create-block/lib/prompts.js +++ b/packages/create-block/lib/prompts.js @@ -11,7 +11,6 @@ const upperFirst = ( [ firstLetter, ...rest ] ) => // Block metadata. const slug = { type: 'input', - name: 'slug', message: 'The block slug used for identification (also the output folder name):', validate( input ) { @@ -25,7 +24,6 @@ const slug = { const namespace = { type: 'input', - name: 'namespace', message: 'The internal namespace for the block name (something unique for your products):', validate( input ) { @@ -39,25 +37,22 @@ const namespace = { const title = { type: 'input', - name: 'title', message: 'The display title for your block:', - filter( input ) { + transformer( input ) { return input && upperFirst( input ); }, }; const description = { type: 'input', - name: 'description', message: 'The short description for your block (optional):', - filter( input ) { + transformer( input ) { return input && upperFirst( input ); }, }; const dashicon = { type: 'input', - name: 'dashicon', message: 'The dashicon to make it easier to identify your block (optional):', validate( input ) { @@ -67,29 +62,41 @@ const dashicon = { return true; }, - filter( input ) { + transformer( input ) { return input && input.replace( /dashicon(s)?-/, '' ); }, }; const category = { - type: 'list', - name: 'category', + type: 'select', message: 'The category name to help users browse and discover your block:', - choices: [ 'text', 'media', 'design', 'widgets', 'theme', 'embed' ], + choices: [ 'text', 'media', 'design', 'widgets', 'theme', 'embed' ].map( + ( value ) => ( { value } ) + ), +}; + +const textdomain = { + type: 'input', + message: + 'The text domain used to make strings translatable in the block (optional):', + validate( input ) { + if ( input.length && ! /^[a-z][a-z0-9\-]*$/.test( input ) ) { + return 'Invalid text domain specified. Text domain can contain only lowercase alphanumeric characters or dashes, and start with a letter.'; + } + + return true; + }, }; // Plugin header fields. const pluginURI = { type: 'input', - name: 'pluginURI', message: 'The home page of the plugin (optional). Unique URL outside of WordPress.org:', }; const version = { type: 'input', - name: 'version', message: 'The current version number of the plugin:', validate( input ) { // Regular expression was copied from https://semver.org. @@ -105,32 +112,27 @@ const version = { const author = { type: 'input', - name: 'author', message: 'The name of the plugin author (optional). Multiple authors may be listed using commas:', }; const license = { type: 'input', - name: 'license', message: 'The short name of the plugin’s license (optional):', }; const licenseURI = { type: 'input', - name: 'licenseURI', message: 'A link to the full text of the license (optional):', }; const domainPath = { type: 'input', - name: 'domainPath', message: 'A custom domain path for the translations (optional):', }; const updateURI = { type: 'input', - name: 'updateURI', message: 'A custom update URI for the plugin (optional):', }; @@ -141,6 +143,7 @@ module.exports = { description, dashicon, category, + textdomain, pluginURI, version, author, diff --git a/packages/create-block/lib/scaffold.js b/packages/create-block/lib/scaffold.js index 73b9f549908867..bc7cb3b8bfcd32 100644 --- a/packages/create-block/lib/scaffold.js +++ b/packages/create-block/lib/scaffold.js @@ -26,6 +26,7 @@ module.exports = async ( description, dashicon, category, + textdomain, attributes, supports, author, @@ -95,7 +96,7 @@ module.exports = async ( customPackageJSON, customBlockJSON, example, - textdomain: slug, + textdomain: textdomain || slug, rootDirectory, } ); diff --git a/packages/create-block/lib/templates.js b/packages/create-block/lib/templates.js index 4e70ee66fd3a40..db78ee80aa429a 100644 --- a/packages/create-block/lib/templates.js +++ b/packages/create-block/lib/templates.js @@ -1,6 +1,7 @@ /** * External dependencies */ +const inquirer = require( '@inquirer/prompts' ); const { command } = require( 'execa' ); const glob = require( 'fast-glob' ); const { resolve } = require( 'path' ); @@ -157,7 +158,7 @@ const configToTemplate = async ( { }; }; -const getPluginTemplate = async ( templateName ) => { +const getProjectTemplate = async ( templateName ) => { if ( predefinedPluginTemplates[ templateName ] ) { return await configToTemplate( predefinedPluginTemplates[ templateName ] @@ -224,12 +225,13 @@ const getPluginTemplate = async ( templateName ) => { } }; -const getDefaultValues = ( pluginTemplate, variant ) => { +const getDefaultValues = ( projectTemplate, variant ) => { return { $schema: 'https://schemas.wp.org/trunk/block.json', apiVersion: 3, namespace: 'create-block', category: 'widgets', + textdomain: '', author: 'The WordPress Contributors', license: 'GPL-2.0-or-later', licenseURI: 'https://www.gnu.org/licenses/gpl-2.0.html', @@ -243,20 +245,33 @@ const getDefaultValues = ( pluginTemplate, variant ) => { editorStyle: 'file:./index.css', style: 'file:./style-index.css', transformer: ( view ) => view, - ...pluginTemplate.defaultValues, - ...pluginTemplate.variants?.[ variant ], - variantVars: getVariantVars( pluginTemplate.variants, variant ), + ...projectTemplate.defaultValues, + ...projectTemplate.variants?.[ variant ], + variantVars: getVariantVars( projectTemplate.variants, variant ), }; }; -const getPrompts = ( pluginTemplate, keys, variant ) => { - const defaultValues = getDefaultValues( pluginTemplate, variant ); - return keys.map( ( promptName ) => { - return { - ...prompts[ promptName ], +const runPrompts = async ( + projectTemplate, + promptNames, + variant, + optionsValues +) => { + const defaultValues = getDefaultValues( projectTemplate, variant ); + const result = {}; + for ( const promptName of promptNames ) { + if ( Object.keys( optionsValues ).includes( promptName ) ) { + continue; + } + + const { type, ...config } = prompts[ promptName ]; + result[ promptName ] = await inquirer[ type ]( { + ...config, default: defaultValues[ promptName ], - }; - } ); + } ); + } + + return result; }; const getVariantVars = ( variants, variant ) => { @@ -277,7 +292,7 @@ const getVariantVars = ( variants, variant ) => { }; module.exports = { - getPluginTemplate, getDefaultValues, - getPrompts, + getProjectTemplate, + runPrompts, }; diff --git a/packages/create-block/package.json b/packages/create-block/package.json index 375fee43ba1f73..728cf04b3f4437 100644 --- a/packages/create-block/package.json +++ b/packages/create-block/package.json @@ -31,6 +31,7 @@ "wp-create-block": "./index.js" }, "dependencies": { + "@inquirer/prompts": "^7.2.0", "@wordpress/lazy-import": "*", "chalk": "^4.0.0", "change-case": "^4.1.2", @@ -38,7 +39,6 @@ "commander": "^9.2.0", "execa": "^4.0.2", "fast-glob": "^3.2.7", - "inquirer": "^7.1.0", "make-dir": "^3.0.0", "mustache": "^4.0.0", "npm-package-arg": "^8.1.5", diff --git a/packages/data/README.md b/packages/data/README.md index 67c01af24bde32..00105722bd04fb 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -392,7 +392,7 @@ Creates a new store registry, given an optional object of initial store configur _Parameters_ - _storeConfigs_ `Object`: Initial store configurations. -- _parent_ `Object?`: Parent registry. +- _parent_ `?Object`: Parent registry. _Returns_ diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index 3e7a8fdd8b5a07..8db8bfbbbb702d 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -49,7 +49,7 @@ function getStoreName( storeNameOrDescriptor ) { * configurations. * * @param {Object} storeConfigs Initial store configurations. - * @param {Object?} parent Parent registry. + * @param {?Object} parent Parent registry. * * @return {WPDataRegistry} Data registry. */ diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 887c279714ec01..965d98e80d6aea 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -6,9 +6,13 @@ - Fixed commonjs export ([#67962](https://github.com/WordPress/gutenberg/pull/67962)) +### Features + +- Add support for hierarchical visualization of data. `DataViews` gets a new prop `getItemLevel` that should return the hierarchical level of the item. The view can use `view.showLevels` to display the levels. It's up to the consumer data source to prepare this information. + ## 4.10.0 (2024-12-11) -## Breaking Changes +### Breaking Changes - Support showing or hiding title, media and description fields ([#67477](https://github.com/WordPress/gutenberg/pull/67477)). - Unify the `title`, `media` and `description` fields for the different layouts. So instead of the previous `view.layout.mediaField`, `view.layout.primaryField` and `view.layout.columnFields`, all the layouts now support these three fields with the following config ([#67477](https://github.com/WordPress/gutenberg/pull/67477)): @@ -23,7 +27,7 @@ const view = { }; ``` -## Internal +### Internal - Upgraded `@ariakit/react` (v0.4.13) and `@ariakit/test` (v0.4.5) ([#65907](https://github.com/WordPress/gutenberg/pull/65907)). - Upgraded `@ariakit/react` (v0.4.15) and `@ariakit/test` (v0.4.7) ([#67404](https://github.com/WordPress/gutenberg/pull/67404)). diff --git a/packages/dataviews/README.md b/packages/dataviews/README.md index 6f74a13d8f197a..4cce66a6ae6b26 100644 --- a/packages/dataviews/README.md +++ b/packages/dataviews/README.md @@ -2,8 +2,8 @@ The DataViews package offers two React components and a few utilities to work with a list of data: -- `DataViews`: to render the dataset using different types of layouts (table, grid, list) and interaction capabilities (search, filters, sorting, etc.). -- `DataForm`: to edit the items of the dataset. +- `DataViews`: to render the dataset using different types of layouts (table, grid, list) and interaction capabilities (search, filters, sorting, etc.). +- `DataForm`: to edit the items of the dataset. ## Installation @@ -23,13 +23,15 @@ npm install @wordpress/dataviews --save The `DataViews` component receives data and some other configuration to render the dataset. It'll call the `onChangeView` callback every time the user has interacted with the dataset in some way (sorted, filtered, changed layout, etc.): -![DataViews flow](https://developer.wordpress.org/files/2024/09/368600071-20aa078f-7c3d-406d-8dd0-8b764addd22a.png "DataViews flow") +![DataViews flow](https://developer.wordpress.org/files/2024/09/368600071-20aa078f-7c3d-406d-8dd0-8b764addd22a.png 'DataViews flow') Example: ```jsx const Example = () => { - const onChangeView = () => { /* React to user changes. */ } + const onChangeView = () => { + /* React to user changes. */ + }; return ( <DataViews @@ -45,7 +47,6 @@ const Example = () => { }; ``` - ### Properties #### `data`: `Object[]` @@ -87,6 +88,19 @@ Example: } ``` +#### `getItemLevel`: `function` + +A function that receives an item and returns its hierarchical level. It's optional, but this property must be passed for DataViews to display the hierarchical levels of the data if `view.showLevels` is true. + +Example: + +```js +// Example implementation +{ + getItemLevel={ ( item ) => item.level } +} +``` + #### `fields`: `Object[]` The fields describe the visible items for each record in the dataset and how they behave (how to sort them, display them, etc.). See "Fields API" for a description of every property. @@ -185,21 +199,23 @@ Properties: - `field`: the field used for sorting the dataset. - `direction`: the direction to use for sorting, one of `asc` or `desc`. + - `titleField`: The id of the field representing the title of the record. - `mediaField`: The id of the field representing the media of the record. - `descriptionField`: The id of the field representing the description of the record. - `showTitle`: Whether the title should be shown in the UI. `true` by default. - `showMedia`: Whether the media should be shown in the UI. `true` by default. - `showDescription`: Whether the description should be shown in the UI. `true` by default. +- `showLevels`: Whether to display the hierarchical levels for the data. `false` by default. See related `getItemLevel` DataView prop. - `fields`: a list of remaining field `id` that are visible in the UI and the specific order in which they are displayed. - `layout`: config that is specific to a particular layout type. ##### Properties of `layout` -| Properties of `layout` | Table | Grid | List | -| --------------------------------------------------------------------------------------------------------------- | ----- | ---- | ---- | -| `badgeFields`: a list of field's `id` to render without label and styled as badges. | | ✓ | | -| `styles`: additional `width`, `maxWidth`, `minWidth` styles for each field column. | ✓ | | | +| Properties of `layout` | Table | Grid | List | +| ----------------------------------------------------------------------------------- | ----- | ---- | ---- | +| `badgeFields`: a list of field's `id` to render without label and styled as badges. | | ✓ | | +| `styles`: additional `width`, `maxWidth`, `minWidth` styles for each field column. | ✓ | | | #### `onChangeView`: `function` @@ -302,8 +318,8 @@ const actions = [ RenderModal: ( { items, closeModal, onActionPerformed } ) => ( <div> <p>Are you sure you want to delete { items.length } item(s)?</p> - <Button - variant="primary" + <Button + variant="primary" onClick={() => { console.log( 'Deleting items:', items ); onActionPerformed(); @@ -348,7 +364,7 @@ const defaultLayouts = { }, grid: { showMedia: true, - } + }, }; ``` @@ -366,11 +382,11 @@ Callback that signals the user selected one of more items. It receives the list If `selection` and `onChangeSelection` are provided, the `DataViews` component behaves like a controlled component. Otherwise, it behaves like an uncontrolled component. -### `isItemClickable`: `function` +#### `isItemClickable`: `function` A function that determines if a media field or a primary field is clickable. It receives an item as an argument and returns a boolean value indicating whether the item can be clicked. -### `onClickItem`: `function` +#### `onClickItem`: `function` A callback function that is triggered when a user clicks on a media field or primary field. This function is currently implemented only in the `grid` and `list` views. @@ -395,8 +411,8 @@ const Example = () => { form={ form } onChange={ onChange } /> - ) -} + ); +}; ``` ### Properties @@ -439,8 +455,30 @@ const fields = [ #### `form`: `Object[]` -- `type`: either `regular` or `panel`. -- `fields`: a list of fields ids that should be rendered. +- `type`: either `regular` or `panel`. +- `labelPosition`: either `side`, `top`, or `none`. +- `fields`: a list of fields ids that should be rendered. Field ids can also be defined as an object and allow you to define a `layout`, `labelPosition` or `children` if displaying combined fields. See "Form Field API" for a description of every property. + +Example: + +```js +const form = { + type: 'panel', + fields: [ + 'title', + 'data', + { + id: 'status', + label: 'Status & Visibility', + children: [ 'status', 'password' ], + }, + { + id: 'featured_media', + layout: 'regular', + }, + ], +}; +``` #### `onChange`: `function` @@ -471,10 +509,10 @@ const onChange = ( edits ) => { return ( <DataForm - data={data} - fields={fields} - form={form} - onChange={onChange} + data={ data } + fields={ fields } + form={ form } + onChange={ onChange } /> ); ``` @@ -487,16 +525,16 @@ Utility to apply the view config (filters, search, sorting, and pagination) to a Parameters: -- `data`: the dataset, as described in the "data" property of DataViews. -- `view`: the view config, as described in the "view" property of DataViews. -- `fields`: the fields config, as described in the "fields" property of DataViews. +- `data`: the dataset, as described in the "data" property of DataViews. +- `view`: the view config, as described in the "view" property of DataViews. +- `fields`: the fields config, as described in the "fields" property of DataViews. Returns an object containing: -- `data`: the new dataset, with the view config applied. -- `paginationInfo`: object containing the following properties: - - `totalItems`: total number of items for the current view config. - - `totalPages`: total number of pages for the current view config. +- `data`: the new dataset, with the view config applied. +- `paginationInfo`: object containing the following properties: + - `totalItems`: total number of items for the current view config. + - `totalPages`: total number of pages for the current view config. ### `isItemValid` @@ -504,9 +542,9 @@ Utility is used to determine whether or not the given item's value is valid acco Parameters: -- `item`: the item, as described in the "data" property of DataForm. -- `fields`: the fields config, as described in the "fields" property of DataForm. -- `form`: the form config, as described in the "form" property of DataForm. +- `item`: the item, as described in the "data" property of DataForm. +- `fields`: the fields config, as described in the "fields" property of DataForm. +- `form`: the form config, as described in the "form" property of DataForm. Returns a boolean indicating if the item is valid (true) or not (false). @@ -516,17 +554,17 @@ Returns a boolean indicating if the item is valid (true) or not (false). The unique identifier of the action. -- Type: `string` -- Required -- Example: `move-to-trash` +- Type: `string` +- Required +- Example: `move-to-trash` -### `label` +### `label` The user facing description of the action. -- Type: `string | function` -- Required -- Example: +- Type: `string | function` +- Required +- Example: ```js { @@ -538,7 +576,7 @@ or ```js { - label: ( items ) => items.length > 1 ? 'Delete items' : 'Delete item' + label: ( items ) => ( items.length > 1 ? 'Delete items' : 'Delete item' ); } ``` @@ -546,27 +584,27 @@ or Whether the action should be displayed inline (primary) or only displayed in the "More actions" menu (secondary). -- Type: `boolean` -- Optional +- Type: `boolean` +- Optional ### `icon` Icon to show for primary actions. -- Type: SVG element -- Required for primary actions, optional for secondary actions. +- Type: SVG element +- Required for primary actions, optional for secondary actions. ### `isEligible` Function that determines whether the action can be performed for a given record. -- Type: `function` -- Optional. If not present, action is considered eligible for all items. -- Example: +- Type: `function` +- Optional. If not present, action is considered eligible for all items. +- Example: ```js { - isEligible: ( item ) => item.status === 'published' + isEligible: ( item ) => item.status === 'published'; } ``` @@ -574,47 +612,47 @@ Function that determines whether the action can be performed for a given record. Whether the action can delete data, in which case the UI communicates it via a red color. -- Type: `boolean` -- Optional +- Type: `boolean` +- Optional ### `supportsBulk` Whether the action can operate over multiple items at once. -- Type: `boolean` -- Optional -- Default: `false` +- Type: `boolean` +- Optional +- Default: `false` ### `disabled` Whether the action is disabled. -- Type: `boolean` -- Optional -- Default: `false` +- Type: `boolean` +- Optional +- Default: `false` ### `context` Where this action would be visible. -- Type: `string` -- Optional -- One of: `list`, `single` +- Type: `string` +- Optional +- One of: `list`, `single` ### `callback` Function that performs the required action. -- Type: `function` -- Either `callback` or `RenderModal` must be provided. If `RenderModal` is provided, `callback` will be ignored -- Example: +- Type: `function` +- Either `callback` or `RenderModal` must be provided. If `RenderModal` is provided, `callback` will be ignored +- Example: ```js { callback: ( items, { onActionPerformed } ) => { // Perform action. onActionPerformed?.( items ); - } + }; } ``` @@ -622,9 +660,9 @@ Function that performs the required action. Component to render UI in a modal for the action. -- Type: `ReactElement` -- Either `callback` or `RenderModal` must be provided. If `RenderModal` is provided, `callback` will be ignored. -- Example: +- Type: `ReactElement` +- Either `callback` or `RenderModal` must be provided. If `RenderModal` is provided, `callback` will be ignored. +- Example: ```jsx { @@ -648,7 +686,7 @@ Component to render UI in a modal for the action. </HStack> </form> ); - } + }; } ``` @@ -656,17 +694,16 @@ Component to render UI in a modal for the action. Controls visibility of the modal's header when using `RenderModal`. -- Type: `boolean` -- Optional -- When false and using `RenderModal`, the action's label is used in modal header +- Type: `boolean` +- Optional +- When false and using `RenderModal`, the action's label is used in modal header ### `modalHeader` The header text to show in the modal. -- Type: `string` -- Optional - +- Type: `string` +- Optional ## Fields API @@ -674,13 +711,15 @@ The header text to show in the modal. The unique identifier of the field. -- Type: `string`. -- Required. +- Type: `string`. +- Required. Example: ```js -{ id: 'field_id' } +{ + id: 'field_id'; +} ``` ### `type` @@ -689,44 +728,50 @@ Field type. One of `text`, `integer`, `datetime`. If a field declares a `type`, it gets default implementations for the `sort`, `isValid`, and `Edit` functions if no other values are specified. -- Type: `string`. -- Optional. +- Type: `string`. +- Optional. Example: ```js -{ type: 'text' } +{ + type: 'text'; +} ``` ### `label` The field's name. This will be used across the UI. -- Type: `string`. -- Optional. -- Defaults to the `id` value. +- Type: `string`. +- Optional. +- Defaults to the `id` value. Example: ```js -{ label: 'Title' } +{ + label: 'Title'; +} ``` ### `header` React component used by the layouts to display the field name — useful to add icons, etc. It's complementary to the `label` property. -- Type: React component. -- Optional. -- Defaults to the `label` value. -- Props: none. -- Returns a React element that represents the field's name. +- Type: React component. +- Optional. +- Defaults to the `label` value. +- Props: none. +- Returns a React element that represents the field's name. Example: ```js { - header: () => { /* Returns a react element. */ } + header: () => { + /* Returns a react element. */ + }; } ``` @@ -734,18 +779,20 @@ Example: React component that returns the value of a field. This value is used to sort or filter the fields. -- Type: React component. -- Optional. -- Defaults to `item[ id ]`. -- Props: - - `item` value to be processed. -- Returns a value that represents the field. +- Type: React component. +- Optional. +- Defaults to `item[ id ]`. +- Props: + - `item` value to be processed. +- Returns a value that represents the field. Example: ```js { - getValue: ( { item } ) => { /* The field's value. */ }; + getValue: ( { item } ) => { + /* The field's value. */ + }; } ``` @@ -753,18 +800,20 @@ Example: React component that renders the field. This is used by the layouts. -- Type: React component. -- Optional. -- Defaults to `getValue`. -- Props - - `item` value to be processed. -- Returns a React element that represents the field's value. +- Type: React component. +- Optional. +- Defaults to `getValue`. +- Props + - `item` value to be processed. +- Returns a React element that represents the field's value. Example: ```js { - render: ( { item} ) => { /* React element to be displayed. */ } + render: ( { item } ) => { + /* React element to be displayed. */ + }; } ``` @@ -772,26 +821,21 @@ Example: React component that renders the control to edit the field. -- Type: React component | `string`. If it's a string, it needs to be one of `text`, `integer`, `datetime`, `radio`, `select`. -- Required by DataForm. Optional if the field provided a `type`. -- Props: - - `data`: the item to be processed - - `field`: the field definition - - `onChange`: the callback with the updates - - `hideLabelFromVision`: boolean representing if the label should be hidden -- Returns a React element to edit the field's value. +- Type: React component | `string`. If it's a string, it needs to be one of `text`, `integer`, `datetime`, `radio`, `select`. +- Required by DataForm. Optional if the field provided a `type`. +- Props: + - `data`: the item to be processed + - `field`: the field definition + - `onChange`: the callback with the updates + - `hideLabelFromVision`: boolean representing if the label should be hidden +- Returns a React element to edit the field's value. Example: ```js // A custom control defined by the field. { - Edit: ( { - data, - field, - onChange, - hideLabelFromVision - } ) => { + Edit: ( { data, field, onChange, hideLabelFromVision } ) => { const value = field.getValue( { item: data } ); return ( @@ -801,14 +845,14 @@ Example: hideLabelFromVision /> ); - } + }; } ``` ```js // Use one of the core controls. { - Edit: 'radio' + Edit: 'radio'; } ``` @@ -816,7 +860,7 @@ Example: // Edit is optional when field's type is present. // The field will use the default Edit function for text. { - type: 'text' + type: 'text'; } ``` @@ -833,16 +877,16 @@ Example: Function to sort the records. -- Type: `function`. -- Optional. -- Args - - `a`: the first item to compare - - `b`: the second item to compare - - `direction`: either `asc` (ascending) or `desc` (descending) -- Returns a number where: - - a negative value indicates that `a` should come before `b` - - a positive value indicates that `a` should come after `b` - - 0 indicates that `a` and `b` are considered equal +- Type: `function`. +- Optional. +- Args + - `a`: the first item to compare + - `b`: the second item to compare + - `direction`: either `asc` (ascending) or `desc` (descending) +- Returns a number where: + - a negative value indicates that `a` should come before `b` + - a positive value indicates that `a` should come after `b` + - 0 indicates that `a` and `b` are considered equal Example: @@ -853,7 +897,7 @@ Example: return direction === 'asc' ? a.localeCompare( b ) : b.localeCompare( a ); - } + }; } ``` @@ -861,7 +905,7 @@ Example: // If field type is provided, // the field gets a default sort function. { - type: 'number' + type: 'number'; } ``` @@ -869,8 +913,10 @@ Example: // Even if a field type is provided, // fields can override the default sort function assigned for that type. { - type: 'number' - sort: ( a, b, direction ) => { /* Custom sort */ } + type: 'number'; + sort: ( a, b, direction ) => { + /* Custom sort */ + }; } ``` @@ -878,13 +924,13 @@ Example: Function to validate a field's value. -- Type: function. -- Optional. -- Args - - `item`: the data to validate - - `context`: an object containing the following props: - - `elements`: the elements defined by the field -- Returns a boolean, indicating if the field is valid or not. +- Type: function. +- Optional. +- Args + - `item`: the data to validate + - `context`: an object containing the following props: + - `elements`: the elements defined by the field +- Returns a boolean, indicating if the field is valid or not. Example: @@ -893,7 +939,7 @@ Example: { isValid: ( item, context ) => { return !! item; - } + }; } ``` @@ -918,18 +964,20 @@ Example: Function that indicates if the field should be visible. -- Type: `function`. -- Optional. -- Args - - `item`: the data to be processed -- Returns a `boolean` indicating if the field should be visible (`true`) or not (`false`). +- Type: `function`. +- Optional. +- Args + - `item`: the data to be processed +- Returns a `boolean` indicating if the field should be visible (`true`) or not (`false`). Example: ```js // Custom isVisible function. { - isVisible: ( item ) => { /* Custom implementation. */ } + isVisible: ( item ) => { + /* Custom implementation. */ + }; } ``` @@ -937,54 +985,60 @@ Example: Boolean indicating if the field is sortable. -- Type: `boolean`. -- Optional. -- Defaults to `true`. +- Type: `boolean`. +- Optional. +- Defaults to `true`. Example: ```js -{ enableSorting: true } +{ + enableSorting: true; +} ``` ### `enableHiding` Boolean indicating if the field can be hidden. -- Type: `boolean`. -- Optional. -- Defaults to `true`. +- Type: `boolean`. +- Optional. +- Defaults to `true`. Example: ```js -{ enableHiding: true } +{ + enableHiding: true; +} ``` ### `enableGlobalSearch` Boolean indicating if the field is searchable. -- Type: `boolean`. -- Optional. -- Defaults to `false`. +- Type: `boolean`. +- Optional. +- Defaults to `false`. Example: ```js -{ enableGlobalSearch: true } +{ + enableGlobalSearch: true; +} ``` ### `elements` List of valid values for a field. If provided, it creates a DataViews' filter for the field. DataForm's edit control will also use these values. (See `Edit` field property.) -- Type: `array` of objects. -- Optional. -- Each object can have the following properties: - - `value`: the value to match against the field's value. (Required) - - `label`: the name to display to users. (Required) - - `description`: optional, a longer description of the item. +- Type: `array` of objects. +- Optional. +- Each object can have the following properties: + - `value`: the value to match against the field's value. (Required) + - `label`: the name to display to users. (Required) + - `description`: optional, a longer description of the item. Example: @@ -995,7 +1049,7 @@ Example: { value: '2', label: 'Product B' }, { value: '3', label: 'Product C' }, { value: '4', label: 'Product D' }, - ] + ]; } ``` @@ -1003,11 +1057,11 @@ Example: Configuration of the filters. -- Type: `object`. -- Optional. -- Properties: - - `operators`: the list of operators supported by the field. See "operators" below. A filter will support the `isAny` and `isNone` multi-selection operators by default. - - `isPrimary`: boolean, optional. Indicates if the filter is primary. A primary filter is always visible and is not listed in the "Add filter" component, except for the list layout where it behaves like a secondary filter. +- Type: `object`. +- Optional. +- Properties: + - `operators`: the list of operators supported by the field. See "operators" below. A filter will support the `isAny` and `isNone` multi-selection operators by default. + - `isPrimary`: boolean, optional. Indicates if the filter is primary. A primary filter is always visible and is not listed in the "Add filter" component, except for the list layout where it behaves like a secondary filter. Operators: @@ -1028,7 +1082,7 @@ Example: // Set a filter as primary. { filterBy: { - isPrimary: true + isPrimary: true; } } ``` @@ -1037,7 +1091,7 @@ Example: // Configure a filter as single-selection. { filterBy: { - operators: [ `is`, `isNot` ] + operators: [ `is`, `isNot` ]; } } ``` @@ -1046,11 +1100,91 @@ Example: // Configure a filter as multi-selection with all the options. { filterBy: { - operators: [ `isAny`, `isNone`, `isAll`, `isNotAll` ] + operators: [ `isAny`, `isNone`, `isAll`, `isNotAll` ]; } } ``` +## Form Field API + +### `id` + +The unique identifier of the field. + +- Type: `string`. +- Required. + +Example: + +```js +{ + id: 'field_id'; +} +``` + +### `layout` + +The same as the `form.type`, either `regular` or `panel` only for the individual field. It defaults to `form.type`. + +- Type: `string`. + +Example: + +```js +{ + id: 'field_id', + layout: 'regular' +} +``` + +### `labelPosition` + +The same as the `form.labelPosition`, either `side`, `top`, or `none` for the individual field. It defaults to `form.labelPosition`. + +- Type: `string`. + +Example: + +```js +{ + id: 'field_id', + labelPosition: 'none' +} +``` + +### `label` + +The label used when displaying a combined field, this requires the use of `children` as well. + +- Type: `string`. + +Example: + +```js +{ + id: 'field_id', + label: 'Combined Field', + children: [ 'field1', 'field2' ] +} +``` + +### `children` + +Groups a set of fields defined within children. For example if you want to display multiple fields within the Panel dropdown you can use children ( see example ). + +- Type: `Array< string | FormField >`. + +Example: + +```js +{ + id: 'status', + layout: 'panel', + label: 'Combined Field', + children: [ 'field1', 'field2' ], +} +``` + ## Contributing to this package This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. diff --git a/packages/dataviews/src/components/dataviews-context/index.ts b/packages/dataviews/src/components/dataviews-context/index.ts index 4bef3ecdbcbb4a..992048f9097064 100644 --- a/packages/dataviews/src/components/dataviews-context/index.ts +++ b/packages/dataviews/src/components/dataviews-context/index.ts @@ -26,8 +26,10 @@ type DataViewsContextType< Item > = { openedFilter: string | null; setOpenedFilter: ( openedFilter: string | null ) => void; getItemId: ( item: Item ) => string; + getItemLevel?: ( item: Item ) => number; onClickItem?: ( item: Item ) => void; isItemClickable: ( item: Item ) => boolean; + containerWidth: number; }; const DataViewsContext = createContext< DataViewsContextType< any > >( { @@ -45,6 +47,7 @@ const DataViewsContext = createContext< DataViewsContextType< any > >( { openedFilter: null, getItemId: ( item ) => item.id, isItemClickable: () => true, + containerWidth: 0, } ); export default DataViewsContext; diff --git a/packages/dataviews/src/components/dataviews-footer/style.scss b/packages/dataviews/src/components/dataviews-footer/style.scss index cdb1359ccee393..a5cd4dcac9ca02 100644 --- a/packages/dataviews/src/components/dataviews-footer/style.scss +++ b/packages/dataviews/src/components/dataviews-footer/style.scss @@ -11,15 +11,12 @@ z-index: z-index(".dataviews-footer"); } - -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .dataviews-footer { padding: $grid-unit-15 $grid-unit-30; } } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 560px) { .dataviews-footer { flex-direction: column !important; diff --git a/packages/dataviews/src/components/dataviews-layout/index.tsx b/packages/dataviews/src/components/dataviews-layout/index.tsx index ebc251eae36a7a..d30b1d39c6524d 100644 --- a/packages/dataviews/src/components/dataviews-layout/index.tsx +++ b/packages/dataviews/src/components/dataviews-layout/index.tsx @@ -21,6 +21,7 @@ export default function DataViewsLayout() { data, fields, getItemId, + getItemLevel, isLoading, view, onChangeView, @@ -40,6 +41,7 @@ export default function DataViewsLayout() { data={ data } fields={ fields } getItemId={ getItemId } + getItemLevel={ getItemLevel } isLoading={ isLoading } onChangeView={ onChangeView } onChangeSelection={ onChangeSelection } diff --git a/packages/dataviews/src/components/dataviews-view-config/index.tsx b/packages/dataviews/src/components/dataviews-view-config/index.tsx index bd16f3f9cdca3b..0b3512714e14a4 100644 --- a/packages/dataviews/src/components/dataviews-view-config/index.tsx +++ b/packages/dataviews/src/components/dataviews-view-config/index.tsx @@ -152,6 +152,7 @@ function SortFieldControl() { direction: view?.sort?.direction || 'desc', field: value, }, + showLevels: false, } ); } } /> @@ -194,6 +195,7 @@ function SortDirectionControl() { )?.id || '', }, + showLevels: false, } ); return; } diff --git a/packages/dataviews/src/components/dataviews-view-config/style.scss b/packages/dataviews/src/components/dataviews-view-config/style.scss index 0fd97b916b4aa8..692dddfb7a90b4 100644 --- a/packages/dataviews/src/components/dataviews-view-config/style.scss +++ b/packages/dataviews/src/components/dataviews-view-config/style.scss @@ -43,7 +43,6 @@ display: none; } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 500px) { .dataviews-settings-section.dataviews-settings-section { grid-template-columns: repeat(2, 1fr); diff --git a/packages/dataviews/src/components/dataviews/index.tsx b/packages/dataviews/src/components/dataviews/index.tsx index 99d9b6d684b08c..a0a89488136548 100644 --- a/packages/dataviews/src/components/dataviews/index.tsx +++ b/packages/dataviews/src/components/dataviews/index.tsx @@ -8,6 +8,7 @@ import type { ReactNode } from 'react'; */ import { __experimentalHStack as HStack } from '@wordpress/components'; import { useMemo, useState } from '@wordpress/element'; +import { useResizeObserver } from '@wordpress/compose'; /** * Internal dependencies @@ -47,6 +48,7 @@ type DataViewsProps< Item > = { onClickItem?: ( item: Item ) => void; isItemClickable?: ( item: Item ) => boolean; header?: ReactNode; + getItemLevel?: ( item: Item ) => number; } & ( Item extends ItemWithId ? { getItemId?: ( item: Item ) => string } : { getItemId: ( item: Item ) => string } ); @@ -64,6 +66,7 @@ export default function DataViews< Item >( { actions = EMPTY_ARRAY, data, getItemId = defaultGetItemId, + getItemLevel, isLoading = false, paginationInfo, defaultLayouts, @@ -73,6 +76,15 @@ export default function DataViews< Item >( { isItemClickable = defaultIsItemClickable, header, }: DataViewsProps< Item > ) { + const [ containerWidth, setContainerWidth ] = useState( 0 ); + const containerRef = useResizeObserver( + ( resizeObserverEntries: any ) => { + setContainerWidth( + resizeObserverEntries[ 0 ].borderBoxSize[ 0 ].inlineSize + ); + }, + { box: 'border-box' } + ); const [ selectionState, setSelectionState ] = useState< string[] >( [] ); const isUncontrolled = selectionProperty === undefined || onChangeSelection === undefined; @@ -115,11 +127,13 @@ export default function DataViews< Item >( { openedFilter, setOpenedFilter, getItemId, + getItemLevel, isItemClickable, onClickItem, + containerWidth, } } > - <div className="dataviews-wrapper"> + <div className="dataviews-wrapper" ref={ containerRef }> <HStack alignment="top" justify="space-between" diff --git a/packages/dataviews/src/components/dataviews/style.scss b/packages/dataviews/src/components/dataviews/style.scss index b38447094c99a9..3c85115c06dddf 100644 --- a/packages/dataviews/src/components/dataviews/style.scss +++ b/packages/dataviews/src/components/dataviews/style.scss @@ -33,7 +33,6 @@ @include reduce-motion( "transition" ); } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .dataviews__view-actions, .dataviews-filters__container { diff --git a/packages/dataviews/src/dataviews-layouts/grid/index.tsx b/packages/dataviews/src/dataviews-layouts/grid/index.tsx index cdb70219d229ad..e8f8a46002ebdf 100644 --- a/packages/dataviews/src/dataviews-layouts/grid/index.tsx +++ b/packages/dataviews/src/dataviews-layouts/grid/index.tsx @@ -13,6 +13,7 @@ import { Spinner, Flex, FlexItem, + privateApis as componentsPrivateApis, } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useInstanceId } from '@wordpress/compose'; @@ -20,6 +21,7 @@ import { useInstanceId } from '@wordpress/compose'; /** * Internal dependencies */ +import { unlock } from '../../lock-unlock'; import ItemActions from '../../components/dataviews-item-actions'; import DataViewsSelectionCheckbox from '../../components/dataviews-selection-checkbox'; import { @@ -35,6 +37,7 @@ import type { import type { SetSelection } from '../../private-types'; import getClickableItemProps from '../utils/get-clickable-item-props'; import { useUpdatedPreviewSizeOnViewportChange } from './preview-size-picker'; +const { Badge } = unlock( componentsPrivateApis ); interface GridItemProps< Item > { view: ViewGridType; @@ -175,12 +178,12 @@ function GridItem< Item >( { > { badgeFields.map( ( field ) => { return ( - <FlexItem + <Badge key={ field.id } className="dataviews-view-grid__field-value" > <field.render item={ item } /> - </FlexItem> + </Badge> ); } ) } </HStack> diff --git a/packages/dataviews/src/dataviews-layouts/grid/preview-size-picker.tsx b/packages/dataviews/src/dataviews-layouts/grid/preview-size-picker.tsx index b48c6422bd6b37..027632090b31b4 100644 --- a/packages/dataviews/src/dataviews-layouts/grid/preview-size-picker.tsx +++ b/packages/dataviews/src/dataviews-layouts/grid/preview-size-picker.tsx @@ -3,7 +3,6 @@ */ import { RangeControl } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { useViewportMatch } from '@wordpress/compose'; import { useMemo, useContext } from '@wordpress/element'; /** @@ -12,7 +11,9 @@ import { useMemo, useContext } from '@wordpress/element'; import DataViewsContext from '../../components/dataviews-context'; import type { ViewGrid } from '../../types'; -const viewportBreaks = { +const viewportBreaks: { + [ key: string ]: { min: number; max: number; default: number }; +} = { xhuge: { min: 3, max: 6, default: 5 }, huge: { min: 2, max: 4, default: 4 }, xlarge: { min: 2, max: 3, default: 3 }, @@ -20,38 +21,35 @@ const viewportBreaks = { mobile: { min: 1, max: 2, default: 2 }, }; -function useViewPortBreakpoint() { - const isXHuge = useViewportMatch( 'xhuge', '>=' ); - const isHuge = useViewportMatch( 'huge', '>=' ); - const isXlarge = useViewportMatch( 'xlarge', '>=' ); - const isLarge = useViewportMatch( 'large', '>=' ); - const isMobile = useViewportMatch( 'mobile', '>=' ); +/** + * Breakpoints were adjusted from media queries breakpoints to account for + * the sidebar width. This was done to match the existing styles we had. + */ +const BREAKPOINTS = { + xhuge: 1520, + huge: 1140, + xlarge: 780, + large: 480, + mobile: 0, +}; - if ( isXHuge ) { - return 'xhuge'; - } - if ( isHuge ) { - return 'huge'; - } - if ( isXlarge ) { - return 'xlarge'; - } - if ( isLarge ) { - return 'large'; - } - if ( isMobile ) { - return 'mobile'; +function useViewPortBreakpoint() { + const containerWidth = useContext( DataViewsContext ).containerWidth; + for ( const [ key, value ] of Object.entries( BREAKPOINTS ) ) { + if ( containerWidth >= value ) { + return key; + } } - return null; + return 'mobile'; } export function useUpdatedPreviewSizeOnViewportChange() { - const viewport = useViewPortBreakpoint(); const view = useContext( DataViewsContext ).view as ViewGrid; + const viewport = useViewPortBreakpoint(); return useMemo( () => { const previewSize = view.layout?.previewSize; let newPreviewSize; - if ( ! viewport || ! previewSize ) { + if ( ! previewSize ) { return; } const breakValues = viewportBreaks[ viewport ]; @@ -69,9 +67,8 @@ export default function PreviewSizePicker() { const viewport = useViewPortBreakpoint(); const context = useContext( DataViewsContext ); const view = context.view as ViewGrid; - const breakValues = viewportBreaks[ viewport || 'mobile' ]; + const breakValues = viewportBreaks[ viewport ]; const previewSizeToUse = view.layout?.previewSize || breakValues.default; - const marks = useMemo( () => Array.from( @@ -84,11 +81,9 @@ export default function PreviewSizePicker() { ), [ breakValues ] ); - - if ( ! viewport ) { + if ( viewport === 'mobile' ) { return null; } - return ( <RangeControl __nextHasNoMarginBottom diff --git a/packages/dataviews/src/dataviews-layouts/grid/style.scss b/packages/dataviews/src/dataviews-layouts/grid/style.scss index 51db297b4025b7..333e6e9a4caf9f 100644 --- a/packages/dataviews/src/dataviews-layouts/grid/style.scss +++ b/packages/dataviews/src/dataviews-layouts/grid/style.scss @@ -3,6 +3,7 @@ grid-template-rows: max-content; padding: 0 $grid-unit-60 $grid-unit-30; transition: padding ease-out 0.1s; + container-type: inline-size; @include reduce-motion("transition"); @@ -111,36 +112,29 @@ &:not(:empty) { padding-bottom: $grid-unit-15; } - - .dataviews-view-grid__field-value { - width: fit-content; - background: $gray-100; - padding: 0 $grid-unit-10; - min-height: $grid-unit-30; - border-radius: $radius-small; - display: flex; - align-items: center; - font-size: 12px; - } } } .dataviews-view-grid.dataviews-view-grid { - grid-template-columns: repeat(1, minmax(0, 1fr)); - - @include break-mobile() { + /** + * Breakpoints were adjusted from media queries breakpoints to account for + * the sidebar width. This was done to match the existing styles we had. + */ + @container (max-width: 480px) { + grid-template-columns: repeat(1, minmax(0, 1fr)); + padding-left: $grid-unit-30; + padding-right: $grid-unit-30; + } + @container (min-width: 480px) { grid-template-columns: repeat(2, minmax(0, 1fr)); } - - @include break-xlarge() { + @container (min-width: 780px) { grid-template-columns: repeat(3, minmax(0, 1fr)); } - - @include break-huge() { + @container (min-width: 1140px) { grid-template-columns: repeat(4, minmax(0, 1fr)); } - - @include break-xhuge() { + @container (min-width: 1520px) { grid-template-columns: repeat(5, minmax(0, 1fr)); } } @@ -163,14 +157,6 @@ top: $grid-unit-10; } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ -@container (max-width: 430px) { - .dataviews-view-grid { - padding-left: $grid-unit-30; - padding-right: $grid-unit-30; - } -} - .dataviews-view-grid__media--clickable { cursor: pointer; } diff --git a/packages/dataviews/src/dataviews-layouts/table/column-header-menu.tsx b/packages/dataviews/src/dataviews-layouts/table/column-header-menu.tsx index 55cd8d867eff40..1d8d22193bbd07 100644 --- a/packages/dataviews/src/dataviews-layouts/table/column-header-menu.tsx +++ b/packages/dataviews/src/dataviews-layouts/table/column-header-menu.tsx @@ -142,6 +142,7 @@ const _HeaderMenu = forwardRef( function HeaderMenu< Item >( field: fieldId, direction, }, + showLevels: false, } ); } } > diff --git a/packages/dataviews/src/dataviews-layouts/table/column-primary.tsx b/packages/dataviews/src/dataviews-layouts/table/column-primary.tsx index 6db65be72bdd4c..6ac4057b0973ba 100644 --- a/packages/dataviews/src/dataviews-layouts/table/column-primary.tsx +++ b/packages/dataviews/src/dataviews-layouts/table/column-primary.tsx @@ -14,6 +14,7 @@ import getClickableItemProps from '../utils/get-clickable-item-props'; function ColumnPrimary< Item >( { item, + level, titleField, mediaField, descriptionField, @@ -21,6 +22,7 @@ function ColumnPrimary< Item >( { isItemClickable, }: { item: Item; + level?: number; titleField?: NormalizedField< Item >; mediaField?: NormalizedField< Item >; descriptionField?: NormalizedField< Item >; @@ -44,6 +46,11 @@ function ColumnPrimary< Item >( { <VStack spacing={ 0 }> { titleField && ( <div { ...clickableProps }> + { level !== undefined && ( + <span className="dataviews-view-table__level"> + { '—'.repeat( level ) }&nbsp; + </span> + ) } <titleField.render item={ item } /> </div> ) } diff --git a/packages/dataviews/src/dataviews-layouts/table/index.tsx b/packages/dataviews/src/dataviews-layouts/table/index.tsx index b010b3ff154fbb..855e0584563b71 100644 --- a/packages/dataviews/src/dataviews-layouts/table/index.tsx +++ b/packages/dataviews/src/dataviews-layouts/table/index.tsx @@ -40,6 +40,7 @@ interface TableColumnFieldProps< Item > { interface TableRowProps< Item > { hasBulkActions: boolean; item: Item; + level?: number; actions: Action< Item >[]; fields: NormalizedField< Item >[]; id: string; @@ -75,6 +76,7 @@ function TableColumnField< Item >( { function TableRow< Item >( { hasBulkActions, item, + level, actions, fields, id, @@ -160,6 +162,7 @@ function TableRow< Item >( { <td> <ColumnPrimary item={ item } + level={ level } titleField={ showTitle ? titleField : undefined } mediaField={ showMedia ? mediaField : undefined } descriptionField={ @@ -210,6 +213,7 @@ function ViewTable< Item >( { data, fields, getItemId, + getItemLevel, isLoading = false, onChangeView, onChangeSelection, @@ -375,6 +379,12 @@ function ViewTable< Item >( { <TableRow key={ getItemId( item ) } item={ item } + level={ + view.showLevels && + typeof getItemLevel === 'function' + ? getItemLevel( item ) + : undefined + } hasBulkActions={ hasBulkActions } actions={ actions } fields={ fields } diff --git a/packages/dataviews/src/dataviews-layouts/table/style.scss b/packages/dataviews/src/dataviews-layouts/table/style.scss index 5a713dd428c127..5a4ac01b566f74 100644 --- a/packages/dataviews/src/dataviews-layouts/table/style.scss +++ b/packages/dataviews/src/dataviews-layouts/table/style.scss @@ -203,7 +203,6 @@ } } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .dataviews-view-table tr td:first-child, .dataviews-view-table tr th:first-child { diff --git a/packages/dataviews/src/test/dataform.tsx b/packages/dataviews/src/test/dataform.tsx new file mode 100644 index 00000000000000..534151a0a4ab58 --- /dev/null +++ b/packages/dataviews/src/test/dataform.tsx @@ -0,0 +1,348 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import Dataform from '../components/dataform/index'; + +const noop = () => {}; + +const fields = [ + { + id: 'title', + label: 'Title', + type: 'text' as const, + }, + { + id: 'order', + label: 'Order', + type: 'integer' as const, + }, + { + id: 'author', + label: 'Author', + type: 'integer' as const, + elements: [ + { value: 1, label: 'Jane' }, + { value: 2, label: 'John' }, + ], + }, +]; + +const form = { + fields: [ 'title', 'order', 'author' ], +}; + +const data = { + title: 'Hello World', + author: 1, + order: 1, +}; + +const fieldsSelector = { + title: { + view: () => + screen.getByRole( 'button', { + name: /edit title/i, + } ), + edit: () => + screen.getByRole( 'textbox', { + name: /title/i, + } ), + }, + author: { + view: () => + screen.getByRole( 'button', { + name: /edit author/i, + } ), + edit: () => + screen.queryByRole( 'combobox', { + name: /author/i, + } ), + }, + order: { + view: () => + screen.getByRole( 'button', { + name: /edit order/i, + } ), + edit: () => + screen.getByRole( 'spinbutton', { + name: /order/i, + } ), + }, +}; + +describe( 'DataForm component', () => { + describe( 'in regular mode', () => { + it( 'should display fields', () => { + render( + <Dataform + onChange={ noop } + fields={ fields } + form={ form } + data={ data } + /> + ); + + expect( fieldsSelector.title.edit() ).toBeInTheDocument(); + expect( fieldsSelector.order.edit() ).toBeInTheDocument(); + expect( fieldsSelector.author.edit() ).toBeInTheDocument(); + } ); + + it( 'should render custom Edit component', () => { + const fieldsWithCustomEditComponent = fields.map( ( field ) => { + if ( field.id === 'title' ) { + return { + ...field, + Edit: () => { + return <span>This is the Title Field</span>; + }, + }; + } + return field; + } ); + + render( + <Dataform + onChange={ noop } + fields={ fieldsWithCustomEditComponent } + form={ form } + data={ data } + /> + ); + + const titleField = screen.getByText( 'This is the Title Field' ); + expect( titleField ).toBeInTheDocument(); + } ); + + it( 'should call onChange with the correct value for each typed character', async () => { + const onChange = jest.fn(); + render( + <Dataform + onChange={ onChange } + fields={ fields } + form={ form } + data={ { ...data, title: '' } } + /> + ); + + const titleInput = fieldsSelector.title.edit(); + const user = userEvent.setup(); + await user.clear( titleInput ); + expect( titleInput ).toHaveValue( '' ); + const newValue = 'Hello folks!'; + await user.type( titleInput, newValue ); + expect( onChange ).toHaveBeenCalledTimes( newValue.length ); + for ( let i = 0; i < newValue.length; i++ ) { + expect( onChange ).toHaveBeenNthCalledWith( i + 1, { + title: newValue[ i ], + } ); + } + } ); + + it( 'should wrap fields in HStack when labelPosition is set to side', async () => { + const { container } = render( + <Dataform + onChange={ noop } + fields={ fields } + form={ { ...form, labelPosition: 'side' } } + data={ data } + /> + ); + + expect( + // It is used here to ensure that the fields are wrapped in HStack. This happens when the labelPosition is set to side. + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + container.querySelectorAll( "[data-wp-component='HStack']" ) + ).toHaveLength( 3 ); + } ); + + it( 'should render combined fields correctly', async () => { + const formWithCombinedFields = { + fields: [ + 'order', + { + id: 'title', + children: [ 'title', 'author' ], + label: "Title and author's name", + }, + ], + }; + + render( + <Dataform + onChange={ noop } + fields={ fields } + form={ formWithCombinedFields } + data={ data } + /> + ); + + expect( + screen.getByText( "Title and author's name" ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'in panel mode', () => { + const formPanelMode = { + ...form, + type: 'panel' as const, + }; + it( 'should display fields', async () => { + render( + <Dataform + onChange={ noop } + fields={ fields } + form={ formPanelMode } + data={ data } + /> + ); + + const user = await userEvent.setup(); + + for ( const field of Object.values( fieldsSelector ) ) { + const button = field.view(); + await user.click( button ); + expect( field.edit() ).toBeInTheDocument(); + } + } ); + + it( 'should call onChange with the correct value for each typed character', async () => { + const onChange = jest.fn(); + render( + <Dataform + onChange={ onChange } + fields={ fields } + form={ formPanelMode } + data={ { ...data, title: '' } } + /> + ); + + const titleButton = fieldsSelector.title.view(); + const user = await userEvent.setup(); + await user.click( titleButton ); + const input = fieldsSelector.title.edit(); + expect( input ).toHaveValue( '' ); + const newValue = 'Hello folks!'; + await user.type( input, newValue ); + expect( onChange ).toHaveBeenCalledTimes( newValue.length ); + for ( let i = 0; i < newValue.length; i++ ) { + expect( onChange ).toHaveBeenNthCalledWith( i + 1, { + title: newValue[ i ], + } ); + } + } ); + + it( 'should wrap fields in HStack when labelPosition is set to side', async () => { + const { container } = render( + <Dataform + onChange={ noop } + fields={ fields } + form={ { ...formPanelMode, labelPosition: 'side' } } + data={ data } + /> + ); + + expect( + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + container.querySelectorAll( "[data-wp-component='HStack']" ) + ).toHaveLength( 3 ); + } ); + + it( 'should render combined fields correctly', async () => { + const formWithCombinedFields = { + ...formPanelMode, + fields: [ + 'order', + { + id: 'title', + children: [ 'title', 'author' ], + label: "Title and author's name", + }, + ], + }; + + render( + <Dataform + onChange={ noop } + fields={ fields } + form={ formWithCombinedFields } + data={ data } + /> + ); + + const button = screen.getByRole( 'button', { + name: /edit title and author's name/i, + } ); + const user = await userEvent.setup(); + await user.click( button ); + expect( fieldsSelector.title.edit() ).toBeInTheDocument(); + expect( fieldsSelector.author.edit() ).toBeInTheDocument(); + } ); + + it( 'should render custom render component', async () => { + const fieldsWithCustomRenderFunction = fields.map( ( field ) => { + return { + ...field, + render: () => { + return <span>This is the { field.id } field</span>; + }, + }; + } ); + + render( + <Dataform + onChange={ noop } + fields={ fieldsWithCustomRenderFunction } + form={ formPanelMode } + data={ data } + /> + ); + + const titleField = screen.getByText( 'This is the title field' ); + const orderField = screen.getByText( 'This is the order field' ); + const authorField = screen.getByText( 'This is the author field' ); + expect( titleField ).toBeInTheDocument(); + expect( orderField ).toBeInTheDocument(); + expect( authorField ).toBeInTheDocument(); + } ); + + it( 'should render custom Edit component', async () => { + const fieldsWithTitleCustomEditComponent = fields.map( + ( field ) => { + if ( field.id === 'title' ) { + return { + ...field, + Edit: () => { + return <span>This is the Title Field</span>; + }, + }; + } + return field; + } + ); + + render( + <Dataform + onChange={ noop } + fields={ fieldsWithTitleCustomEditComponent } + form={ formPanelMode } + data={ data } + /> + ); + + const titleField = screen.getByText( data.title ); + const user = await userEvent.setup(); + await user.click( titleField ); + const titleEditField = screen.getByText( + 'This is the Title Field' + ); + expect( titleEditField ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/packages/dataviews/src/types.ts b/packages/dataviews/src/types.ts index 96fd4a8cd01afc..820f75364df204 100644 --- a/packages/dataviews/src/types.ts +++ b/packages/dataviews/src/types.ts @@ -322,6 +322,11 @@ interface ViewBase { * Whether to show the description */ showDescription?: boolean; + + /** + * Whether to show the hierarchical levels. + */ + showLevels?: boolean; } export interface ColumnStyle { @@ -480,6 +485,7 @@ export interface ViewBaseProps< Item > { data: Item[]; fields: NormalizedField< Item >[]; getItemId: ( item: Item ) => string; + getItemLevel?: ( item: Item ) => number; isLoading?: boolean; onChangeView: ( view: View ) => void; onChangeSelection: SetSelection; diff --git a/packages/dataviews/tsconfig.json b/packages/dataviews/tsconfig.json index 78e68b5a7c98b4..3f0865cc398544 100644 --- a/packages/dataviews/tsconfig.json +++ b/packages/dataviews/tsconfig.json @@ -4,7 +4,12 @@ "compilerOptions": { "rootDir": "src", "declarationDir": "build-types", - "checkJs": false + "types": [ + "gutenberg-env", + "gutenberg-test-env", + "jest", + "@testing-library/jest-dom" + ] }, "references": [ { "path": "../components" }, @@ -17,5 +22,13 @@ { "path": "../private-apis" }, { "path": "../warning" } ], - "include": [ "src" ] + "include": [ "src" ], + "exclude": [ + "src/**/*.android.js", + "src/**/*.ios.js", + "src/**/*.native.js", + "src/**/react-native-*", + "src/**/stories/**/*.js", // only exclude js files, tsx files should be checked + "src/**/test/**/*.js" // only exclude js files, ts{x} files should be checked + ] } diff --git a/packages/e2e-test-utils/README.md b/packages/e2e-test-utils/README.md index 30548961db26a4..5d445b2697d5ef 100644 --- a/packages/e2e-test-utils/README.md +++ b/packages/e2e-test-utils/README.md @@ -212,7 +212,7 @@ Create a new user account. _Parameters_ - _username_ `string`: User name. -- _object_ `Object?`: Optional Settings for the new user account. +- _object_ `?Object`: Optional Settings for the new user account. - _object.firstName_ `[string]`: First name. - _object.lastName_ `[string]`: Last name. - _object.role_ `[string]`: Role. Defaults to Administrator. @@ -252,7 +252,7 @@ Deletes a theme from the site, activating another theme if necessary. _Parameters_ - _slug_ `string`: Theme slug. -- _settings_ `Object?`: Optional settings object. +- _settings_ `?Object`: Optional settings object. - _settings.newThemeSlug_ `?string`: A theme to switch to if the theme to delete is active. Required if the theme to delete is active. - _settings.newThemeSearchTerm_ `?string`: A search term to use if the new theme is not findable by its slug. @@ -488,7 +488,7 @@ Installs a theme from the WP.org repository. _Parameters_ - _slug_ `string`: Theme slug. -- _settings_ `Object?`: Optional settings object. +- _settings_ `?Object`: Optional settings object. - _settings.searchTerm_ `?string`: Search term to use if the theme is not findable by its slug. ### isCurrentURL diff --git a/packages/e2e-test-utils/src/create-user.js b/packages/e2e-test-utils/src/create-user.js index 317f23c4c58d40..ac28a59482e381 100644 --- a/packages/e2e-test-utils/src/create-user.js +++ b/packages/e2e-test-utils/src/create-user.js @@ -14,7 +14,7 @@ import { visitAdminPage } from './visit-admin-page'; * Create a new user account. * * @param {string} username User name. - * @param {Object?} object Optional Settings for the new user account. + * @param {?Object} object Optional Settings for the new user account. * @param {string} [object.firstName] First name. * @param {string} [object.lastName] Last name. * @param {string} [object.role] Role. Defaults to Administrator. diff --git a/packages/e2e-test-utils/src/delete-theme.js b/packages/e2e-test-utils/src/delete-theme.js index 8b59c9f1e7a112..b09bc6424b99bd 100644 --- a/packages/e2e-test-utils/src/delete-theme.js +++ b/packages/e2e-test-utils/src/delete-theme.js @@ -12,7 +12,7 @@ import { isThemeInstalled } from './theme-installed'; * Deletes a theme from the site, activating another theme if necessary. * * @param {string} slug Theme slug. - * @param {Object?} settings Optional settings object. + * @param {?Object} settings Optional settings object. * @param {?string} settings.newThemeSlug A theme to switch to if the theme to delete is active. Required if the theme to delete is active. * @param {?string} settings.newThemeSearchTerm A search term to use if the new theme is not findable by its slug. */ diff --git a/packages/e2e-test-utils/src/install-theme.js b/packages/e2e-test-utils/src/install-theme.js index 7f11e5da88ef83..8adf7fe58a20cf 100644 --- a/packages/e2e-test-utils/src/install-theme.js +++ b/packages/e2e-test-utils/src/install-theme.js @@ -10,7 +10,7 @@ import { isThemeInstalled } from './theme-installed'; * Installs a theme from the WP.org repository. * * @param {string} slug Theme slug. - * @param {Object?} settings Optional settings object. + * @param {?Object} settings Optional settings object. * @param {?string} settings.searchTerm Search term to use if the theme is not findable by its slug. */ export async function installTheme( slug, { searchTerm } = {} ) { diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/assets/10x10_e2e_test_image_blue.png b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/assets/10x10_e2e_test_image_blue.png new file mode 100644 index 00000000000000..c4f8e7c5146d36 Binary files /dev/null and b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/assets/10x10_e2e_test_image_blue.png differ diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/block.json b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/block.json new file mode 100644 index 00000000000000..644ea70f74dca1 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test/router-styles-blue", + "title": "E2E Interactivity tests - router styles - Blue", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewStyle": "file:./style.css", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/render.php new file mode 100644 index 00000000000000..3f5da308db092a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/render.php @@ -0,0 +1,35 @@ +<?php +/** + * HTML for testing the iAPI's style assets management. + * + * @package gutenberg-test-interactive-blocks + * + * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable + */ + +add_action( + 'wp_enqueue_scripts', + function () { + wp_enqueue_style( + 'blue-from-link', + plugin_dir_url( __FILE__ ) . 'style-from-link.css', + array() + ); + + $custom_css = ' + .blue-from-inline { + color: rgb(0, 0, 255); + } + '; + + wp_register_style( 'test-router-styles', false ); + wp_enqueue_style( 'test-router-styles' ); + wp_add_inline_style( 'test-router-styles', $custom_css ); + } +); + +$wrapper_attributes = get_block_wrapper_attributes( + array( 'data-testid' => 'blue-block' ) +); +?> +<p <?php echo $wrapper_attributes; ?>>Blue</p> diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/style-from-link.css b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/style-from-link.css new file mode 100644 index 00000000000000..f55f12f4d594cf --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/style-from-link.css @@ -0,0 +1,7 @@ +.blue-from-link { + color: rgb(0, 0, 255); +} + +.background-from-link { + background-image: url('./assets/10x10_e2e_test_image_blue.png'); +} \ No newline at end of file diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/style.css b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/style.css new file mode 100644 index 00000000000000..84d891e90242a5 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-blue/style.css @@ -0,0 +1,4 @@ +.wp-block-test-router-styles-blue, +.blue { + color: rgb(0, 0, 255); +} \ No newline at end of file diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/assets/10x10_e2e_test_image_green.png b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/assets/10x10_e2e_test_image_green.png new file mode 100644 index 00000000000000..34ec87925d8c50 Binary files /dev/null and b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/assets/10x10_e2e_test_image_green.png differ diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/block.json b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/block.json new file mode 100644 index 00000000000000..e2edda625571b9 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test/router-styles-green", + "title": "E2E Interactivity tests - router styles - Green", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewStyle": "file:./style.css", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/render.php new file mode 100644 index 00000000000000..4418a2d3ab0f3d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/render.php @@ -0,0 +1,35 @@ +<?php +/** + * HTML for testing the iAPI's style assets management. + * + * @package gutenberg-test-interactive-blocks + * + * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable + */ + +add_action( + 'wp_enqueue_scripts', + function () { + wp_enqueue_style( + 'green-from-link', + plugin_dir_url( __FILE__ ) . 'style-from-link.css', + array() + ); + + $custom_css = ' + .green-from-inline { + color: rgb(0, 255, 0); + } + '; + + wp_register_style( 'test-router-styles', false ); + wp_enqueue_style( 'test-router-styles' ); + wp_add_inline_style( 'test-router-styles', $custom_css ); + } +); + +$wrapper_attributes = get_block_wrapper_attributes( + array( 'data-testid' => 'green-block' ) +); +?> +<p <?php echo $wrapper_attributes; ?>>Green</p> diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/style-from-link.css b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/style-from-link.css new file mode 100644 index 00000000000000..b3d7d7b111e52a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/style-from-link.css @@ -0,0 +1,7 @@ +.green-from-link { + color: rgb(0, 255, 0); +} + +.background-from-link { + background-image: url('./assets/10x10_e2e_test_image_green.png'); +} \ No newline at end of file diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/style.css b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/style.css new file mode 100644 index 00000000000000..0c457588f625cb --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-green/style.css @@ -0,0 +1,4 @@ +.wp-block-test-router-styles-green, +.green { + color: rgb(0, 255, 0); +} \ No newline at end of file diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/assets/10x10_e2e_test_image_red.png b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/assets/10x10_e2e_test_image_red.png new file mode 100644 index 00000000000000..3264bf6427c276 Binary files /dev/null and b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/assets/10x10_e2e_test_image_red.png differ diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/block.json b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/block.json new file mode 100644 index 00000000000000..582d7019062c6e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/block.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test/router-styles-red", + "title": "E2E Interactivity tests - router styles - Red", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewStyle": "file:./style.css", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/render.php new file mode 100644 index 00000000000000..e8474cf69b825a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/render.php @@ -0,0 +1,35 @@ +<?php +/** + * HTML for testing the iAPI's style assets management. + * + * @package gutenberg-test-interactive-blocks + * + * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable + */ + +add_action( + 'wp_enqueue_scripts', + function () { + wp_enqueue_style( + 'red-from-link', + plugin_dir_url( __FILE__ ) . 'style-from-link.css', + array() + ); + + $custom_css = ' + .red-from-inline { + color: rgb(255, 0, 0); + } + '; + + wp_register_style( 'test-router-styles', false ); + wp_enqueue_style( 'test-router-styles' ); + wp_add_inline_style( 'test-router-styles', $custom_css ); + } +); + +$wrapper_attributes = get_block_wrapper_attributes( + array( 'data-testid' => 'red-block' ) +); +?> +<p <?php echo $wrapper_attributes; ?>>Red</p> diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/style-from-link.css b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/style-from-link.css new file mode 100644 index 00000000000000..0f7d6228079897 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/style-from-link.css @@ -0,0 +1,7 @@ +.red-from-link { + color: rgb(255, 0, 0); +} + +.background-from-link { + background-image: url('./assets/10x10_e2e_test_image_red.png'); +} \ No newline at end of file diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/style.css b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/style.css new file mode 100644 index 00000000000000..eac7e3af16e0b5 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-red/style.css @@ -0,0 +1,4 @@ +.wp-block-test-router-styles-red, +.red { + color: rgb(255, 0, 0); +} \ No newline at end of file diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/block.json b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/block.json new file mode 100644 index 00000000000000..a1a95b4c81e3b6 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/block.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 2, + "name": "test/router-styles-wrapper", + "title": "E2E Interactivity tests - router styles - Wrapper", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScriptModule": "file:./view.js", + "viewStyle": "file:./style.css", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/render.php b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/render.php new file mode 100644 index 00000000000000..6373e8e9bc235b --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/render.php @@ -0,0 +1,70 @@ +<?php +/** + * HTML for testing the iAPI's style assets management. + * + * @package gutenberg-test-interactive-blocks + * + * @phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable + */ + +$wrapper_attributes = get_block_wrapper_attributes(); +?> +<div <?php echo $wrapper_attributes; ?>> + <!-- These get colored when the corresponding block is present. --> + <fieldset> + <legend>Styles from block styles</legend> + <p data-testid="red" class="red">Red</p> + <p data-testid="green" class="green">Green</p> + <p data-testid="blue" class="blue">Blue</p> + <p data-testid="all" class="red green blue">All</p> + </fieldset> + + <!-- These get colored when the corresponding block enqueues a referenced stylesheet. --> + <fieldset> + <legend>Styles from referenced style sheets</legend> + <p data-testid="red-from-link" class="red-from-link">Red from link</p> + <p data-testid="green-from-link" class="green-from-link">Green from link</p> + <p data-testid="blue-from-link" class="blue-from-link">Blue from link</p> + <p data-testid="all-from-link" class="red-from-link green-from-link blue-from-link">All from link</p> + <div data-testid="background-from-link"class="background-from-link" style="width: 10px; height: 10px"></div> + </fieldset> + + <!-- These get colored when the corresponding block adds inline style. --> + <fieldset> + <legend>Styles from inline styles</legend> + <p data-testid="red-from-inline" class="red-from-inline">Red</p> + <p data-testid="green-from-inline" class="green-from-inline">Green</p> + <p data-testid="blue-from-inline" class="blue-from-inline">Blue</p> + <p data-testid="all-from-inline" class="red-from-inline green-from-inline blue-from-inline">All</p> + </fieldset> + + <!-- Links to pages with different blocks combination. --> + <nav data-wp-interactive="test/router-styles"> + <?php foreach ( $attributes['links'] as $label => $link ) : ?> + <a + data-testid="link <?php echo $label; ?>" + data-wp-on--click="actions.navigate" + href="<?php echo $link; ?>" + > + <?php echo $label; ?> + </a> + <?php endforeach; ?> + </nav> + + <!-- HTML updated on navigation. --> + <div + data-wp-interactive="test/router-styles" + data-wp-router-region="router-styles" + > + <?php echo $content; ?> + </div> + + <!-- Text to check whether a navigation was client-side. --> + <div + data-testid="client-side navigation" + data-wp-interactive="test/router-styles" + data-wp-bind--hidden="!state.clientSideNavigation" + > + Client-side navigation + </div> +</div> diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/style.css b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/style.css new file mode 100644 index 00000000000000..12773560c4180f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/style.css @@ -0,0 +1,3 @@ +.wp-block-test-router-styles-wrapper { + color: rgb(160, 12, 60); +} \ No newline at end of file diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.asset.php b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.asset.php new file mode 100644 index 00000000000000..bdaec8d1b67a9d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.asset.php @@ -0,0 +1,9 @@ +<?php return array( + 'dependencies' => array( + '@wordpress/interactivity', + array( + 'id' => '@wordpress/interactivity-router', + 'import' => 'dynamic', + ), + ), +); diff --git a/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.js b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.js new file mode 100644 index 00000000000000..5b3b42f2b413e4 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/router-styles-wrapper/view.js @@ -0,0 +1,20 @@ +/** + * WordPress dependencies + */ +import { store } from '@wordpress/interactivity'; + +const { state } = store( 'test/router-styles', { + state: { + clientSideNavigation: false, + }, + actions: { + *navigate( e ) { + e.preventDefault(); + const { actions } = yield import( + '@wordpress/interactivity-router' + ); + yield actions.navigate( e.target.href ); + state.clientSideNavigation = true; + }, + }, +} ); diff --git a/packages/edit-post/src/store/selectors.js b/packages/edit-post/src/store/selectors.js index f6516dd0206c00..58c802f579e0d1 100644 --- a/packages/edit-post/src/store/selectors.js +++ b/packages/edit-post/src/store/selectors.js @@ -550,7 +550,7 @@ export function areMetaBoxesInitialized( state ) { /** * Retrieves the template of the currently edited post. * - * @return {Object?} Post Template. + * @return {?Object} Post Template. */ export const getEditedPostTemplate = createRegistrySelector( ( select ) => () => { diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 51045be503de31..299f0a67da9b7a 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -56,6 +56,7 @@ "@wordpress/icons": "*", "@wordpress/keyboard-shortcuts": "*", "@wordpress/keycodes": "*", + "@wordpress/media-utils": "5.14.0", "@wordpress/notices": "*", "@wordpress/patterns": "*", "@wordpress/plugins": "*", diff --git a/packages/edit-site/src/components/editor-canvas-container/style.scss b/packages/edit-site/src/components/editor-canvas-container/style.scss index 52ac29da0696f6..07d666fb293c59 100644 --- a/packages/edit-site/src/components/editor-canvas-container/style.scss +++ b/packages/edit-site/src/components/editor-canvas-container/style.scss @@ -1,6 +1,10 @@ .edit-site-editor-canvas-container { height: 100%; + // This is the gray background color that's applied behind "isolation mode". + // The color normally comes from .editor-visual-editor, but that class is missing here. + background-color: $gray-300; + // Controls height of editor and editor canvas container (style book, global styles revisions previews etc.) iframe { display: block; diff --git a/packages/edit-site/src/components/global-styles/style.scss b/packages/edit-site/src/components/global-styles/style.scss index 68cc40c4b62066..99b1c8c92bbd02 100644 --- a/packages/edit-site/src/components/global-styles/style.scss +++ b/packages/edit-site/src/components/global-styles/style.scss @@ -169,10 +169,18 @@ top: $grid-unit; opacity: 0; + &.edit-site-global-styles__shadow-editor__remove-button { + border: none; + } + .edit-site-global-styles__shadow-editor__dropdown-toggle:hover + &, &:focus, &:hover { - border: none; + opacity: 1; + } + + @media (hover: none) { + // Show reset button on devices that do not support hover. opacity: 1; } } diff --git a/packages/edit-site/src/components/page-patterns/style.scss b/packages/edit-site/src/components/page-patterns/style.scss index 1ee6ceb94ddbfc..d5520e5d97cdfc 100644 --- a/packages/edit-site/src/components/page-patterns/style.scss +++ b/packages/edit-site/src/components/page-patterns/style.scss @@ -92,7 +92,6 @@ } } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .edit-site-page-patterns-dataviews .edit-site-patterns__section-header { padding-left: $grid-unit-30; diff --git a/packages/edit-site/src/components/page/style.scss b/packages/edit-site/src/components/page/style.scss index 03e062a576b6e6..7759081e36f592 100644 --- a/packages/edit-site/src/components/page/style.scss +++ b/packages/edit-site/src/components/page/style.scss @@ -41,7 +41,6 @@ } } -/* stylelint-disable-next-line scss/at-rule-no-unknown -- '@container' not globally permitted */ @container (max-width: 430px) { .edit-site-page-header { padding: $grid-unit-20 $grid-unit-30; diff --git a/packages/edit-site/src/components/post-list/index.js b/packages/edit-site/src/components/post-list/index.js index bbf5d654ddb57a..6ab3a47efb4653 100644 --- a/packages/edit-site/src/components/post-list/index.js +++ b/packages/edit-site/src/components/post-list/index.js @@ -194,6 +194,10 @@ function getItemId( item ) { return item.id.toString(); } +function getItemLevel( item ) { + return item.level; +} + export default function PostList( { postType } ) { const [ view, setView ] = useView( postType ); const defaultViews = useDefaultViews( { postType } ); @@ -219,7 +223,6 @@ export default function PostList( { postType } ) { }, [ location.path, location.query.isCustom, history ] ); - const getActiveViewFilters = ( views, match ) => { const found = views.find( ( { slug } ) => slug === match ); return found?.filters ?? []; @@ -300,6 +303,7 @@ export default function PostList( { postType } ) { _embed: 'author', order: view.sort?.direction, orderby: view.sort?.field, + orderby_hierarchy: !! view.showLevels, search: view.search, ...filters, }; @@ -421,6 +425,7 @@ export default function PostList( { postType } ) { history.navigate( `/${ postType }/${ id }?canvas=edit` ); } } getItemId={ getItemId } + getItemLevel={ getItemLevel } defaultLayouts={ defaultLayouts } header={ window.__experimentalQuickEditDataViews && diff --git a/packages/edit-site/src/components/sidebar-dataviews/default-views.js b/packages/edit-site/src/components/sidebar-dataviews/default-views.js index c08a2c1a57c58e..c6edf7d2dd1203 100644 --- a/packages/edit-site/src/components/sidebar-dataviews/default-views.js +++ b/packages/edit-site/src/components/sidebar-dataviews/default-views.js @@ -39,9 +39,10 @@ const DEFAULT_POST_BASE = { page: 1, perPage: 20, sort: { - field: 'date', - direction: 'desc', + field: 'title', + direction: 'asc', }, + showLevels: true, titleField: 'title', mediaField: 'featured_media', fields: [ 'author', 'status' ], diff --git a/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js b/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js index 030512a38fab3a..de12bbe466bf3b 100644 --- a/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js +++ b/packages/edit-site/src/components/sidebar-global-styles-wrapper/index.js @@ -6,7 +6,7 @@ import { useMemo, useState } from '@wordpress/element'; import { privateApis as routerPrivateApis } from '@wordpress/router'; import { useViewportMatch } from '@wordpress/compose'; import { Button } from '@wordpress/components'; -import { addQueryArgs } from '@wordpress/url'; +import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; import { seen } from '@wordpress/icons'; /** @@ -15,15 +15,15 @@ import { seen } from '@wordpress/icons'; import GlobalStylesUI from '../global-styles/ui'; import Page from '../page'; import { unlock } from '../../lock-unlock'; -import StyleBook from '../style-book'; -import { STYLE_BOOK_COLOR_GROUPS } from '../style-book/constants'; const { useLocation, useHistory } = unlock( routerPrivateApis ); const GlobalStylesPageActions = ( { isStyleBookOpened, setIsStyleBookOpened, + path, } ) => { + const history = useHistory(); return ( <Button isPressed={ isStyleBookOpened } @@ -31,19 +31,26 @@ const GlobalStylesPageActions = ( { label={ __( 'Style Book' ) } onClick={ () => { setIsStyleBookOpened( ! isStyleBookOpened ); + const updatedPath = ! isStyleBookOpened + ? addQueryArgs( path, { preview: 'stylebook' } ) + : removeQueryArgs( path, 'preview' ); + // Navigate to the updated path. + history.navigate( updatedPath ); } } size="compact" /> ); }; -export default function GlobalStylesUIWrapper() { +/** + * Hook to deal with navigation and location state. + * + * @return {Array} The current section and a function to update it. + */ +export const useSection = () => { const { path, query } = useLocation(); const history = useHistory(); - const { canvas = 'view' } = query; - const [ isStyleBookOpened, setIsStyleBookOpened ] = useState( false ); - const isMobileViewport = useViewportMatch( 'medium', '<' ); - const [ section, onChangeSection ] = useMemo( () => { + return useMemo( () => { return [ query.section ?? '/', ( updatedSection ) => { @@ -55,6 +62,16 @@ export default function GlobalStylesUIWrapper() { }, ]; }, [ path, query.section, history ] ); +}; + +export default function GlobalStylesUIWrapper() { + const { path } = useLocation(); + + const [ isStyleBookOpened, setIsStyleBookOpened ] = useState( + path.includes( 'preview=stylebook' ) + ); + const isMobileViewport = useViewportMatch( 'medium', '<' ); + const [ section, onChangeSection ] = useSection(); return ( <> @@ -64,6 +81,7 @@ export default function GlobalStylesUIWrapper() { <GlobalStylesPageActions isStyleBookOpened={ isStyleBookOpened } setIsStyleBookOpened={ setIsStyleBookOpened } + path={ path } /> ) : null } @@ -75,45 +93,6 @@ export default function GlobalStylesUIWrapper() { onPathChange={ onChangeSection } /> </Page> - { canvas === 'view' && isStyleBookOpened && ( - <StyleBook - enableResizing={ false } - showCloseButton={ false } - showTabs={ false } - isSelected={ ( blockName ) => - // Match '/blocks/core%2Fbutton' and - // '/blocks/core%2Fbutton/typography', but not - // '/blocks/core%2Fbuttons'. - section === - `/blocks/${ encodeURIComponent( blockName ) }` || - section.startsWith( - `/blocks/${ encodeURIComponent( blockName ) }/` - ) - } - path={ section } - onSelect={ ( blockName ) => { - if ( - STYLE_BOOK_COLOR_GROUPS.find( - ( group ) => group.slug === blockName - ) - ) { - // Go to color palettes Global Styles. - onChangeSection( '/colors/palette' ); - return; - } - if ( blockName === 'typography' ) { - // Go to typography Global Styles. - onChangeSection( '/typography' ); - return; - } - - // Now go to the selected block. - onChangeSection( - `/blocks/${ encodeURIComponent( blockName ) }` - ); - } } - /> - ) } </> ); } diff --git a/packages/edit-site/src/components/sidebar-navigation-item/style.scss b/packages/edit-site/src/components/sidebar-navigation-item/style.scss index 230967c4c7e0ed..57b7e84bd57a8b 100644 --- a/packages/edit-site/src/components/sidebar-navigation-item/style.scss +++ b/packages/edit-site/src/components/sidebar-navigation-item/style.scss @@ -18,6 +18,7 @@ &[aria-current="true"] { background: $gray-800; color: $white; + font-weight: $font-weight-medium; } // Make sure the focus style is drawn on top of the current item background. diff --git a/packages/edit-site/src/components/site-editor-routes/stylebook.js b/packages/edit-site/src/components/site-editor-routes/stylebook.js index a30c4a7c04945e..cb1e414098ab3f 100644 --- a/packages/edit-site/src/components/site-editor-routes/stylebook.js +++ b/packages/edit-site/src/components/site-editor-routes/stylebook.js @@ -22,7 +22,7 @@ export const stylebookRoute = { ) } /> ), - preview: <StyleBookPreview />, - mobile: <StyleBookPreview />, + preview: <StyleBookPreview isStatic />, + mobile: <StyleBookPreview isStatic />, }, }; diff --git a/packages/edit-site/src/components/site-editor-routes/styles.js b/packages/edit-site/src/components/site-editor-routes/styles.js index cf29dbebea3733..a1827bee763390 100644 --- a/packages/edit-site/src/components/site-editor-routes/styles.js +++ b/packages/edit-site/src/components/site-editor-routes/styles.js @@ -10,6 +10,7 @@ import Editor from '../editor'; import { unlock } from '../../lock-unlock'; import SidebarNavigationScreenGlobalStyles from '../sidebar-navigation-screen-global-styles'; import GlobalStylesUIWrapper from '../sidebar-global-styles-wrapper'; +import { StyleBookPreview } from '../style-book'; const { useLocation } = unlock( routerPrivateApis ); @@ -30,7 +31,10 @@ export const stylesRoute = { areas: { content: <GlobalStylesUIWrapper />, sidebar: <SidebarNavigationScreenGlobalStyles backPath="/" />, - preview: <Editor />, + preview( { query } ) { + const isStylebook = query.preview === 'stylebook'; + return isStylebook ? <StyleBookPreview /> : <Editor />; + }, mobile: <MobileGlobalStylesUI />, }, widths: { diff --git a/packages/edit-site/src/components/style-book/index.js b/packages/edit-site/src/components/style-book/index.js index da69ed734166ed..28a693e76958d7 100644 --- a/packages/edit-site/src/components/style-book/index.js +++ b/packages/edit-site/src/components/style-book/index.js @@ -32,8 +32,11 @@ import { useContext, useRef, useLayoutEffect, + useEffect, } from '@wordpress/element'; import { ENTER, SPACE } from '@wordpress/keycodes'; +import { uploadMedia } from '@wordpress/media-utils'; +import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies @@ -47,6 +50,9 @@ import { } from './categories'; import { getExamples } from './examples'; import { store as siteEditorStore } from '../../store'; +import { useSection } from '../sidebar-global-styles-wrapper'; +import { STYLE_BOOK_COLOR_GROUPS } from '../style-book/constants'; +import { GlobalStylesRenderer } from '../global-styles-renderer'; const { ExperimentalBlockEditorProvider, @@ -346,25 +352,67 @@ function StyleBook( { /** * Style Book Preview component renders the stylebook without the Editor dependency. * - * @param {Object} props Component props. - * @param {string} props.path Path to the selected block. - * @param {Object} props.userConfig User configuration. - * @param {Function} props.isSelected Function to check if a block is selected. - * @param {Function} props.onSelect Function to select a block. + * @param {Object} props Component props. + * @param {Object} props.userConfig User configuration. + * @param {boolean} props.isStatic Whether the stylebook is static or clickable. * @return {Object} Style Book Preview component. */ -export const StyleBookPreview = ( { - path = '', - userConfig = {}, - isSelected, - onSelect, -} ) => { +export const StyleBookPreview = ( { userConfig = {}, isStatic = false } ) => { const siteEditorSettings = useSelect( ( select ) => select( siteEditorStore ).getSettings(), [] ); + + const canUserUploadMedia = useSelect( + ( select ) => + select( coreStore ).canUser( 'create', { + kind: 'root', + name: 'media', + } ), + [] + ); + // Update block editor settings because useMultipleOriginColorsAndGradients fetch colours from there. - dispatch( blockEditorStore ).updateSettings( siteEditorSettings ); + useEffect( () => { + dispatch( blockEditorStore ).updateSettings( { + ...siteEditorSettings, + mediaUpload: canUserUploadMedia ? uploadMedia : undefined, + } ); + }, [ siteEditorSettings, canUserUploadMedia ] ); + + const [ section, onChangeSection ] = useSection(); + + const isSelected = ( blockName ) => { + // Match '/blocks/core%2Fbutton' and + // '/blocks/core%2Fbutton/typography', but not + // '/blocks/core%2Fbuttons'. + return ( + section === `/blocks/${ encodeURIComponent( blockName ) }` || + section.startsWith( + `/blocks/${ encodeURIComponent( blockName ) }/` + ) + ); + }; + + const onSelect = ( blockName ) => { + if ( + STYLE_BOOK_COLOR_GROUPS.find( + ( group ) => group.slug === blockName + ) + ) { + // Go to color palettes Global Styles. + onChangeSection( '/colors/palette' ); + return; + } + if ( blockName === 'typography' ) { + // Go to typography Global Styles. + onChangeSection( '/typography' ); + return; + } + + // Now go to the selected block. + onChangeSection( `/blocks/${ encodeURIComponent( blockName ) }` ); + }; const [ resizeObserver, sizes ] = useResizeObserver(); const colors = useMultiOriginPalettes(); @@ -372,7 +420,7 @@ export const StyleBookPreview = ( { const examplesForSinglePageUse = getExamplesForSinglePageUse( examples ); const { base: baseConfig } = useContext( GlobalStylesContext ); - const goTo = getStyleBookNavigationFromPath( path ); + const goTo = getStyleBookNavigationFromPath( section ); const mergedConfig = useMemo( () => { if ( ! isObjectEmpty( userConfig ) && ! isObjectEmpty( baseConfig ) ) { @@ -399,13 +447,14 @@ export const StyleBookPreview = ( { <div className="edit-site-style-book"> { resizeObserver } <BlockEditorProvider settings={ settings }> + <GlobalStylesRenderer disableRootPadding /> <StyleBookBody examples={ examplesForSinglePageUse } settings={ settings } goTo={ goTo } sizes={ sizes } - isSelected={ isSelected } - onSelect={ onSelect } + isSelected={ ! isStatic ? isSelected : null } + onSelect={ ! isStatic ? onSelect : null } /> </BlockEditorProvider> </div> diff --git a/packages/edit-widgets/src/store/transformers.js b/packages/edit-widgets/src/store/transformers.js index 3b42e3141ff5f0..12a2f9d32933a8 100644 --- a/packages/edit-widgets/src/store/transformers.js +++ b/packages/edit-widgets/src/store/transformers.js @@ -46,7 +46,7 @@ export function transformWidgetToBlock( widget ) { * Converts a block to a widget entity record. * * @param {Object} block The block. - * @param {Object?} relatedWidget A related widget entity record from the API (optional). + * @param {?Object} relatedWidget A related widget entity record from the API (optional). * @return {Object} the widget object (converted from block). */ export function transformBlockToWidget( block, relatedWidget = {} ) { diff --git a/packages/editor/src/components/commands/index.js b/packages/editor/src/components/commands/index.js index 0040a09fbdc07d..d495dcaaef3379 100644 --- a/packages/editor/src/components/commands/index.js +++ b/packages/editor/src/components/commands/index.js @@ -25,6 +25,7 @@ import { store as noticesStore } from '@wordpress/notices'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { store as coreStore, useEntityRecord } from '@wordpress/core-data'; import { store as interfaceStore } from '@wordpress/interface'; +import { getPath } from '@wordpress/url'; import { decodeEntities } from '@wordpress/html-entities'; /** @@ -90,6 +91,19 @@ const getEditorCommandLoader = () => const { openModal, enableComplementaryArea, disableComplementaryArea } = useDispatch( interfaceStore ); const { getCurrentPostId } = useSelect( editorStore ); + const { isBlockBasedTheme, canCreateTemplate } = useSelect( + ( select ) => { + return { + isBlockBasedTheme: + select( coreStore ).getCurrentTheme()?.is_block_theme, + canCreateTemplate: select( coreStore ).canUser( 'create', { + kind: 'postType', + name: 'wp_template', + } ), + }; + }, + [] + ); const allowSwitchEditorMode = isCodeEditingEnabled && isRichEditingEnabled; @@ -271,6 +285,21 @@ const getEditorCommandLoader = () => }, } ); } + if ( canCreateTemplate && isBlockBasedTheme ) { + const isSiteEditor = getPath( window.location.href )?.includes( + 'site-editor.php' + ); + if ( ! isSiteEditor ) { + commands.push( { + name: 'core/go-to-site-editor', + label: __( 'Open Site Editor' ), + callback: ( { close } ) => { + close(); + document.location = 'site-editor.php'; + }, + } ); + } + } return { commands, diff --git a/packages/editor/src/components/more-menu/index.js b/packages/editor/src/components/more-menu/index.js index 9e062e5e5adc50..f5eaa45e4ed696 100644 --- a/packages/editor/src/components/more-menu/index.js +++ b/packages/editor/src/components/more-menu/index.js @@ -113,7 +113,6 @@ export default function MoreMenu() { <ActionItem.Slot name="core/plugin-more-menu" label={ __( 'Plugins' ) } - as={ MenuGroup } fillProps={ { onClick: onClose } } /> <MenuGroup label={ __( 'Tools' ) }> diff --git a/packages/editor/src/components/post-card-panel/index.js b/packages/editor/src/components/post-card-panel/index.js index 7849f014ab49c8..78f9522ba5f444 100644 --- a/packages/editor/src/components/post-card-panel/index.js +++ b/packages/editor/src/components/post-card-panel/index.js @@ -6,6 +6,7 @@ import { __experimentalHStack as HStack, __experimentalVStack as VStack, __experimentalText as Text, + privateApis as componentsPrivateApis, } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; @@ -25,6 +26,7 @@ import { unlock } from '../../lock-unlock'; import PostActions from '../post-actions'; import usePageTypeBadge from '../../utils/pageTypeBadge'; import { getTemplateInfo } from '../../utils/get-template-info'; +const { Badge } = unlock( componentsPrivateApis ); /** * Renders a title of the post type and the available quick actions available within a 3-dot dropdown. @@ -109,11 +111,11 @@ export default function PostCardPanel( { className="editor-post-card-panel__title" as="h2" > - { title } + <span className="editor-post-card-panel__title-name"> + { title } + </span> { pageTypeBadge && postIds.length === 1 && ( - <span className="editor-post-card-panel__title-badge"> - { pageTypeBadge } - </span> + <Badge>{ pageTypeBadge }</Badge> ) } </Text> <PostActions diff --git a/packages/editor/src/components/post-card-panel/style.scss b/packages/editor/src/components/post-card-panel/style.scss index c3638b313a8285..5fa54c67f14e55 100644 --- a/packages/editor/src/components/post-card-panel/style.scss +++ b/packages/editor/src/components/post-card-panel/style.scss @@ -9,7 +9,6 @@ &.editor-post-card-panel__title { @include heading-medium(); margin: 0; - padding: 2px 0; display: flex; align-items: center; flex-wrap: wrap; @@ -34,19 +33,11 @@ margin-bottom: $grid-unit-10; } + .editor-post-card-panel__title-name { + padding: 2px 0; + } + .editor-post-card-panel__description { color: $gray-700; } } - -.editor-post-card-panel__title-badge { - background: $gray-100; - color: $gray-800; - padding: 0 $grid-unit-05; - border-radius: $radius-small; - font-size: 12px; - font-weight: 400; - flex-shrink: 0; - line-height: $grid-unit-05 * 5; - display: inline-block; -} diff --git a/packages/editor/src/components/preview-dropdown/index.js b/packages/editor/src/components/preview-dropdown/index.js index 6fa35c673430cc..a081564e48ea8d 100644 --- a/packages/editor/src/components/preview-dropdown/index.js +++ b/packages/editor/src/components/preview-dropdown/index.js @@ -190,7 +190,6 @@ export default function PreviewDropdown( { forceIsAutosaveable, disabled } ) { ) } <ActionItem.Slot name="core/plugin-preview-menu" - as={ MenuGroup } fillProps={ { onClick: onClose } } /> </> diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js index 74c1f1ea100b37..ef2eb093f793e4 100644 --- a/packages/editor/src/store/private-actions.js +++ b/packages/editor/src/store/private-actions.js @@ -34,7 +34,7 @@ export function setCurrentTemplateId( id ) { /** * Create a block based template. * - * @param {Object?} template Template to create and assign. + * @param {?Object} template Template to create and assign. */ export const createTemplate = ( template ) => diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index 252e1e65dae872..59c8998aba5cc5 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,12 +2,16 @@ ## Unreleased +### Internal + +- Refactored the code to use new API introduced together with `@inquirer/prompts` instead of legacy `inquirer` package ([#67877](https://github.com/WordPress/gutenberg/pull/67877)). + ## 10.14.0 (2024-12-11) ### Enhancements -- Add phpMyAdmin as an optional service. Enabled via the new `phpmyadminPort` environment config, as well as env vars `WP_ENV_PHPMYADMIN_PORT` and `WP_ENV_TESTS_PHPMYADMIN_PORT`. -- Add support for WordPress multisite installations. Enabled via the new `multisite` environment config. +- Add phpMyAdmin as an optional service. Enabled via the new `phpmyadminPort` environment config, as well as env vars `WP_ENV_PHPMYADMIN_PORT` and `WP_ENV_TESTS_PHPMYADMIN_PORT`. +- Add support for WordPress multisite installations. Enabled via the new `multisite` environment config. ### Internal diff --git a/packages/env/lib/commands/destroy.js b/packages/env/lib/commands/destroy.js index 46b923dc3c9aca..016838ea218442 100644 --- a/packages/env/lib/commands/destroy.js +++ b/packages/env/lib/commands/destroy.js @@ -5,7 +5,7 @@ const { v2: dockerCompose } = require( 'docker-compose' ); const fs = require( 'fs' ).promises; const path = require( 'path' ); -const inquirer = require( 'inquirer' ); +const { confirm } = require( '@inquirer/prompts' ); /** * Promisified dependencies @@ -40,14 +40,19 @@ module.exports = async function destroy( { spinner, scripts, debug } ) { 'WARNING! This will remove Docker containers, volumes, networks, and images associated with the WordPress instance.' ); - const { yesDelete } = await inquirer.prompt( [ - { - type: 'confirm', - name: 'yesDelete', + let yesDelete = false; + try { + yesDelete = await confirm( { message: 'Are you sure you want to continue?', default: false, - }, - ] ); + } ); + } catch ( error ) { + if ( error.name === 'ExitPromptError' ) { + console.log( 'Cancelled.' ); + process.exit( 1 ); + } + throw error; + } spinner.start(); diff --git a/packages/env/lib/commands/start.js b/packages/env/lib/commands/start.js index e476fd8c2b67b7..db05b82060d2c5 100644 --- a/packages/env/lib/commands/start.js +++ b/packages/env/lib/commands/start.js @@ -6,7 +6,7 @@ const { v2: dockerCompose } = require( 'docker-compose' ); const util = require( 'util' ); const path = require( 'path' ); const fs = require( 'fs' ).promises; -const inquirer = require( 'inquirer' ); +const { confirm } = require( '@inquirer/prompts' ); /** * Promisified dependencies @@ -328,15 +328,21 @@ async function checkForLegacyInstall( spinner ) { ' and ' ) }. Installs are now in your home folder.\n` ); - const { yesDelete } = await inquirer.prompt( [ - { - type: 'confirm', - name: 'yesDelete', + let yesDelete = false; + try { + yesDelete = confirm( { message: 'Do you wish to delete these old installs to reclaim disk space?', default: true, - }, - ] ); + } ); + } catch ( error ) { + if ( error.name === 'ExitPromptError' ) { + console.log( 'Cancelled.' ); + process.exit( 1 ); + } + throw error; + } + if ( yesDelete ) { await Promise.all( installs.map( ( install ) => rimraf( install ) ) ); spinner.info( 'Old installs deleted successfully.' ); diff --git a/packages/env/package.json b/packages/env/package.json index d86d518e41e497..f28345746b5891 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -36,12 +36,12 @@ "wp-env": "bin/wp-env" }, "dependencies": { + "@inquirer/prompts": "^7.2.0", "chalk": "^4.0.0", "copy-dir": "^1.3.0", "docker-compose": "^0.24.3", "extract-zip": "^1.6.7", "got": "^11.8.5", - "inquirer": "^7.1.0", "js-yaml": "^3.13.1", "ora": "^4.0.2", "rimraf": "^5.0.10", diff --git a/packages/fields/src/actions/permanently-delete-post.tsx b/packages/fields/src/actions/permanently-delete-post.tsx index 688ba5b9918df8..136fcdda9a3e68 100644 --- a/packages/fields/src/actions/permanently-delete-post.tsx +++ b/packages/fields/src/actions/permanently-delete-post.tsx @@ -2,10 +2,19 @@ * WordPress dependencies */ import { store as coreStore } from '@wordpress/core-data'; -import { __, sprintf } from '@wordpress/i18n'; +import { __, _n, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import type { Action } from '@wordpress/dataviews'; import { trash } from '@wordpress/icons'; +import { useState } from '@wordpress/element'; +import { useDispatch } from '@wordpress/data'; +import { + Button, + __experimentalText as Text, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { decodeEntities } from '@wordpress/html-entities'; /** * Internal dependencies @@ -25,93 +34,155 @@ const permanentlyDeletePost: Action< PostWithPermissions > = { const { status, permissions } = item; return status === 'trash' && permissions?.delete; }, - async callback( posts, { registry, onActionPerformed } ) { + hideModalHeader: true, + RenderModal: ( { items, closeModal, onActionPerformed } ) => { + const [ isBusy, setIsBusy ] = useState( false ); const { createSuccessNotice, createErrorNotice } = - registry.dispatch( noticesStore ); - const { deleteEntityRecord } = registry.dispatch( coreStore ); - const promiseResult = await Promise.allSettled( - posts.map( ( post ) => { - return deleteEntityRecord( - 'postType', - post.type, - post.id, - { force: true }, - { throwOnError: true } - ); - } ) + useDispatch( noticesStore ); + const { deleteEntityRecord } = useDispatch( coreStore ); + + return ( + <VStack spacing="5"> + <Text> + { items.length > 1 + ? sprintf( + // translators: %d: number of items to delete. + _n( + 'Are you sure you want to permanently delete %d item?', + 'Are you sure you want to permanently delete %d items?', + items.length + ), + items.length + ) + : sprintf( + // translators: %s: The post's title + __( + 'Are you sure you want to permanently delete "%s"?' + ), + decodeEntities( getItemTitle( items[ 0 ] ) ) + ) } + </Text> + <HStack justify="right"> + <Button + variant="tertiary" + onClick={ closeModal } + disabled={ isBusy } + accessibleWhenDisabled + __next40pxDefaultSize + > + { __( 'Cancel' ) } + </Button> + <Button + variant="primary" + onClick={ async () => { + setIsBusy( true ); + const promiseResult = await Promise.allSettled( + items.map( ( post ) => + deleteEntityRecord( + 'postType', + post.type, + post.id, + { force: true }, + { throwOnError: true } + ) + ) + ); + + // If all the promises were fulfilled with success. + if ( + promiseResult.every( + ( { status } ) => status === 'fulfilled' + ) + ) { + let successMessage; + if ( promiseResult.length === 1 ) { + successMessage = sprintf( + /* translators: The posts's title. */ + __( '"%s" permanently deleted.' ), + getItemTitle( items[ 0 ] ) + ); + } else { + successMessage = __( + 'The items were permanently deleted.' + ); + } + createSuccessNotice( successMessage, { + type: 'snackbar', + id: 'permanently-delete-post-action', + } ); + onActionPerformed?.( items ); + } else { + // If there was at lease one failure. + let errorMessage; + // If we were trying to permanently delete a single post. + if ( promiseResult.length === 1 ) { + const typedError = promiseResult[ 0 ] as { + reason?: CoreDataError; + }; + if ( typedError.reason?.message ) { + errorMessage = + typedError.reason.message; + } else { + errorMessage = __( + 'An error occurred while permanently deleting the item.' + ); + } + // If we were trying to permanently delete multiple posts + } else { + const errorMessages = new Set(); + const failedPromises = promiseResult.filter( + ( { status } ) => status === 'rejected' + ); + for ( const failedPromise of failedPromises ) { + const typedError = failedPromise as { + reason?: CoreDataError; + }; + if ( typedError.reason?.message ) { + errorMessages.add( + typedError.reason.message + ); + } + } + if ( errorMessages.size === 0 ) { + errorMessage = __( + 'An error occurred while permanently deleting the items.' + ); + } else if ( errorMessages.size === 1 ) { + errorMessage = sprintf( + /* translators: %s: an error message */ + __( + 'An error occurred while permanently deleting the items: %s' + ), + [ ...errorMessages ][ 0 ] + ); + } else { + errorMessage = sprintf( + /* translators: %s: a list of comma separated error messages */ + __( + 'Some errors occurred while permanently deleting the items: %s' + ), + [ ...errorMessages ].join( ',' ) + ); + } + } + createErrorNotice( errorMessage, { + type: 'snackbar', + } ); + } + + setIsBusy( false ); + closeModal?.(); + } } + isBusy={ isBusy } + disabled={ isBusy } + accessibleWhenDisabled + __next40pxDefaultSize + > + { __( 'Delete permanently' ) } + </Button> + </HStack> + </VStack> ); - // If all the promises were fulfilled with success. - if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) { - let successMessage; - if ( promiseResult.length === 1 ) { - successMessage = sprintf( - /* translators: The posts's title. */ - __( '"%s" permanently deleted.' ), - getItemTitle( posts[ 0 ] ) - ); - } else { - successMessage = __( 'The items were permanently deleted.' ); - } - createSuccessNotice( successMessage, { - type: 'snackbar', - id: 'permanently-delete-post-action', - } ); - onActionPerformed?.( posts ); - } else { - // If there was at lease one failure. - let errorMessage; - // If we were trying to permanently delete a single post. - if ( promiseResult.length === 1 ) { - const typedError = promiseResult[ 0 ] as { - reason?: CoreDataError; - }; - if ( typedError.reason?.message ) { - errorMessage = typedError.reason.message; - } else { - errorMessage = __( - 'An error occurred while permanently deleting the item.' - ); - } - // If we were trying to permanently delete multiple posts - } else { - const errorMessages = new Set(); - const failedPromises = promiseResult.filter( - ( { status } ) => status === 'rejected' - ); - for ( const failedPromise of failedPromises ) { - const typedError = failedPromise as { - reason?: CoreDataError; - }; - if ( typedError.reason?.message ) { - errorMessages.add( typedError.reason.message ); - } - } - if ( errorMessages.size === 0 ) { - errorMessage = __( - 'An error occurred while permanently deleting the items.' - ); - } else if ( errorMessages.size === 1 ) { - errorMessage = sprintf( - /* translators: %s: an error message */ - __( - 'An error occurred while permanently deleting the items: %s' - ), - [ ...errorMessages ][ 0 ] - ); - } else { - errorMessage = sprintf( - /* translators: %s: a list of comma separated error messages */ - __( - 'Some errors occurred while permanently deleting the items: %s' - ), - [ ...errorMessages ].join( ',' ) - ); - } - } - createErrorNotice( errorMessage, { - type: 'snackbar', - } ); - } }, }; diff --git a/packages/fields/src/fields/page-title/style.scss b/packages/fields/src/fields/page-title/style.scss deleted file mode 100644 index def56aa466a8a1..00000000000000 --- a/packages/fields/src/fields/page-title/style.scss +++ /dev/null @@ -1,10 +0,0 @@ -.fields-field__page-title__badge { - background: $gray-100; - color: $gray-800; - padding: 0 $grid-unit-05; - border-radius: $radius-small; - font-size: 12px; - font-weight: 400; - flex-shrink: 0; - line-height: $grid-unit-05 * 5; -} diff --git a/packages/fields/src/fields/page-title/view.tsx b/packages/fields/src/fields/page-title/view.tsx index 0be4c16d5d29ae..eb5184362ec82b 100644 --- a/packages/fields/src/fields/page-title/view.tsx +++ b/packages/fields/src/fields/page-title/view.tsx @@ -5,12 +5,15 @@ import { __ } from '@wordpress/i18n'; import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import type { Settings } from '@wordpress/core-data'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; /** * Internal dependencies */ import type { CommonPost } from '../../types'; import { BaseTitleView } from '../title/view'; +import { unlock } from '../../lock-unlock'; +const { Badge } = unlock( componentsPrivateApis ); export default function PageTitleView( { item }: { item: CommonPost } ) { const { frontPageId, postsPageId } = useSelect( ( select ) => { @@ -27,11 +30,11 @@ export default function PageTitleView( { item }: { item: CommonPost } ) { return ( <BaseTitleView item={ item } className="fields-field__page-title"> { [ frontPageId, postsPageId ].includes( item.id as number ) && ( - <span className="fields-field__page-title__badge"> + <Badge> { item.id === frontPageId ? __( 'Homepage' ) : __( 'Posts Page' ) } - </span> + </Badge> ) } </BaseTitleView> ); diff --git a/packages/fields/src/style.scss b/packages/fields/src/style.scss index d9a571270fbb68..96b1f816de5b61 100644 --- a/packages/fields/src/style.scss +++ b/packages/fields/src/style.scss @@ -3,5 +3,4 @@ @import "./fields/featured-image/style.scss"; @import "./fields/template/style.scss"; @import "./fields/title/style.scss"; -@import "./fields/page-title/style.scss"; @import "./fields/pattern-title/style.scss"; diff --git a/packages/interactivity-router/src/assets/styles.ts b/packages/interactivity-router/src/assets/styles.ts new file mode 100644 index 00000000000000..ddb41eabc7a758 --- /dev/null +++ b/packages/interactivity-router/src/assets/styles.ts @@ -0,0 +1,79 @@ +const cssUrlRegEx = + /url\(\s*(?:(["'])((?:\\.|[^\n\\"'])+)\1|((?:\\.|[^\s,"'()\\])+))\s*\)/g; + +const resolveUrl = ( relativeUrl: string, baseUrl: string ) => { + try { + return new URL( relativeUrl, baseUrl ).toString(); + } catch ( e ) { + return relativeUrl; + } +}; + +const withAbsoluteUrls = ( cssText: string, baseUrl: string ) => + cssText.replace( + cssUrlRegEx, + ( _match, quotes = '', relUrl1, relUrl2 ) => + `url(${ quotes }${ resolveUrl( + relUrl1 || relUrl2, + baseUrl + ) }${ quotes })` + ); + +const styleSheetCache = new Map< string, Promise< CSSStyleSheet > >(); + +const getCachedSheet = async ( + sheetId: string, + factory: () => Promise< CSSStyleSheet > +) => { + if ( ! styleSheetCache.has( sheetId ) ) { + styleSheetCache.set( sheetId, factory() ); + } + return styleSheetCache.get( sheetId ); +}; + +const sheetFromLink = async ( + { id, href, sheet: elementSheet }: HTMLLinkElement, + baseUrl: string +) => { + const sheetId = id || href; + const sheetUrl = resolveUrl( href, baseUrl ); + + if ( elementSheet ) { + return getCachedSheet( sheetId, () => { + const sheet = new CSSStyleSheet(); + for ( const { cssText } of elementSheet.cssRules ) { + sheet.insertRule( withAbsoluteUrls( cssText, sheetUrl ) ); + } + return Promise.resolve( sheet ); + } ); + } + return getCachedSheet( sheetId, async () => { + const response = await fetch( href ); + const text = await response.text(); + const sheet = new CSSStyleSheet(); + await sheet.replace( withAbsoluteUrls( text, sheetUrl ) ); + return sheet; + } ); +}; + +const sheetFromStyle = async ( { textContent }: HTMLStyleElement ) => { + const sheetId = textContent; + return getCachedSheet( sheetId, async () => { + const sheet = new CSSStyleSheet(); + await sheet.replace( textContent ); + return sheet; + } ); +}; + +export const generateCSSStyleSheets = ( + doc: Document, + baseUrl: string = ( doc.location || window.location ).href +): Promise< CSSStyleSheet >[] => + [ ...doc.querySelectorAll( 'style,link[rel=stylesheet]' ) ].map( + ( element ) => { + if ( 'LINK' === element.nodeName ) { + return sheetFromLink( element as HTMLLinkElement, baseUrl ); + } + return sheetFromStyle( element as HTMLStyleElement ); + } + ); diff --git a/packages/interactivity-router/src/head.ts b/packages/interactivity-router/src/head.ts deleted file mode 100644 index 69139348b582ff..00000000000000 --- a/packages/interactivity-router/src/head.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * The cache of prefetched stylesheets and scripts. - */ -export const headElements = new Map< - string, - { tag: HTMLElement; text?: string } ->(); - -/** - * Helper to update only the necessary tags in the head. - * - * @async - * @param newHead The head elements of the new page. - */ -export const updateHead = async ( newHead: HTMLHeadElement[] ) => { - // Helper to get the tag id store in the cache. - const getTagId = ( tag: Element ) => tag.id || tag.outerHTML; - - // Map incoming head tags by their content. - const newHeadMap = new Map< string, Element >(); - for ( const child of newHead ) { - newHeadMap.set( getTagId( child ), child ); - } - - const toRemove: Element[] = []; - - // Detect nodes that should be added or removed. - for ( const child of document.head.children ) { - const id = getTagId( child ); - // Always remove styles and links as they might change. - if ( child.nodeName === 'LINK' || child.nodeName === 'STYLE' ) { - toRemove.push( child ); - } else if ( newHeadMap.has( id ) ) { - newHeadMap.delete( id ); - } else if ( child.nodeName !== 'SCRIPT' && child.nodeName !== 'META' ) { - toRemove.push( child ); - } - } - - await Promise.all( - [ ...headElements.entries() ] - .filter( ( [ , { tag } ] ) => tag.nodeName === 'SCRIPT' ) - .map( async ( [ url ] ) => { - await import( /* webpackIgnore: true */ url ); - } ) - ); - - // Prepare new assets. - const toAppend = [ ...newHeadMap.values() ]; - - // Apply the changes. - toRemove.forEach( ( n ) => n.remove() ); - document.head.append( ...toAppend ); -}; - -/** - * Fetches and processes head assets (stylesheets and scripts) from a specified document. - * - * @async - * @param doc The document from which to fetch head assets. It should support standard DOM querying methods. - * - * @return Returns an array of HTML elements representing the head assets. - */ -export const fetchHeadAssets = async ( - doc: Document -): Promise< HTMLElement[] > => { - const headTags = []; - - // We only want to fetch module scripts because regular scripts (without - // `async` or `defer` attributes) can depend on the execution of other scripts. - // Scripts found in the head are blocking and must be executed in order. - const scripts = doc.querySelectorAll< HTMLScriptElement >( - 'script[type="module"][src]' - ); - - scripts.forEach( ( script ) => { - const src = script.getAttribute( 'src' ); - if ( ! headElements.has( src ) ) { - // add the <link> elements to prefetch the module scripts - const link = doc.createElement( 'link' ); - link.rel = 'modulepreload'; - link.href = src; - document.head.append( link ); - headElements.set( src, { tag: script } ); - } - } ); - - const stylesheets = doc.querySelectorAll< HTMLLinkElement >( - 'link[rel=stylesheet]' - ); - - await Promise.all( - Array.from( stylesheets ).map( async ( tag ) => { - const href = tag.getAttribute( 'href' ); - if ( ! href ) { - return; - } - - if ( ! headElements.has( href ) ) { - try { - const response = await fetch( href ); - const text = await response.text(); - headElements.set( href, { - tag, - text, - } ); - } catch ( e ) { - // eslint-disable-next-line no-console - console.error( e ); - } - } - - const headElement = headElements.get( href ); - const styleElement = doc.createElement( 'style' ); - styleElement.textContent = headElement.text; - - headTags.push( styleElement ); - } ) - ); - - return [ - doc.querySelector( 'title' ), - ...doc.querySelectorAll( 'style' ), - ...headTags, - ]; -}; diff --git a/packages/interactivity-router/src/index.ts b/packages/interactivity-router/src/index.ts index 0c10e896ce1ef5..ded21d35dd5886 100644 --- a/packages/interactivity-router/src/index.ts +++ b/packages/interactivity-router/src/index.ts @@ -6,7 +6,7 @@ import { store, privateApis, getConfig } from '@wordpress/interactivity'; /** * Internal dependencies */ -import { fetchHeadAssets, updateHead, headElements } from './head'; +import { generateCSSStyleSheets } from './assets/styles'; const { directivePrefix, @@ -37,16 +37,18 @@ interface PrefetchOptions { interface VdomParams { vdom?: typeof initialVdom; + baseUrl?: string; } interface Page { regions: Record< string, any >; - head: HTMLHeadElement[]; + styles: Promise< CSSStyleSheet >[]; + scriptModules: string[]; title: string; initialData: any; } -type RegionsToVdom = ( dom: Document, params?: VdomParams ) => Promise< Page >; +type RegionsToVdom = ( dom: Document, params?: VdomParams ) => Page; // Check if the navigation mode is full page or region based. const navigationMode: 'regionBased' | 'fullPage' = @@ -73,7 +75,7 @@ const fetchPage = async ( url: string, { html }: { html: string } ) => { html = await res.text(); } const dom = new window.DOMParser().parseFromString( html, 'text/html' ); - return regionsToVdom( dom ); + return regionsToVdom( dom, { baseUrl: url } ); } catch ( e ) { return false; } @@ -81,12 +83,17 @@ const fetchPage = async ( url: string, { html }: { html: string } ) => { // Return an object with VDOM trees of those HTML regions marked with a // `router-region` directive. -const regionsToVdom: RegionsToVdom = async ( dom, { vdom } = {} ) => { +const regionsToVdom: RegionsToVdom = ( dom, { vdom, baseUrl } = {} ) => { const regions = { body: undefined }; - let head: HTMLElement[]; + const styles = generateCSSStyleSheets( dom, baseUrl ); + const scriptModules = [ + ...dom.querySelectorAll< HTMLScriptElement >( + 'script[type=module][src]' + ), + ].map( ( s ) => s.src ); + if ( globalThis.IS_GUTENBERG_PLUGIN ) { if ( navigationMode === 'fullPage' ) { - head = await fetchHeadAssets( dom ); regions.body = vdom ? vdom.get( document.body ) : toVdom( dom.body ); @@ -103,15 +110,28 @@ const regionsToVdom: RegionsToVdom = async ( dom, { vdom } = {} ) => { } const title = dom.querySelector( 'title' )?.innerText; const initialData = parseServerData( dom ); - return { regions, head, title, initialData }; + return { regions, styles, scriptModules, title, initialData }; }; // Render all interactive regions contained in the given page. const renderRegions = async ( page: Page ) => { + // Wait for styles and modules to be ready. + await Promise.all( [ + ...page.styles, + ...page.scriptModules.map( + ( src ) => import( /* webpackIgnore: true */ src ) + ), + ] ); + // Replace style sheets. + const sheets = await Promise.all( page.styles ); + window.document + .querySelectorAll( 'style,link[rel=stylesheet]' ) + .forEach( ( element ) => element.remove() ); + window.document.adoptedStyleSheets = sheets; + if ( globalThis.IS_GUTENBERG_PLUGIN ) { if ( navigationMode === 'fullPage' ) { - // Once this code is tested and more mature, the head should be updated for region based navigation as well. - await updateHead( page.head ); + // Update HTML. const fragment = getRegionRootFragment( document.body ); batch( () => { populateServerData( page.initialData ); @@ -169,23 +189,14 @@ window.addEventListener( 'popstate', async () => { // Initialize the router and cache the initial page using the initial vDOM. // Once this code is tested and more mature, the head should be updated for // region based navigation as well. -if ( globalThis.IS_GUTENBERG_PLUGIN ) { - if ( navigationMode === 'fullPage' ) { - // Cache the scripts. Has to be called before fetching the assets. - [].map.call( - document.querySelectorAll( 'script[type="module"][src]' ), - ( script ) => { - headElements.set( script.getAttribute( 'src' ), { - tag: script, - } ); - } - ); - await fetchHeadAssets( document ); - } -} pages.set( getPagePath( window.location.href ), - Promise.resolve( regionsToVdom( document, { vdom: initialVdom } ) ) + Promise.resolve( + regionsToVdom( document, { + vdom: initialVdom, + baseUrl: window.location.href, + } ) + ) ); // Check if the link is valid for client-side navigation. diff --git a/packages/interface/CHANGELOG.md b/packages/interface/CHANGELOG.md index a0ed9cd83525cc..172d70b09fad31 100644 --- a/packages/interface/CHANGELOG.md +++ b/packages/interface/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Breaking Changes + +- `ActionItem.Slot`: Render as `MenuGroup` by default ([#67985](https://github.com/WordPress/gutenberg/pull/67985)). + ## 8.3.0 (2024-12-11) ## 8.2.0 (2024-11-27) diff --git a/packages/interface/src/components/action-item/README.md b/packages/interface/src/components/action-item/README.md index 15c627adfd3296..5611e044c8a985 100644 --- a/packages/interface/src/components/action-item/README.md +++ b/packages/interface/src/components/action-item/README.md @@ -24,11 +24,11 @@ Property used to change the event bubbling behavior, passed to the `Slot` compon ### as -The component used as the container of the fills. Defaults to the `ButtonGroup` component. +The component used as the container of the fills. Defaults to the `MenuGroup` component. - Type: `Component` - Required: no -- Default: `ButtonGroup` +- Default: `MenuGroup` ## ActionItem diff --git a/packages/interface/src/components/action-item/index.js b/packages/interface/src/components/action-item/index.js index 4bd5a11e8d71f8..2f3fdd6d3ca301 100644 --- a/packages/interface/src/components/action-item/index.js +++ b/packages/interface/src/components/action-item/index.js @@ -1,14 +1,14 @@ /** * WordPress dependencies */ -import { ButtonGroup, Button, Slot, Fill } from '@wordpress/components'; +import { MenuGroup, Button, Slot, Fill } from '@wordpress/components'; import { Children } from '@wordpress/element'; const noop = () => {}; function ActionItemSlot( { name, - as: Component = ButtonGroup, + as: Component = MenuGroup, fillProps = {}, bubblesVirtually, ...props diff --git a/packages/rich-text/src/store/selectors.js b/packages/rich-text/src/store/selectors.js index df87c6a99211a2..16572e301c1dba 100644 --- a/packages/rich-text/src/store/selectors.js +++ b/packages/rich-text/src/store/selectors.js @@ -75,7 +75,7 @@ export const getFormatTypes = createSelector( * }; * ``` * - * @return {Object?} Format type. + * @return {?Object} Format type. */ export function getFormatType( state, name ) { return state.formatTypes[ name ]; diff --git a/phpunit/class-gutenberg-hierarchical-sort-test.php b/phpunit/class-gutenberg-hierarchical-sort-test.php new file mode 100644 index 00000000000000..31b78b272a29a2 --- /dev/null +++ b/phpunit/class-gutenberg-hierarchical-sort-test.php @@ -0,0 +1,207 @@ +<?php + +/** + * Test the build_post_ids_to_display function. + * + * @package Gutenberg + */ +class GutenbergHierarchicalSortTest extends WP_UnitTestCase { + + public function test_return_all_post_ids() { + /* + * Keep this updated as the input array changes. + * The sorted hierarchy would be as follows: + * + * 12 + * - 3 + * -- 6 + * -- 5 + * - 4 + * -- 7 + * 8 + * - 9 + * -- 11 + * - 10 + * + */ + $input = array( + (object) array( + 'ID' => 11, + 'post_parent' => 9, + ), + (object) array( + 'ID' => 12, + 'post_parent' => 0, + ), + (object) array( + 'ID' => 8, + 'post_parent' => 0, + ), + (object) array( + 'ID' => 3, + 'post_parent' => 12, + ), + (object) array( + 'ID' => 6, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 7, + 'post_parent' => 4, + ), + (object) array( + 'ID' => 9, + 'post_parent' => 8, + ), + (object) array( + 'ID' => 4, + 'post_parent' => 12, + ), + (object) array( + 'ID' => 5, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 10, + 'post_parent' => 8, + ), + ); + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $result = $hs->sort( $input ); + $this->assertEquals( array( 12, 3, 6, 5, 4, 7, 8, 9, 11, 10 ), $result['post_ids'] ); + $this->assertEquals( + array( + 12 => 0, + 3 => 1, + 6 => 2, + 5 => 2, + 4 => 1, + 7 => 2, + 8 => 0, + 9 => 1, + 11 => 2, + 10 => 1, + ), + $result['levels'] + ); + } + + public function test_return_orphans() { + /* + * Keep this updated as the input array changes. + * The sorted hierarchy would be as follows: + * + * - 11 (orphan) + * - 4 (orphan) + * -- 7 + * + */ + $input = array( + (object) array( + 'ID' => 11, + 'post_parent' => 2, + ), + (object) array( + 'ID' => 7, + 'post_parent' => 4, + ), + (object) array( + 'ID' => 4, + 'post_parent' => 2, + ), + ); + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $result = $hs->sort( $input ); + $this->assertEquals( array( 11, 4, 7 ), $result['post_ids'] ); + $this->assertEquals( + array( + 11 => 1, + 4 => 1, + 7 => 2, + ), + $result['levels'] + ); + } + + public function test_post_with_empty_post_parent_are_considered_top_level() { + /* + * Keep this updated as the input array changes. + * The sorted hierarchy would be as follows: + * + * 2 + * - 3 + * -- 5 + * -- 6 + * - 4 + * -- 7 + * 8 + * - 9 + * -- 11 + * - 10 + * + */ + $input = array( + (object) array( + 'ID' => 11, + 'post_parent' => 9, + ), + (object) array( + 'ID' => 2, + 'post_parent' => 0, + ), + (object) array( + 'ID' => 8, + 'post_parent' => '', // Empty post parent, should be considered top-level. + ), + (object) array( + 'ID' => 3, + 'post_parent' => 2, + ), + (object) array( + 'ID' => 5, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 7, + 'post_parent' => 4, + ), + (object) array( + 'ID' => 9, + 'post_parent' => 8, + ), + (object) array( + 'ID' => 4, + 'post_parent' => 2, + ), + (object) array( + 'ID' => 6, + 'post_parent' => 3, + ), + (object) array( + 'ID' => 10, + 'post_parent' => 8, + ), + ); + + $hs = Gutenberg_Hierarchical_Sort::get_instance(); + $result = $hs->sort( $input ); + $this->assertEquals( array( 2, 3, 5, 6, 4, 7, 8, 9, 11, 10 ), $result['post_ids'] ); + $this->assertEquals( + array( + 2 => 0, + 3 => 1, + 5 => 2, + 6 => 2, + 4 => 1, + 7 => 2, + 8 => 0, + 9 => 1, + 11 => 2, + 10 => 1, + ), + $result['levels'] + ); + } +} diff --git a/storybook/decorators/with-max-width-wrapper.js b/storybook/decorators/with-max-width-wrapper.js index ff979b93f213bf..84fb73f20b68f7 100644 --- a/storybook/decorators/with-max-width-wrapper.js +++ b/storybook/decorators/with-max-width-wrapper.js @@ -3,15 +3,12 @@ */ import styled from '@emotion/styled'; -/** - * A Storybook decorator to wrap a story in a div applying a max width and - * padding. This can be used to simulate real world constraints on components - * such as being located within the WordPress editor sidebars. - */ - -const Wrapper = styled.div` - max-width: 248px; -`; +const maxWidthWrapperMap = { + none: 0, + 'wordpress-sidebar': 248, + 'small-container': 600, + 'large-container': 960, +}; const Indicator = styled.div` display: flex; @@ -27,14 +24,19 @@ const Indicator = styled.div` `; export const WithMaxWidthWrapper = ( Story, context ) => { - if ( context.globals.maxWidthWrapper === 'none' ) { + /** + * A Storybook decorator to wrap a story in a div applying a max width. + * This can be used to simulate real world constraints on components + * such as being located within the WordPress editor sidebars. + */ + const maxWidth = maxWidthWrapperMap[ context.globals.maxWidthWrapper ]; + if ( ! maxWidth ) { return <Story { ...context } />; } - return ( - <Wrapper> + <div style={ { maxWidth } }> <Story { ...context } /> - <Indicator>Max-Width Wrapper - 248px</Indicator> - </Wrapper> + <Indicator>{ `Max-Width Wrapper - ${ maxWidth }px` }</Indicator> + </div> ); }; diff --git a/storybook/preview.js b/storybook/preview.js index b29fceec846ffe..b74640d9bcfbcf 100644 --- a/storybook/preview.js +++ b/storybook/preview.js @@ -86,6 +86,8 @@ export const globalTypes = { items: [ { value: 'none', title: 'None' }, { value: 'wordpress-sidebar', title: 'WP Sidebar' }, + { value: 'small-container', title: 'Small container' }, + { value: 'large-container', title: 'Large container' }, ], }, }, @@ -106,6 +108,9 @@ export const parameters = { sort: 'requiredFirst', }, docs: { + controls: { + sort: 'requiredFirst', + }, // Flips the order of the description and the primary component story // so the component is always visible before the fold. page: () => ( diff --git a/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-1-chromium.png b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-1-chromium.png new file mode 100644 index 00000000000000..4bc0f7a6b1dd70 Binary files /dev/null and b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-1-chromium.png differ diff --git a/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-2-chromium.png b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-2-chromium.png new file mode 100644 index 00000000000000..7339cccdb78f28 Binary files /dev/null and b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-2-chromium.png differ diff --git a/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-3-chromium.png b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-3-chromium.png new file mode 100644 index 00000000000000..97943030eb1e88 Binary files /dev/null and b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-3-chromium.png differ diff --git a/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-4-chromium.png b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-4-chromium.png new file mode 100644 index 00000000000000..b7c455784e8a42 Binary files /dev/null and b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-4-chromium.png differ diff --git a/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-5-chromium.png b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-5-chromium.png new file mode 100644 index 00000000000000..b7c455784e8a42 Binary files /dev/null and b/test/e2e/specs/interactivity/__snapshots__/Router-styles-should-support-relative-URLs-in-referenced-style-sheets-5-chromium.png differ diff --git a/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts index fd850a6e39fae2..74436673f10b79 100644 --- a/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts +++ b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts @@ -6,6 +6,30 @@ import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; type AddPostWithBlockOptions = { alias?: string; attributes?: Record< string, any >; + innerBlocks?: Block[]; +}; + +type Block = [ + type: string, + attributes?: Record< string, any >, + innerBlocks?: Block[], +]; + +const generateBlockMarkup = ( [ + type, + attributes, + innerBlocks, +]: Block ): string => { + const typeAndAttributes = attributes + ? `${ type } ${ JSON.stringify( attributes ) }` + : type; + + if ( ! innerBlocks ) { + return `<!-- wp:${ typeAndAttributes } /-->`; + } + return `<!-- wp:${ typeAndAttributes } -->${ innerBlocks + .map( generateBlockMarkup ) + .join( '' ) }<!--/ wp:${ type } -->`; }; export default class InteractivityUtils { @@ -40,7 +64,7 @@ export default class InteractivityUtils { async addPostWithBlock( name: string, - { attributes, alias }: AddPostWithBlockOptions = {} + { attributes, alias, innerBlocks }: AddPostWithBlockOptions = {} ) { const block = attributes ? `${ name } ${ JSON.stringify( attributes ) }` @@ -50,8 +74,14 @@ export default class InteractivityUtils { alias = block; } + const content = generateBlockMarkup( [ + name, + attributes, + innerBlocks, + ] ); + const payload = { - content: `<!-- wp:${ block } /-->`, + content, status: 'publish' as 'publish', date_gmt: '2023-01-01T00:00:00', title: alias, diff --git a/test/e2e/specs/interactivity/router-styles.spec.ts b/test/e2e/specs/interactivity/router-styles.spec.ts new file mode 100644 index 00000000000000..7bc575af37816c --- /dev/null +++ b/test/e2e/specs/interactivity/router-styles.spec.ts @@ -0,0 +1,232 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +const COLOR_RED = 'rgb(255, 0, 0)'; +const COLOR_GREEN = 'rgb(0, 255, 0)'; +const COLOR_BLUE = 'rgb(0, 0, 255)'; +const COLOR_WRAPPER = 'rgb(160, 12, 60)'; + +test.describe( 'Router styles', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + const red = await utils.addPostWithBlock( + 'test/router-styles-wrapper', + { + alias: 'red', + innerBlocks: [ [ 'test/router-styles-red' ] ], + } + ); + const green = await utils.addPostWithBlock( + 'test/router-styles-wrapper', + { + alias: 'green', + innerBlocks: [ [ 'test/router-styles-green' ] ], + } + ); + const blue = await utils.addPostWithBlock( + 'test/router-styles-wrapper', + { + alias: 'blue', + innerBlocks: [ [ 'test/router-styles-blue' ] ], + } + ); + + const all = await utils.addPostWithBlock( + 'test/router-styles-wrapper', + { + alias: 'all', + innerBlocks: [ + [ 'test/router-styles-red' ], + [ 'test/router-styles-green' ], + [ 'test/router-styles-blue' ], + ], + } + ); + + await utils.addPostWithBlock( 'test/router-styles-wrapper', { + alias: 'none', + attributes: { links: { red, green, blue, all } }, + } ); + } ); + + test.beforeEach( async ( { page, interactivityUtils: utils } ) => { + await page.goto( utils.getLink( 'none' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should add and remove styles from style tags', async ( { + page, + } ) => { + const csn = page.getByTestId( 'client-side navigation' ); + const red = page.getByTestId( 'red' ); + const green = page.getByTestId( 'green' ); + const blue = page.getByTestId( 'blue' ); + const all = page.getByTestId( 'all' ); + + await expect( red ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( green ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( blue ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( all ).toHaveCSS( 'color', COLOR_WRAPPER ); + + await page.getByTestId( 'link red' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_RED ); + await expect( green ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( blue ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( all ).toHaveCSS( 'color', COLOR_RED ); + + await page.getByTestId( 'link green' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( green ).toHaveCSS( 'color', COLOR_GREEN ); + await expect( blue ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( all ).toHaveCSS( 'color', COLOR_GREEN ); + + await page.getByTestId( 'link blue' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( green ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( blue ).toHaveCSS( 'color', COLOR_BLUE ); + await expect( all ).toHaveCSS( 'color', COLOR_BLUE ); + + await page.getByTestId( 'link all' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_RED ); + await expect( green ).toHaveCSS( 'color', COLOR_GREEN ); + await expect( blue ).toHaveCSS( 'color', COLOR_BLUE ); + await expect( all ).toHaveCSS( 'color', COLOR_BLUE ); + } ); + + test( 'should add and remove styles from referenced style sheets', async ( { + page, + } ) => { + const csn = page.getByTestId( 'client-side navigation' ); + const red = page.getByTestId( 'red-from-link' ); + const green = page.getByTestId( 'green-from-link' ); + const blue = page.getByTestId( 'blue-from-link' ); + const all = page.getByTestId( 'all-from-link' ); + + await expect( red ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( green ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( blue ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( all ).toHaveCSS( 'color', COLOR_WRAPPER ); + + await page.getByTestId( 'link red' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_RED ); + await expect( green ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( blue ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( all ).toHaveCSS( 'color', COLOR_RED ); + + await page.getByTestId( 'link green' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( green ).toHaveCSS( 'color', COLOR_GREEN ); + await expect( blue ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( all ).toHaveCSS( 'color', COLOR_GREEN ); + + await page.getByTestId( 'link blue' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( green ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( blue ).toHaveCSS( 'color', COLOR_BLUE ); + await expect( all ).toHaveCSS( 'color', COLOR_BLUE ); + + await page.getByTestId( 'link all' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_RED ); + await expect( green ).toHaveCSS( 'color', COLOR_GREEN ); + await expect( blue ).toHaveCSS( 'color', COLOR_BLUE ); + await expect( all ).toHaveCSS( 'color', COLOR_BLUE ); + } ); + + test( 'should support relative URLs in referenced style sheets', async ( { + page, + } ) => { + const csn = page.getByTestId( 'client-side navigation' ); + const background = page.getByTestId( 'background-from-link' ); + + await expect( background ).toHaveScreenshot(); + + await page.getByTestId( 'link red' ).click(); + + await expect( csn ).toBeVisible(); + await expect( background ).toHaveScreenshot(); + + await page.getByTestId( 'link green' ).click(); + + await expect( csn ).toBeVisible(); + await expect( background ).toHaveScreenshot(); + + await page.getByTestId( 'link blue' ).click(); + + await expect( csn ).toBeVisible(); + await expect( background ).toHaveScreenshot(); + + await page.getByTestId( 'link all' ).click(); + + await expect( csn ).toBeVisible(); + await expect( background ).toHaveScreenshot(); + } ); + + test( 'should update style tags with modified content', async ( { + page, + } ) => { + const csn = page.getByTestId( 'client-side navigation' ); + const red = page.getByTestId( 'red-from-inline' ); + const green = page.getByTestId( 'green-from-inline' ); + const blue = page.getByTestId( 'blue-from-inline' ); + const all = page.getByTestId( 'all-from-inline' ); + + await expect( red ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( green ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( blue ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( all ).toHaveCSS( 'color', COLOR_WRAPPER ); + + await page.getByTestId( 'link red' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_RED ); + await expect( green ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( blue ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( all ).toHaveCSS( 'color', COLOR_RED ); + + await page.getByTestId( 'link green' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( green ).toHaveCSS( 'color', COLOR_GREEN ); + await expect( blue ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( all ).toHaveCSS( 'color', COLOR_GREEN ); + + await page.getByTestId( 'link blue' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( green ).toHaveCSS( 'color', COLOR_WRAPPER ); + await expect( blue ).toHaveCSS( 'color', COLOR_BLUE ); + await expect( all ).toHaveCSS( 'color', COLOR_BLUE ); + + await page.getByTestId( 'link all' ).click(); + + await expect( csn ).toBeVisible(); + await expect( red ).toHaveCSS( 'color', COLOR_RED ); + await expect( green ).toHaveCSS( 'color', COLOR_GREEN ); + await expect( blue ).toHaveCSS( 'color', COLOR_BLUE ); + await expect( all ).toHaveCSS( 'color', COLOR_BLUE ); + } ); +} ); diff --git a/test/e2e/specs/site-editor/template-registration.spec.js b/test/e2e/specs/site-editor/template-registration.spec.js index ed89c7d18bf3fb..2960367fc32ef1 100644 --- a/test/e2e/specs/site-editor/template-registration.spec.js +++ b/test/e2e/specs/site-editor/template-registration.spec.js @@ -100,8 +100,7 @@ test.describe( 'Block template registration', () => { page, } ) => { // Create a post. - await admin.visitAdminPage( '/post-new.php' ); - await page.getByLabel( 'Close', { exact: true } ).click(); + await admin.createNewPost(); await editor.insertBlock( { name: 'core/paragraph', attributes: { content: 'User-created post.' }, @@ -128,7 +127,7 @@ test.describe( 'Block template registration', () => { blockTemplateRegistrationUtils, } ) => { // Create a post. - await admin.visitAdminPage( '/post-new.php' ); + await admin.createNewPost(); await editor.insertBlock( { name: 'core/paragraph', attributes: { content: 'User-created post.' }, diff --git a/test/performance/specs/site-editor.spec.js b/test/performance/specs/site-editor.spec.js index c09cfe3c67b444..5a0c7f0e952116 100644 --- a/test/performance/specs/site-editor.spec.js +++ b/test/performance/specs/site-editor.spec.js @@ -395,7 +395,7 @@ test.describe( 'Site Editor Performance', () => { await requestUtils.activateTheme( 'twentytwentyfour' ); } ); - const perPage = 20; + const perPage = 9; test( 'Run the test', async ( { page, admin, requestUtils } ) => { await Promise.all( diff --git a/test/unit/config/global-mocks.js b/test/unit/config/global-mocks.js index 8db2c180fadf3a..ce64f03b514be8 100644 --- a/test/unit/config/global-mocks.js +++ b/test/unit/config/global-mocks.js @@ -3,7 +3,6 @@ */ import { TextDecoder, TextEncoder } from 'node:util'; import { Blob as BlobPolyfill, File as FilePolyfill } from 'node:buffer'; -import 'core-js/stable/structured-clone'; jest.mock( '@wordpress/compose', () => { return { @@ -50,6 +49,3 @@ if ( ! global.TextEncoder ) { // Override jsdom built-ins with native node implementation. global.Blob = BlobPolyfill; global.File = FilePolyfill; - -// Polyfill structuredClone for jsdom. -global.structuredClone = structuredClone;