initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -0,0 +1,85 @@
/**
* External dependencies
*/
import { defaultAddressFields } from '@woocommerce/settings';
import prepareAddressFields from '@woocommerce/base-components/cart-checkout/address-form/prepare-address-fields';
import { isEmail } from '@wordpress/url';
import type {
CartResponseBillingAddress,
CartResponseShippingAddress,
} from '@woocommerce/types';
/**
* pluckAddress takes a full address object and returns relevant fields for calculating
* shipping, so we can track when one of them change to update rates.
*
* @param {Object} address An object containing all address information
* @param {string} address.country The country.
* @param {string} address.state The state.
* @param {string} address.city The city.
* @param {string} address.postcode The postal code.
*
* @return {Object} pluckedAddress An object containing shipping address that are needed to fetch an address.
*/
export const pluckAddress = ( {
country = '',
state = '',
city = '',
postcode = '',
}: CartResponseBillingAddress | CartResponseShippingAddress ): {
country: string;
state: string;
city: string;
postcode: string;
} => ( {
country: country.trim(),
state: state.trim(),
city: city.trim(),
postcode: postcode ? postcode.replace( ' ', '' ).toUpperCase() : '',
} );
/**
* pluckEmail takes a full address object and returns only the email address, if set and valid. Otherwise returns an empty string.
*
* @param {Object} address An object containing all address information
* @param {string} address.email The email address.
* @return {string} The email address.
*/
export const pluckEmail = ( {
email = '',
}: CartResponseBillingAddress ): string =>
isEmail( email ) ? email.trim() : '';
/**
* Type-guard.
*/
const isValidAddressKey = (
key: string,
address: CartResponseBillingAddress | CartResponseShippingAddress
): key is keyof typeof address => {
return key in address;
};
/**
* Sets fields to an empty string in an address if they are hidden by the settings in countryLocale.
*
* @param {Object} address The address to empty fields from.
* @return {Object} The address with hidden fields values removed.
*/
export const emptyHiddenAddressFields = <
T extends CartResponseBillingAddress | CartResponseShippingAddress
>(
address: T
): T => {
const fields = Object.keys( defaultAddressFields );
const addressFields = prepareAddressFields( fields, {}, address.country );
const newAddress = Object.assign( {}, address ) as T;
addressFields.forEach( ( { key = '', hidden = false } ) => {
if ( hidden && isValidAddressKey( key, address ) ) {
newAddress[ key ] = '';
}
} );
return newAddress;
};

View File

@ -0,0 +1,20 @@
/**
* Internal dependencies
*/
import { fromEntriesPolyfill } from './from-entries-polyfill';
/**
* Get an array of selected shipping rates keyed by Package ID.
*
* @param {Array} shippingRates Array of shipping rates.
* @return {Object} Object containing the package IDs and selected rates in the format: { [packageId:string]: rateId:string }
*/
export const deriveSelectedShippingRates = ( shippingRates ) =>
fromEntriesPolyfill(
shippingRates.map(
( { package_id: packageId, shipping_rates: packageRates } ) => [
packageId,
packageRates.find( ( rate ) => rate.selected )?.rate_id,
]
)
);

View File

@ -0,0 +1,58 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Given a JS error or a fetch response error, parse and format it so it can be displayed to the user.
*
* @param {Object} error Error object.
* @param {Function} [error.json] If a json method is specified, it will try parsing the error first.
* @param {string} [error.message] If a message is specified, it will be shown to the user.
* @param {string} [error.type] The context in which the error was triggered.
* @return {Promise<{message:string;type:string;}>} Error object containing a message and type.
*/
export const formatError = async ( error ) => {
if ( typeof error.json === 'function' ) {
try {
const parsedError = await error.json();
return {
message: parsedError.message,
type: parsedError.type || 'api',
};
} catch ( e ) {
return {
message: e.message,
type: 'general',
};
}
}
return {
message: error.message,
type: error.type || 'general',
};
};
/**
* Given an API response object, formats the error message into something more human readable.
*
* @param {Object} response Response object.
* @return {string} Error message.
*/
export const formatStoreApiErrorMessage = ( response ) => {
if ( response.data && response.code === 'rest_invalid_param' ) {
const invalidParams = Object.values( response.data.params );
if ( invalidParams[ 0 ] ) {
return invalidParams[ 0 ];
}
}
return (
response?.message ||
__(
'Something went wrong. Please contact us to get assistance.',
'woocommerce'
)
);
};

View File

@ -0,0 +1,13 @@
/**
* A polyfill for Object.fromEntries function.
*
* @param {Array<[string, unknown]>} array Array to be turned back to object
* @return {Record< string, unknown >} the newly created object
*/
export const fromEntriesPolyfill = (
array: Array< [ string, unknown ] >
): Record< string, unknown > =>
array.reduce< Record< string, unknown > >( ( obj, [ key, val ] ) => {
obj[ key ] = val;
return obj;
}, {} );

View File

@ -0,0 +1,38 @@
/**
* Given some block attributes, gets attributes from the dataset or uses defaults.
*
* @param {Object} blockAttributes Object containing block attributes.
* @param {Array} rawAttributes Dataset from DOM.
* @return {Array} Array of parsed attributes.
*/
export const getValidBlockAttributes = ( blockAttributes, rawAttributes ) => {
const attributes = [];
Object.keys( blockAttributes ).forEach( ( key ) => {
if ( typeof rawAttributes[ key ] !== 'undefined' ) {
switch ( blockAttributes[ key ].type ) {
case 'boolean':
attributes[ key ] =
rawAttributes[ key ] !== 'false' &&
rawAttributes[ key ] !== false;
break;
case 'number':
attributes[ key ] = parseInt( rawAttributes[ key ], 10 );
break;
case 'array':
case 'object':
attributes[ key ] = JSON.parse( rawAttributes[ key ] );
break;
default:
attributes[ key ] = rawAttributes[ key ];
break;
}
} else {
attributes[ key ] = blockAttributes[ key ].default;
}
} );
return attributes;
};
export default getValidBlockAttributes;

View File

@ -0,0 +1,9 @@
export * from './errors';
export * from './address';
export * from './shipping-rates';
export * from './legacy-events';
export * from './render-frontend';
export * from './get-valid-block-attributes';
export * from './product-data';
export * from './derive-selected-shipping-rates';
export * from './from-entries-polyfill';

View File

@ -0,0 +1,131 @@
/**
* External dependencies
*/
import { isString } from '@woocommerce/types';
interface lazyLoadScriptParams {
handle: string;
src: string;
version?: string;
after?: string;
before?: string;
translations?: string;
}
interface appendScriptAttributesParam {
id: string;
innerHTML?: string;
onerror?: OnErrorEventHandlerNonNull;
onload?: () => void;
src?: string;
}
/**
* In WP, registered scripts are loaded into the page with an element like this:
* `<script src='...' id='[SCRIPT_ID]'></script>`
* This function checks whether an element matching that selector exists.
* Useful to know if a script has already been appended to the page.
*/
const isScriptTagInDOM = ( scriptId: string ): boolean => {
const scriptElements = document.querySelectorAll( `script#${ scriptId }` );
return scriptElements.length > 0;
};
/**
* Appends a script element to the document body if a script with the same id
* doesn't exist.
*/
const appendScript = ( attributes: appendScriptAttributesParam ): void => {
// Abort if id is not valid or a script with the same id exists.
if ( ! isString( attributes.id ) || isScriptTagInDOM( attributes.id ) ) {
return;
}
const scriptElement = document.createElement( 'script' );
for ( const attr in attributes ) {
// We could technically be iterating over inherited members here, so
// if this is the case we should skip it.
if ( ! attributes.hasOwnProperty( attr ) ) {
continue;
}
const key = attr as keyof appendScriptAttributesParam;
// Skip the keys that aren't strings, because TS can't be sure which
// key in the scriptElement object we're assigning to.
if ( key === 'onload' || key === 'onerror' ) {
continue;
}
// This assignment stops TS complaining about the value maybe being
// undefined following the isString check below.
const value = attributes[ key ];
if ( isString( value ) ) {
scriptElement[ key ] = value;
}
}
// Now that we've assigned all the strings, we can explicitly assign to the
// function keys.
if ( typeof attributes.onload === 'function' ) {
scriptElement.onload = attributes.onload;
}
if ( typeof attributes.onerror === 'function' ) {
scriptElement.onerror = attributes.onerror;
}
document.body.appendChild( scriptElement );
};
/**
* Appends a `<script>` tag to the document body based on the src and handle
* parameters. In addition, it appends additional script tags to load the code
* needed for translations and any before and after inline scripts. See these
* documentation pages for more information:
*
* https://developer.wordpress.org/reference/functions/wp_set_script_translations/
* https://developer.wordpress.org/reference/functions/wp_add_inline_script/
*/
const lazyLoadScript = ( {
handle,
src,
version,
after,
before,
translations,
}: lazyLoadScriptParams ): Promise< void > => {
return new Promise( ( resolve, reject ) => {
if ( isScriptTagInDOM( `${ handle }-js` ) ) {
resolve();
}
if ( translations ) {
appendScript( {
id: `${ handle }-js-translations`,
innerHTML: translations,
} );
}
if ( before ) {
appendScript( {
id: `${ handle }-js-before`,
innerHTML: before,
} );
}
const onload = () => {
if ( after ) {
appendScript( {
id: `${ handle }-js-after`,
innerHTML: after,
} );
}
resolve();
};
appendScript( {
id: `${ handle }-js`,
onerror: reject,
onload,
src: version ? `${ src }?ver=${ version }` : src,
} );
} );
};
export default lazyLoadScript;

View File

@ -0,0 +1,106 @@
/**
* External dependencies
*/
import type { AddToCartEventDetail } from '@woocommerce/type-defs/events';
const CustomEvent = window.CustomEvent || null;
interface DispatchedEventProperties {
// Whether the event bubbles.
bubbles?: boolean;
// Whether the event is cancelable.
cancelable?: boolean;
// See https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail
detail?: unknown;
// Element that dispatches the event. By default, the body.
element?: Element | null;
}
/**
* Wrapper function to dispatch an event.
*/
export const dispatchEvent = (
name: string,
{
bubbles = false,
cancelable = false,
element,
detail = {},
}: DispatchedEventProperties
): void => {
if ( ! CustomEvent ) {
return;
}
if ( ! element ) {
element = document.body;
}
const event = new CustomEvent( name, {
bubbles,
cancelable,
detail,
} );
element.dispatchEvent( event );
};
let fragmentRequestTimeoutId: ReturnType< typeof setTimeout >;
// This is a hack to trigger cart updates till we migrate to block based cart
// that relies on the store, see
// https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/1247
export const triggerFragmentRefresh = (): void => {
if ( fragmentRequestTimeoutId ) {
clearTimeout( fragmentRequestTimeoutId );
}
fragmentRequestTimeoutId = setTimeout( () => {
dispatchEvent( 'wc_fragment_refresh', {
bubbles: true,
cancelable: true,
} );
}, 50 );
};
export const triggerAddingToCartEvent = (): void => {
dispatchEvent( 'wc-blocks_adding_to_cart', {
bubbles: true,
cancelable: true,
} );
};
export const triggerAddedToCartEvent = ( {
preserveCartData = false,
}: AddToCartEventDetail ): void => {
dispatchEvent( 'wc-blocks_added_to_cart', {
bubbles: true,
cancelable: true,
detail: { preserveCartData },
} );
};
/**
* Function that listens to a jQuery event and dispatches a native JS event.
* Useful to convert WC Core events into events that can be read by blocks.
*
* Returns a function to remove the jQuery event handler. Ideally it should be
* used when the component is unmounted.
*/
export const translateJQueryEventToNative = (
// Name of the jQuery event to listen to.
jQueryEventName: string,
// Name of the native event to dispatch.
nativeEventName: string,
// Whether the event bubbles.
bubbles = false,
// Whether the event is cancelable.
cancelable = false
): ( () => void ) => {
if ( typeof jQuery !== 'function' ) {
return () => void null;
}
const eventDispatcher = () => {
dispatchEvent( nativeEventName, { bubbles, cancelable } );
};
jQuery( document ).on( jQueryEventName, eventDispatcher );
return () => jQuery( document ).off( jQueryEventName, eventDispatcher );
};

View File

@ -0,0 +1,30 @@
interface preloadScriptParams {
handle: string;
src: string;
version?: string;
}
/**
* Appends a `<link>` tag to the document head to preload a script based on the
* src and handle parameters.
*/
const preloadScript = ( {
handle,
src,
version,
}: preloadScriptParams ): void => {
const handleScriptElements = document.querySelectorAll(
`#${ handle }-js, #${ handle }-js-prefetch`
);
if ( handleScriptElements.length === 0 ) {
const prefetchLink = document.createElement( 'link' );
prefetchLink.href = version ? `${ src }?ver=${ version }` : src;
prefetchLink.rel = 'preload';
prefetchLink.as = 'script';
prefetchLink.id = `${ handle }-js-prefetch`;
document.head.appendChild( prefetchLink );
}
};
export default preloadScript;

View File

@ -0,0 +1,31 @@
/**
* Check a product object to see if it can be purchased.
*
* @param {Object} product Product object.
* @return {boolean} True if purchasable.
*/
export const productIsPurchasable = ( product ) => {
return product.is_purchasable || false;
};
/**
* Check if the product is supported by the blocks add to cart form.
*
* @param {Object} product Product object.
* @return {boolean} True if supported.
*/
export const productSupportsAddToCartForm = ( product ) => {
/**
* @todo Define supported product types for add to cart form.
*
* When introducing the form-element registration system, include a method of defining if a
* product type has support.
*
* If, as an example, we went with an inner block system for the add to cart form, we could allow
* a type to be registered along with it's default Block template. Registered types would then be
* picked up here, as well as the core types which would be defined elsewhere.
*/
const supportedTypes = [ 'simple', 'variable' ];
return supportedTypes.includes( product.type || 'simple' );
};

View File

@ -0,0 +1,174 @@
/**
* External dependencies
*/
import { render, Suspense } from '@wordpress/element';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
// Some blocks take care of rendering their inner blocks automatically. For
// example, the empty cart. In those cases, we don't want to trigger the render
// function of inner components on load. Instead, the wrapper block can trigger
// the event `wc-blocks_render_blocks_frontend` to render its inner blocks.
const selectorsToSkipOnLoad = [ '.wp-block-woocommerce-cart' ];
// Given an element and a list of wrappers, check if the element is inside at
// least one of the wrappers.
const isElementInsideWrappers = ( el, wrappers ) => {
return Array.prototype.some.call(
wrappers,
( wrapper ) => wrapper.contains( el ) && ! wrapper.isSameNode( el )
);
};
/**
* Renders a block component in each `containers` node.
*
* @param {Object} props Render props.
* @param {Function} props.Block React component to use as a
* replacement.
* @param {NodeList} props.containers Containers to replace with
* the Block component.
* @param {Function} [props.getProps] Function to generate the
* props object for the block.
* @param {Function} [props.getErrorBoundaryProps] Function to generate the
* props object for the error
* boundary.
*/
const renderBlockInContainers = ( {
Block,
containers,
getProps = () => ( {} ),
getErrorBoundaryProps = () => ( {} ),
} ) => {
if ( containers.length === 0 ) {
return;
}
// Use Array.forEach for IE11 compatibility.
Array.prototype.forEach.call( containers, ( el, i ) => {
const props = getProps( el, i );
const errorBoundaryProps = getErrorBoundaryProps( el, i );
const attributes = {
...el.dataset,
...( props.attributes || {} ),
};
el.classList.remove( 'is-loading' );
render(
<BlockErrorBoundary { ...errorBoundaryProps }>
<Suspense fallback={ <div className="wc-block-placeholder" /> }>
<Block { ...props } attributes={ attributes } />
</Suspense>
</BlockErrorBoundary>,
el
);
} );
};
/**
* Renders the block frontend in the elements matched by the selector which are
* outside the wrapper elements.
*
* @param {Object} props Render props.
* @param {Function} props.Block React component to use as a
* replacement.
* @param {string} props.selector CSS selector to match the
* elements to replace.
* @param {Function} [props.getProps] Function to generate the
* props object for the block.
* @param {Function} [props.getErrorBoundaryProps] Function to generate the
* props object for the error
* boundary.
* @param {NodeList} props.wrappers All elements matched by the
* selector which are inside
* the wrapper will be ignored.
*/
const renderBlockOutsideWrappers = ( {
Block,
getProps,
getErrorBoundaryProps,
selector,
wrappers,
} ) => {
const containers = document.body.querySelectorAll( selector );
// Filter out blocks inside the wrappers.
if ( wrappers.length > 0 ) {
Array.prototype.filter.call( containers, ( el ) => {
return ! isElementInsideWrappers( el, wrappers );
} );
}
renderBlockInContainers( {
Block,
containers,
getProps,
getErrorBoundaryProps,
} );
};
/**
* Renders the block frontend in the elements matched by the selector inside the
* wrapper element.
*
* @param {Object} props Render props.
* @param {Function} props.Block React component to use as a
* replacement.
* @param {string} props.selector CSS selector to match the
* elements to replace.
* @param {Function} [props.getProps] Function to generate the
* props object for the block.
* @param {Function} [props.getErrorBoundaryProps] Function to generate the
* props object for the error
* boundary.
* @param {Element} props.wrapper Wrapper element to query the
* selector inside.
*/
const renderBlockInsideWrapper = ( {
Block,
getProps,
getErrorBoundaryProps,
selector,
wrapper,
} ) => {
const containers = wrapper.querySelectorAll( selector );
renderBlockInContainers( {
Block,
containers,
getProps,
getErrorBoundaryProps,
} );
};
/**
* Renders the block frontend on page load. If the block is contained inside a
* wrapper element that should be excluded from initial load, it adds the
* appropriate event listeners to render the block when the
* `blocks_render_blocks_frontend` event is triggered.
*
* @param {Object} props Render props.
* @param {Function} props.Block React component to use as a
* replacement.
* @param {string} props.selector CSS selector to match the
* elements to replace.
* @param {Function} [props.getProps] Function to generate the
* props object for the block.
* @param {Function} [props.getErrorBoundaryProps] Function to generate the
* props object for the error
* boundary.
*/
export const renderFrontend = ( props ) => {
const wrappersToSkipOnLoad = document.body.querySelectorAll(
selectorsToSkipOnLoad.join( ',' )
);
renderBlockOutsideWrappers( {
...props,
wrappers: wrappersToSkipOnLoad,
} );
// For each wrapper, add an event listener to render the inner blocks when
// `wc-blocks_render_blocks_frontend` event is triggered.
Array.prototype.forEach.call( wrappersToSkipOnLoad, ( wrapper ) => {
wrapper.addEventListener( 'wc-blocks_render_blocks_frontend', () => {
renderBlockInsideWrapper( { ...props, wrapper } );
} );
} );
};
export default renderFrontend;

View File

@ -0,0 +1,19 @@
/**
* Get the number of packages in a shippingRates array.
*
* @param {Array} shippingRates Shipping rates and packages array.
*/
export const getShippingRatesPackageCount = ( shippingRates ) => {
return shippingRates.length;
};
/**
* Get the number of rates in a shippingRates array.
*
* @param {Array} shippingRates Shipping rates and packages array.
*/
export const getShippingRatesRateCount = ( shippingRates ) => {
return shippingRates.reduce( function ( count, shippingPackage ) {
return count + shippingPackage.shipping_rates.length;
}, 0 );
};

View File

@ -0,0 +1,24 @@
/**
* External dependencies
*/
import { emptyHiddenAddressFields } from '@woocommerce/base-utils';
describe( 'emptyHiddenAddressFields', () => {
it( "Removes state from an address where the country doesn't use states", () => {
const address = {
first_name: 'Jonny',
last_name: 'Awesome',
company: 'WordPress',
address_1: '123 Address Street',
address_2: 'Address 2',
city: 'Vienna',
postcode: '1120',
country: 'AT',
state: 'CA', // This should be removed.
email: 'jonny.awesome@email.com',
phone: '',
};
const filteredAddress = emptyHiddenAddressFields( address );
expect( filteredAddress ).toHaveProperty( 'state', '' );
} );
} );

View File

@ -0,0 +1,42 @@
/**
* Internal dependencies
*/
import { formatError } from '../errors';
describe( 'formatError', () => {
test( 'should format general errors', async () => {
const error = await formatError( {
message: 'Lorem Ipsum',
} );
const expectedError = {
message: 'Lorem Ipsum',
type: 'general',
};
expect( error ).toEqual( expectedError );
} );
test( 'should format API errors', async () => {
const error = await formatError( {
json: () => Promise.resolve( { message: 'Lorem Ipsum' } ),
} );
const expectedError = {
message: 'Lorem Ipsum',
type: 'api',
};
expect( error ).toEqual( expectedError );
} );
test( 'should format JSON parse errors', async () => {
const error = await formatError( {
json: () => Promise.reject( { message: 'Lorem Ipsum' } ),
} );
const expectedError = {
message: 'Lorem Ipsum',
type: 'general',
};
expect( error ).toEqual( expectedError );
} );
} );