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 ( - { - createTemplate( template ); - // We will be navigated way so no need to close the dropdown. - } } - > - { title } - - ); - } ) } - - + <> + + { () => ( + + + { missingTemplates.map( ( template ) => { + const { + title, + description, + slug, + onClick, + icon, + } = template; + return ( + + onClick + ? onClick( template ) + : createTemplate( template ) + } + > + { title } + + ); + } ) } + + + ) } + + { 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 ) ), + } ) ); +};