UNPKG

@eightshift/frontend-libs

Version:

A collection of useful frontend utility modules. powered by Eightshift

797 lines (689 loc) 28.5 kB
import React from 'react'; import { __ } from '@wordpress/i18n'; import { InnerBlocks } from '@wordpress/block-editor'; import { registerBlockType, registerBlockVariation } from '@wordpress/blocks'; import { dispatch, select } from '@wordpress/data'; import { addFilter } from '@wordpress/hooks'; import { createElement } from '@wordpress/element'; import { getUnique } from './css-variables'; import { blockIcons } from '@eightshift/ui-components/icons'; import { STORE_NAME, setStoreGlobalWindow, setStore, setConfigFlags } from './store'; import { camelCase, kebabCase, lowerFirst, upperFirst } from '@eightshift/ui-components/utilities'; /** * Register all Block Editor blocks using WP `registerBlockType` method. * Due to restrictions in dynamic import using dynamic names all blocks are registered using `require.context`. * * @param {object} globalManifest - Must provide global blocks setting manifest.json. * @param {function?} [wrapperComponent] - Callback function that returns a `Wrapper`. * @param {object} wrapperManifest - `Wrapper` manifest. * @param {function} componentsManifestPath - **Must provide `require.context` for all components `manifest.json`s.** * @param {function} blocksManifestPath - **Must provide `require.context` for all blocks manifest.json-s.** * @param {function} blocksEditComponentPath - **Must provide `require.context` for all blocks JavaScript files (unable to add only block edit file due to dynamic naming).** * @param {function?} [hooksComponentPath] - Function of hooks JavaScript files in a block from `require.context`. * @param {function?} [transformsComponentPath] - Function of transforms JavaScript files in a block from `require.context`. * @param {function?} [deprecationsComponentPath] - Function of deprecations JavaScript files in a block from `require.context`. * @param {function?} [overridesComponentPath] - Function of overrides JavaScript files in a block from `require.context`. * * @access public * * @returns {mixed} * * Usage: * ```js * registerBlocks( * globalSettings, * Wrapper, * WrapperManifest, * require.context('./../../components', true, /manifest.json$/), * require.context('./../../custom', true, /manifest.json$/), * require.context('./../../custom', true, /-block.js$/), * require.context('./../../custom', true, /-hooks.js$/), * require.context('./../../custom', true, /-transforms.js$/), * require.context('./../../custom', true, /-deprecations.js$/), * require.context('./../../custom', true, /-overrides.js$/), * ); * ``` */ export const registerBlocks = (globalManifest = {}, wrapperComponent = null, wrapperManifest = {}, componentsManifestPath, blocksManifestPath, blocksEditComponentPath, hooksComponentPath = null, transformsComponentPath = null, deprecationsComponentPath = null, overridesComponentPath = null) => { const componentsManifest = componentsManifestPath.keys().map(componentsManifestPath); const blocksManifests = blocksManifestPath.keys().map(blocksManifestPath); // Set all store values. setStore(); dispatch(STORE_NAME).setSettings(globalManifest); dispatch(STORE_NAME).setBlocks(blocksManifests); dispatch(STORE_NAME).setComponents(componentsManifest); setConfigFlags(); if (select(STORE_NAME).getConfigUseWrapper()) { dispatch(STORE_NAME).setWrapper(wrapperManifest); } setStoreGlobalWindow(); // Iterate blocks to register. blocksManifests.forEach((blockManifestOriginal) => { const { active = true } = blockManifestOriginal; // If block has active key set to false the block will not show in the block editor. if (active) { let blockManifest = { ...blockManifestOriginal }; // Get Block edit component from block name and blocksEditComponentPath. const blockComponent = getBlockEditComponent(blockManifest.blockName, blocksEditComponentPath, 'block'); // Get Block Transforms component from block name and transformsComponentPath. if (transformsComponentPath !== null) { const blockTransformsComponent = getBlockGenericComponent(blockManifest.blockName, transformsComponentPath, 'transforms'); if (blockTransformsComponent !== null) { blockManifest = { ...blockManifest, transforms: blockTransformsComponent }; } } // Get Block Deprecations component from block name and deprecationsComponentPath. if (deprecationsComponentPath !== null) { const blockDeprecationsComponent = getBlockGenericComponent(blockManifest.blockName, deprecationsComponentPath, 'deprecations'); if (blockDeprecationsComponent !== null) { blockManifest = { ...blockManifest, deprecated: blockDeprecationsComponent }; } } // Get Block Hooks component from block name and hooksComponentPath. if (hooksComponentPath !== null) { const blockHooksComponent = getBlockGenericComponent(blockManifest.blockName, hooksComponentPath, 'hooks'); if (blockHooksComponent !== null) { blockHooksComponent(); } } // Get Block Overrides component from block name and overridesComponentPath. if (overridesComponentPath !== null) { const blockOverridesComponent = getBlockGenericComponent(blockManifest.blockName, overridesComponentPath, 'overrides'); if (blockOverridesComponent !== null) { blockManifest = { ...blockManifest, ...blockOverridesComponent }; } } // Pass data to registerBlock helper to get final output for registerBlockType. const blockDetails = registerBlock(globalManifest, wrapperManifest, componentsManifest, blockManifest, wrapperComponent, blockComponent); // Format the 'deprecated' attribute details to match the format Gutenberg wants. if (blockDetails?.options?.deprecated) { // Compute once and let both the attributes spread and the migrate closure capture it. const baseAttributes = getAttributes(globalManifest, wrapperManifest, componentsManifest, blockManifest); blockDetails.options.deprecated = blockDetails.options.deprecated.map((deprecation) => { if (deprecation?.attributes && deprecation?.migrate) { return { ...deprecation, isEligible: deprecation?.isEligible ?? (() => true), save: blockDetails.options.save, }; } return { attributes: { ...baseAttributes, ...deprecation.oldAttributes, }, migrate: (attributes) => { return { ...baseAttributes, ...attributes, ...deprecation.newAttributes(attributes), }; }, isEligible: deprecation?.isEligible ?? ((attributes) => Object.keys(deprecation.oldAttributes).every((v) => Object.keys(attributes).includes(v))), save: blockDetails.options.save, }; }); } // Native WP method for block registration. registerBlockType(blockDetails.blockName, blockDetails.options); } }); // Add icon foreground and background colors as CSS variables for later use. const { background: backgroundGlobal, foreground: foregroundGlobal } = globalManifest; document.documentElement.style.setProperty('--es-admin-block-icon-foreground', foregroundGlobal); document.documentElement.style.setProperty('--es-admin-block-icon-background', backgroundGlobal); // Set all data to the dom that is necessary for project. if (process.env.NODE_ENV !== 'test') { // Require set like this because some import issue with jest unit tests. const { blocksFilterHook } = require('./hooks'); addFilter('editor.BlockListBlock', `eightshift/${select(STORE_NAME).getSettingsNamespace()}`, blocksFilterHook); } }; /** * Register all Variations Editor blocks using WP `registerBlockVariation` method. * Due to restrictions in dynamic import using dynamic names all block are register using `require.context`. * * @param {object} globalManifest - **Must provide global blocks setting `manifest.json`.** * @param {function} variationsManifestPath - **Must provide require.context for all variations `manifest.json`s.** * @param {function} [blocksManifestPath] - **require.context for all blocks `manifest.json`s.** * @param {function?} [overridesComponentPath] - Function of overrides JavaScript files in a block from `require.context`. * * @access public * * @returns {null} * * Usage: * ```js * registerVariations( * globalSettings, * require.context('./../../variations', true, /manifest.json$/), * require.context('./../../custom', true, /manifest.json$/), * require.context('./../../variations', true, /-overrides.js$/), * ); * ``` */ export const registerVariations = (globalManifest = {}, variationsManifestPath, blocksManifestPath = null, overridesComponentPath = null) => { const variationsManifests = variationsManifestPath.keys().map(variationsManifestPath); // Set all store values. dispatch(STORE_NAME).setVariations(variationsManifests); // Iterate variations to register. variationsManifests.forEach((variationManifestOriginal) => { const { active = true } = variationManifestOriginal; // If variation has active key set to false the variation will not show in the block editor. if (active) { let variationManifest = { ...variationManifestOriginal }; // Get Block Overrides component from block name and overridesComponentPath. if (overridesComponentPath !== null) { const blockOverridesComponent = getBlockGenericComponent(variationManifest.name, overridesComponentPath, 'overrides'); if (blockOverridesComponent !== null) { variationManifest = { ...variationManifest, ...blockOverridesComponent }; } } // Pass data to registerVariation helper to get final output for registerBlockVariation. const blockDetails = registerVariation(globalManifest, variationManifest, blocksManifestPath !== null ? blocksManifestPath.keys().map(blocksManifestPath) : []); // Native WP method for block registration. registerBlockVariation(blockDetails.blockName, blockDetails.options); } }); }; //--------------------------------------------------------------- // Private methods const getBlockComponent = (blockName, paths, fileName, required) => { const component = paths .keys() .filter((filePath) => filePath === `./${blockName}/${blockName}-${fileName}.js`) .map(paths)[0]; if (typeof component === 'undefined') { if (!required) return null; throw Error(`It looks like you are missing block edit component for block: ${blockName}, please check if you have ${blockName}-block.js file in your block folder.`); } const callback = component[Object.keys(component)[0]]; if (required && typeof callback === 'undefined') { throw Error(`It looks like you are missing block edit component for block: ${blockName}, please check if you have ${blockName}-block.js file in your block folder.`); } return callback ?? null; }; /** * Filter array of JS paths and get the correct edit components. * * @param {string} blockName - Provided block name to find corresponding edit component. * @param {function} paths - Function of all JavaScript files in a block got from require.context. * @param {string} fileName - Block partial name. * * @access private * * @returns {function} * */ export const getBlockEditComponent = (blockName, paths, fileName) => getBlockComponent(blockName, paths, fileName, true); /** * Filter array of JS paths and get the correct transforms, hooks, etc components. * * @param {string} blockName - Provided block name to find corresponding edit component. * @param {function} paths - Function of all JavaScript files in a block got from require.context. * @param {string} fileName - Block partial name. * * @access private * * @returns {function} * */ export const getBlockGenericComponent = (blockName, paths, fileName) => getBlockComponent(blockName, paths, fileName, false); /** * Check if namespace is defined in block or in global manifest settings and return namespace. * * @param {object} globalManifest - Global manifest. * @param {object} blockManifest - Block manifest. * * @access private * * @returns {string?} */ export const getNamespace = (globalManifest, blockManifest) => { return typeof blockManifest.namespace === 'undefined' ? globalManifest.namespace : blockManifest.namespace; }; /** * Return full block name used in Block Editor with correct namespace. * * @param {object} globalManifest - Global manifest. * @param {object} blockManifest - Block manifest. * * @access private * * @returns {string} */ export const getFullBlockName = (globalManifest, blockManifest) => { return `${getNamespace(globalManifest, blockManifest)}/${blockManifest.blockName}`; }; /** * Return full block name used in Block Editor with correct namespace. * * @param {object} globalManifest - Global manifest. * @param {object} blockManifest - Block manifest. * * @access private * * @returns {string} */ export const getFullBlockNameVariation = (globalManifest, blockManifest) => { return `${getNamespace(globalManifest, blockManifest)}/${blockManifest.parentName}`; }; /** * Return save function based on hasInnerBlocks option of block. * * @param {object} blockManifest - Block manifest. * * @access private * * @returns {function} Save callback. */ export const getSaveCallback = (blockManifest) => { const { hasInnerBlocks } = blockManifest; if (hasInnerBlocks && typeof InnerBlocks !== 'undefined') { return () => createElement(InnerBlocks.Content); } return () => null; }; /** * Return merge function based on existence of `mergeableAttributes` option of block. * * @access private * * @param {object} blockManifest - Block manifest. */ export const getMergeCallback = (blockManifest) => { const { mergeableAttributes } = blockManifest; if (mergeableAttributes) { return (receiver, merger) => { let outputObject = {}; for (const { attribute: attributeName, mergeStrategy } of mergeableAttributes) { const attribute = Object.keys(receiver).find((k) => k === attributeName); switch (mergeStrategy) { case 'append': { outputObject[attribute] = `${receiver[attribute] ?? ''}${merger[attribute] ?? ''}`; break; } case 'useDestinationAttribute': { outputObject[attribute] = merger[attribute] ?? ''; break; } case 'addNumericIntValue': { outputObject[attribute] = parseInt(receiver[attribute] ?? '0') + parseInt(merger[attribute] ?? '0'); break; } case 'addNumericFloatValue': { outputObject[attribute] = parseFloat(receiver[attribute] ?? '0') + parseFloat(merger[attribute] ?? '0'); break; } case 'addNumericPixelValue': { const receiverRaw = receiver[attribute] ?? '0px'; const mergerRaw = merger[attribute] ?? '0px'; const receiverUnit = String(receiverRaw).replace(/[\d.-]/g, ''); const receiverValue = parseInt(receiverRaw, 10) || 0; const mergerValue = parseInt(mergerRaw, 10) || 0; outputObject[attribute] = `${receiverValue + mergerValue}${receiverUnit}`; break; } default: { // "useSourceAttribute" is default outputObject[attribute] = receiver[attribute] ?? ''; break; } } } return outputObject; }; } return () => null; }; /** * Return edit function wrapped with Wrapper component. * * @param {React.Component} Component - Component to render inside the wrapper. * @param {React.Component} Wrapper - Wrapper component. * * @access private * * @returns {React.Component} */ export const getEditCallback = (Component, Wrapper) => { // Config is set once during init via setConfigFlags() — read it now instead of on every render. const useWrapper = select(STORE_NAME).getConfigUseWrapper(); if (useWrapper) { return (props) => ( <Wrapper props={props}> <Component {...props} /> </Wrapper> ); } return (props) => <Component {...props} />; }; /** * Set icon object with icon, background and foreground. * * @param {object} globalManifest - Global manifest. * @param {object} blockManifest - Block manifest. * * @access private * * @returns {object} */ export const getIconOptions = (globalManifest, blockManifest) => { const { background: backgroundGlobal, foreground: foregroundGlobal } = globalManifest; const { icon } = blockManifest; if (typeof icon === 'undefined') { return {}; } // Use built-in icons if 'src' is provided and the // icon exists in the library if (icon.src !== undefined && blockIcons[icon.src] !== undefined) { return { background: typeof icon.background === 'undefined' ? backgroundGlobal : icon.background, foreground: typeof icon.foreground === 'undefined' ? foregroundGlobal : icon.foreground, src: <span dangerouslySetInnerHTML={{ __html: blockIcons[icon.src] }} />, }; } return { background: typeof icon.background === 'undefined' ? backgroundGlobal : icon.background, foreground: typeof icon.foreground === 'undefined' ? foregroundGlobal : icon.foreground, src: icon.src.includes('<svg') ? <span dangerouslySetInnerHTML={{ __html: icon.src }} /> : icon.src, }; }; /** * Iterate over attributes or example attributes object in block/component manifest and append the parent prefixes. * * @param {object} manifest - Object of component/block manifest to get data from. * @param {string} newName - New renamed component name. * @param {string} realName - Original real component name. * @param {boolean} [isExample=false] - Type of items to iterate, if false example key will be use, if true attributes will be used. * @param {string} [parent=''] - Parent component key with stacked parent component names for the final output. * @param {boolean} [currentAttributes=false] - Check if current attribute is a part of the current component. * * @access private * * @returns {object} */ export const prepareComponentAttribute = (manifest, newName, realName, isExample = false, parent = '', currentAttributes = false) => { const output = {}; // Define different data point for attributes or example. const componentAttributes = isExample ? manifest?.example?.attributes : manifest?.attributes; // It can occur that attributes or example key is missing in manifest so bailout. if (typeof componentAttributes === 'undefined') { return output; } // Prepare parent case. const newParent = camelCase(parent); // Loop-invariant — compute once if we'll need it. const realNameLowerCamel = currentAttributes ? lowerFirst(camelCase(realName)) : ''; // Iterate each attribute and attach parent prefixes. for (const [componentAttribute, value] of Object.entries(componentAttributes)) { let attribute = componentAttribute; // If there is a attribute name switch use the new one. if (newName !== realName) { attribute = componentAttribute.replace(realName, newName); } // Check if current attribute is used strip component prefix from attribute and replace it with parent prefix. if (currentAttributes) { attribute = componentAttribute.replace(realNameLowerCamel, ''); } // Wrapper attributes that should not be modified. const isWrapperAttribute = attribute.startsWith('wrapper') || attribute.startsWith('showWrapper'); // Determine if parent is empty and if parent name is the same as component/block name and skip wrapper attributes. const attributeName = isWrapperAttribute ? attribute : `${newParent}${upperFirst(attribute)}`; // Output new attribute names. output[attributeName] = value; } return output; }; /** * Iterate over component object in block manifest and check if the component exists in the project. * If components contains more component this function will run recursively. * * @param {object} componentsManifest - Object of components manifest to iterate. * @param {object} manifest - Object of component/block manifest to get the data from. * @param {boolean} [isExample=false] - Type of items to iterate, if true example key will be used, if false attributes will be used. * @param {string} [parent=''] - Parent component key with stacked parent component names for the final output. * * @access private * * @returns {object} */ export const prepareComponentAttributes = (componentsManifest, manifest, isExample = false, parent = '') => { const output = {}; const { components = {} } = manifest; // Determine if this is component or block and provide the name, not used for anything important but only to output the error msg. const name = manifest?.blockName ? manifest.blockName : manifest.componentName; const newParent = parent === '' ? name : parent; // Iterate over components key in manifest recursively and check component names. for (const [newComponentName, realComponentName] of Object.entries(components)) { // Hoist kebabCase out of the predicate and short-circuit with .find() instead of .filter()[0]. const realComponentKebab = kebabCase(realComponentName); const component = componentsManifest.find((item) => item.componentName === realComponentKebab); // Bailout if component doesn't exist. if (!component) { throw Error(`Component specified in "${name}" manifest doesn't exist in your components list. Please check if you project has "${realComponentName}" component.`); } let outputAttributes = {}; // If component has more components do recursive loop. if (component?.components) { outputAttributes = prepareComponentAttributes(componentsManifest, component, isExample, `${newParent}${upperFirst(camelCase(newComponentName))}`); } else { // Output the component attributes if there is no nesting left, and append the parent prefixes. outputAttributes = prepareComponentAttribute(component, newComponentName, realComponentName, isExample, newParent); } // Populate the output recursively. Object.assign(output, outputAttributes); } // Add the current block/component attributes to the output. Object.assign(output, prepareComponentAttribute(manifest, '', name, isExample, newParent, true)); return output; }; /** * Get Block attributes combined in one: "shared, global, wrapper, components, block". * * @param {object} globalManifest - Global manifest. * @param {object} wrapperManifest - `Wrapper` manifest. * @param {object} componentsManifest - Component manifest to iterate through. * @param {object} parentManifest - Block or component (parent) manifest. * * @returns {object} Object of all attributes registered for a specific block. * * @access private * * Usage: * ```js * getAttributes(globalManifest, wrapperManifest, componentManifests, manifest) * ``` */ export const getAttributes = (globalManifest, wrapperManifest, componentsManifest, parentManifest) => { const { blockName } = parentManifest; const { attributes: attributesGlobal, blockClassPrefix = 'block' } = globalManifest; const output = { blockName: { type: 'string', default: blockName, }, blockClientId: { type: 'string', }, blockTopLevelId: { // Used to pass reference to all components. type: 'string', default: getUnique(), }, blockFullName: { type: 'string', default: getFullBlockName(globalManifest, parentManifest), }, blockClass: { type: 'string', default: `${blockClassPrefix}-${blockName}`, }, blockJsClass: { type: 'string', default: `js-${blockClassPrefix}-${blockName}`, }, ...(typeof attributesGlobal === 'undefined' ? {} : attributesGlobal), ...(wrapperManifest?.attributes ?? {}), ...prepareComponentAttributes(componentsManifest, parentManifest), }; return output; }; /** * Get Block example attributes combined in one: "components and block". * * @param {object} manifest - Block/component manifest. * @param {string} [parent=''] - Parent component key with stacked parent component names for the final output. * * @returns {object} * * @access private * * Manifest: * ```js * { * attributes: { * buttonUse: { * type: "string", * default: true * }, * buttonSize: { * type: "string", * default: "big" * }, * buttonContent: { * type: "string" * }, * } * } * ``` * * Usage: * ```js * getExample('button', manifest); * ``` * * Output: * ```js * { * "buttonUse": true, * "buttonSize": "big", * "buttonContent": "", * } * ``` */ export const getExample = (parent = '', manifest = {}) => { return prepareComponentAttributes(select(STORE_NAME).getComponents(), manifest, true, parent); }; /** * Map and prepare all options from block manifest.json file for usage in registerBlockVariation method. * * @param {object} globalManifest - Global manifest. * @param {object} variationManifest - Variation manifest. * @param {Array} blocksManifest - Blocks manifests. * * @access private * * @returns {object} */ export const registerVariation = (globalManifest = {}, variationManifest = {}) => { const parentBlockManifest = select(STORE_NAME).getBlock(variationManifest.parentName); // Append globalManifest data in to output. if (variationManifest['icon']) { variationManifest['icon'] = getIconOptions(globalManifest, variationManifest); } else { // There is no icon passed to variation, use it's parent icon instead variationManifest['icon'] = parentBlockManifest?.icon; } // Set full example list. if (typeof variationManifest['example'] === 'undefined') { variationManifest['example'] = {}; } // Set full examples list. variationManifest['example'].viewportWidth = 800; variationManifest['example'].attributes = { ...parentBlockManifest?.example?.attributes, ...variationManifest['attributes'], ...variationManifest['example']?.attributes, }; // This is a full block name used in Block Editor. const fullBlockName = getFullBlockNameVariation(globalManifest, variationManifest); return { blockName: fullBlockName, options: { ...variationManifest, blockName: fullBlockName, }, }; }; /** * Map and prepare all options from block manifest.json file for usage in registerBlockType method. * * @param {object} globalManifest - Global manifest. * @param {object} wrapperManifest - `Wrapper` manifest. * @param {object} componentsManifest - Manifest of all components in a single object. * @param {object} blockManifest - Block manifest. * @param {function} wrapperComponent - Callback function that returns a `Wrapper`. * @param {function} blockComponent - Edit callback function. * * @access private * * @returns {object} */ export const registerBlock = (globalManifest = {}, wrapperManifest = {}, componentsManifest = {}, blockManifest = {}, wrapperComponent, blockComponent) => { const icon = getIconOptions(globalManifest, blockManifest); const fullBlockName = getFullBlockName(globalManifest, blockManifest); const attributes = getAttributes(globalManifest, wrapperManifest, componentsManifest, blockManifest); const fullAttributes = { metadata: { type: 'object', }, ...attributes, }; const exampleAttributes = {}; for (const [key, value] of Object.entries(attributes)) { if (value?.default) { exampleAttributes[key] = value.default; } } const example = { ...(blockManifest['example'] ?? {}), viewportWidth: 800, attributes: { ...exampleAttributes, ...getExample('', blockManifest), }, }; // Closure-static values for the label callback — read once at register time. const blockTitle = blockManifest?.title; const labelFallback = blockTitle ?? fullBlockName; return { blockName: fullBlockName, options: { ...blockManifest, apiVersion: 3, icon, attributes: fullAttributes, example, blockName: fullBlockName, edit: getEditCallback(blockComponent, wrapperComponent), save: getSaveCallback(blockManifest), merge: getMergeCallback(blockManifest), // WP 6.4+ Block renaming support __experimentalBlockRenaming: true, __experimentalLabel: (attributes, { context }) => { const customName = attributes?.metadata?.name ?? labelFallback; if (context === 'visual' && customName !== blockTitle) { return `${customName} (${blockTitle})`; } if (context === 'accessibility') { const { content } = attributes; return !content || content?.length === 0 ? __('Empty', 'eightshift-frontend-libs') : content; } return customName; }, __experimentalMetadata: true, ...(globalManifest?.blockOptions ?? {}), }, }; };