Skip to content

Commit 41c7450

Browse files
ntsekourasjameskostermcsf
authored
[Site Editor]: Expand the template types that can be added - single custom post type and specific posts templates (#41189)
* WIP: [Site Editor]: Expand the template types that can be added * Fix typo * Remove icons in general vs specific selection modal * Update post lookup styling to match linkcontrol * Fix modal width, set suggestion list height * fix linting * remove `archive` additions and check for available posts by excluding the existing ones before adding the menu item * add safeguard for `existingTemplates` * add link in suggestions list * clean up comments * update formatting after trunk change * use gutenberg_get_block_template for updates in templates * add `icon` in Post Types REST API and handle only dashicons in the templates list for now * use `rest_prepare_post_type` filter * address feedback part 1 * update jsdoc * remove mixin * try different labels * Apply suggestions from code review Co-authored-by: Miguel Fonseca <miguelcsf@gmail.com> * address feedback Co-authored-by: James Koster <james@jameskoster.co.uk> Co-authored-by: Miguel Fonseca <miguelcsf@gmail.com>
1 parent 6250a1f commit 41c7450

File tree

7 files changed

+680
-48
lines changed

7 files changed

+680
-48
lines changed

lib/compat/wordpress-6.1/class-gutenberg-rest-templates-controller.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,75 @@ public function create_item( $request ) {
9595
);
9696
}
9797

98+
/**
99+
* Updates a single template.
100+
*
101+
* @since 5.8.0
102+
*
103+
* @param WP_REST_Request $request Full details about the request.
104+
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
105+
*/
106+
public function update_item( $request ) {
107+
$template = gutenberg_get_block_template( $request['id'], $this->post_type );
108+
if ( ! $template ) {
109+
return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.', 'gutenberg' ), array( 'status' => 404 ) );
110+
}
111+
112+
$post_before = get_post( $template->wp_id );
113+
114+
if ( isset( $request['source'] ) && 'theme' === $request['source'] ) {
115+
wp_delete_post( $template->wp_id, true );
116+
$request->set_param( 'context', 'edit' );
117+
118+
$template = gutenberg_get_block_template( $request['id'], $this->post_type );
119+
$response = $this->prepare_item_for_response( $template, $request );
120+
121+
return rest_ensure_response( $response );
122+
}
123+
124+
$changes = $this->prepare_item_for_database( $request );
125+
126+
if ( is_wp_error( $changes ) ) {
127+
return $changes;
128+
}
129+
130+
if ( 'custom' === $template->source ) {
131+
$update = true;
132+
$result = wp_update_post( wp_slash( (array) $changes ), false );
133+
} else {
134+
$update = false;
135+
$post_before = null;
136+
$result = wp_insert_post( wp_slash( (array) $changes ), false );
137+
}
138+
139+
if ( is_wp_error( $result ) ) {
140+
if ( 'db_update_error' === $result->get_error_code() ) {
141+
$result->add_data( array( 'status' => 500 ) );
142+
} else {
143+
$result->add_data( array( 'status' => 400 ) );
144+
}
145+
return $result;
146+
}
147+
148+
$template = gutenberg_get_block_template( $request['id'], $this->post_type );
149+
$fields_update = $this->update_additional_fields_for_object( $template, $request );
150+
if ( is_wp_error( $fields_update ) ) {
151+
return $fields_update;
152+
}
153+
154+
$request->set_param( 'context', 'edit' );
155+
156+
$post = get_post( $template->wp_id );
157+
/** This action is documented in wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php */
158+
do_action( "rest_after_insert_{$this->post_type}", $post, $request, false );
159+
160+
wp_after_insert_post( $post, $update, $post_before );
161+
162+
$response = $this->prepare_item_for_response( $template, $request );
163+
164+
return rest_ensure_response( $response );
165+
}
166+
98167
/**
99168
* Prepares a single template for create or update.
100169
*

lib/compat/wordpress-6.1/rest-api.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,18 @@ function gutenberg_update_templates_template_parts_rest_controller( $args, $post
1919
return $args;
2020
}
2121
add_filter( 'register_post_type_args', 'gutenberg_update_templates_template_parts_rest_controller', 10, 2 );
22+
23+
24+
/**
25+
* Add the post type's `icon`(menu_icon) in the response.
26+
* When we backport this change we will need to add the
27+
* `icon` to WP_REST_Post_Types_Controller schema.
28+
*
29+
* @param WP_REST_Response $response The response object.
30+
* @param WP_Post_Type $post_type The original post type object.
31+
*/
32+
function gutenberg_update_post_types_rest_response( $response, $post_type ) {
33+
$response->data['icon'] = $post_type->menu_icon;
34+
return $response;
35+
}
36+
add_filter( 'rest_prepare_post_type', 'gutenberg_update_post_types_rest_response', 10, 2 );

packages/base-styles/_mixins.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,6 @@
202202
}
203203
}
204204

205-
206205
/**
207206
* Allows users to opt-out of animations via OS-level preferences.
208207
*/
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { useState, useMemo, useEffect } from '@wordpress/element';
5+
import { __, sprintf } from '@wordpress/i18n';
6+
import {
7+
Button,
8+
Flex,
9+
FlexItem,
10+
Modal,
11+
SearchControl,
12+
TextHighlight,
13+
__experimentalText as Text,
14+
__experimentalHeading as Heading,
15+
__unstableComposite as Composite,
16+
__unstableUseCompositeState as useCompositeState,
17+
__unstableCompositeItem as CompositeItem,
18+
} from '@wordpress/components';
19+
import { useDebounce } from '@wordpress/compose';
20+
import { useEntityRecords } from '@wordpress/core-data';
21+
22+
/**
23+
* Internal dependencies
24+
*/
25+
import { mapToIHasNameAndId } from './utils';
26+
27+
const EMPTY_ARRAY = [];
28+
const BASE_QUERY = {
29+
order: 'asc',
30+
_fields: 'id,title,slug,link',
31+
context: 'view',
32+
};
33+
34+
function SuggestionListItem( {
35+
suggestion,
36+
search,
37+
onSelect,
38+
entityForSuggestions,
39+
composite,
40+
} ) {
41+
const baseCssClass =
42+
'edit-site-custom-template-modal__suggestions_list__list-item';
43+
return (
44+
<CompositeItem
45+
role="option"
46+
as={ Button }
47+
{ ...composite }
48+
className={ baseCssClass }
49+
onClick={ () => {
50+
const title = sprintf(
51+
// 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"
52+
__( '%1$s: %2$s' ),
53+
entityForSuggestions.labels.singular_name,
54+
suggestion.name
55+
);
56+
onSelect( {
57+
title,
58+
description: sprintf(
59+
// translators: Represents the description of a user's custom template in the Site Editor, e.g. "Template for Post: Hello, WordPress"
60+
__( 'Template for %1$s' ),
61+
title
62+
),
63+
slug: `single-${ entityForSuggestions.slug }-${ suggestion.slug }`,
64+
} );
65+
} }
66+
>
67+
<span className={ `${ baseCssClass }__title` }>
68+
<TextHighlight text={ suggestion.name } highlight={ search } />
69+
</span>
70+
{ suggestion.link && (
71+
<span className={ `${ baseCssClass }__info` }>
72+
{ suggestion.link }
73+
</span>
74+
) }
75+
</CompositeItem>
76+
);
77+
}
78+
79+
function SuggestionList( { entityForSuggestions, onSelect } ) {
80+
const composite = useCompositeState( { orientation: 'vertical' } );
81+
const [ suggestions, setSuggestions ] = useState( EMPTY_ARRAY );
82+
// We need to track two values, the search input's value(searchInputValue)
83+
// and the one we want to debounce(search) and make REST API requests.
84+
const [ searchInputValue, setSearchInputValue ] = useState( '' );
85+
const [ search, setSearch ] = useState( '' );
86+
const debouncedSearch = useDebounce( setSearch, 250 );
87+
const query = {
88+
...BASE_QUERY,
89+
search,
90+
orderby: search ? 'relevance' : 'modified',
91+
exclude: entityForSuggestions.postsToExclude,
92+
per_page: search ? 20 : 10,
93+
};
94+
const { records: searchResults, hasResolved: searchHasResolved } =
95+
useEntityRecords(
96+
entityForSuggestions.type,
97+
entityForSuggestions.slug,
98+
query
99+
);
100+
useEffect( () => {
101+
if ( search !== searchInputValue ) {
102+
debouncedSearch( searchInputValue );
103+
}
104+
}, [ search, searchInputValue ] );
105+
const entitiesInfo = useMemo( () => {
106+
if ( ! searchResults?.length ) return EMPTY_ARRAY;
107+
return mapToIHasNameAndId( searchResults, 'title.rendered' );
108+
}, [ searchResults ] );
109+
// Update suggestions only when the query has resolved.
110+
useEffect( () => {
111+
if ( ! searchHasResolved ) return;
112+
setSuggestions( entitiesInfo );
113+
}, [ entitiesInfo, searchHasResolved ] );
114+
return (
115+
<>
116+
<SearchControl
117+
onChange={ setSearchInputValue }
118+
value={ searchInputValue }
119+
label={ entityForSuggestions.labels.search_items }
120+
placeholder={ entityForSuggestions.labels.search_items }
121+
/>
122+
{ !! suggestions?.length && (
123+
<Composite
124+
{ ...composite }
125+
role="listbox"
126+
className="edit-site-custom-template-modal__suggestions_list"
127+
>
128+
{ suggestions.map( ( suggestion ) => (
129+
<SuggestionListItem
130+
key={ suggestion.slug }
131+
suggestion={ suggestion }
132+
search={ search }
133+
onSelect={ onSelect }
134+
entityForSuggestions={ entityForSuggestions }
135+
composite={ composite }
136+
/>
137+
) ) }
138+
</Composite>
139+
) }
140+
{ search && ! suggestions?.length && (
141+
<p className="edit-site-custom-template-modal__no-results">
142+
{ entityForSuggestions.labels.not_found }
143+
</p>
144+
) }
145+
</>
146+
);
147+
}
148+
149+
function AddCustomTemplateModal( { onClose, onSelect, entityForSuggestions } ) {
150+
const [ showSearchEntities, setShowSearchEntities ] = useState(
151+
entityForSuggestions.hasGeneralTemplate
152+
);
153+
const baseCssClass = 'edit-site-custom-template-modal';
154+
return (
155+
<Modal
156+
title={ sprintf(
157+
// translators: %s: Name of the post type e.g: "Post".
158+
__( 'Add template: %s' ),
159+
entityForSuggestions.labels.singular_name
160+
) }
161+
className={ baseCssClass }
162+
closeLabel={ __( 'Close' ) }
163+
onRequestClose={ onClose }
164+
>
165+
{ ! showSearchEntities && (
166+
<>
167+
<p>
168+
{ __(
169+
'Select whether to create a single template for all items or a specific one.'
170+
) }
171+
</p>
172+
<Flex
173+
className={ `${ baseCssClass }__contents` }
174+
gap="4"
175+
align="initial"
176+
>
177+
<FlexItem
178+
isBlock
179+
onClick={ () => {
180+
const { slug, title, description } =
181+
entityForSuggestions.template;
182+
onSelect( { slug, title, description } );
183+
} }
184+
>
185+
<Heading level={ 5 }>
186+
{ entityForSuggestions.labels.all_items }
187+
</Heading>
188+
<Text as="span">
189+
{
190+
// translators: The user is given the choice to set up a template for all items of a post type, or just a specific one.
191+
__( 'For all items' )
192+
}
193+
</Text>
194+
</FlexItem>
195+
<FlexItem
196+
isBlock
197+
onClick={ () => {
198+
setShowSearchEntities( true );
199+
} }
200+
>
201+
<Heading level={ 5 }>
202+
{ entityForSuggestions.labels.singular_name }
203+
</Heading>
204+
<Text as="span">
205+
{
206+
// translators: The user is given the choice to set up a template for all items of a post type, or just a specific one.
207+
__( 'For a specific item' )
208+
}
209+
</Text>
210+
</FlexItem>
211+
</Flex>
212+
</>
213+
) }
214+
{ showSearchEntities && (
215+
<>
216+
<p>
217+
{ __(
218+
'This template will be used only for the specific item chosen.'
219+
) }
220+
</p>
221+
<SuggestionList
222+
entityForSuggestions={ entityForSuggestions }
223+
onSelect={ onSelect }
224+
/>
225+
</>
226+
) }
227+
</Modal>
228+
);
229+
}
230+
231+
export default AddCustomTemplateModal;

0 commit comments

Comments
 (0)