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