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 );
+ }
+}