diff --git a/packages/base-styles/_mixins.scss b/packages/base-styles/_mixins.scss
index 069f1b2f974285..78faa17f8dac2a 100644
--- a/packages/base-styles/_mixins.scss
+++ b/packages/base-styles/_mixins.scss
@@ -202,6 +202,11 @@
}
}
+@mixin mark-style() {
+ background: rgba(var(--wp-admin-theme-color--rgb), 0.1);
+ font-weight: 500;
+}
+
/**
* 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 00000000000000..b2db2e0f1bc510
--- /dev/null
+++ b/packages/edit-site/src/components/add-new-template/add-custom-template-modal.js
@@ -0,0 +1,253 @@
+/**
+ * WordPress dependencies
+ */
+import { useState, useMemo, useEffect } from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ Button,
+ Flex,
+ FlexItem,
+ Icon,
+ Modal,
+ SearchControl,
+ TextHighlight,
+ __experimentalText as Text,
+ __experimentalHeading as Heading,
+ __unstableComposite as Composite,
+ __unstableUseCompositeState as useCompositeState,
+ __unstableCompositeItem as CompositeItem,
+} from '@wordpress/components';
+import { pin, globe } from '@wordpress/icons';
+import { useSelect } from '@wordpress/data';
+import { useDebounce } from '@wordpress/compose';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import { useExistingEntitiesToExclude, mapToIHasNameAndId } from './utils';
+
+const EMPTY_ARRAY = [];
+const BASE_QUERY = {
+ order: 'asc',
+ _fields: 'id,title,slug',
+ context: 'view',
+};
+
+function SuggestionListItem( {
+ suggestion,
+ search,
+ onSelect,
+ entityForSuggestions,
+ composite,
+} ) {
+ return (
+ {
+ const title = `${ entityForSuggestions.labels.singular }: ${ suggestion.name }`;
+ onSelect( {
+ title,
+ description: `Template for ${ title }`,
+ slug: `single-${ entityForSuggestions.slug }-${ suggestion.slug }`,
+ } );
+ } }
+ >
+
+
+ );
+}
+
+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 );
+ // Get ids of existing entities to exclude from search results.
+ const [
+ entitiesToExclude,
+ entitiesToExcludeHasResolved,
+ ] = useExistingEntitiesToExclude( entityForSuggestions );
+ const { searchResults, searchHasResolved } = useSelect(
+ ( select ) => {
+ // Wait for the request of finding the excluded items first.
+ if ( ! entitiesToExcludeHasResolved ) {
+ return {
+ searchResults: EMPTY_ARRAY,
+ searchHasResolved: false,
+ };
+ }
+ const { getEntityRecords, hasFinishedResolution } = select(
+ coreStore
+ );
+ const selectorArgs = [
+ entityForSuggestions.type,
+ entityForSuggestions.slug,
+ {
+ ...BASE_QUERY,
+ search,
+ orderby: !! search ? 'relevance' : 'modified',
+ exclude: entitiesToExclude,
+ per_page: !! search ? 20 : 10,
+ },
+ ];
+ return {
+ searchResults: getEntityRecords( ...selectorArgs ),
+ searchHasResolved: hasFinishedResolution(
+ 'getEntityRecords',
+ selectorArgs
+ ),
+ };
+ },
+ [ search, entitiesToExclude, entitiesToExcludeHasResolved ]
+ );
+ 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 && (
+ // TODO: we should add a max-height with overflow here..
+ // also check about a min-height as results might cause layout shift.
+
+ { suggestions.map( ( suggestion ) => (
+
+ ) ) }
+
+ ) }
+ { !! search && ! suggestions?.length && (
+
+ { __( 'No results were found.' ) }
+
+ ) }
+ >
+ );
+}
+
+function AddCustomTemplateModal( { onClose, onSelect, entityForSuggestions } ) {
+ const [ showSearchEntities, setShowSearchEntities ] = useState(
+ entityForSuggestions.hasGeneralTemplate
+ );
+ const baseCssClass = 'edit-site-custom-template-modal';
+ return (
+
+ { ! showSearchEntities && (
+ <>
+
+ { __(
+ 'What type of template would you like to create?'
+ ) }
+
+
+
+ onSelect( {
+ slug: entityForSuggestions.template.slug,
+ title: entityForSuggestions.template.title,
+ description:
+ entityForSuggestions.template
+ .description,
+ } )
+ }
+ >
+
+ { __( 'General' ) }
+
+ { sprintf(
+ // translators: %s: Name of the post type in plural e.g: "Posts".
+ __( 'Design a template for a all %s.' ),
+ entityForSuggestions.labels.plural
+ ) }
+
+
+ {
+ setShowSearchEntities( true );
+ } }
+ >
+
+ { __( 'Specific' ) }
+
+ { sprintf(
+ // translators: %s: Name of the post type e.g: "Post".
+ __(
+ 'Design a template for a specific %s.'
+ ),
+ entityForSuggestions.labels.singular
+ ) }
+
+
+
+ >
+ ) }
+ { showSearchEntities && (
+ <>
+
+ { sprintf(
+ // translators: %s: Name of the post type e.g: "Post".
+ __(
+ 'Select the %s you would like to design a template for.'
+ ),
+ entityForSuggestions.labels.singular
+ ) }
+
+
+ >
+ ) }
+
+ );
+}
+
+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 1b18f989d011b7..747134d8428133 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,17 +31,21 @@ 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, usePostTypesHaveEntities } from './utils';
import { useHistory } from '../routes';
import { store as editSiteStore } from '../../store';
+// TODO: check why we need info from `__experimentalGetDefaultTemplateTypes` and here in js..
const DEFAULT_TEMPLATE_SLUGS = [
'front-page',
+ // TODO: Info about this need to be change from `post` to make it clear we are creating `single` template.
'single',
'page',
'index',
@@ -72,9 +77,15 @@ const TEMPLATE_ICONS = {
export default function NewTemplate( { postType } ) {
const history = useHistory();
- const { templates, defaultTemplateTypes } = useSelect(
+ const postTypes = usePostTypes();
+ const postTypesHaveEntities = usePostTypesHaveEntities();
+ 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 }
@@ -92,7 +103,6 @@ export default function NewTemplate( { postType } ) {
async function createTemplate( template ) {
try {
const { title, description, slug } = template;
-
const newTemplate = await saveEntityRecord(
'postType',
'wp_template',
@@ -130,8 +140,12 @@ export default function NewTemplate( { postType } ) {
}
}
- const existingTemplateSlugs = map( templates, 'slug' );
+ const existingTemplateSlugs = ( existingTemplates || [] ).map(
+ ( { slug } ) => slug
+ );
+ // TODO: rename to missingDefaultTemplates(or combine these arrays like`missingPostTypeTemplates`).
+ // Also it's weird that we don't have a single source of truth for the default templates. Needs looking..
const missingTemplates = filter(
defaultTemplateTypes,
( template ) =>
@@ -139,54 +153,159 @@ export default function NewTemplate( { postType } ) {
! includes( existingTemplateSlugs, template.slug )
);
- if ( ! missingTemplates.length ) {
+ // TODO: make all strings translatable.
+ const extraTemplates = ( postTypes || [] ).reduce(
+ ( accumulator, _postType ) => {
+ const {
+ slug,
+ labels: { singular_name: singularName },
+ menu_icon: icon,
+ name,
+ } = _postType;
+ const hasGeneralTemplate = existingTemplateSlugs?.includes(
+ `single-${ slug }`
+ );
+ const hasEntities = postTypesHaveEntities?.[ slug ];
+ const menuItem = {
+ slug: `single-${ slug }`,
+ title: sprintf(
+ // translators: %s: Name of the post type e.g: "Post".
+ __( 'Single %s' ),
+ singularName
+ ),
+ // title: `Single ${ singularName }`,
+ description: sprintf(
+ // translators: %s: Name of the post type e.g: "Post".
+ __( 'Displays a single %s.' ),
+ singularName
+ ),
+ icon,
+ };
+ // We have a different template creation flow only if they have entities.
+ if ( hasEntities ) {
+ menuItem.onClick = ( template ) => {
+ const slugsWithTemplates = (
+ existingTemplates || []
+ ).reduce( ( _accumulator, existingTemplate ) => {
+ const prefix = `single-${ slug }-`;
+ if ( existingTemplate.slug.startsWith( prefix ) ) {
+ _accumulator.push(
+ existingTemplate.slug.substring( prefix.length )
+ );
+ }
+ return _accumulator;
+ }, [] );
+ setShowCustomTemplateModal( true );
+ setEntityForSuggestions( {
+ type: 'postType',
+ slug,
+ labels: { singular: singularName, plural: name },
+ hasGeneralTemplate,
+ template,
+ slugsWithTemplates,
+ } );
+ };
+ }
+ // 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 );
+ }
+ // Add conditionally the `archive-$post_type` item.
+ // `post` is a special post type and doesn't have `archive-post`.
+ if (
+ slug !== 'post' &&
+ ! existingTemplateSlugs?.includes( `archive-${ slug }` )
+ ) {
+ accumulator.push( {
+ slug: `archive-${ slug }`,
+ title: sprintf(
+ // translators: %s: Name of the post type e.g: "Post".
+ __( '%s archive' ),
+ singularName
+ ),
+ description: sprintf(
+ // translators: %s: Name of the post type in plural e.g: "Posts".
+ __( 'Displays archive of %s.' ),
+ name
+ ),
+ icon,
+ } );
+ }
+ return accumulator;
+ },
+ []
+ );
+ // TODO: better handling here.
+ if ( ! missingTemplates.length && ! extraTemplates.length ) {
return null;
}
-
// Update the sort order to match the DEFAULT_TEMPLATE_SLUGS order.
- missingTemplates.sort( ( template1, template2 ) => {
+ // TODO: check sorting with new items.
+ 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 78527882b330cf..66bde3c369987a 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,68 @@
}
}
}
+
+.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);
+ }
+ }
+ }
+}
+
+
+.edit-site-custom-template-modal__suggestions_list {
+ margin-top: $grid-unit-20;
+
+ @include break-small() {
+ max-height: 256px;
+ overflow: scroll;
+ border: 1px solid $gray-400;
+ border-radius: $radius-block-ui;
+ padding: $grid-unit;
+ }
+
+ &__list-item {
+ display: block;
+ width: 100%;
+ text-align: left;
+
+ mark {
+ @include mark-style();
+ }
+ }
+}
+
+.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 00000000000000..50264a4f57a58d
--- /dev/null
+++ b/packages/edit-site/src/components/add-new-template/utils.js
@@ -0,0 +1,87 @@
+/**
+ * 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';
+
+export const usePostTypes = () => {
+ const postTypes = useSelect(
+ ( select ) => select( coreStore ).getPostTypes( { per_page: -1 } ),
+ []
+ );
+ const excludedPostTypes = [ 'attachment', 'page' ];
+ const filteredPostTypes = postTypes?.filter(
+ ( { viewable, slug } ) =>
+ viewable && ! excludedPostTypes.includes( slug )
+ );
+ return filteredPostTypes;
+};
+
+export const usePostTypesHaveEntities = () => {
+ const postTypes = usePostTypes();
+ const postTypesHaveEntities = useSelect(
+ ( select ) => {
+ return postTypes?.reduce( ( accumulator, { slug } ) => {
+ accumulator[ slug ] = !! select( coreStore ).getEntityRecords(
+ 'postType',
+ slug,
+ {
+ per_page: 1,
+ _fields: 'id',
+ context: 'view',
+ }
+ )?.length;
+ return accumulator;
+ }, {} );
+ },
+ // It's important to use `length` as a dependency because `usePostTypes`
+ // returns a new array every time and will triger a rerender.
+ // We can't avoid that because `post types` endpoint doesn't allow filtering
+ // with `viewable` prop right now.
+ [ postTypes?.length ]
+ );
+ return postTypesHaveEntities;
+};
+
+export const useExistingEntitiesToExclude = ( entityForSuggestions ) => {
+ const { slugsWithTemplates, type, slug } = entityForSuggestions;
+ const { results, hasResolved } = useSelect( ( select ) => {
+ if ( ! slugsWithTemplates.length ) {
+ return {
+ results: [],
+ hasResolved: true,
+ };
+ }
+ const { getEntityRecords, hasFinishedResolution } = select( coreStore );
+ const selectorArgs = [
+ type,
+ slug,
+ {
+ _fields: 'id',
+ slug: slugsWithTemplates,
+ context: 'view',
+ },
+ ];
+ return {
+ results: getEntityRecords( ...selectorArgs ),
+ hasResolved: hasFinishedResolution(
+ 'getEntityRecords',
+ selectorArgs
+ ),
+ };
+ }, [] );
+ return [ ( results || [] ).map( ( { id } ) => id ), hasResolved ];
+};
+
+export const mapToIHasNameAndId = ( entities, path ) => {
+ return ( entities || [] ).map( ( entity ) => ( {
+ ...entity,
+ name: decodeEntities( get( entity, path ) ),
+ } ) );
+};