diff --git a/lib/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php b/lib/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php
index f32524f071137..7c73d799aa843 100644
--- a/lib/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php
+++ b/lib/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php
@@ -95,6 +95,75 @@ public function create_item( $request ) {
);
}
+ /**
+ * Updates a single template.
+ *
+ * @since 5.8.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function update_item( $request ) {
+ $template = gutenberg_get_block_template( $request['id'], $this->post_type );
+ if ( ! $template ) {
+ return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.', 'gutenberg' ), array( 'status' => 404 ) );
+ }
+
+ $post_before = get_post( $template->wp_id );
+
+ if ( isset( $request['source'] ) && 'theme' === $request['source'] ) {
+ wp_delete_post( $template->wp_id, true );
+ $request->set_param( 'context', 'edit' );
+
+ $template = gutenberg_get_block_template( $request['id'], $this->post_type );
+ $response = $this->prepare_item_for_response( $template, $request );
+
+ return rest_ensure_response( $response );
+ }
+
+ $changes = $this->prepare_item_for_database( $request );
+
+ if ( is_wp_error( $changes ) ) {
+ return $changes;
+ }
+
+ if ( 'custom' === $template->source ) {
+ $update = true;
+ $result = wp_update_post( wp_slash( (array) $changes ), false );
+ } else {
+ $update = false;
+ $post_before = null;
+ $result = wp_insert_post( wp_slash( (array) $changes ), false );
+ }
+
+ if ( is_wp_error( $result ) ) {
+ if ( 'db_update_error' === $result->get_error_code() ) {
+ $result->add_data( array( 'status' => 500 ) );
+ } else {
+ $result->add_data( array( 'status' => 400 ) );
+ }
+ return $result;
+ }
+
+ $template = gutenberg_get_block_template( $request['id'], $this->post_type );
+ $fields_update = $this->update_additional_fields_for_object( $template, $request );
+ if ( is_wp_error( $fields_update ) ) {
+ return $fields_update;
+ }
+
+ $request->set_param( 'context', 'edit' );
+
+ $post = get_post( $template->wp_id );
+ /** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
+ do_action( "rest_after_insert_{$this->post_type}", $post, $request, false );
+
+ wp_after_insert_post( $post, $update, $post_before );
+
+ $response = $this->prepare_item_for_response( $template, $request );
+
+ return rest_ensure_response( $response );
+ }
+
/**
* Prepares a single template for create or update.
*
diff --git a/lib/compat/wordpress-6.1/rest-api.php b/lib/compat/wordpress-6.1/rest-api.php
index cfb53c9b4d237..30c68981c4f11 100644
--- a/lib/compat/wordpress-6.1/rest-api.php
+++ b/lib/compat/wordpress-6.1/rest-api.php
@@ -19,3 +19,18 @@ function gutenberg_update_templates_template_parts_rest_controller( $args, $post
return $args;
}
add_filter( 'register_post_type_args', 'gutenberg_update_templates_template_parts_rest_controller', 10, 2 );
+
+
+/**
+ * Add the post type's `icon`(menu_icon) in the response.
+ * When we backport this change we will need to add the
+ * `icon` to WP_REST_Post_Types_Controller schema.
+ *
+ * @param WP_REST_Response $response The response object.
+ * @param WP_Post_Type $post_type The original post type object.
+ */
+function gutenberg_update_post_types_rest_response( $response, $post_type ) {
+ $response->data['icon'] = $post_type->menu_icon;
+ return $response;
+}
+add_filter( 'rest_prepare_post_type', 'gutenberg_update_post_types_rest_response', 10, 2 );
diff --git a/packages/base-styles/_mixins.scss b/packages/base-styles/_mixins.scss
index 069f1b2f97428..ac538de7ce911 100644
--- a/packages/base-styles/_mixins.scss
+++ b/packages/base-styles/_mixins.scss
@@ -202,7 +202,6 @@
}
}
-
/**
* Allows users to opt-out of animations via OS-level preferences.
*/
diff --git a/packages/edit-site/src/components/add-new-template/add-custom-template-modal.js b/packages/edit-site/src/components/add-new-template/add-custom-template-modal.js
new file mode 100644
index 0000000000000..3c5980eb8088a
--- /dev/null
+++ b/packages/edit-site/src/components/add-new-template/add-custom-template-modal.js
@@ -0,0 +1,231 @@
+/**
+ * WordPress dependencies
+ */
+import { useState, useMemo, useEffect } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ Button,
+ Flex,
+ FlexItem,
+ Modal,
+ SearchControl,
+ TextHighlight,
+ __experimentalText as Text,
+ __experimentalHeading as Heading,
+ __unstableComposite as Composite,
+ __unstableUseCompositeState as useCompositeState,
+ __unstableCompositeItem as CompositeItem,
+} from '@wordpress/components';
+import { useDebounce } from '@wordpress/compose';
+import { useEntityRecords } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import { mapToIHasNameAndId } from './utils';
+
+const EMPTY_ARRAY = [];
+const BASE_QUERY = {
+ order: 'asc',
+ _fields: 'id,title,slug,link',
+ context: 'view',
+};
+
+function SuggestionListItem( {
+ suggestion,
+ search,
+ onSelect,
+ entityForSuggestions,
+ composite,
+} ) {
+ const baseCssClass =
+ 'edit-site-custom-template-modal__suggestions_list__list-item';
+ return (
+ {
+ const title = sprintf(
+ // translators: Represents the title of a user's custom template in the Site Editor, where %1$s is the singular name of a post type and %2$s is the name of the post, e.g. "Post: Hello, WordPress"
+ __( '%1$s: %2$s' ),
+ entityForSuggestions.labels.singular_name,
+ suggestion.name
+ );
+ onSelect( {
+ title,
+ description: sprintf(
+ // translators: Represents the description of a user's custom template in the Site Editor, e.g. "Template for Post: Hello, WordPress"
+ __( 'Template for %1$s' ),
+ title
+ ),
+ slug: `single-${ entityForSuggestions.slug }-${ suggestion.slug }`,
+ } );
+ } }
+ >
+
+
+
+ { suggestion.link && (
+
+ { suggestion.link }
+
+ ) }
+
+ );
+}
+
+function SuggestionList( { entityForSuggestions, onSelect } ) {
+ const composite = useCompositeState( { orientation: 'vertical' } );
+ const [ suggestions, setSuggestions ] = useState( EMPTY_ARRAY );
+ // We need to track two values, the search input's value(searchInputValue)
+ // and the one we want to debounce(search) and make REST API requests.
+ const [ searchInputValue, setSearchInputValue ] = useState( '' );
+ const [ search, setSearch ] = useState( '' );
+ const debouncedSearch = useDebounce( setSearch, 250 );
+ const query = {
+ ...BASE_QUERY,
+ search,
+ orderby: search ? 'relevance' : 'modified',
+ exclude: entityForSuggestions.postsToExclude,
+ per_page: search ? 20 : 10,
+ };
+ const { records: searchResults, hasResolved: searchHasResolved } =
+ useEntityRecords(
+ entityForSuggestions.type,
+ entityForSuggestions.slug,
+ query
+ );
+ useEffect( () => {
+ if ( search !== searchInputValue ) {
+ debouncedSearch( searchInputValue );
+ }
+ }, [ search, searchInputValue ] );
+ const entitiesInfo = useMemo( () => {
+ if ( ! searchResults?.length ) return EMPTY_ARRAY;
+ return mapToIHasNameAndId( searchResults, 'title.rendered' );
+ }, [ searchResults ] );
+ // Update suggestions only when the query has resolved.
+ useEffect( () => {
+ if ( ! searchHasResolved ) return;
+ setSuggestions( entitiesInfo );
+ }, [ entitiesInfo, searchHasResolved ] );
+ return (
+ <>
+
+ { !! suggestions?.length && (
+
+ { suggestions.map( ( suggestion ) => (
+
+ ) ) }
+
+ ) }
+ { search && ! suggestions?.length && (
+
+ { entityForSuggestions.labels.not_found }
+
+ ) }
+ >
+ );
+}
+
+function AddCustomTemplateModal( { onClose, onSelect, entityForSuggestions } ) {
+ const [ showSearchEntities, setShowSearchEntities ] = useState(
+ entityForSuggestions.hasGeneralTemplate
+ );
+ const baseCssClass = 'edit-site-custom-template-modal';
+ return (
+
+ { ! showSearchEntities && (
+ <>
+
+ { __(
+ 'Select whether to create a single template for all items or a specific one.'
+ ) }
+
+
+ {
+ const { slug, title, description } =
+ entityForSuggestions.template;
+ onSelect( { slug, title, description } );
+ } }
+ >
+
+ { entityForSuggestions.labels.all_items }
+
+
+ {
+ // translators: The user is given the choice to set up a template for all items of a post type, or just a specific one.
+ __( 'For all items' )
+ }
+
+
+ {
+ setShowSearchEntities( true );
+ } }
+ >
+
+ { entityForSuggestions.labels.singular_name }
+
+
+ {
+ // translators: The user is given the choice to set up a template for all items of a post type, or just a specific one.
+ __( 'For a specific item' )
+ }
+
+
+
+ >
+ ) }
+ { showSearchEntities && (
+ <>
+
+ { __(
+ 'This template will be used only for the specific item chosen.'
+ ) }
+
+
+ >
+ ) }
+
+ );
+}
+
+export default AddCustomTemplateModal;
diff --git a/packages/edit-site/src/components/add-new-template/new-template.js b/packages/edit-site/src/components/add-new-template/new-template.js
index 41d9471b2ca5d..f8b2f9ec26d31 100644
--- a/packages/edit-site/src/components/add-new-template/new-template.js
+++ b/packages/edit-site/src/components/add-new-template/new-template.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { filter, includes, map } from 'lodash';
+import { filter, includes } from 'lodash';
/**
* WordPress dependencies
@@ -12,6 +12,7 @@ import {
MenuItem,
NavigableMenu,
} from '@wordpress/components';
+import { useState } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { store as editorStore } from '@wordpress/editor';
@@ -30,12 +31,14 @@ import {
search,
tag,
} from '@wordpress/icons';
-import { __ } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
/**
* Internal dependencies
*/
+import AddCustomTemplateModal from './add-custom-template-modal';
+import { usePostTypes, usePostTypesEntitiesInfo } from './utils';
import { useHistory } from '../routes';
import { store as editSiteStore } from '../../store';
@@ -72,9 +75,13 @@ const TEMPLATE_ICONS = {
export default function NewTemplate( { postType } ) {
const history = useHistory();
- const { templates, defaultTemplateTypes } = useSelect(
+ const postTypes = usePostTypes();
+ const [ showCustomTemplateModal, setShowCustomTemplateModal ] =
+ useState( false );
+ const [ entityForSuggestions, setEntityForSuggestions ] = useState( {} );
+ const { existingTemplates, defaultTemplateTypes } = useSelect(
( select ) => ( {
- templates: select( coreStore ).getEntityRecords(
+ existingTemplates: select( coreStore ).getEntityRecords(
'postType',
'wp_template',
{ per_page: -1 }
@@ -84,6 +91,7 @@ export default function NewTemplate( { postType } ) {
} ),
[]
);
+ const postTypesEntitiesInfo = usePostTypesEntitiesInfo( existingTemplates );
const { saveEntityRecord } = useDispatch( coreStore );
const { createErrorNotice } = useDispatch( noticesStore );
const { setTemplate } = useDispatch( editSiteStore );
@@ -91,7 +99,6 @@ export default function NewTemplate( { postType } ) {
async function createTemplate( template ) {
try {
const { title, description, slug } = template;
-
const newTemplate = await saveEntityRecord(
'postType',
'wp_template',
@@ -128,9 +135,9 @@ export default function NewTemplate( { postType } ) {
} );
}
}
-
- const existingTemplateSlugs = map( templates, 'slug' );
-
+ const existingTemplateSlugs = ( existingTemplates || [] ).map(
+ ( { slug } ) => slug
+ );
const missingTemplates = filter(
defaultTemplateTypes,
( template ) =>
@@ -138,54 +145,124 @@ export default function NewTemplate( { postType } ) {
! includes( existingTemplateSlugs, template.slug )
);
- if ( ! missingTemplates.length ) {
+ const extraTemplates = ( postTypes || [] ).reduce(
+ ( accumulator, _postType ) => {
+ const { slug, labels, icon } = _postType;
+ const hasGeneralTemplate = existingTemplateSlugs?.includes(
+ `single-${ slug }`
+ );
+ const hasEntities = postTypesEntitiesInfo?.[ slug ]?.hasEntities;
+ const menuItem = {
+ slug: `single-${ slug }`,
+ title: sprintf(
+ // translators: %s: Name of the post type e.g: "Post".
+ __( 'Single item: %s' ),
+ labels.singular_name
+ ),
+ description: sprintf(
+ // translators: %s: Name of the post type e.g: "Post".
+ __( 'Displays a single item: %s.' ),
+ labels.singular_name
+ ),
+ // `icon` is the `menu_icon` property of a post type. We
+ // only handle `dashicons` for now, even if the `menu_icon`
+ // also supports urls and svg as values.
+ icon: icon?.startsWith( 'dashicons-' )
+ ? icon.slice( 10 )
+ : null,
+ };
+ // We have a different template creation flow only if they have entities.
+ if ( hasEntities ) {
+ menuItem.onClick = ( template ) => {
+ setShowCustomTemplateModal( true );
+ setEntityForSuggestions( {
+ type: 'postType',
+ slug,
+ labels,
+ hasGeneralTemplate,
+ template,
+ postsToExclude:
+ postTypesEntitiesInfo[ slug ].existingPosts,
+ } );
+ };
+ }
+ // We don't need to add the menu item if there are no
+ // entities and the general template exists.
+ if ( ! hasGeneralTemplate || hasEntities ) {
+ accumulator.push( menuItem );
+ }
+ return accumulator;
+ },
+ []
+ );
+ if ( ! missingTemplates.length && ! extraTemplates.length ) {
return null;
}
-
// Update the sort order to match the DEFAULT_TEMPLATE_SLUGS order.
- missingTemplates.sort( ( template1, template2 ) => {
+ missingTemplates?.sort( ( template1, template2 ) => {
return (
DEFAULT_TEMPLATE_SLUGS.indexOf( template1.slug ) -
DEFAULT_TEMPLATE_SLUGS.indexOf( template2.slug )
);
} );
-
+ // Append all extra templates at the end of the list for now.
+ missingTemplates.push( ...extraTemplates );
return (
-
- { () => (
-
-
- { map( missingTemplates, ( template ) => {
- const { title, description, slug } = template;
- return (
-
- );
- } ) }
-
-
+ <>
+
+ { () => (
+
+
+ { missingTemplates.map( ( template ) => {
+ const {
+ title,
+ description,
+ slug,
+ onClick,
+ icon,
+ } = template;
+ return (
+
+ );
+ } ) }
+
+
+ ) }
+
+ { showCustomTemplateModal && (
+ setShowCustomTemplateModal( false ) }
+ onSelect={ createTemplate }
+ entityForSuggestions={ entityForSuggestions }
+ />
) }
-
+ >
);
}
diff --git a/packages/edit-site/src/components/add-new-template/style.scss b/packages/edit-site/src/components/add-new-template/style.scss
index 78527882b330c..81fe785680062 100644
--- a/packages/edit-site/src/components/add-new-template/style.scss
+++ b/packages/edit-site/src/components/add-new-template/style.scss
@@ -9,3 +9,119 @@
}
}
}
+
+.edit-site-custom-template-modal {
+ &__contents {
+ > div {
+ text-align: center;
+ cursor: pointer;
+ padding: $grid-unit-30;
+ border: 1px solid $gray-300;
+ border-radius: $radius-block-ui;
+ width: 256px;
+ display: flex;
+ flex-direction: column;
+ gap: $grid-unit;
+ align-items: center;
+ justify-content: center;
+
+ span {
+ color: $gray-700;
+ }
+
+ &:hover {
+ border-color: var(--wp-admin-theme-color);
+
+ h5 {
+ color: var(--wp-admin-theme-color);
+ }
+ }
+
+ &:focus {
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color);
+ }
+ }
+ }
+
+ .components-search-control {
+ input[type="search"].components-search-control__input {
+ background: $white;
+ border: 1px solid $gray-300;
+
+ &:focus {
+ border-color: var(--wp-admin-theme-color);
+ box-shadow: 0 0 0 1px var(--wp-admin-theme-color);
+ }
+ }
+ }
+
+ @include break-medium() {
+ width: 456px;
+ }
+}
+
+.edit-site-custom-template-modal__suggestions_list {
+ margin-top: $grid-unit-20;
+
+ @include break-small() {
+ height: 232px;
+ overflow: scroll;
+ }
+
+ &__list-item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ height: auto;
+
+ mark {
+ font-weight: 700;
+ background: none;
+ }
+
+ &:hover {
+ background-color: $gray-100;
+
+ mark {
+ color: var(--wp-admin-theme-color);
+ }
+ }
+
+ &:focus {
+ background-color: $gray-100;
+ }
+
+ &:focus:not(:disabled) {
+ box-shadow: 0 0 0 var(--wp-admin-border-width-focus) var(--wp-admin-theme-color) inset;
+ }
+
+ &__title,
+ &__info {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: block;
+ }
+
+ &__title {
+ font-weight: 500;
+ margin-bottom: 0.2em;
+ }
+
+ &__info {
+ color: $gray-700;
+ font-size: 0.9em;
+ line-height: 1.3;
+ word-break: break-all;
+ }
+ }
+}
+
+.edit-site-custom-template-modal__no-results {
+ border: 1px solid $gray-400;
+ border-radius: $radius-block-ui;
+ padding: $grid-unit-20;
+ margin-bottom: 0;
+ margin-top: $grid-unit-20;
+}
diff --git a/packages/edit-site/src/components/add-new-template/utils.js b/packages/edit-site/src/components/add-new-template/utils.js
new file mode 100644
index 0000000000000..0726d019d45e5
--- /dev/null
+++ b/packages/edit-site/src/components/add-new-template/utils.js
@@ -0,0 +1,125 @@
+/**
+ * External dependencies
+ */
+import { get } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+import { decodeEntities } from '@wordpress/html-entities';
+import { useMemo } from '@wordpress/element';
+
+export const usePostTypes = () => {
+ const postTypes = useSelect(
+ ( select ) => select( coreStore ).getPostTypes( { per_page: -1 } ),
+ []
+ );
+ return useMemo( () => {
+ const excludedPostTypes = [ 'attachment', 'page' ];
+ return postTypes?.filter(
+ ( { viewable, slug } ) =>
+ viewable && ! excludedPostTypes.includes( slug )
+ );
+ }, [ postTypes ] );
+};
+
+/**
+ * @typedef {Object} PostTypeEntitiesInfo
+ * @property {boolean} hasEntities If a postType has available entities.
+ * @property {number[]} existingPosts An array of the existing entities ids.
+ */
+
+/**
+ * Helper hook that returns information about a post type having
+ * posts that we can create a specific template for.
+ *
+ * First we need to find the existing posts with an associated template,
+ * to query afterwards for any remaing post, by excluding them.
+ *
+ * @param {string[]} existingTemplates The existing templates.
+ * @return {Record} An object with the postTypes as `keys` and PostTypeEntitiesInfo as values.
+ */
+export const usePostTypesEntitiesInfo = ( existingTemplates ) => {
+ const postTypes = usePostTypes();
+ const slugsToExcludePerEntity = useMemo( () => {
+ return postTypes?.reduce( ( accumulator, _postType ) => {
+ const slugsWithTemplates = ( existingTemplates || [] ).reduce(
+ ( _accumulator, existingTemplate ) => {
+ const prefix = `single-${ _postType.slug }-`;
+ if ( existingTemplate.slug.startsWith( prefix ) ) {
+ _accumulator.push(
+ existingTemplate.slug.substring( prefix.length )
+ );
+ }
+ return _accumulator;
+ },
+ []
+ );
+ if ( slugsWithTemplates.length ) {
+ accumulator[ _postType.slug ] = slugsWithTemplates;
+ }
+ return accumulator;
+ }, {} );
+ }, [ postTypes, existingTemplates ] );
+ const postsToExcludePerEntity = useSelect(
+ ( select ) => {
+ if ( ! slugsToExcludePerEntity ) {
+ return;
+ }
+ const postsToExclude = Object.entries(
+ slugsToExcludePerEntity
+ ).reduce( ( accumulator, [ slug, slugsWithTemplates ] ) => {
+ const postsWithTemplates = select( coreStore ).getEntityRecords(
+ 'postType',
+ slug,
+ {
+ _fields: 'id',
+ context: 'view',
+ slug: slugsWithTemplates,
+ }
+ );
+ if ( postsWithTemplates?.length ) {
+ accumulator[ slug ] = postsWithTemplates;
+ }
+ return accumulator;
+ }, {} );
+ return postsToExclude;
+ },
+ [ slugsToExcludePerEntity ]
+ );
+ const entitiesInfo = useSelect(
+ ( select ) => {
+ return postTypes?.reduce( ( accumulator, { slug } ) => {
+ const existingPosts =
+ postsToExcludePerEntity?.[ slug ]?.map(
+ ( { id } ) => id
+ ) || [];
+ accumulator[ slug ] = {
+ hasEntities: !! select( coreStore ).getEntityRecords(
+ 'postType',
+ slug,
+ {
+ per_page: 1,
+ _fields: 'id',
+ context: 'view',
+ exclude: existingPosts,
+ }
+ )?.length,
+ existingPosts,
+ };
+ return accumulator;
+ }, {} );
+ },
+ [ postTypes, postsToExcludePerEntity ]
+ );
+ return entitiesInfo;
+};
+
+export const mapToIHasNameAndId = ( entities, path ) => {
+ return ( entities || [] ).map( ( entity ) => ( {
+ ...entity,
+ name: decodeEntities( get( entity, path ) ),
+ } ) );
+};