From 992bcde7651e93d3115f6c54e8bb485acd808bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Juh=C3=A9=20Lluveras?= Date: Tue, 13 Aug 2024 11:57:24 +0200 Subject: [PATCH] Add plugin template registration API (#61577) Co-authored-by: Aljullu Co-authored-by: cbravobernal Co-authored-by: ntsekouras Co-authored-by: peterwilsoncc Co-authored-by: talldan Co-authored-by: youknowriad Co-authored-by: mtias Co-authored-by: gziolo Co-authored-by: SantosGuillamot Co-authored-by: TimothyBJacobs Co-authored-by: ellatrix Co-authored-by: ndiego Co-authored-by: carolinan Co-authored-by: nerrad Co-authored-by: annezazu --- backport-changelog/6.7/7125.md | 3 + lib/compat/wordpress-6.7/block-templates.php | 41 ++ ...utenberg-rest-templates-controller-6-7.php | 203 ++++++++++ .../class-wp-block-templates-registry.php | 256 +++++++++++++ lib/compat/wordpress-6.7/compat.php | 114 ++++++ lib/compat/wordpress-6.7/rest-api.php | 51 +++ lib/load.php | 4 + .../core-data/src/entity-types/wp-template.ts | 4 + .../plugins/block-template-registration.php | 72 ++++ .../src/utils/is-template-removable.js | 6 +- .../src/utils/is-template-revertable.js | 3 +- .../src/dataviews/actions/reset-post.tsx | 3 +- .../editor/src/dataviews/actions/utils.ts | 4 +- packages/editor/src/dataviews/types.ts | 3 + packages/editor/src/store/private-actions.js | 2 +- .../src/store/utils/is-template-revertable.js | 3 +- phpunit/block-template-test.php | 37 ++ ...tenberg-rest-templates-controller-test.php | 119 ++++++ ...class-wp-block-templates-registry-test.php | 192 ++++++++++ .../site-editor/template-registration.spec.js | 356 ++++++++++++++++++ 20 files changed, 1469 insertions(+), 7 deletions(-) create mode 100644 backport-changelog/6.7/7125.md create mode 100644 lib/compat/wordpress-6.7/block-templates.php create mode 100644 lib/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php create mode 100644 lib/compat/wordpress-6.7/class-wp-block-templates-registry.php create mode 100644 lib/compat/wordpress-6.7/compat.php create mode 100644 packages/e2e-tests/plugins/block-template-registration.php create mode 100644 phpunit/block-template-test.php create mode 100644 phpunit/class-gutenberg-rest-templates-controller-test.php create mode 100644 phpunit/class-wp-block-templates-registry-test.php create mode 100644 test/e2e/specs/site-editor/template-registration.spec.js diff --git a/backport-changelog/6.7/7125.md b/backport-changelog/6.7/7125.md new file mode 100644 index 00000000000000..ce208decd2d145 --- /dev/null +++ b/backport-changelog/6.7/7125.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/7125 + +* https://github.com/WordPress/gutenberg/pull/61577 diff --git a/lib/compat/wordpress-6.7/block-templates.php b/lib/compat/wordpress-6.7/block-templates.php new file mode 100644 index 00000000000000..e270ab226c1d9f --- /dev/null +++ b/lib/compat/wordpress-6.7/block-templates.php @@ -0,0 +1,41 @@ +register( $template_name, $args ); + } +} + +if ( ! function_exists( 'wp_unregister_block_template' ) ) { + /** + * Unregister a template. + * + * @param string $template_name Template name in the form of `plugin_uri//template_name`. + * @return true|WP_Error True on success, WP_Error on failure or if the template doesn't exist. + */ + function wp_unregister_block_template( $template_name ) { + return WP_Block_Templates_Registry::get_instance()->unregister( $template_name ); + } +} diff --git a/lib/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php b/lib/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php new file mode 100644 index 00000000000000..ed67dded75ecb1 --- /dev/null +++ b/lib/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php @@ -0,0 +1,203 @@ +post_type ); + } else { + $template = get_block_template( $request['id'], $this->post_type ); + } + + if ( ! $template ) { + return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.' ), array( 'status' => 404 ) ); + } + + return $this->prepare_item_for_response( $template, $request ); + } + + /** + * Prepare a single template output for response + * + * @param WP_Block_Template $item Template instance. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + // @core-merge: Fix wrong author in plugin templates. + public function prepare_item_for_response( $item, $request ) { + $template = $item; + + $fields = $this->get_fields_for_response( $request ); + + if ( 'plugin' !== $item->origin ) { + return parent::prepare_item_for_response( $item, $request ); + } + $cloned_item = clone $item; + // Set the origin as theme when calling the previous `prepare_item_for_response()` to prevent warnings when generating the author text. + $cloned_item->origin = 'theme'; + $response = parent::prepare_item_for_response( $cloned_item, $request ); + $data = $response->data; + + if ( rest_is_field_included( 'origin', $fields ) ) { + $data['origin'] = 'plugin'; + } + + if ( rest_is_field_included( 'plugin', $fields ) ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $cloned_item->slug ); + if ( $registered_template ) { + $data['plugin'] = $registered_template->plugin; + } + } + + if ( rest_is_field_included( 'author_text', $fields ) ) { + $data['author_text'] = $this->get_wp_templates_author_text_field( $template ); + } + + if ( rest_is_field_included( 'original_source', $fields ) ) { + $data['original_source'] = $this->get_wp_templates_original_source_field( $template ); + } + + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = $this->prepare_links( $template->id ); + $response->add_links( $links ); + if ( ! empty( $links['self']['href'] ) ) { + $actions = $this->get_available_actions(); + $self = $links['self']['href']; + foreach ( $actions as $rel ) { + $response->add_link( $rel, $self ); + } + } + } + + return $response; + } + + /** + * Returns the source from where the template originally comes from. + * + * @param WP_Block_Template $template_object Template instance. + * @return string Original source of the template one of theme, plugin, site, or user. + */ + // @core-merge: Changed the comments format (from inline to multi-line) in the entire function. + private static function get_wp_templates_original_source_field( $template_object ) { + if ( 'wp_template' === $template_object->type || 'wp_template_part' === $template_object->type ) { + /* + * Added by theme. + * Template originally provided by a theme, but customized by a user. + * Templates originally didn't have the 'origin' field so identify + * older customized templates by checking for no origin and a 'theme' + * or 'custom' source. + */ + if ( $template_object->has_theme_file && + ( 'theme' === $template_object->origin || ( + empty( $template_object->origin ) && in_array( + $template_object->source, + array( + 'theme', + 'custom', + ), + true + ) ) + ) + ) { + return 'theme'; + } + + // Added by plugin. + // @core-merge: Removed `$template_object->has_theme_file` check from this if clause. + if ( 'plugin' === $template_object->origin ) { + return 'plugin'; + } + + /* + * Added by site. + * Template was created from scratch, but has no author. Author support + * was only added to templates in WordPress 5.9. Fallback to showing the + * site logo and title. + */ + if ( empty( $template_object->has_theme_file ) && 'custom' === $template_object->source && empty( $template_object->author ) ) { + return 'site'; + } + } + + // Added by user. + return 'user'; + } + + /** + * Returns a human readable text for the author of the template. + * + * @param WP_Block_Template $template_object Template instance. + * @return string Human readable text for the author. + */ + private static function get_wp_templates_author_text_field( $template_object ) { + $original_source = self::get_wp_templates_original_source_field( $template_object ); + switch ( $original_source ) { + case 'theme': + $theme_name = wp_get_theme( $template_object->theme )->get( 'Name' ); + return empty( $theme_name ) ? $template_object->theme : $theme_name; + case 'plugin': + // @core-merge: Prioritize plugin name instead of theme name for plugin-registered templates. + if ( ! function_exists( 'get_plugins' ) || ! function_exists( 'get_plugin_data' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + if ( isset( $template_object->plugin ) ) { + $plugins = wp_get_active_and_valid_plugins(); + + foreach ( $plugins as $plugin_file ) { + $plugin_basename = plugin_basename( $plugin_file ); + // Split basename by '/' to get the plugin slug. + list( $plugin_slug, ) = explode( '/', $plugin_basename ); + + if ( $plugin_slug === $template_object->plugin ) { + $plugin_data = get_plugin_data( $plugin_file ); + + if ( ! empty( $plugin_data['Name'] ) ) { + return $plugin_data['Name']; + } + + break; + } + } + } + + /* + * Fall back to the theme name if the plugin is not defined. That's needed to keep backwards + * compatibility with templates that were registered before the plugin attribute was added. + */ + $plugins = get_plugins(); + $plugin_basename = plugin_basename( sanitize_text_field( $template_object->theme . '.php' ) ); + if ( isset( $plugins[ $plugin_basename ] ) && isset( $plugins[ $plugin_basename ]['Name'] ) ) { + return $plugins[ $plugin_basename ]['Name']; + } + return isset( $template_object->plugin ) ? + $template_object->plugin : + $template_object->theme; + // @core-merge: End of changes to merge in core. + case 'site': + return get_bloginfo( 'name' ); + case 'user': + $author = get_user_by( 'id', $template_object->author ); + if ( ! $author ) { + return __( 'Unknown author' ); + } + return $author->get( 'display_name' ); + } + } +} diff --git a/lib/compat/wordpress-6.7/class-wp-block-templates-registry.php b/lib/compat/wordpress-6.7/class-wp-block-templates-registry.php new file mode 100644 index 00000000000000..db53f735e13b3d --- /dev/null +++ b/lib/compat/wordpress-6.7/class-wp-block-templates-registry.php @@ -0,0 +1,256 @@ + $instance` pairs. + * + * @since 6.7.0 + * @var WP_Block_Template[] $registered_block_templates Registered templates. + */ + private $registered_templates = array(); + + /** + * Container for the main instance of the class. + * + * @since 6.7.0 + * @var WP_Block_Templates_Registry|null + */ + private static $instance = null; + + /** + * Registers a template. + * + * @since 6.7.0 + * + * @param string $template_name Template name including namespace. + * @param array $args Optional. Array of template arguments. + * @return WP_Block_Template|WP_Error The registered template on success, or false on failure. + */ + public function register( $template_name, $args = array() ) { + + $template = null; + + $error_message = ''; + $error_code = ''; + if ( ! is_string( $template_name ) ) { + $error_message = __( 'Template names must be strings.', 'gutenberg' ); + $error_code = 'template_name_no_string'; + } elseif ( preg_match( '/[A-Z]+/', $template_name ) ) { + $error_message = __( 'Template names must not contain uppercase characters.', 'gutenberg' ); + $error_code = 'template_name_no_uppercase'; + } elseif ( ! preg_match( '/^[a-z0-9-]+\/\/[a-z0-9-]+$/', $template_name ) ) { + $error_message = __( 'Template names must contain a namespace prefix. Example: my-plugin//my-custom-template', 'gutenberg' ); + $error_code = 'template_no_prefix'; + } elseif ( $this->is_registered( $template_name ) ) { + /* translators: %s: Template name. */ + $error_message = sprintf( __( 'Template "%s" is already registered.', 'gutenberg' ), $template_name ); + $error_code = 'template_already_registered'; + } + + if ( $error_message ) { + _doing_it_wrong( + __METHOD__, + $error_message, + '6.7.0' + ); + return new WP_Error( $error_code, $error_message ); + } + + if ( ! $template ) { + $theme_name = get_stylesheet(); + list( $plugin, $slug ) = explode( '//', $template_name ); + $default_template_types = get_default_block_template_types(); + + $template = new WP_Block_Template(); + $template->id = $theme_name . '//' . $slug; + $template->theme = $theme_name; + $template->plugin = $plugin; + $template->author = null; + $template->content = isset( $args['content'] ) ? $args['content'] : ''; + $template->source = 'plugin'; + $template->slug = $slug; + $template->type = 'wp_template'; + $template->title = isset( $args['title'] ) ? $args['title'] : $template_name; + $template->description = isset( $args['description'] ) ? $args['description'] : ''; + $template->status = 'publish'; + $template->origin = 'plugin'; + $template->is_custom = ! isset( $default_template_types[ $template_name ] ); + $template->post_types = isset( $args['post_types'] ) ? $args['post_types'] : array(); + } + + $this->registered_templates[ $template_name ] = $template; + + return $template; + } + + /** + * Retrieves all registered templates. + * + * @since 6.7.0 + * + * @return WP_Block_Template[]|false Associative array of `$template_name => $template` pairs. + */ + public function get_all_registered() { + return $this->registered_templates; + } + + /** + * Retrieves a registered template by its name. + * + * @since 6.7.0 + * + * @param string $template_name Template name including namespace. + * @return WP_Block_Template|null|false The registered template, or null if it is not registered. + */ + public function get_registered( $template_name ) { + if ( ! $this->is_registered( $template_name ) ) { + return null; + } + + return $this->registered_templates[ $template_name ]; + } + + /** + * Retrieves a registered template by its slug. + * + * @since 6.7.0 + * + * @param string $template_slug Slug of the template. + * @return WP_Block_Template|null The registered template, or null if it is not registered. + */ + public function get_by_slug( $template_slug ) { + $all_templates = $this->get_all_registered(); + + if ( ! $all_templates ) { + return null; + } + + foreach ( $all_templates as $template ) { + if ( $template->slug === $template_slug ) { + return $template; + } + } + + return null; + } + + /** + * Retrieves registered templates matching a query. + * + * @since 6.7.0 + * + * @param array $query { + * Arguments to retrieve templates. Optional, empty by default. + * + * @type string[] $slug__in List of slugs to include. + * @type string[] $slug__not_in List of slugs to skip. + * @type string $post_type Post type to get the templates for. + * } + */ + public function get_by_query( $query = array() ) { + $all_templates = $this->get_all_registered(); + + if ( ! $all_templates ) { + return array(); + } + + $query = wp_parse_args( + $query, + array( + 'slug__in' => array(), + 'slug__not_in' => array(), + 'post_type' => '', + ) + ); + $slugs_to_include = $query['slug__in']; + $slugs_to_skip = $query['slug__not_in']; + $post_type = $query['post_type']; + + $matching_templates = array(); + foreach ( $all_templates as $template_name => $template ) { + if ( $slugs_to_include && ! in_array( $template->slug, $slugs_to_include, true ) ) { + continue; + } + + if ( $slugs_to_skip && in_array( $template->slug, $slugs_to_skip, true ) ) { + continue; + } + + if ( $post_type && ! in_array( $post_type, $template->post_types, true ) ) { + continue; + } + + $matching_templates[ $template_name ] = $template; + } + + return $matching_templates; + } + + /** + * Checks if a template is registered. + * + * @since 6.7.0 + * + * @param string $template_name Template name. + * @return bool True if the template is registered, false otherwise. + */ + public function is_registered( $template_name ) { + return isset( $this->registered_templates[ $template_name ] ); + } + + /** + * Unregisters a template. + * + * @since 6.7.0 + * + * @param string $template_name Template name including namespace. + * @return WP_Block_Template|false The unregistered template on success, or false on failure. + */ + public function unregister( $template_name ) { + if ( ! $this->is_registered( $template_name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Template name. */ + sprintf( __( 'Template "%s" is not registered.', 'gutenberg' ), $template_name ), + '6.7.0' + ); + /* translators: %s: Template name. */ + return new WP_Error( 'template_not_registered', __( 'Template "%s" is not registered.', 'gutenberg' ) ); + } + + $unregistered_template = $this->registered_templates[ $template_name ]; + unset( $this->registered_templates[ $template_name ] ); + + return $unregistered_template; + } + + /** + * Utility method to retrieve the main instance of the class. + * + * The instance will be created if it does not exist yet. + * + * @since 6.7.0 + * + * @return WP_Block_Templates_Registry The main instance. + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + } +} diff --git a/lib/compat/wordpress-6.7/compat.php b/lib/compat/wordpress-6.7/compat.php new file mode 100644 index 00000000000000..7021cab2053eff --- /dev/null +++ b/lib/compat/wordpress-6.7/compat.php @@ -0,0 +1,114 @@ + $value ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $query_result[ $key ]->slug ); + if ( $registered_template ) { + $query_result[ $key ]->plugin = $registered_template->plugin; + $query_result[ $key ]->origin = + 'theme' !== $query_result[ $key ]->origin && 'theme' !== $query_result[ $key ]->source ? + 'plugin' : + $query_result[ $key ]->origin; + } + } + + if ( ! isset( $query['wp_id'] ) ) { + $template_files = _gutenberg_get_block_templates_files( $template_type, $query ); + + /* + * Add templates registered in the template registry. Filtering out the ones which have a theme file. + */ + $registered_templates = WP_Block_Templates_Registry::get_instance()->get_by_query( $query ); + $matching_registered_templates = array_filter( + $registered_templates, + function ( $registered_template ) use ( $template_files ) { + foreach ( $template_files as $template_file ) { + if ( $template_file['slug'] === $registered_template->slug ) { + return false; + } + } + return true; + } + ); + $query_result = array_merge( $query_result, $matching_registered_templates ); + } + + return $query_result; +} +add_filter( 'get_block_templates', '_gutenberg_add_block_templates_from_registry', 10, 3 ); + +/** + * Hooks into `get_block_template` to add the `plugin` property when necessary. + * + * @param [WP_Block_Template|null] $block_template The found block template, or null if there isn’t one. + * @return [WP_Block_Template|null] The block template that was already found with the plugin property defined if it was reigstered by a plugin. + */ +function _gutenberg_add_block_template_plugin_attribute( $block_template ) { + if ( $block_template ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $block_template->slug ); + if ( $registered_template ) { + $block_template->plugin = $registered_template->plugin; + $block_template->origin = + 'theme' !== $block_template->origin && 'theme' !== $block_template->source ? + 'plugin' : + $block_template->origin; + } + } + + return $block_template; +} +add_filter( 'get_block_template', '_gutenberg_add_block_template_plugin_attribute', 10, 1 ); + +/** + * Hooks into `get_block_file_template` so templates from the registry are also returned. + * + * @param WP_Block_Template|null $block_template The found block template, or null if there is none. + * @param string $id Template unique identifier (example: 'theme_slug//template_slug'). + * @return WP_Block_Template|null The block template that was already found or from the registry. In case the template was already found, add the necessary details from the registry. + */ +function _gutenberg_add_block_file_templates_from_registry( $block_template, $id ) { + if ( $block_template ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $block_template->slug ); + if ( $registered_template ) { + $block_template->plugin = $registered_template->plugin; + $block_template->origin = + 'theme' !== $block_template->origin && 'theme' !== $block_template->source ? + 'plugin' : + $block_template->origin; + } + return $block_template; + } + + $parts = explode( '//', $id, 2 ); + + if ( count( $parts ) < 2 ) { + return $block_template; + } + + list( , $slug ) = $parts; + return WP_Block_Templates_Registry::get_instance()->get_by_slug( $slug ); +} +add_filter( 'get_block_file_template', '_gutenberg_add_block_file_templates_from_registry', 10, 2 ); diff --git a/lib/compat/wordpress-6.7/rest-api.php b/lib/compat/wordpress-6.7/rest-api.php index 713d31c4632c74..fe2aac9c2580ae 100644 --- a/lib/compat/wordpress-6.7/rest-api.php +++ b/lib/compat/wordpress-6.7/rest-api.php @@ -29,3 +29,54 @@ function gutenberg_block_editor_preload_paths_6_7( $paths, $context ) { return $paths; } add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_block_editor_preload_paths_6_7', 10, 2 ); + +if ( ! function_exists( 'wp_api_template_registry' ) ) { + /** + * Hook in to the template and template part post types and modify the rest + * endpoint to include modifications to read templates from the + * BlockTemplatesRegistry. + * + * @param array $args Current registered post type args. + * @param string $post_type Name of post type. + * + * @return array + */ + function wp_api_template_registry( $args, $post_type ) { + if ( 'wp_template' === $post_type || 'wp_template_part' === $post_type ) { + $args['rest_controller_class'] = 'Gutenberg_REST_Templates_Controller_6_7'; + } + return $args; + } +} +add_filter( 'register_post_type_args', 'wp_api_template_registry', 10, 2 ); + +/** + * Adds `plugin` fields to WP_REST_Templates_Controller class. + */ +function gutenberg_register_wp_rest_templates_controller_plugin_field() { + + register_rest_field( + 'wp_template', + 'plugin', + array( + 'get_callback' => function ( $template_object ) { + if ( $template_object ) { + $registered_template = WP_Block_Templates_Registry::get_instance()->get_by_slug( $template_object['slug'] ); + if ( $registered_template ) { + return $registered_template->plugin; + } + } + + return; + }, + 'update_callback' => null, + 'schema' => array( + 'type' => 'string', + 'description' => __( 'Plugin that registered the template.', 'gutenberg' ), + 'readonly' => true, + 'context' => array( 'view', 'edit', 'embed' ), + ), + ) + ); +} +add_action( 'rest_api_init', 'gutenberg_register_wp_rest_templates_controller_plugin_field' ); diff --git a/lib/load.php b/lib/load.php index c5f12af1654df2..b501f0abd1c978 100644 --- a/lib/load.php +++ b/lib/load.php @@ -41,6 +41,7 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.6/rest-api.php'; // WordPress 6.7 compat. + require __DIR__ . '/compat/wordpress-6.7/class-gutenberg-rest-templates-controller-6-7.php'; require __DIR__ . '/compat/wordpress-6.7/rest-api.php'; // Plugin specific code. @@ -101,9 +102,12 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.6/post.php'; // WordPress 6.7 compat. +require __DIR__ . '/compat/wordpress-6.7/block-templates.php'; require __DIR__ . '/compat/wordpress-6.7/blocks.php'; require __DIR__ . '/compat/wordpress-6.7/block-bindings.php'; require __DIR__ . '/compat/wordpress-6.7/script-modules.php'; +require __DIR__ . '/compat/wordpress-6.7/class-wp-block-templates-registry.php'; +require __DIR__ . '/compat/wordpress-6.7/compat.php'; // Experimental features. require __DIR__ . '/experimental/block-editor-settings-mobile.php'; diff --git a/packages/core-data/src/entity-types/wp-template.ts b/packages/core-data/src/entity-types/wp-template.ts index ac6db09035f193..70d3e40c295dcf 100644 --- a/packages/core-data/src/entity-types/wp-template.ts +++ b/packages/core-data/src/entity-types/wp-template.ts @@ -73,6 +73,10 @@ declare module './base-entity-records' { * Post ID. */ wp_id: number; + /** + * Plugin that registered the template. + */ + plugin?: string; /** * Theme file exists. */ diff --git a/packages/e2e-tests/plugins/block-template-registration.php b/packages/e2e-tests/plugins/block-template-registration.php new file mode 100644 index 00000000000000..a7c75552849658 --- /dev/null +++ b/packages/e2e-tests/plugins/block-template-registration.php @@ -0,0 +1,72 @@ + 'Plugin Template', + 'description' => 'A template registered by a plugin.', + 'content' => '

This is a plugin-registered template.

', + 'post_types' => array( 'post' ), + ) + ); + add_action( + 'category_template_hierarchy', + function () { + return array( 'plugin-template' ); + } + ); + + // Custom template overridden by the theme. + wp_register_block_template( + 'gutenberg//custom-template', + array( + 'title' => 'Custom Template (overridden by the theme)', + 'description' => 'A custom template registered by a plugin and overridden by a theme.', + 'content' => '

This is a plugin-registered template and overridden by a theme.

', + 'post_types' => array( 'post' ), + ) + ); + + // Custom template used to test unregistration. + wp_register_block_template( + 'gutenberg//plugin-unregistered-template', + array( + 'title' => 'Plugin Unregistered Template', + 'description' => 'A plugin-registered template that is unregistered.', + 'content' => '

This is a plugin-registered template that is also unregistered.

', + ) + ); + wp_unregister_block_template( 'gutenberg//plugin-unregistered-template' ); + + // Custom template used to test overriding default WP templates. + wp_register_block_template( + 'gutenberg//page', + array( + 'title' => 'Plugin Page Template', + 'description' => 'A plugin-registered page template.', + 'content' => '

This is a plugin-registered page template.

', + ) + ); + + // Custom template used to test overriding default WP templates which can be created by the user. + wp_register_block_template( + 'gutenberg//author-admin', + array( + 'title' => 'Plugin Author Template', + 'description' => 'A plugin-registered author template.', + 'content' => '

This is a plugin-registered author template.

', + ) + ); + } +); diff --git a/packages/edit-site/src/utils/is-template-removable.js b/packages/edit-site/src/utils/is-template-removable.js index 9cb1de23daab75..f81cb74b022e73 100644 --- a/packages/edit-site/src/utils/is-template-removable.js +++ b/packages/edit-site/src/utils/is-template-removable.js @@ -7,7 +7,7 @@ import { TEMPLATE_ORIGINS } from './constants'; * Check if a template is removable. * * @param {Object} template The template entity to check. - * @return {boolean} Whether the template is revertable. + * @return {boolean} Whether the template is removable. */ export default function isTemplateRemovable( template ) { if ( ! template ) { @@ -15,6 +15,8 @@ export default function isTemplateRemovable( template ) { } return ( - template.source === TEMPLATE_ORIGINS.custom && ! template.has_theme_file + template.source === TEMPLATE_ORIGINS.custom && + ! Boolean( template.plugin ) && + ! template.has_theme_file ); } diff --git a/packages/edit-site/src/utils/is-template-revertable.js b/packages/edit-site/src/utils/is-template-revertable.js index a6274d07ebebb6..42413b06cd48ec 100644 --- a/packages/edit-site/src/utils/is-template-revertable.js +++ b/packages/edit-site/src/utils/is-template-revertable.js @@ -15,7 +15,8 @@ export default function isTemplateRevertable( template ) { } /* eslint-disable camelcase */ return ( - template?.source === TEMPLATE_ORIGINS.custom && template?.has_theme_file + template?.source === TEMPLATE_ORIGINS.custom && + ( Boolean( template?.plugin ) || template?.has_theme_file ) ); /* eslint-enable camelcase */ } diff --git a/packages/editor/src/dataviews/actions/reset-post.tsx b/packages/editor/src/dataviews/actions/reset-post.tsx index 59199555ddd4db..cc4cea8f5c82c0 100644 --- a/packages/editor/src/dataviews/actions/reset-post.tsx +++ b/packages/editor/src/dataviews/actions/reset-post.tsx @@ -32,7 +32,8 @@ const resetPost: Action< Post > = { return ( isTemplateOrTemplatePart( item ) && item?.source === TEMPLATE_ORIGINS.custom && - item?.has_theme_file + ( Boolean( item.type === 'wp_template' && item?.plugin ) || + item?.has_theme_file ) ); }, icon: backup, diff --git a/packages/editor/src/dataviews/actions/utils.ts b/packages/editor/src/dataviews/actions/utils.ts index 7da1f71728365b..33a2be16397f3f 100644 --- a/packages/editor/src/dataviews/actions/utils.ts +++ b/packages/editor/src/dataviews/actions/utils.ts @@ -57,6 +57,8 @@ export function isTemplateRemovable( template: Template | TemplatePart ) { return ( [ template.source, template.source ].includes( TEMPLATE_ORIGINS.custom - ) && ! template.has_theme_file + ) && + ! Boolean( template.type === 'wp_template' && template?.plugin ) && + ! template.has_theme_file ); } diff --git a/packages/editor/src/dataviews/types.ts b/packages/editor/src/dataviews/types.ts index 514953d6691290..d207410ca2b6a5 100644 --- a/packages/editor/src/dataviews/types.ts +++ b/packages/editor/src/dataviews/types.ts @@ -34,6 +34,8 @@ export interface Template extends CommonPost { type: 'wp_template'; is_custom: boolean; source: string; + origin: string; + plugin?: string; has_theme_file: boolean; id: string; } @@ -41,6 +43,7 @@ export interface Template extends CommonPost { export interface TemplatePart extends CommonPost { type: 'wp_template_part'; source: string; + origin: string; has_theme_file: boolean; id: string; area: string; diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js index 0996d6eb8b9d32..e22929011256d5 100644 --- a/packages/editor/src/store/private-actions.js +++ b/packages/editor/src/store/private-actions.js @@ -269,7 +269,7 @@ export const revertTemplate = const fileTemplatePath = addQueryArgs( `${ templateEntityConfig.baseURL }/${ template.id }`, - { context: 'edit', source: 'theme' } + { context: 'edit', source: template.origin } ); const fileTemplate = await apiFetch( { path: fileTemplatePath } ); diff --git a/packages/editor/src/store/utils/is-template-revertable.js b/packages/editor/src/store/utils/is-template-revertable.js index a09715af875bc2..2cb674920e3e4c 100644 --- a/packages/editor/src/store/utils/is-template-revertable.js +++ b/packages/editor/src/store/utils/is-template-revertable.js @@ -18,6 +18,7 @@ export default function isTemplateRevertable( templateOrTemplatePart ) { return ( templateOrTemplatePart.source === TEMPLATE_ORIGINS.custom && - templateOrTemplatePart.has_theme_file + ( Boolean( templateOrTemplatePart?.plugin ) || + templateOrTemplatePart?.has_theme_file ) ); } diff --git a/phpunit/block-template-test.php b/phpunit/block-template-test.php new file mode 100644 index 00000000000000..6589aad90b8053 --- /dev/null +++ b/phpunit/block-template-test.php @@ -0,0 +1,37 @@ +assertArrayHasKey( $template_name, $templates ); + + wp_unregister_block_template( $template_name ); + } + + public function test_get_block_template_from_registry() { + $template_name = 'test-plugin//test-template'; + $args = array( + 'title' => 'Test Template', + ); + + wp_register_block_template( $template_name, $args ); + + $template = get_block_template( 'block-theme//test-template' ); + + $this->assertEquals( 'Test Template', $template->title ); + + wp_unregister_block_template( $template_name ); + } +} diff --git a/phpunit/class-gutenberg-rest-templates-controller-test.php b/phpunit/class-gutenberg-rest-templates-controller-test.php new file mode 100644 index 00000000000000..14735246c6fb20 --- /dev/null +++ b/phpunit/class-gutenberg-rest-templates-controller-test.php @@ -0,0 +1,119 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + } + + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + + $template_name = 'test-plugin//test-template'; + $args = array( + 'content' => 'Template content', + 'title' => 'Test Template', + 'description' => 'Description of test template', + 'post_types' => array( 'post', 'page' ), + ); + + wp_register_block_template( $template_name, $args ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/test-plugin//test-template' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertNotWPError( $response, "Fetching a registered template shouldn't cause an error." ); + + $data = $response->get_data(); + + $this->assertSame( 'default//test-template', $data['id'], 'Template ID mismatch.' ); + $this->assertSame( 'default', $data['theme'], 'Template theme mismatch.' ); + $this->assertSame( 'Template content', $data['content']['raw'], 'Template content mismatch.' ); + $this->assertSame( 'test-template', $data['slug'], 'Template slug mismatch.' ); + $this->assertSame( 'plugin', $data['source'], "Template source should be 'plugin'." ); + $this->assertSame( 'plugin', $data['origin'], "Template origin should be 'plugin'." ); + $this->assertSame( 'test-plugin', $data['author_text'], 'Template author text mismatch.' ); + $this->assertSame( 'Description of test template', $data['description'], 'Template description mismatch.' ); + $this->assertSame( 'Test Template', $data['title']['rendered'], 'Template title mismatch.' ); + $this->assertSame( 'test-plugin', $data['plugin'], 'Plugin name mismatch.' ); + + wp_unregister_block_template( $template_name ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/templates/test-plugin//test-template' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertNotWPError( $response, "Fetching an unregistered template shouldn't cause an error." ); + $this->assertSame( 404, $response->get_status(), 'Fetching an unregistered template should return 404.' ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_register_routes() { + // Already present in core test class: Tests_REST_WpRestTemplatesController. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_items() { + // Already present in core test class: Tests_REST_WpRestTemplatesController. + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Already present in core test class: Tests_REST_WpRestTemplatesController. + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Already present in core test class: Tests_REST_WpRestTemplatesController. + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Already present in core test class: Tests_REST_WpRestTemplatesController. + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Already present in core test class: Tests_REST_WpRestTemplatesController. + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Already present in core test class: Tests_REST_WpRestTemplatesController. + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Already present in core test class: Tests_REST_WpRestTemplatesController. + } +} diff --git a/phpunit/class-wp-block-templates-registry-test.php b/phpunit/class-wp-block-templates-registry-test.php new file mode 100644 index 00000000000000..fb8436eb6153d4 --- /dev/null +++ b/phpunit/class-wp-block-templates-registry-test.php @@ -0,0 +1,192 @@ +register( $template_name ); + + $this->assertSame( $template->slug, 'test-template' ); + + self::$registry->unregister( $template_name ); + } + + public function test_register_template_invalid_name() { + // Try to register a template with invalid name (non-string). + $template_name = array( 'invalid-template-name' ); + + $this->setExpectedIncorrectUsage( 'WP_Block_Templates_Registry::register' ); + $result = self::$registry->register( $template_name ); + + $this->assertWPError( $result ); + $this->assertSame( 'template_name_no_string', $result->get_error_code(), 'Error code mismatch.' ); + $this->assertSame( 'Template names must be strings.', $result->get_error_message(), 'Error message mismatch.' ); + } + + public function test_register_template_invalid_name_uppercase() { + // Try to register a template with uppercase characters in the name. + $template_name = 'test-plugin//Invalid-Template-Name'; + + $this->setExpectedIncorrectUsage( 'WP_Block_Templates_Registry::register' ); + $result = self::$registry->register( $template_name ); + + $this->assertWPError( $result ); + $this->assertSame( 'template_name_no_uppercase', $result->get_error_code(), 'Error code mismatch.' ); + $this->assertSame( 'Template names must not contain uppercase characters.', $result->get_error_message(), 'Error message mismatch.' ); + } + + public function test_register_template_no_prefix() { + // Try to register a template without a namespace. + $this->setExpectedIncorrectUsage( 'WP_Block_Templates_Registry::register' ); + $result = self::$registry->register( 'template-no-plugin', array() ); + + $this->assertWPError( $result ); + $this->assertSame( 'template_no_prefix', $result->get_error_code(), 'Error code mismatch.' ); + $this->assertSame( 'Template names must contain a namespace prefix. Example: my-plugin//my-custom-template', $result->get_error_message(), 'Error message mismatch.' ); + } + + public function test_register_template_already_exists() { + // Register the template for the first time. + $template_name = 'test-plugin//duplicate-template'; + self::$registry->register( $template_name ); + + // Try to register the same template again. + $this->setExpectedIncorrectUsage( 'WP_Block_Templates_Registry::register' ); + $result = self::$registry->register( $template_name ); + + $this->assertWPError( $result ); + $this->assertSame( 'template_already_registered', $result->get_error_code(), 'Error code mismatch.' ); + $this->assertStringContainsString( 'Template "test-plugin//duplicate-template" is already registered.', $result->get_error_message(), 'Error message mismatch.' ); + + self::$registry->unregister( $template_name ); + } + + public function test_get_all_registered() { + $template_name_1 = 'test-plugin//template-1'; + $template_name_2 = 'test-plugin//template-2'; + self::$registry->register( $template_name_1 ); + self::$registry->register( $template_name_2 ); + + $all_templates = self::$registry->get_all_registered(); + + $this->assertIsArray( $all_templates, 'Registered templates should be an array.' ); + $this->assertCount( 2, $all_templates, 'Registered templates should contain 2 items.' ); + $this->assertArrayHasKey( 'test-plugin//template-1', $all_templates, 'Registered templates should contain "test-plugin//template-1".' ); + $this->assertArrayHasKey( 'test-plugin//template-2', $all_templates, 'Registered templates should contain "test-plugin//template-2".' ); + + self::$registry->unregister( $template_name_1 ); + self::$registry->unregister( $template_name_2 ); + } + + public function test_get_registered() { + $template_name = 'test-plugin//registered-template'; + $args = array( + 'content' => 'Template content', + 'title' => 'Registered Template', + 'description' => 'Description of registered template', + 'post_types' => array( 'post', 'page' ), + ); + self::$registry->register( $template_name, $args ); + + $registered_template = self::$registry->get_registered( $template_name ); + + $this->assertSame( 'default', $registered_template->theme, 'Template theme mismatch.' ); + $this->assertSame( 'registered-template', $registered_template->slug, 'Template slug mismatch.' ); + $this->assertSame( 'default//registered-template', $registered_template->id, 'Template ID mismatch.' ); + $this->assertSame( 'Registered Template', $registered_template->title, 'Template title mismatch.' ); + $this->assertSame( 'Template content', $registered_template->content, 'Template content mismatch.' ); + $this->assertSame( 'Description of registered template', $registered_template->description, 'Template description mismatch.' ); + $this->assertSame( 'plugin', $registered_template->source, "Template source should be 'plugin'." ); + $this->assertSame( 'plugin', $registered_template->origin, "Template origin should be 'plugin'." ); + $this->assertEquals( array( 'post', 'page' ), $registered_template->post_types, 'Template post types mismatch.' ); + $this->assertSame( 'test-plugin', $registered_template->plugin, 'Plugin name mismatch.' ); + + self::$registry->unregister( $template_name ); + } + + public function test_get_by_slug() { + $slug = 'slug-template'; + $template_name = 'test-plugin//' . $slug; + $args = array( + 'content' => 'Template content', + 'title' => 'Slug Template', + ); + self::$registry->register( $template_name, $args ); + + $registered_template = self::$registry->get_by_slug( $slug ); + + $this->assertNotNull( $registered_template, 'Registered template should not be null.' ); + $this->assertSame( $slug, $registered_template->slug, 'Template slug mismatch.' ); + + self::$registry->unregister( $template_name ); + } + + public function test_get_by_query() { + $template_name_1 = 'test-plugin//query-template-1'; + $template_name_2 = 'test-plugin//query-template-2'; + $args_1 = array( + 'content' => 'Template content 1', + 'title' => 'Query Template 1', + ); + $args_2 = array( + 'content' => 'Template content 2', + 'title' => 'Query Template 2', + ); + self::$registry->register( $template_name_1, $args_1 ); + self::$registry->register( $template_name_2, $args_2 ); + + $query = array( + 'slug__in' => array( 'query-template-1' ), + ); + $results = self::$registry->get_by_query( $query ); + + $this->assertCount( 1, $results, 'Query result should contain 1 item.' ); + $this->assertArrayHasKey( $template_name_1, $results, 'Query result should contain "test-plugin//query-template-1".' ); + + self::$registry->unregister( $template_name_1 ); + self::$registry->unregister( $template_name_2 ); + } + + public function test_is_registered() { + $template_name = 'test-plugin//is-registered-template'; + $args = array( + 'content' => 'Template content', + 'title' => 'Is Registered Template', + ); + self::$registry->register( $template_name, $args ); + + $this->assertTrue( self::$registry->is_registered( $template_name ) ); + + self::$registry->unregister( $template_name ); + } + + public function test_unregister() { + $template_name = 'test-plugin//unregister-template'; + $args = array( + 'content' => 'Template content', + 'title' => 'Unregister Template', + ); + $template = self::$registry->register( $template_name, $args ); + + $unregistered_template = self::$registry->unregister( $template_name ); + + $this->assertEquals( $template, $unregistered_template, 'Unregistered template should be the same as the registered one.' ); + $this->assertFalse( self::$registry->is_registered( $template_name ), 'Template should not be registered after unregistering.' ); + } +} diff --git a/test/e2e/specs/site-editor/template-registration.spec.js b/test/e2e/specs/site-editor/template-registration.spec.js new file mode 100644 index 00000000000000..132e3a8c49a902 --- /dev/null +++ b/test/e2e/specs/site-editor/template-registration.spec.js @@ -0,0 +1,356 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.use( { + blockTemplateRegistrationUtils: async ( { editor, page }, use ) => { + await use( new BlockTemplateRegistrationUtils( { editor, page } ) ); + }, +} ); + +test.describe( 'Block template registration', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + await requestUtils.activatePlugin( + 'gutenberg-test-block-template-registration' + ); + } ); + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-block-template-registration' + ); + } ); + test.afterEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllTemplates( 'wp_template' ); + await requestUtils.deleteAllPosts(); + } ); + + test( 'templates can be registered and edited', async ( { + admin, + editor, + page, + blockTemplateRegistrationUtils, + } ) => { + // Verify template is applied to the frontend. + await page.goto( '/?cat=1' ); + await expect( + page.getByText( 'This is a plugin-registered template.' ) + ).toBeVisible(); + + // Verify template is listed in the Site Editor. + await admin.visitSiteEditor( { + postType: 'wp_template', + } ); + await blockTemplateRegistrationUtils.searchForTemplate( + 'Plugin Template' + ); + await expect( page.getByText( 'Plugin Template' ) ).toBeVisible(); + await expect( + page.getByText( 'A template registered by a plugin.' ) + ).toBeVisible(); + await expect( page.getByText( 'AuthorGutenberg' ) ).toBeVisible(); + + // Verify the template contents are rendered in the editor. + await page.getByText( 'Plugin Template' ).click(); + await expect( + editor.canvas.getByText( 'This is a plugin-registered template.' ) + ).toBeVisible(); + + // Verify edits persist in the frontend. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'User-edited template' }, + } ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + await page.goto( '/?cat=1' ); + await expect( page.getByText( 'User-edited template' ) ).toBeVisible(); + + // Verify template can be reset. + await admin.visitSiteEditor( { + postType: 'wp_template', + } ); + const resetNotice = page + .getByLabel( 'Dismiss this notice' ) + .getByText( `"Plugin Template" reset.` ); + const savedButton = page.getByRole( 'button', { + name: 'Saved', + } ); + await blockTemplateRegistrationUtils.searchForTemplate( + 'Plugin Template' + ); + const searchResults = page.getByLabel( 'Actions' ); + await searchResults.first().click(); + await page.getByRole( 'menuitem', { name: 'Reset' } ).click(); + await page.getByRole( 'button', { name: 'Reset' } ).click(); + + await expect( resetNotice ).toBeVisible(); + await expect( savedButton ).toBeVisible(); + await page.goto( '/?cat=1' ); + await expect( + page.getByText( 'Content edited template.' ) + ).toBeHidden(); + } ); + + test( 'registered templates are available in the Swap template screen', async ( { + admin, + editor, + page, + } ) => { + // Create a post. + await admin.visitAdminPage( '/post-new.php' ); + await page.getByLabel( 'Close', { exact: true } ).click(); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'User-created post.' }, + } ); + + // Swap template. + await page.getByRole( 'button', { name: 'Post' } ).click(); + await page.getByRole( 'button', { name: 'Template options' } ).click(); + await page.getByRole( 'menuitem', { name: 'Swap template' } ).click(); + await page.getByText( 'Plugin Template' ).click(); + + // Verify the template is applied. + const postId = await editor.publishPost(); + await page.goto( `?p=${ postId }` ); + await expect( + page.getByText( 'This is a plugin-registered template.' ) + ).toBeVisible(); + } ); + + test( 'themes can override registered templates', async ( { + admin, + editor, + page, + blockTemplateRegistrationUtils, + } ) => { + // Create a post. + await admin.visitAdminPage( '/post-new.php' ); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'User-created post.' }, + } ); + + // Swap template. + await page.getByRole( 'button', { name: 'Post' } ).click(); + await page.getByRole( 'button', { name: 'Template options' } ).click(); + await page.getByRole( 'menuitem', { name: 'Swap template' } ).click(); + await page.getByText( 'Custom', { exact: true } ).click(); + + // Verify the theme template is applied. + const postId = await editor.publishPost(); + await page.goto( `?p=${ postId }` ); + await expect( + page.getByText( 'Custom template for Posts' ) + ).toBeVisible(); + await expect( + page.getByText( + 'This is a plugin-registered template and overridden by a theme.' + ) + ).toBeHidden(); + + // Verify the plugin-registered template doesn't appear in the Site Editor. + await admin.visitSiteEditor( { + postType: 'wp_template', + } ); + await blockTemplateRegistrationUtils.searchForTemplate( 'Custom' ); + await expect( + page.getByText( 'Custom Template (overridden by the theme)' ) + ).toBeHidden(); + // Verify the theme template shows the theme name as the author. + await expect( page.getByText( 'AuthorEmptytheme' ) ).toBeVisible(); + } ); + + test( 'templates can be deleted if the registered plugin is deactivated', async ( { + admin, + editor, + page, + requestUtils, + blockTemplateRegistrationUtils, + } ) => { + // Make an edit to the template. + await admin.visitSiteEditor( { + postType: 'wp_template', + } ); + await blockTemplateRegistrationUtils.searchForTemplate( + 'Plugin Template' + ); + await page.getByText( 'Plugin Template' ).click(); + await expect( + editor.canvas.getByText( 'This is a plugin-registered template.' ) + ).toBeVisible(); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'User-customized template' }, + } ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + + // Deactivate plugin. + await requestUtils.deactivatePlugin( + 'gutenberg-test-block-template-registration' + ); + + // Verify template can be deleted. + await admin.visitSiteEditor( { + postType: 'wp_template', + } ); + const deletedNotice = page + .getByLabel( 'Dismiss this notice' ) + .getByText( `"Plugin Template" deleted.` ); + const savedButton = page.getByRole( 'button', { + name: 'Saved', + } ); + await blockTemplateRegistrationUtils.searchForTemplate( + 'Plugin Template' + ); + const searchResults = page.getByLabel( 'Actions' ); + await searchResults.first().click(); + await page.getByRole( 'menuitem', { name: 'Delete' } ).click(); + await page.getByRole( 'button', { name: 'Delete' } ).click(); + + await expect( deletedNotice ).toBeVisible(); + await expect( savedButton ).toBeVisible(); + + // Expect template to no longer appear in the Site Editor. + await expect( page.getByLabel( 'Actions' ) ).toBeHidden(); + + // Reactivate plugin. + await requestUtils.activatePlugin( + 'gutenberg-test-block-template-registration' + ); + } ); + + test( 'registered templates can be unregistered', async ( { + admin, + page, + blockTemplateRegistrationUtils, + } ) => { + await admin.visitSiteEditor( { + postType: 'wp_template', + } ); + await blockTemplateRegistrationUtils.searchForTemplate( + 'Plugin Unregistered Template' + ); + await expect( + page.getByText( 'Plugin Unregistered Template' ) + ).toBeHidden(); + } ); + + test( 'WP default templates can be overridden by plugins', async ( { + page, + } ) => { + await page.goto( '?page_id=2' ); + await expect( + page.getByText( 'This is a plugin-registered page template.' ) + ).toBeVisible(); + } ); + + test( 'user-customized templates cannot be overridden by plugins', async ( { + admin, + editor, + page, + requestUtils, + blockTemplateRegistrationUtils, + } ) => { + await requestUtils.deactivatePlugin( + 'gutenberg-test-block-template-registration' + ); + + // Create an author template. + await admin.visitSiteEditor( { + postType: 'wp_template', + } ); + await page.getByLabel( 'Add New Template' ).click(); + await page.getByRole( 'button', { name: 'Author Archives' } ).click(); + await page + .getByRole( 'button', { name: 'Author For a specific item' } ) + .click(); + await page.getByRole( 'option', { name: 'admin' } ).click(); + await expect( page.getByText( 'Choose a pattern' ) ).toBeVisible(); + await page.getByLabel( 'Close', { exact: true } ).click(); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Author template customized by the user.' }, + } ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + + await requestUtils.activatePlugin( + 'gutenberg-test-block-template-registration' + ); + + // Verify the template edited by the user has priority over the one registered by the theme. + await page.goto( '?author=1' ); + await expect( + page.getByText( 'Author template customized by the user.' ) + ).toBeVisible(); + await expect( + page.getByText( 'This is a plugin-registered author template.' ) + ).toBeHidden(); + + // Verify the template registered by the plugin is not visible in the Site Editor. + await admin.visitSiteEditor( { + postType: 'wp_template', + } ); + await blockTemplateRegistrationUtils.searchForTemplate( + 'Plugin Author Template' + ); + await expect( page.getByText( 'Plugin Author Template' ) ).toBeHidden(); + + // Reset the user-modified template. + const resetNotice = page + .getByLabel( 'Dismiss this notice' ) + .getByText( `"Author: Admin" reset.` ); + await page.getByPlaceholder( 'Search' ).fill( 'Author: admin' ); + await page.getByRole( 'link', { name: 'Author: Admin' } ).click(); + const actions = page.getByLabel( 'Actions' ); + await actions.first().click(); + await page.getByRole( 'menuitem', { name: 'Reset' } ).click(); + await page.getByRole( 'button', { name: 'Reset' } ).click(); + + await expect( resetNotice ).toBeVisible(); + + // Verify the template registered by the plugin is applied in the editor... + await expect( + editor.canvas.getByText( 'Author template customized by the user.' ) + ).toBeHidden(); + await expect( + editor.canvas.getByText( + 'This is a plugin-registered author template.' + ) + ).toBeVisible(); + + // ... and the frontend. + await page.goto( '?author=1' ); + await expect( + page.getByText( 'Author template customized by the user.' ) + ).toBeHidden(); + await expect( + page.getByText( 'This is a plugin-registered author template.' ) + ).toBeVisible(); + } ); +} ); + +class BlockTemplateRegistrationUtils { + constructor( { page } ) { + this.page = page; + } + + async searchForTemplate( searchTerm ) { + const searchResults = this.page.getByLabel( 'Actions' ); + await expect + .poll( async () => await searchResults.count() ) + .toBeGreaterThan( 0 ); + const initialSearchResultsCount = await searchResults.count(); + await this.page.getByPlaceholder( 'Search' ).fill( searchTerm ); + await expect + .poll( async () => await searchResults.count() ) + .toBeLessThan( initialSearchResultsCount ); + } +}