@wordpress/block-library
Version:
Block library for the WordPress editor.
524 lines (496 loc) • 16.9 kB
JavaScript
/**
* WordPress dependencies
*/
import { useSelect } from '@wordpress/data';
import { useMemo } from '@wordpress/element';
import { store as coreStore } from '@wordpress/core-data';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { decodeEntities } from '@wordpress/html-entities';
import { __ } from '@wordpress/i18n';
import {
cloneBlock,
getBlockSupport,
store as blocksStore,
} from '@wordpress/blocks';
/** @typedef {import('@wordpress/blocks').WPBlockVariation} WPBlockVariation */
/** @typedef {import('@wordpress/components/build-types/query-controls/types').OrderByOption} OrderByOption */
/**
* @typedef IHasNameAndId
* @property {string|number} id The entity's id.
* @property {string} name The entity's name.
*/
/**
* The object used in Query block that contains info and helper mappings
* from an array of IHasNameAndId objects.
*
* @typedef {Object} QueryEntitiesInfo
* @property {IHasNameAndId[]} entities The array of entities.
* @property {Object<string, IHasNameAndId>} mapById Object mapping with the id as key and the entity as value.
* @property {Object<string, IHasNameAndId>} mapByName Object mapping with the name as key and the entity as value.
* @property {string[]} names Array with the entities' names.
*/
/**
* Returns a helper object with mapping from Objects that implement
* the `IHasNameAndId` interface. The returned object is used for
* integration with `FormTokenField` component.
*
* @param {IHasNameAndId[]} entities The entities to extract of helper object.
* @return {QueryEntitiesInfo} The object with the entities information.
*/
export const getEntitiesInfo = ( entities ) => {
const mapping = entities?.reduce(
( accumulator, entity ) => {
const { mapById, mapByName, names } = accumulator;
mapById[ entity.id ] = entity;
mapByName[ entity.name ] = entity;
names.push( entity.name );
return accumulator;
},
{ mapById: {}, mapByName: {}, names: [] }
);
return {
entities,
...mapping,
};
};
/**
* Helper util to return a value from a certain path of the object.
* Path is specified as a string of properties, separated by dots,
* for example: "parent.child".
*
* @param {Object} object Input object.
* @param {string} path Path to the object property.
* @return {*} Value of the object property at the specified path.
*/
export const getValueFromObjectPath = ( object, path ) => {
const normalizedPath = path.split( '.' );
let value = object;
normalizedPath.forEach( ( fieldName ) => {
value = value?.[ fieldName ];
} );
return value;
};
/**
* Helper util to map records to add a `name` prop from a
* provided path, in order to handle all entities in the same
* fashion(implementing`IHasNameAndId` interface).
*
* @param {Object[]} entities The array of entities.
* @param {string} path The path to map a `name` property from the entity.
* @return {IHasNameAndId[]} An array of entities that now implement the `IHasNameAndId` interface.
*/
export const mapToIHasNameAndId = ( entities, path ) => {
return ( entities || [] ).map( ( entity ) => ( {
...entity,
name: decodeEntities( getValueFromObjectPath( entity, path ) ),
} ) );
};
/**
* Returns a helper object that contains:
* 1. An `options` object from the available post types, to be passed to a `SelectControl`.
* 2. A helper map with available taxonomies per post type.
* 3. A helper map with post format support per post type.
*
* @return {Object} The helper object related to post types.
*/
export const usePostTypes = () => {
const postTypes = useSelect( ( select ) => {
const { getPostTypes } = select( coreStore );
const excludedPostTypes = [ 'attachment' ];
const filteredPostTypes = getPostTypes( { per_page: -1 } )?.filter(
( { viewable, slug } ) =>
viewable && ! excludedPostTypes.includes( slug )
);
return filteredPostTypes;
}, [] );
const postTypesTaxonomiesMap = useMemo( () => {
if ( ! postTypes?.length ) {
return;
}
return postTypes.reduce( ( accumulator, type ) => {
accumulator[ type.slug ] = type.taxonomies;
return accumulator;
}, {} );
}, [ postTypes ] );
const postTypesSelectOptions = useMemo(
() =>
( postTypes || [] ).map( ( { labels, slug } ) => ( {
label: labels.singular_name,
value: slug,
} ) ),
[ postTypes ]
);
const postTypeFormatSupportMap = useMemo( () => {
if ( ! postTypes?.length ) {
return {};
}
return postTypes.reduce( ( accumulator, type ) => {
accumulator[ type.slug ] =
type.supports?.[ 'post-formats' ] || false;
return accumulator;
}, {} );
}, [ postTypes ] );
return {
postTypesTaxonomiesMap,
postTypesSelectOptions,
postTypeFormatSupportMap,
};
};
/**
* Hook that returns the taxonomies associated with a specific post type.
*
* @param {string} postType The post type from which to retrieve the associated taxonomies.
* @return {Object[]} An array of the associated taxonomies.
*/
export const useTaxonomies = ( postType ) => {
const taxonomies = useSelect(
( select ) => {
const { getTaxonomies, getPostType } = select( coreStore );
// Does the post type have taxonomies?
if ( getPostType( postType )?.taxonomies?.length > 0 ) {
return getTaxonomies( {
type: postType,
per_page: -1,
} );
}
return [];
},
[ postType ]
);
return useMemo( () => {
return taxonomies?.filter(
( { visibility } ) => !! visibility?.publicly_queryable
);
}, [ taxonomies ] );
};
/**
* Hook that returns whether a specific post type is hierarchical.
*
* @param {string} postType The post type to check.
* @return {boolean} Whether a specific post type is hierarchical.
*/
export function useIsPostTypeHierarchical( postType ) {
return useSelect(
( select ) => {
const type = select( coreStore ).getPostType( postType );
return type?.viewable && type?.hierarchical;
},
[ postType ]
);
}
/**
* List of avaiable options to order by.
*
* @param {string} postType The post type to check.
* @return {OrderByOption[]} List of order options.
*/
export function useOrderByOptions( postType ) {
const supportsCustomOrder = useSelect(
( select ) => {
const type = select( coreStore ).getPostType( postType );
return !! type?.supports?.[ 'page-attributes' ];
},
[ postType ]
);
return useMemo( () => {
const orderByOptions = [
{
label: __( 'Newest to oldest' ),
value: 'date/desc',
},
{
label: __( 'Oldest to newest' ),
value: 'date/asc',
},
{
/* translators: Label for ordering posts by title in ascending order. */
label: __( 'A → Z' ),
value: 'title/asc',
},
{
/* translators: Label for ordering posts by title in descending order. */
label: __( 'Z → A' ),
value: 'title/desc',
},
];
if ( supportsCustomOrder ) {
orderByOptions.push(
{
/* translators: Label for ordering posts by ascending menu order. */
label: __( 'Ascending by order' ),
value: 'menu_order/asc',
},
{
/* translators: Label for ordering posts by descending menu order. */
label: __( 'Descending by order' ),
value: 'menu_order/desc',
}
);
}
return orderByOptions;
}, [ supportsCustomOrder ] );
}
/**
* Hook that returns the query properties' names defined by the active
* block variation, to determine which block's filters to show.
*
* @param {Object} attributes Block attributes.
* @return {string[]} An array of the query attributes.
*/
export function useAllowedControls( attributes ) {
return useSelect(
( select ) =>
select( blocksStore ).getActiveBlockVariation(
'core/query',
attributes
)?.allowedControls,
[ attributes ]
);
}
export function isControlAllowed( allowedControls, key ) {
// Every controls is allowed if the list is not defined.
if ( ! allowedControls ) {
return true;
}
return allowedControls.includes( key );
}
/**
* Clones a pattern's blocks and then recurses over that list of blocks,
* transforming them to retain some `query` attribute properties.
* For now we retain the `postType` and `inherit` properties as they are
* fundamental for the expected functionality of the block and don't affect
* its design and presentation.
*
* Returns the cloned/transformed blocks and array of existing Query Loop
* client ids for further manipulation, in order to avoid multiple recursions.
*
* @param {WPBlock[]} blocks The list of blocks to look through and transform(mutate).
* @param {Record<string,*>} queryBlockAttributes The existing Query Loop's attributes.
* @return {{ newBlocks: WPBlock[], queryClientIds: string[] }} An object with the cloned/transformed blocks and all the Query Loop clients from these blocks.
*/
export const getTransformedBlocksFromPattern = (
blocks,
queryBlockAttributes
) => {
const {
query: { postType, inherit },
namespace,
} = queryBlockAttributes;
const clonedBlocks = blocks.map( ( block ) => cloneBlock( block ) );
const queryClientIds = [];
const blocksQueue = [ ...clonedBlocks ];
while ( blocksQueue.length > 0 ) {
const block = blocksQueue.shift();
if ( block.name === 'core/query' ) {
block.attributes.query = {
...block.attributes.query,
postType,
inherit,
};
if ( namespace ) {
block.attributes.namespace = namespace;
}
queryClientIds.push( block.clientId );
}
block.innerBlocks?.forEach( ( innerBlock ) => {
blocksQueue.push( innerBlock );
} );
}
return { newBlocks: clonedBlocks, queryClientIds };
};
/**
* Helper hook that determines if there is an active variation of the block
* and if there are available specific patterns for this variation.
* If there are, these patterns are going to be the only ones suggested to
* the user in setup and replace flow, without including the default ones
* for Query Loop.
*
* If there are no such patterns, the default ones for Query Loop are going
* to be suggested.
*
* @param {string} clientId The block's client ID.
* @param {Object} attributes The block's attributes.
* @return {string} The block name to be used in the patterns suggestions.
*/
export function useBlockNameForPatterns( clientId, attributes ) {
return useSelect(
( select ) => {
const activeVariationName = select(
blocksStore
).getActiveBlockVariation( 'core/query', attributes )?.name;
if ( ! activeVariationName ) {
return 'core/query';
}
const { getBlockRootClientId, getPatternsByBlockTypes } =
select( blockEditorStore );
const rootClientId = getBlockRootClientId( clientId );
const activePatterns = getPatternsByBlockTypes(
`core/query/${ activeVariationName }`,
rootClientId
);
return activePatterns.length > 0
? `core/query/${ activeVariationName }`
: 'core/query';
},
[ clientId, attributes ]
);
}
/**
* Helper hook that determines if there is an active variation of the block
* and if there are available specific scoped `block` variations connected with
* this variation.
*
* If there are, these variations are going to be the only ones suggested
* to the user in setup flow when clicking to `start blank`, without including
* the default ones for Query Loop.
*
* If there are no such scoped `block` variations, the default ones for Query
* Loop are going to be suggested.
*
* The way we determine such variations is with the convention that they have the `namespace`
* attribute defined as an array. This array should contain the names(`name` property) of any
* variations they want to be connected to.
* For example, if we have a `Query Loop` scoped `inserter` variation with the name `products`,
* we can connect a scoped `block` variation by setting its `namespace` attribute to `['products']`.
* If the user selects this variation, the `namespace` attribute will be overridden by the
* main `inserter` variation.
*
* @param {Object} attributes The block's attributes.
* @return {WPBlockVariation[]} The block variations to be suggested in setup flow, when clicking to `start blank`.
*/
export function useScopedBlockVariations( attributes ) {
const { activeVariationName, blockVariations } = useSelect(
( select ) => {
const { getActiveBlockVariation, getBlockVariations } =
select( blocksStore );
return {
activeVariationName: getActiveBlockVariation(
'core/query',
attributes
)?.name,
blockVariations: getBlockVariations( 'core/query', 'block' ),
};
},
[ attributes ]
);
const variations = useMemo( () => {
// Filter out the variations that have defined a `namespace` attribute,
// which means they are 'connected' to specific variations of the block.
const isNotConnected = ( variation ) =>
! variation.attributes?.namespace;
if ( ! activeVariationName ) {
return blockVariations.filter( isNotConnected );
}
const connectedVariations = blockVariations.filter( ( variation ) =>
variation.attributes?.namespace?.includes( activeVariationName )
);
if ( !! connectedVariations.length ) {
return connectedVariations;
}
return blockVariations.filter( isNotConnected );
}, [ activeVariationName, blockVariations ] );
return variations;
}
/**
* Hook that returns the block patterns for a specific block type.
*
* @param {string} clientId The block's client ID.
* @param {string} name The block type name.
* @return {Object[]} An array of valid block patterns.
*/
export const usePatterns = ( clientId, name ) => {
return useSelect(
( select ) => {
const { getBlockRootClientId, getPatternsByBlockTypes } =
select( blockEditorStore );
const rootClientId = getBlockRootClientId( clientId );
return getPatternsByBlockTypes( name, rootClientId );
},
[ name, clientId ]
);
};
/**
* The object returned by useUnsupportedBlocks with info about the type of
* unsupported blocks present inside the Query block.
*
* @typedef {Object} UnsupportedBlocksInfo
* @property {boolean} hasBlocksFromPlugins True if blocks from plugins are present.
* @property {boolean} hasPostContentBlock True if a 'core/post-content' block is present.
* @property {boolean} hasUnsupportedBlocks True if there are any unsupported blocks.
*/
/**
* Hook that returns an object with information about the unsupported blocks
* present inside a Query Loop with the given `clientId`. The returned object
* contains props that are true when a certain type of unsupported block is
* present.
*
* @param {string} clientId The block's client ID.
* @return {UnsupportedBlocksInfo} The object containing the information.
*/
export const useUnsupportedBlocks = ( clientId ) => {
return useSelect(
( select ) => {
const { getClientIdsOfDescendants, getBlockName } =
select( blockEditorStore );
const blocks = {};
getClientIdsOfDescendants( clientId ).forEach(
( descendantClientId ) => {
const blockName = getBlockName( descendantClientId );
/*
* Client side navigation can be true in two states:
* - supports.interactivity = true;
* - supports.interactivity.clientNavigation = true;
*/
const blockSupportsInteractivity = Object.is(
getBlockSupport( blockName, 'interactivity' ),
true
);
const blockSupportsInteractivityClientNavigation =
getBlockSupport(
blockName,
'interactivity.clientNavigation'
);
const blockInteractivity =
blockSupportsInteractivity ||
blockSupportsInteractivityClientNavigation;
if ( ! blockInteractivity ) {
blocks.hasBlocksFromPlugins = true;
} else if ( blockName === 'core/post-content' ) {
blocks.hasPostContentBlock = true;
}
}
);
blocks.hasUnsupportedBlocks =
blocks.hasBlocksFromPlugins || blocks.hasPostContentBlock;
return blocks;
},
[ clientId ]
);
};
/**
* Helper function that returns the query context from the editor based on the
* available template slug.
*
* @param {string} templateSlug Current template slug based on context.
* @return {Object} An object with isSingular and templateType properties.
*/
export function getQueryContextFromTemplate( templateSlug ) {
// In the Post Editor, the template slug is not available.
if ( ! templateSlug ) {
return { isSingular: true };
}
let isSingular = false;
let templateType = templateSlug === 'wp' ? 'custom' : templateSlug;
const singularTemplates = [ '404', 'blank', 'single', 'page', 'custom' ];
const templateTypeFromSlug = templateSlug.includes( '-' )
? templateSlug.split( '-', 1 )[ 0 ]
: templateSlug;
const queryFromTemplateSlug = templateSlug.includes( '-' )
? templateSlug.split( '-' ).slice( 1 ).join( '-' )
: '';
if ( queryFromTemplateSlug ) {
templateType = templateTypeFromSlug;
}
isSingular = singularTemplates.includes( templateType );
return { isSingular, templateType };
}