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 index 06ee2c70dedda0..964c1f9f5099d2 100644 --- 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 @@ -26,34 +26,6 @@ import { mapToIHasNameAndId } from './utils'; const EMPTY_ARRAY = []; -function selectSuggestion( suggestion, onSelect, entityForSuggestions ) { - const { - labels, - slug, - config: { templateSlug, templatePrefix }, - } = entityForSuggestions; - 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 or taxonomy and %2$s is the name of the post or term, e.g. "Post: Hello, WordPress", "Category: shoes" - __( '%1$s: %2$s' ), - labels.singular_name, - suggestion.name - ); - let newTemplateSlug = `${ templateSlug || slug }-${ suggestion.slug }`; - if ( templatePrefix ) { - newTemplateSlug = templatePrefix + newTemplateSlug; - } - const newTemplate = { - 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: newTemplateSlug, - }; - onSelect( newTemplate ); -} - function SuggestionListItem( { suggestion, search, @@ -70,7 +42,11 @@ function SuggestionListItem( { { ...composite } className={ baseCssClass } onClick={ () => - selectSuggestion( suggestion, onSelect, entityForSuggestions ) + onSelect( + entityForSuggestions.config.getSpecificTemplate( + suggestion + ) + ) } > @@ -98,18 +74,16 @@ function useDebouncedInput() { } function useSearchSuggestions( entityForSuggestions, search ) { - const { config, postsToExclude } = entityForSuggestions; + const { config } = entityForSuggestions; const query = useMemo( () => ( { order: 'asc', - _fields: 'id,name,title,slug,link', context: 'view', search, - orderBy: config.getOrderBy( { search } ), - exclude: postsToExclude, per_page: search ? 20 : 10, + ...config.queryArgs( search ), } ), - [ search, config, postsToExclude ] + [ search, config ] ); const { records: searchResults, hasResolved: searchHasResolved } = useEntityRecords( 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 cdc907f27115c8..0529a93b5bd194 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 @@ -36,13 +36,8 @@ import AddCustomTemplateModal from './add-custom-template-modal'; import { useExistingTemplates, useDefaultTemplateTypes, - entitiesConfig, - usePostTypes, - usePostTypePage, - useTaxonomies, - useTaxonomyCategory, - useTaxonomyTag, - useExtraTemplates, + useTaxonomiesMenuItems, + usePostTypeMenuItems, } from './utils'; import AddCustomGenericTemplateModal from './add-custom-generic-template-modal'; import { useHistory } from '../routes'; @@ -225,19 +220,11 @@ function useMissingTemplates( setEntityForSuggestions, setShowCustomTemplateModal ) { - const postTypes = usePostTypes(); - const pagePostType = usePostTypePage(); - const taxonomies = useTaxonomies(); - const categoryTaxonomy = useTaxonomyCategory(); - const tagTaxonomy = useTaxonomyTag(); - const existingTemplates = useExistingTemplates(); const defaultTemplateTypes = useDefaultTemplateTypes(); - const existingTemplateSlugs = ( existingTemplates || [] ).map( ( { slug } ) => slug ); - const missingDefaultTemplates = ( defaultTemplateTypes || [] ).filter( ( template ) => DEFAULT_TEMPLATE_SLUGS.includes( template.slug ) && @@ -247,49 +234,39 @@ function useMissingTemplates( setShowCustomTemplateModal( true ); setEntityForSuggestions( _entityForSuggestions ); }; - // TODO: find better names for these variables. `useExtraTemplates` returns an array of items. - const categoryMenuItem = useExtraTemplates( - categoryTaxonomy, - entitiesConfig.category, - onClickMenuItem - ); - const tagMenuItem = useExtraTemplates( - tagTaxonomy, - entitiesConfig.tag, - onClickMenuItem - ); - const pageMenuItem = useExtraTemplates( - pagePostType, - entitiesConfig.page, - onClickMenuItem - ); // We need to replace existing default template types with // the create specific template functionality. The original // info (title, description, etc.) is preserved in the - // `useExtraTemplates` hook. + // used hooks. const enhancedMissingDefaultTemplateTypes = [ ...missingDefaultTemplates ]; - [ categoryMenuItem, tagMenuItem, pageMenuItem ].forEach( ( menuItem ) => { - if ( ! menuItem?.length ) { - return; - } - const matchIndex = enhancedMissingDefaultTemplateTypes.findIndex( - ( template ) => template.slug === menuItem[ 0 ].slug - ); - // Some default template types might have been filtered above from - // `missingDefaultTemplates` because they only check for the general - // template. So here we either replace or append the item, augmented - // with the check if it has available specific item to create a - // template for. - if ( matchIndex > -1 ) { - enhancedMissingDefaultTemplateTypes.splice( - matchIndex, - 1, - menuItem[ 0 ] + const { defaultTaxonomiesMenuItems, taxonomiesMenuItems } = + useTaxonomiesMenuItems( onClickMenuItem ); + const { defaultPostTypesMenuItems, postTypesMenuItems } = + usePostTypeMenuItems( onClickMenuItem ); + [ ...defaultTaxonomiesMenuItems, ...defaultPostTypesMenuItems ].forEach( + ( menuItem ) => { + if ( ! menuItem ) { + return; + } + const matchIndex = enhancedMissingDefaultTemplateTypes.findIndex( + ( template ) => template.slug === menuItem.slug ); - } else { - enhancedMissingDefaultTemplateTypes.push( menuItem[ 0 ] ); + // Some default template types might have been filtered above from + // `missingDefaultTemplates` because they only check for the general + // template. So here we either replace or append the item, augmented + // with the check if it has available specific item to create a + // template for. + if ( matchIndex > -1 ) { + enhancedMissingDefaultTemplateTypes.splice( + matchIndex, + 1, + menuItem + ); + } else { + enhancedMissingDefaultTemplateTypes.push( menuItem ); + } } - } ); + ); // Update the sort order to match the DEFAULT_TEMPLATE_SLUGS order. enhancedMissingDefaultTemplateTypes?.sort( ( template1, template2 ) => { return ( @@ -297,20 +274,10 @@ function useMissingTemplates( DEFAULT_TEMPLATE_SLUGS.indexOf( template2.slug ) ); } ); - const extraPostTypeTemplates = useExtraTemplates( - postTypes, - entitiesConfig.postType, - onClickMenuItem - ); - const extraTaxonomyTemplates = useExtraTemplates( - taxonomies, - entitiesConfig.taxonomy, - onClickMenuItem - ); const missingTemplates = [ ...enhancedMissingDefaultTemplateTypes, - ...extraPostTypeTemplates, - ...extraTaxonomyTemplates, + ...postTypesMenuItems, + ...taxonomiesMenuItems, ]; return missingTemplates; } diff --git a/packages/edit-site/src/components/add-new-template/utils.js b/packages/edit-site/src/components/add-new-template/utils.js index 555af52b9cfdd5..a52eceae0b38f2 100644 --- a/packages/edit-site/src/components/add-new-template/utils.js +++ b/packages/edit-site/src/components/add-new-template/utils.js @@ -42,79 +42,6 @@ export const mapToIHasNameAndId = ( entities, path ) => { * @property {number[]} existingEntitiesIds An array of the existing entities ids. */ -/** - * @typedef {Object} EntityConfig - * @property {string} entityName The entity's name. - * @property {Function} getOrderBy Getter for an entity's `orderBy` query parameter, given the object - * {search} as argument. - * @property {Function} getIcon Getter function for returning an entity's icon for the menu item. - * @property {Function} getTitle Getter function for returning an entity's title for the menu item. - * @property {Function} getDescription Getter function for returning an entity's description for the menu item. - * @property {string} recordNamePath The path to an entity's properties to use as a `name`. If not provided - * is assumed that `name` property exists. - * @property {string} templatePrefix The template prefix to create new templates and check against existing - * templates. For example custom post types need a `single-` prefix to all - * templates(`single-post-hello`), whereas `pages` don't (`page-hello`). - * @property {string} templateSlug If this property is provided, it is going to be used for the creation of - * new templates and the check against existing templates in the place - * of the actual entity's `slug`. An example is `Tag` templates where the - * the Tag's taxonomy slug is `post_tag`, but template hierarchy is based - * on `tag` alias. - */ - -const taxonomyBaseConfig = { - entityName: 'taxonomy', - getOrderBy: ( { search } ) => ( search ? 'name' : 'count' ), - getIcon: () => blockMeta, - getTitle: ( labels ) => - sprintf( - // translators: %s: Name of the taxonomy e.g: "Cagegory". - __( 'Single taxonomy: %s' ), - labels.singular_name - ), - getDescription: ( labels ) => - sprintf( - // translators: %s: Name of the taxonomy e.g: "Product Categories". - __( 'Displays a single taxonomy: %s.' ), - labels.singular_name - ), -}; -const postTypeBaseConfig = { - entityName: 'postType', - getOrderBy: ( { search } ) => ( search ? 'relevance' : 'modified' ), - recordNamePath: 'title.rendered', - // `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. - getIcon: ( _icon ) => - _icon?.startsWith( 'dashicons-' ) ? _icon.slice( 10 ) : post, - getTitle: ( labels ) => - sprintf( - // translators: %s: Name of the post type e.g: "Post". - __( 'Single item: %s' ), - labels.singular_name - ), - getDescription: ( labels ) => - sprintf( - // translators: %s: Name of the post type e.g: "Post". - __( 'Displays a single item: %s.' ), - labels.singular_name - ), -}; -export const entitiesConfig = { - postType: { - ...postTypeBaseConfig, - templatePrefix: 'single-', - }, - page: { ...postTypeBaseConfig }, - taxonomy: { - ...taxonomyBaseConfig, - templatePrefix: 'taxonomy-', - }, - category: { ...taxonomyBaseConfig }, - tag: { ...taxonomyBaseConfig, templateSlug: 'tag' }, -}; - export const useExistingTemplates = () => { return useSelect( ( select ) => @@ -147,22 +74,6 @@ const usePublicPostTypes = () => { }, [ postTypes ] ); }; -// `page` post type is a special case in the template hierarchy, -// so we exclude it from the list of post types and we handle it -// separately. -export const usePostTypes = () => { - const postTypes = usePublicPostTypes(); - return useMemo( () => { - return postTypes?.filter( ( { slug } ) => slug !== 'page' ); - }, [ postTypes ] ); -}; -export const usePostTypePage = () => { - const postTypes = usePublicPostTypes(); - return useMemo( () => { - return postTypes?.filter( ( { slug } ) => slug === 'page' ); - }, [ postTypes ] ); -}; - const usePublicTaxonomies = () => { const taxonomies = useSelect( ( select ) => select( coreStore ).getTaxonomies( { per_page: -1 } ), @@ -175,184 +86,296 @@ const usePublicTaxonomies = () => { }, [ taxonomies ] ); }; -/** - * `category` and `post_tag` are handled specifically in template - * hierarchy so we need to differentiate them and return the rest, - * e.g. `category-$slug` and `taxonomy-$taxonomy-$term`. - */ -export const useTaxonomies = () => { - const taxonomies = usePublicTaxonomies(); - const specialTaxonomies = [ 'category', 'post_tag' ]; - return useMemo( +export const usePostTypeMenuItems = ( onClickMenuItem ) => { + const publicPostTypes = usePublicPostTypes(); + const existingTemplates = useExistingTemplates(); + const defaultTemplateTypes = useDefaultTemplateTypes(); + // `page`is a special case in template hierarchy. + const templatePrefixes = useMemo( () => - taxonomies?.filter( - ( { slug } ) => ! specialTaxonomies.includes( slug ) - ), - [ taxonomies ] + publicPostTypes?.reduce( ( accumulator, { slug } ) => { + let suffix = slug; + if ( slug !== 'page' ) { + suffix = `single-${ suffix }`; + } + accumulator[ slug ] = suffix; + return accumulator; + }, {} ), + [ publicPostTypes ] ); -}; - -export const useTaxonomyCategory = () => { - const taxonomies = usePublicTaxonomies(); - return useMemo( - () => taxonomies?.filter( ( { slug } ) => slug === 'category' ), - [ taxonomies ] + // We need to keep track of naming conflicts. If a conflict + // occurs, we need to add slug. + const postTypeLabels = publicPostTypes?.reduce( + ( accumulator, { labels } ) => { + const singularName = labels.singular_name.toLowerCase(); + accumulator[ singularName ] = + ( accumulator[ singularName ] || 0 ) + 1; + return accumulator; + }, + {} ); -}; -export const useTaxonomyTag = () => { - const taxonomies = usePublicTaxonomies(); - return useMemo( - () => taxonomies?.filter( ( { slug } ) => slug === 'post_tag' ), - [ taxonomies ] + const needsUniqueIdentifier = ( labels, slug ) => { + const singularName = labels.singular_name.toLowerCase(); + return postTypeLabels[ singularName ] > 1 && singularName !== slug; + }; + const postTypesInfo = useEntitiesInfo( 'postType', templatePrefixes ); + const existingTemplateSlugs = ( existingTemplates || [] ).map( + ( { slug } ) => slug ); -}; - -/** - * Helper hook that returns information about an entity having - * records that we can create a specific template for. - * - * For example we can search for `terms` in `taxonomy` entity or - * `posts` in `postType` entity. - * - * First we need to find the existing records with an associated template, - * to query afterwards for any remaing record, by excluding them. - * - * @param {string[]} existingTemplates The existing templates. - * @param {Object[]} entities The array of entities we need to get extra information. - * @param {EntityConfig} entityConfig The entity config. - * @return {Record} An object with the `entities.slug` as `keys` and EntitiesInfo as values. - */ -const useEntitiesInfo = ( - existingTemplates, - entities, - { entityName, templatePrefix, templateSlug } -) => { - const slugsToExcludePerEntity = useMemo( () => { - return entities?.reduce( ( accumulator, entity ) => { - let _prefix = `${ templateSlug || entity.slug }-`; - if ( templatePrefix ) { - _prefix = templatePrefix + _prefix; - } - const slugsWithTemplates = ( existingTemplates || [] ).reduce( - ( _accumulator, existingTemplate ) => { - if ( existingTemplate.slug.startsWith( _prefix ) ) { - _accumulator.push( - existingTemplate.slug.substring( _prefix.length ) - ); - } - return _accumulator; - }, - [] + const menuItems = ( publicPostTypes || [] ).reduce( + ( accumulator, postType ) => { + const { slug, labels, icon } = postType; + // We need to check if the general template is part of the + // defaultTemplateTypes. If it is, just use that info and + // augment it with the specific template functionality. + const generalTemplateSlug = templatePrefixes[ slug ]; + const defaultTemplateType = defaultTemplateTypes?.find( + ( { slug: _slug } ) => _slug === generalTemplateSlug ); - if ( slugsWithTemplates.length ) { - accumulator[ entity.slug ] = slugsWithTemplates; - } - return accumulator; - }, {} ); - }, [ entities, existingTemplates ] ); - const recordsToExcludePerEntity = useSelect( - ( select ) => { - if ( ! slugsToExcludePerEntity ) { - return; + const hasGeneralTemplate = + existingTemplateSlugs?.includes( generalTemplateSlug ); + const _needsUniqueIdentifier = needsUniqueIdentifier( + labels, + slug + ); + let menuItemTitle = sprintf( + // translators: %s: Name of the post type e.g: "Post". + __( 'Single item: %s' ), + labels.singular_name + ); + if ( _needsUniqueIdentifier ) { + menuItemTitle = sprintf( + // translators: %1s: Name of the post type e.g: "Post"; %2s: Slug of the post type e.g: "book". + __( 'Single item: %1$s (%2$s)' ), + labels.singular_name, + slug + ); } - return Object.entries( slugsToExcludePerEntity ).reduce( - ( accumulator, [ slug, slugsWithTemplates ] ) => { - const postsWithTemplates = select( - coreStore - ).getEntityRecords( entityName, slug, { - _fields: 'id', - context: 'view', - slug: slugsWithTemplates, + const menuItem = defaultTemplateType + ? { ...defaultTemplateType } + : { + slug: generalTemplateSlug, + title: menuItemTitle, + 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 ) + : post, + }; + const hasEntities = postTypesInfo?.[ slug ]?.hasEntities; + // We have a different template creation flow only if they have entities. + if ( hasEntities ) { + menuItem.onClick = ( template ) => { + onClickMenuItem( { + type: 'postType', + slug, + config: { + recordNamePath: 'title.rendered', + queryArgs: ( { search } ) => { + return { + _fields: 'id,title,slug,link', + orderBy: search ? 'relevance' : 'modified', + exclude: + postTypesInfo[ slug ] + .existingEntitiesIds, + }; + }, + getSpecificTemplate: ( suggestion ) => { + let 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. "Page: Hello". + __( '%1$s: %2$s' ), + labels.singular_name, + suggestion.name + ); + const description = sprintf( + // translators: Represents the description of a user's custom template in the Site Editor, e.g. "Template for Page: Hello" + __( 'Template for %1$s' ), + title + ); + if ( _needsUniqueIdentifier ) { + title = sprintf( + // translators: Represents the title of a user's custom template in the Site Editor, where %1$s is the template title and %2$s is the slug of the post type, e.g. "Project: Hello (project_type)" + __( '%1$s %2$s' ), + title, + `(${ slug })` + ); + } + return { + title, + description, + slug: `${ templatePrefixes[ slug ] }-${ suggestion.slug }`, + }; + }, + }, + labels, + hasGeneralTemplate, + template, } ); - if ( postsWithTemplates?.length ) { - accumulator[ slug ] = postsWithTemplates; + }; + } + // 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; + }, + [] + ); + // Split menu items into two groups: one for the default post types + // and one for the rest. + const postTypesMenuItems = useMemo( + () => + menuItems.reduce( + ( accumulator, postType ) => { + const { slug } = postType; + let key = 'postTypesMenuItems'; + if ( slug === 'page' ) { + key = 'defaultPostTypesMenuItems'; } + accumulator[ key ].push( postType ); return accumulator; }, - {} - ); - }, - [ slugsToExcludePerEntity ] - ); - const entitiesInfo = useSelect( - ( select ) => { - return entities?.reduce( ( accumulator, { slug } ) => { - const existingEntitiesIds = - recordsToExcludePerEntity?.[ slug ]?.map( - ( { id } ) => id - ) || []; - accumulator[ slug ] = { - hasEntities: !! select( coreStore ).getEntityRecords( - entityName, - slug, - { - per_page: 1, - _fields: 'id', - context: 'view', - exclude: existingEntitiesIds, - } - )?.length, - existingEntitiesIds, - }; - return accumulator; - }, {} ); - }, - [ entities, recordsToExcludePerEntity ] + { defaultPostTypesMenuItems: [], postTypesMenuItems: [] } + ), + [ menuItems ] ); - return entitiesInfo; + return postTypesMenuItems; }; -export const useExtraTemplates = ( - entities, - entityConfig, - onClickMenuItem -) => { +export const useTaxonomiesMenuItems = ( onClickMenuItem ) => { + const publicTaxonomies = usePublicTaxonomies(); const existingTemplates = useExistingTemplates(); const defaultTemplateTypes = useDefaultTemplateTypes(); - const entitiesInfo = useEntitiesInfo( - existingTemplates, - entities, - entityConfig + // `category` and `post_tag` are special cases in template hierarchy. + const templatePrefixes = useMemo( + () => + publicTaxonomies?.reduce( ( accumulator, { slug } ) => { + let suffix = slug; + if ( ! [ 'category', 'post_tag' ].includes( slug ) ) { + suffix = `taxonomy-${ suffix }`; + } + if ( slug === 'post_tag' ) { + suffix = `tag`; + } + accumulator[ slug ] = suffix; + return accumulator; + }, {} ), + [ publicTaxonomies ] ); + // We need to keep track of naming conflicts. If a conflict + // occurs, we need to add slug. + const taxonomyLabels = publicTaxonomies?.reduce( + ( accumulator, { labels } ) => { + const singularName = labels.singular_name.toLowerCase(); + accumulator[ singularName ] = + ( accumulator[ singularName ] || 0 ) + 1; + return accumulator; + }, + {} + ); + const needsUniqueIdentifier = ( labels, slug ) => { + const singularName = labels.singular_name.toLowerCase(); + return taxonomyLabels[ singularName ] > 1 && singularName !== slug; + }; + const taxonomiesInfo = useEntitiesInfo( 'taxonomy', templatePrefixes ); const existingTemplateSlugs = ( existingTemplates || [] ).map( ( { slug } ) => slug ); - const extraTemplates = ( entities || [] ).reduce( - ( accumulator, entity ) => { - const { slug, labels, icon } = entity; - const slugForGeneralTemplate = entityConfig.templateSlug || slug; + const menuItems = ( publicTaxonomies || [] ).reduce( + ( accumulator, taxonomy ) => { + const { slug, labels } = taxonomy; // We need to check if the general template is part of the // defaultTemplateTypes. If it is, just use that info and // augment it with the specific template functionality. + const generalTemplateSlug = templatePrefixes[ slug ]; const defaultTemplateType = defaultTemplateTypes?.find( - ( { slug: _slug } ) => _slug === slugForGeneralTemplate + ( { slug: _slug } ) => _slug === generalTemplateSlug ); - const generalTemplateSlug = - defaultTemplateType?.slug || - `${ entityConfig.templatePrefix }${ slug }`; const hasGeneralTemplate = existingTemplateSlugs?.includes( generalTemplateSlug ); + const _needsUniqueIdentifier = needsUniqueIdentifier( + labels, + slug + ); + let menuItemTitle = sprintf( + // translators: %1s: Name of the taxonomy e.g: "Category". + __( 'Taxonomy: %1$s' ), + labels.singular_name + ); + if ( _needsUniqueIdentifier ) { + menuItemTitle = sprintf( + // translators: %1s: Name of the taxonomy e.g: "Category"; %2s: Slug of the taxonomy e.g: "product_cat". + __( 'Taxonomy: %1$s (%2$s)' ), + labels.singular_name, + slug + ); + } const menuItem = defaultTemplateType ? { ...defaultTemplateType } : { slug: generalTemplateSlug, - title: entityConfig.getTitle( labels ), - description: entityConfig.getDescription( labels ), - icon: entityConfig.getIcon?.( icon ), + title: menuItemTitle, + description: sprintf( + // translators: %s: Name of the taxonomy e.g: "Product Categories". + __( 'Displays taxonomy: %s.' ), + labels.singular_name + ), + icon: blockMeta, }; - const hasEntities = entitiesInfo?.[ slug ]?.hasEntities; + const hasEntities = taxonomiesInfo?.[ slug ]?.hasEntities; // We have a different template creation flow only if they have entities. if ( hasEntities ) { menuItem.onClick = ( template ) => { onClickMenuItem( { - type: entityConfig.entityName, + type: 'taxonomy', slug, - config: entityConfig, + config: { + queryArgs: ( { search } ) => { + return { + _fields: 'id,name,slug,link', + orderBy: search ? 'name' : 'count', + exclude: + taxonomiesInfo[ slug ] + .existingEntitiesIds, + }; + }, + getSpecificTemplate: ( suggestion ) => { + let 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 taxonomy and %2$s is the name of the term, e.g. "Category: shoes". + __( '%1$s: %2$s' ), + labels.singular_name, + suggestion.name + ); + const description = sprintf( + // translators: Represents the description of a user's custom template in the Site Editor, e.g. "Template for Category: shoes" + __( 'Template for %1$s' ), + title + ); + if ( _needsUniqueIdentifier ) { + title = sprintf( + // translators: Represents the title of a user's custom template in the Site Editor, where %1$s is the template title and %2$s is the slug of the taxonomy, e.g. "Category: shoes (product_tag)" + __( '%1$s %2$s' ), + title, + `(${ slug })` + ); + } + return { + title, + description, + slug: `${ templatePrefixes[ slug ] }-${ suggestion.slug }`, + }; + }, + }, labels, hasGeneralTemplate, template, - postsToExclude: - entitiesInfo[ slug ].existingEntitiesIds, } ); }; } @@ -365,5 +388,152 @@ export const useExtraTemplates = ( }, [] ); - return extraTemplates; + // Split menu items into two groups: one for the default taxonomies + // and one for the rest. + const taxonomiesMenuItems = useMemo( + () => + menuItems.reduce( + ( accumulator, taxonomy ) => { + const { slug } = taxonomy; + let key = 'taxonomiesMenuItems'; + if ( [ 'category', 'tag' ].includes( slug ) ) { + key = 'defaultTaxonomiesMenuItems'; + } + accumulator[ key ].push( taxonomy ); + return accumulator; + }, + { defaultTaxonomiesMenuItems: [], taxonomiesMenuItems: [] } + ), + [ menuItems ] + ); + return taxonomiesMenuItems; +}; + +/** + * Helper hook that filters all the existing templates by the given + * object with the entity's slug as key and the template prefix as value. + * + * Example: + * `existingTemplates` is: [ { slug: tag-apple }, { slug: page-about }, { slug: tag } ] + * `templatePrefixes` is: { post_tag: 'tag' } + * It will return: { post_tag: [apple] } + * + * Note: We append the `-` to the given template prefix in this function for our checks. + * + * @param {Record} templatePrefixes An object with the entity's slug as key and the template prefix as value. + * @return {Record} An object with the entity's slug as key and an array with the existing template slugs as value. + */ +const useExistingTemplateSlugs = ( templatePrefixes ) => { + const existingTemplates = useExistingTemplates(); + const existingSlugs = useMemo( () => { + return Object.entries( templatePrefixes || {} ).reduce( + ( accumulator, [ slug, prefix ] ) => { + const slugsWithTemplates = ( existingTemplates || [] ).reduce( + ( _accumulator, existingTemplate ) => { + const _prefix = `${ prefix }-`; + if ( existingTemplate.slug.startsWith( _prefix ) ) { + _accumulator.push( + existingTemplate.slug.substring( + _prefix.length + ) + ); + } + return _accumulator; + }, + [] + ); + if ( slugsWithTemplates.length ) { + accumulator[ slug ] = slugsWithTemplates; + } + return accumulator; + }, + {} + ); + }, [ templatePrefixes, existingTemplates ] ); + return existingSlugs; +}; + +/** + * Helper hook that finds the existing records with an associated template, + * as they need to be excluded from the template suggestions. + * + * @param {string} entityName The entity's name. + * @param {Record} templatePrefixes An object with the entity's slug as key and the template prefix as value. + * @return {Record} An object with the entity's slug as key and the existing records as value. + */ +const useTemplatesToExclude = ( entityName, templatePrefixes ) => { + const slugsToExcludePerEntity = + useExistingTemplateSlugs( templatePrefixes ); + const recordsToExcludePerEntity = useSelect( + ( select ) => { + return Object.entries( slugsToExcludePerEntity || {} ).reduce( + ( accumulator, [ slug, slugsWithTemplates ] ) => { + const entitiesWithTemplates = select( + coreStore + ).getEntityRecords( entityName, slug, { + _fields: 'id', + context: 'view', + slug: slugsWithTemplates, + } ); + if ( entitiesWithTemplates?.length ) { + accumulator[ slug ] = entitiesWithTemplates; + } + return accumulator; + }, + {} + ); + }, + [ slugsToExcludePerEntity ] + ); + return recordsToExcludePerEntity; +}; + +/** + * Helper hook that returns information about an entity having + * records that we can create a specific template for. + * + * For example we can search for `terms` in `taxonomy` entity or + * `posts` in `postType` entity. + * + * First we need to find the existing records with an associated template, + * to query afterwards for any remaining record, by excluding them. + * + * @param {string} entityName The entity's name. + * @param {Record} templatePrefixes An object with the entity's slug as key and the template prefix as value. + * @return {Record} An object with the entity's slug as key and the EntitiesInfo as value. + */ +const useEntitiesInfo = ( entityName, templatePrefixes ) => { + const recordsToExcludePerEntity = useTemplatesToExclude( + entityName, + templatePrefixes + ); + const entitiesInfo = useSelect( + ( select ) => { + return Object.keys( templatePrefixes || {} ).reduce( + ( accumulator, slug ) => { + const existingEntitiesIds = + recordsToExcludePerEntity?.[ slug ]?.map( + ( { id } ) => id + ) || []; + accumulator[ slug ] = { + hasEntities: !! select( coreStore ).getEntityRecords( + entityName, + slug, + { + per_page: 1, + _fields: 'id', + context: 'view', + exclude: existingEntitiesIds, + } + )?.length, + existingEntitiesIds, + }; + return accumulator; + }, + {} + ); + }, + [ templatePrefixes, recordsToExcludePerEntity ] + ); + return entitiesInfo; };