rebase from live enviornment
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
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';
|
||||
import {
|
||||
defaultAddressFields,
|
||||
ShippingAddress,
|
||||
BillingAddress,
|
||||
} from '@woocommerce/settings';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import {
|
||||
SHIPPING_COUNTRIES,
|
||||
SHIPPING_STATES,
|
||||
} from '@woocommerce/block-settings';
|
||||
|
||||
/**
|
||||
* Compare two addresses and see if they are the same.
|
||||
*/
|
||||
export const isSameAddress = < T extends ShippingAddress | BillingAddress >(
|
||||
address1: T,
|
||||
address2: T
|
||||
): boolean => {
|
||||
return Object.keys( defaultAddressFields ).every(
|
||||
( field: string ) =>
|
||||
address1[ field as keyof T ] === address2[ field as keyof T ]
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/*
|
||||
* Formats a shipping address for display.
|
||||
*
|
||||
* @param {Object} address The address to format.
|
||||
* @return {string | null} The formatted address or null if no address is provided.
|
||||
*/
|
||||
export const formatShippingAddress = (
|
||||
address: ShippingAddress | BillingAddress
|
||||
): string | null => {
|
||||
// We bail early if we don't have an address.
|
||||
if ( Object.values( address ).length === 0 ) {
|
||||
return null;
|
||||
}
|
||||
const formattedCountry =
|
||||
typeof SHIPPING_COUNTRIES[ address.country ] === 'string'
|
||||
? decodeEntities( SHIPPING_COUNTRIES[ address.country ] )
|
||||
: '';
|
||||
|
||||
const formattedState =
|
||||
typeof SHIPPING_STATES[ address.country ] === 'object' &&
|
||||
typeof SHIPPING_STATES[ address.country ][ address.state ] === 'string'
|
||||
? decodeEntities(
|
||||
SHIPPING_STATES[ address.country ][ address.state ]
|
||||
)
|
||||
: address.state;
|
||||
|
||||
const addressParts = [];
|
||||
|
||||
addressParts.push( address.postcode.toUpperCase() );
|
||||
addressParts.push( address.city );
|
||||
addressParts.push( formattedState );
|
||||
addressParts.push( formattedCountry );
|
||||
|
||||
const formattedLocation = addressParts.filter( Boolean ).join( ', ' );
|
||||
|
||||
if ( ! formattedLocation ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formattedLocation;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the address has a city and country.
|
||||
*/
|
||||
export const isAddressComplete = (
|
||||
address: ShippingAddress | BillingAddress
|
||||
): boolean => {
|
||||
return !! address.city && !! address.country;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { camelCase } from 'change-case';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { mapKeys } from './map-keys';
|
||||
|
||||
export const camelCaseKeys = ( obj: object ) =>
|
||||
mapKeys( obj, ( _, key ) => camelCase( key ) );
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import type { Options as NoticeOptions } from '@wordpress/notices';
|
||||
import { select, dispatch } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { noticeContexts } from '../context/event-emit/utils';
|
||||
|
||||
export const DEFAULT_ERROR_MESSAGE = __(
|
||||
'Something went wrong. Please contact us to get assistance.',
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns a list of all notice contexts defined by Blocks.
|
||||
*
|
||||
* Contexts are defined in enum format, but this returns an array of strings instead.
|
||||
*/
|
||||
export const getNoticeContexts = () => {
|
||||
return Object.values( noticeContexts );
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for @wordpress/notices createNotice.
|
||||
*/
|
||||
export const createNotice = (
|
||||
status: 'error' | 'warning' | 'info' | 'success',
|
||||
message: string,
|
||||
options: Partial< NoticeOptions >
|
||||
) => {
|
||||
const noticeContext = options?.context;
|
||||
const suppressNotices =
|
||||
select( 'wc/store/payment' ).isExpressPaymentMethodActive();
|
||||
|
||||
if ( suppressNotices || noticeContext === undefined ) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch( 'core/notices' ).createNotice( status, message, {
|
||||
isDismissible: true,
|
||||
...options,
|
||||
context: noticeContext,
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove notices from all contexts.
|
||||
*
|
||||
* @todo Remove this when supported in Gutenberg.
|
||||
* @see https://github.com/WordPress/gutenberg/pull/44059
|
||||
*/
|
||||
export const removeAllNotices = () => {
|
||||
const containers = select(
|
||||
'wc/store/store-notices'
|
||||
).getRegisteredContainers();
|
||||
const { removeNotice } = dispatch( 'core/notices' );
|
||||
const { getNotices } = select( 'core/notices' );
|
||||
|
||||
containers.forEach( ( container ) => {
|
||||
getNotices( container ).forEach( ( notice ) => {
|
||||
removeNotice( notice.id, container );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
export const removeNoticesWithContext = ( context: string ) => {
|
||||
const { removeNotice } = dispatch( 'core/notices' );
|
||||
const { getNotices } = select( 'core/notices' );
|
||||
|
||||
getNotices( context ).forEach( ( notice ) => {
|
||||
removeNotice( notice.id, context );
|
||||
} );
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type DebouncedFunction< T extends ( ...args: any[] ) => any > = ( (
|
||||
...args: Parameters< T >
|
||||
) => void ) & { flush: () => void };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const debounce = < T extends ( ...args: any[] ) => any >(
|
||||
func: T,
|
||||
wait: number,
|
||||
immediate?: boolean
|
||||
): DebouncedFunction< T > => {
|
||||
let timeout: ReturnType< typeof setTimeout > | null;
|
||||
let latestArgs: Parameters< T > | null = null;
|
||||
|
||||
const debounced = ( ( ...args: Parameters< T > ) => {
|
||||
latestArgs = args;
|
||||
if ( timeout ) clearTimeout( timeout );
|
||||
timeout = setTimeout( () => {
|
||||
timeout = null;
|
||||
if ( ! immediate && latestArgs ) func( ...latestArgs );
|
||||
}, wait );
|
||||
if ( immediate && ! timeout ) func( ...args );
|
||||
} ) as DebouncedFunction< T >;
|
||||
|
||||
debounced.flush = () => {
|
||||
if ( timeout && latestArgs ) {
|
||||
func( ...latestArgs );
|
||||
clearTimeout( timeout );
|
||||
timeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { CartShippingRate } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* 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: CartShippingRate[]
|
||||
): Record< string, string > =>
|
||||
Object.fromEntries(
|
||||
shippingRates.map(
|
||||
( { package_id: packageId, shipping_rates: packageRates } ) => [
|
||||
packageId,
|
||||
packageRates.find( ( rate ) => rate.selected )?.rate_id || '',
|
||||
]
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface ErrorObject {
|
||||
/**
|
||||
* Human-readable error message to display.
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* Context in which the error was triggered. That will determine how the error is displayed to the user.
|
||||
*/
|
||||
type: 'api' | 'general' | string;
|
||||
}
|
||||
|
||||
type SimpleError = {
|
||||
message: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export const formatError = async (
|
||||
error: SimpleError | Response
|
||||
): Promise< ErrorObject > => {
|
||||
if ( 'json' in error ) {
|
||||
try {
|
||||
const parsedError = await error.json();
|
||||
return {
|
||||
message: parsedError.message,
|
||||
type: parsedError.type || 'api',
|
||||
};
|
||||
} catch ( e ) {
|
||||
return {
|
||||
// We could only return this if e is instanceof Error but, to avoid changing runtime
|
||||
// behaviour, we'll just cast it instead.
|
||||
message: ( e as Error ).message,
|
||||
type: 'general',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
message: error.message,
|
||||
type: error.type || 'general',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type {
|
||||
PaymentMethods,
|
||||
PaymentMethodIcons as PaymentMethodIconsType,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Get the provider icons from payment methods data.
|
||||
*
|
||||
* @param {PaymentMethods} paymentMethods Payment Method data
|
||||
* @return {PaymentMethodIconsType} Payment Method icons data.
|
||||
*/
|
||||
export const getIconsFromPaymentMethods = (
|
||||
paymentMethods: PaymentMethods
|
||||
): PaymentMethodIconsType => {
|
||||
return Object.values( paymentMethods ).reduce( ( acc, paymentMethod ) => {
|
||||
if ( paymentMethod.icons !== null ) {
|
||||
acc = acc.concat( paymentMethod.icons );
|
||||
}
|
||||
return acc;
|
||||
}, [] as PaymentMethodIconsType );
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { paramCase as kebabCase } from 'change-case';
|
||||
import { getCSSRules } from '@wordpress/style-engine';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
import type { Style as StyleEngineProperties } from '@wordpress/style-engine/src/types';
|
||||
|
||||
/**
|
||||
* Returns the inline styles to add depending on the style object
|
||||
*
|
||||
* @param {Object} styles Styles configuration.
|
||||
* @return {Object} Flattened CSS variables declaration.
|
||||
*/
|
||||
function getInlineStyles( styles = {} ) {
|
||||
const output = {} as Record< string, unknown >;
|
||||
|
||||
getCSSRules( styles, { selector: '' } ).forEach( ( rule ) => {
|
||||
output[ rule.key ] = rule.value;
|
||||
} );
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the classname for a given color.
|
||||
*/
|
||||
function getColorClassName(
|
||||
colorContextName: string | undefined,
|
||||
colorSlug: string | undefined
|
||||
): string {
|
||||
if ( ! colorContextName || ! colorSlug ) {
|
||||
return '';
|
||||
}
|
||||
return `has-${ kebabCase( colorSlug ) }-${ colorContextName }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a CSS class name consisting of all the applicable border color
|
||||
* classes given the current block attributes.
|
||||
*/
|
||||
function getBorderClassName( attributes: {
|
||||
style?: StyleEngineProperties;
|
||||
borderColor?: string;
|
||||
} ) {
|
||||
const { borderColor, style } = attributes;
|
||||
const borderColorClass = borderColor
|
||||
? getColorClassName( 'border-color', borderColor )
|
||||
: '';
|
||||
|
||||
return classnames( {
|
||||
'has-border-color': !! borderColor || !! style?.border?.color,
|
||||
[ borderColorClass ]: !! borderColorClass,
|
||||
} );
|
||||
}
|
||||
|
||||
function getGradientClassName( gradientSlug: string | undefined ) {
|
||||
if ( ! gradientSlug ) {
|
||||
return undefined;
|
||||
}
|
||||
return `has-${ gradientSlug }-gradient-background`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the CSS class names and inline styles for a block's color support
|
||||
* attributes.
|
||||
*/
|
||||
export function getColorClassesAndStyles( props: {
|
||||
style?: StyleEngineProperties;
|
||||
backgroundColor?: string | undefined;
|
||||
textColor?: string | undefined;
|
||||
gradient?: string | undefined;
|
||||
} ) {
|
||||
const { backgroundColor, textColor, gradient, style } = props;
|
||||
|
||||
// Collect color CSS classes.
|
||||
const backgroundClass = getColorClassName(
|
||||
'background-color',
|
||||
backgroundColor
|
||||
);
|
||||
const textClass = getColorClassName( 'color', textColor );
|
||||
|
||||
const gradientClass = getGradientClassName( gradient );
|
||||
const hasGradient = gradientClass || style?.color?.gradient;
|
||||
|
||||
// Determine color CSS class name list.
|
||||
const className = classnames( textClass, gradientClass, {
|
||||
// Don't apply the background class if there's a gradient.
|
||||
[ backgroundClass ]: ! hasGradient && !! backgroundClass,
|
||||
'has-text-color': textColor || style?.color?.text,
|
||||
'has-background':
|
||||
backgroundColor ||
|
||||
style?.color?.background ||
|
||||
gradient ||
|
||||
style?.color?.gradient,
|
||||
'has-link-color': isObject( style?.elements?.link )
|
||||
? style?.elements?.link?.color
|
||||
: undefined,
|
||||
} );
|
||||
|
||||
// Collect inline styles for colors.
|
||||
const colorStyles = style?.color || {};
|
||||
|
||||
return {
|
||||
className,
|
||||
style: getInlineStyles( { color: colorStyles } ),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the CSS class names and inline styles for a block's border support
|
||||
* attributes.
|
||||
*/
|
||||
export function getBorderClassesAndStyles( props: {
|
||||
style?: StyleEngineProperties;
|
||||
borderColor?: string;
|
||||
} ) {
|
||||
const border = props.style?.border || {};
|
||||
const className = getBorderClassName( props );
|
||||
|
||||
return {
|
||||
className,
|
||||
style: getInlineStyles( { border } ),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the CSS class names and inline styles for a block's spacing support
|
||||
* attributes.
|
||||
*/
|
||||
export function getSpacingClassesAndStyles( props: {
|
||||
style?: StyleEngineProperties;
|
||||
} ) {
|
||||
const spacingStyles = props.style?.spacing || {};
|
||||
const styleProp = getInlineStyles( { spacing: spacingStyles } );
|
||||
|
||||
return {
|
||||
className: undefined,
|
||||
style: styleProp,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Returns the navigation type for the page load.
|
||||
*/
|
||||
export const getNavigationType = () => {
|
||||
if (
|
||||
window.performance &&
|
||||
window.performance.getEntriesByType( 'navigation' ).length
|
||||
) {
|
||||
return (
|
||||
window.performance.getEntriesByType(
|
||||
'navigation'
|
||||
)[ 0 ] as PerformanceNavigationTiming
|
||||
).type;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export default getNavigationType;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,18 @@
|
||||
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 './get-icons-from-payment-methods';
|
||||
export * from './create-notice';
|
||||
export * from './get-navigation-type';
|
||||
export * from './map-keys';
|
||||
export * from './camel-case-keys';
|
||||
export * from './snake-case-keys';
|
||||
export * from './debounce';
|
||||
export * from './keyby';
|
||||
export * from './pick';
|
||||
export * from './get-inline-styles';
|
||||
@@ -0,0 +1,7 @@
|
||||
export const keyBy = < T >( array: T[], key: keyof T ) => {
|
||||
return array.reduce( ( acc, value ) => {
|
||||
const computedKey = key ? String( value[ key ] ) : String( value );
|
||||
acc[ computedKey ] = value;
|
||||
return acc;
|
||||
}, {} as Record< string, T > );
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wc: {
|
||||
wcBlocksRegistry: Record< string, unknown >;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, src = '' ): boolean => {
|
||||
// If the store is using a plugin to concatenate scripts, we might have some
|
||||
// cases where we don't detect whether a script has already been loaded.
|
||||
// Because of that, we add an extra protection to the wc-blocks-registry-js
|
||||
// script, to avoid ending up with two registries.
|
||||
if ( scriptId === 'wc-blocks-registry-js' ) {
|
||||
if ( typeof window?.wc?.wcBlocksRegistry === 'object' ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const srcParts = src.split( '?' );
|
||||
if ( srcParts?.length > 1 ) {
|
||||
src = srcParts[ 0 ];
|
||||
}
|
||||
const selector = src
|
||||
? `script#${ scriptId }, script[src*="${ src }"]`
|
||||
: `script#${ scriptId }`;
|
||||
const scriptElements = document.querySelectorAll( selector );
|
||||
|
||||
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, attributes?.src )
|
||||
) {
|
||||
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`, src ) ) {
|
||||
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;
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { AddToCartEventDetail } from '@woocommerce/types';
|
||||
|
||||
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 );
|
||||
};
|
||||
|
||||
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 );
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export const mapKeys = (
|
||||
obj: object,
|
||||
mapper: ( value: unknown, key: string ) => string
|
||||
) =>
|
||||
Object.entries( obj ).reduce(
|
||||
( acc, [ key, value ] ) => ( {
|
||||
...acc,
|
||||
[ mapper( value, key ) ]: value,
|
||||
} ),
|
||||
{}
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Creates an object composed of the picked object properties.
|
||||
*/
|
||||
export const pick = < Type >( object: Type, keys: string[] ): Type => {
|
||||
return keys.reduce( ( obj, key ) => {
|
||||
if ( object && object.hasOwnProperty( key ) ) {
|
||||
obj[ key as keyof Type ] = object[ key as keyof Type ];
|
||||
}
|
||||
return obj;
|
||||
}, {} as Type );
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
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 srcParts = src.split( '?' );
|
||||
if ( srcParts?.length > 1 ) {
|
||||
src = srcParts[ 0 ];
|
||||
}
|
||||
const selector = `#${ handle }-js, #${ handle }-js-prefetch, script[src*="${ src }"]`;
|
||||
const handleScriptElements = document.querySelectorAll( selector );
|
||||
|
||||
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;
|
||||
@@ -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' );
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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' ];
|
||||
|
||||
type BlockProps<
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttribute extends Record< string, unknown >
|
||||
> = TProps & {
|
||||
attributes?: TAttribute;
|
||||
};
|
||||
|
||||
type BlockType<
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttribute extends Record< string, unknown >
|
||||
> = ( props: BlockProps< TProps, TAttribute > ) => JSX.Element | null;
|
||||
|
||||
export type GetPropsFn<
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
> = ( el: HTMLElement, i: number ) => BlockProps< TProps, TAttributes >;
|
||||
|
||||
interface RenderBlockParams<
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
> {
|
||||
// React component to use as a replacement.
|
||||
Block: BlockType< TProps, TAttributes > | null;
|
||||
// Container to replace with the Block component.
|
||||
container: HTMLElement;
|
||||
// Attributes object for the block.
|
||||
attributes: TAttributes;
|
||||
// Props object for the block.
|
||||
props: BlockProps< TProps, TAttributes >;
|
||||
// Props object for the error boundary.
|
||||
errorBoundaryProps?: Record< string, unknown >;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a block component in a single `container` node.
|
||||
*/
|
||||
export const renderBlock = <
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
>( {
|
||||
Block,
|
||||
container,
|
||||
attributes = {} as TAttributes,
|
||||
props = {} as BlockProps< TProps, TAttributes >,
|
||||
errorBoundaryProps = {},
|
||||
}: RenderBlockParams< TProps, TAttributes > ): void => {
|
||||
render(
|
||||
<BlockErrorBoundary { ...errorBoundaryProps }>
|
||||
<Suspense fallback={ <div className="wc-block-placeholder" /> }>
|
||||
{ Block && <Block { ...props } attributes={ attributes } /> }
|
||||
</Suspense>
|
||||
</BlockErrorBoundary>,
|
||||
container,
|
||||
() => {
|
||||
if ( container.classList ) {
|
||||
container.classList.remove( 'is-loading' );
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
interface RenderBlockInContainersParams<
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
> {
|
||||
// React component to use as a replacement.
|
||||
Block: BlockType< TProps, TAttributes > | null;
|
||||
// Containers to replace with the Block component.
|
||||
containers: NodeListOf< Element >;
|
||||
// Function to generate the props object for the block.
|
||||
getProps?: GetPropsFn< TProps, TAttributes >;
|
||||
// Function to generate the props object for the error boundary.
|
||||
getErrorBoundaryProps?: (
|
||||
el: HTMLElement,
|
||||
i: number
|
||||
) => Record< string, unknown >;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a block component in each `containers` node.
|
||||
*/
|
||||
const renderBlockInContainers = <
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
>( {
|
||||
Block,
|
||||
containers,
|
||||
getProps = () => ( {} as BlockProps< TProps, TAttributes > ),
|
||||
getErrorBoundaryProps = () => ( {} ),
|
||||
}: RenderBlockInContainersParams< TProps, TAttributes > ): void => {
|
||||
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 || {} ),
|
||||
};
|
||||
|
||||
renderBlock( {
|
||||
Block,
|
||||
container: el,
|
||||
props,
|
||||
attributes,
|
||||
errorBoundaryProps,
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
// Given an element and a list of wrappers, check if the element is inside at
|
||||
// least one of the wrappers.
|
||||
const isElementInsideWrappers = (
|
||||
el: Element,
|
||||
wrappers: NodeListOf< Element >
|
||||
): boolean => {
|
||||
return Array.prototype.some.call(
|
||||
wrappers,
|
||||
( wrapper ) => wrapper.contains( el ) && ! wrapper.isSameNode( el )
|
||||
);
|
||||
};
|
||||
|
||||
interface RenderBlockOutsideWrappersParams<
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
> extends RenderFrontendParams< TProps, TAttributes > {
|
||||
// All elements matched by the selector which are inside the wrapper will be ignored.
|
||||
wrappers?: NodeListOf< Element >;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the block frontend in the elements matched by the selector which are
|
||||
* outside the wrapper elements.
|
||||
*/
|
||||
const renderBlockOutsideWrappers = <
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
>( {
|
||||
Block,
|
||||
getProps,
|
||||
getErrorBoundaryProps,
|
||||
selector,
|
||||
wrappers,
|
||||
}: RenderBlockOutsideWrappersParams< TProps, TAttributes > ): void => {
|
||||
const containers = document.body.querySelectorAll( selector );
|
||||
// Filter out blocks inside the wrappers.
|
||||
if ( wrappers && wrappers.length > 0 ) {
|
||||
Array.prototype.filter.call( containers, ( el ) => {
|
||||
return ! isElementInsideWrappers( el, wrappers );
|
||||
} );
|
||||
}
|
||||
renderBlockInContainers( {
|
||||
Block,
|
||||
containers,
|
||||
getProps,
|
||||
getErrorBoundaryProps,
|
||||
} );
|
||||
};
|
||||
|
||||
interface RenderBlockInsideWrapperParams<
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
> extends RenderFrontendParams< TProps, TAttributes > {
|
||||
// Wrapper element to query the selector inside.
|
||||
wrapper: HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the block frontend in the elements matched by the selector inside the
|
||||
* wrapper element.
|
||||
*/
|
||||
const renderBlockInsideWrapper = <
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
>( {
|
||||
Block,
|
||||
getProps,
|
||||
getErrorBoundaryProps,
|
||||
selector,
|
||||
wrapper,
|
||||
}: RenderBlockInsideWrapperParams< TProps, TAttributes > ): void => {
|
||||
const containers = wrapper.querySelectorAll( selector );
|
||||
renderBlockInContainers( {
|
||||
Block,
|
||||
containers,
|
||||
getProps,
|
||||
getErrorBoundaryProps,
|
||||
} );
|
||||
};
|
||||
|
||||
interface RenderFrontendParams<
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
> {
|
||||
// React component to use as a replacement.
|
||||
Block: BlockType< TProps, TAttributes > | null;
|
||||
// CSS selector to match the elements to replace.
|
||||
selector: string;
|
||||
// Function to generate the props object for the block.
|
||||
getProps?: GetPropsFn< TProps, TAttributes >;
|
||||
// Function to generate the props object for the error boundary.
|
||||
getErrorBoundaryProps?: (
|
||||
el: HTMLElement,
|
||||
i: number
|
||||
) => Record< string, unknown >;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `wc-blocks_render_blocks_frontend` event is triggered.
|
||||
*/
|
||||
export const renderFrontend = <
|
||||
TProps extends Record< string, unknown >,
|
||||
TAttributes extends Record< string, unknown >
|
||||
>(
|
||||
props:
|
||||
| RenderBlockOutsideWrappersParams< TProps, TAttributes >
|
||||
| RenderBlockInsideWrapperParams< TProps, TAttributes >
|
||||
): void => {
|
||||
const wrappersToSkipOnLoad = document.body.querySelectorAll(
|
||||
selectorsToSkipOnLoad.join( ',' )
|
||||
);
|
||||
|
||||
const { Block, getProps, getErrorBoundaryProps, selector } = props;
|
||||
|
||||
renderBlockOutsideWrappers( {
|
||||
Block,
|
||||
getProps,
|
||||
getErrorBoundaryProps,
|
||||
selector,
|
||||
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;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
CartShippingPackageShippingRate,
|
||||
CartShippingRate,
|
||||
} from '@woocommerce/type-defs/cart';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import { LOCAL_PICKUP_ENABLED } from '@woocommerce/block-settings';
|
||||
|
||||
/**
|
||||
* Get the number of packages in a shippingRates array.
|
||||
*
|
||||
* @param {Array} shippingRates Shipping rates and packages array.
|
||||
*/
|
||||
export const getShippingRatesPackageCount = (
|
||||
shippingRates: CartShippingRate[]
|
||||
) => {
|
||||
return shippingRates.length;
|
||||
};
|
||||
|
||||
const collectableMethodIds = getSetting< string[] >(
|
||||
'collectableMethodIds',
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* If the package rate's method_id is in the collectableMethodIds array, return true.
|
||||
*/
|
||||
export const isPackageRateCollectable = (
|
||||
rate: CartShippingPackageShippingRate
|
||||
): boolean => collectableMethodIds.includes( rate.method_id );
|
||||
|
||||
/**
|
||||
* Check if the specified rates are collectable. Accepts either an array of rate names, or a single string.
|
||||
*/
|
||||
export const hasCollectableRate = (
|
||||
chosenRates: string[] | string
|
||||
): boolean => {
|
||||
if ( ! LOCAL_PICKUP_ENABLED ) {
|
||||
return false;
|
||||
}
|
||||
if ( Array.isArray( chosenRates ) ) {
|
||||
return !! chosenRates.find( ( rate ) =>
|
||||
collectableMethodIds.includes( rate )
|
||||
);
|
||||
}
|
||||
return collectableMethodIds.includes( chosenRates );
|
||||
};
|
||||
/**
|
||||
* Get the number of rates in a shippingRates array.
|
||||
*
|
||||
* @param {Array} shippingRates Shipping rates and packages array.
|
||||
*/
|
||||
export const getShippingRatesRateCount = (
|
||||
shippingRates: CartShippingRate[]
|
||||
) => {
|
||||
return shippingRates.reduce( function ( count, shippingPackage ) {
|
||||
return count + shippingPackage.shipping_rates.length;
|
||||
}, 0 );
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { snakeCase } from 'change-case';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { mapKeys } from './map-keys';
|
||||
|
||||
export const snakeCaseKeys = ( obj: object ) =>
|
||||
mapKeys( obj, ( _, key ) => snakeCase( key ) );
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
emptyHiddenAddressFields,
|
||||
isAddressComplete,
|
||||
formatShippingAddress,
|
||||
} 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', '' );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'isAddressComplete', () => {
|
||||
it( 'correctly checks empty addresses', () => {
|
||||
const address = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
state: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
};
|
||||
expect( isAddressComplete( address ) ).toBe( false );
|
||||
} );
|
||||
|
||||
it( 'correctly checks incomplete addresses', () => {
|
||||
const address = {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
company: 'Company',
|
||||
address_1: '409 Main Street',
|
||||
address_2: 'Apt 1',
|
||||
city: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
state: '',
|
||||
email: 'john.doe@company',
|
||||
phone: '+1234567890',
|
||||
};
|
||||
expect( isAddressComplete( address ) ).toBe( false );
|
||||
|
||||
address.city = 'London';
|
||||
expect( isAddressComplete( address ) ).toBe( false );
|
||||
|
||||
address.postcode = 'W1T 4JG';
|
||||
address.country = 'GB';
|
||||
expect( isAddressComplete( address ) ).toBe( true );
|
||||
} );
|
||||
|
||||
it( 'correctly checks complete addresses', () => {
|
||||
const address = {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
company: 'Company',
|
||||
address_1: '409 Main Street',
|
||||
address_2: 'Apt 1',
|
||||
city: 'London',
|
||||
postcode: 'W1T 4JG',
|
||||
country: 'GB',
|
||||
state: '',
|
||||
email: 'john.doe@company',
|
||||
phone: '+1234567890',
|
||||
};
|
||||
expect( isAddressComplete( address ) ).toBe( true );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'formatShippingAddress', () => {
|
||||
it( 'returns null if address is empty', () => {
|
||||
const address = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
company: '',
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
state: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
};
|
||||
expect( formatShippingAddress( address ) ).toBe( null );
|
||||
} );
|
||||
|
||||
it( 'correctly returns the formatted address', () => {
|
||||
const address = {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
company: 'Company',
|
||||
address_1: '409 Main Street',
|
||||
address_2: 'Apt 1',
|
||||
city: 'London',
|
||||
postcode: 'W1T 4JG',
|
||||
country: 'GB',
|
||||
state: '',
|
||||
email: 'john.doe@company',
|
||||
phone: '+1234567890',
|
||||
};
|
||||
expect( formatShippingAddress( address ) ).toBe(
|
||||
'W1T 4JG, London, United Kingdom (UK)'
|
||||
);
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { formatError } from '../errors';
|
||||
|
||||
describe( 'formatError', () => {
|
||||
const mockResponseBody = JSON.stringify( { message: 'Lorem Ipsum' } );
|
||||
const mockMalformedJson = '{ "message": "Lorem Ipsum"';
|
||||
|
||||
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 mockResponse = new Response( mockResponseBody, { status: 400 } );
|
||||
|
||||
const error = await formatError( mockResponse );
|
||||
const expectedError = {
|
||||
message: 'Lorem Ipsum',
|
||||
type: 'api',
|
||||
};
|
||||
|
||||
expect( error ).toEqual( expectedError );
|
||||
} );
|
||||
|
||||
test( 'should format JSON parse errors', async () => {
|
||||
const mockResponse = new Response( mockMalformedJson, { status: 400 } );
|
||||
|
||||
const error = await formatError( mockResponse );
|
||||
|
||||
expect( error.message ).toContain(
|
||||
'invalid json response body at reason:'
|
||||
);
|
||||
expect( error.type ).toEqual( 'general' );
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
hasCollectableRate,
|
||||
isPackageRateCollectable,
|
||||
} from '@woocommerce/base-utils';
|
||||
import {
|
||||
CartShippingRate,
|
||||
CartShippingPackageShippingRate,
|
||||
} from '@woocommerce/type-defs/cart';
|
||||
import * as blockSettings from '@woocommerce/block-settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
getLocalPickupPrices,
|
||||
getShippingPrices,
|
||||
} from '../../../blocks/checkout/inner-blocks/checkout-shipping-method-block/shared/helpers';
|
||||
|
||||
jest.mock( '@woocommerce/settings', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
...jest.requireActual( '@woocommerce/settings' ),
|
||||
getSetting: jest.fn().mockImplementation( ( setting: string ) => {
|
||||
if ( setting === 'collectableMethodIds' ) {
|
||||
return [ 'local_pickup' ];
|
||||
}
|
||||
return jest
|
||||
.requireActual( '@woocommerce/settings' )
|
||||
.getSetting( setting );
|
||||
} ),
|
||||
};
|
||||
} );
|
||||
jest.mock( '@woocommerce/block-settings', () => ( {
|
||||
__esModule: true,
|
||||
...jest.requireActual( '@woocommerce/block-settings' ),
|
||||
LOCAL_PICKUP_ENABLED: true,
|
||||
} ) );
|
||||
|
||||
// Returns a rate object with the given values
|
||||
const generateRate = (
|
||||
rateId: string,
|
||||
name: string,
|
||||
price: string,
|
||||
instanceID: number,
|
||||
selected = false
|
||||
): typeof testPackage.shipping_rates[ 0 ] => {
|
||||
return {
|
||||
rate_id: rateId,
|
||||
name,
|
||||
description: '',
|
||||
delivery_time: '',
|
||||
price,
|
||||
taxes: '0',
|
||||
instance_id: instanceID,
|
||||
method_id: name.toLowerCase().split( ' ' ).join( '_' ),
|
||||
meta_data: [],
|
||||
selected,
|
||||
currency_code: 'USD',
|
||||
currency_symbol: '$',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
};
|
||||
};
|
||||
|
||||
// A test package with 5 shipping rates
|
||||
const testPackage: CartShippingRate = {
|
||||
package_id: 0,
|
||||
name: 'Shipping',
|
||||
destination: {
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
},
|
||||
items: [],
|
||||
shipping_rates: [
|
||||
generateRate( 'flat_rate:1', 'Flat rate', '10', 1 ),
|
||||
generateRate( 'local_pickup:1', 'Local pickup', '0', 2 ),
|
||||
generateRate( 'local_pickup:2', 'Local pickup', '10', 3 ),
|
||||
generateRate( 'local_pickup:3', 'Local pickup', '50', 4 ),
|
||||
generateRate( 'flat_rate:2', 'Flat rate', '50', 5 ),
|
||||
],
|
||||
};
|
||||
describe( 'Test Min and Max rates', () => {
|
||||
it( 'returns the lowest and highest rates when local pickup method is used', () => {
|
||||
expect( getLocalPickupPrices( testPackage.shipping_rates ) ).toEqual( {
|
||||
min: generateRate( 'local_pickup:1', 'Local pickup', '0', 2 ),
|
||||
|
||||
max: generateRate( 'local_pickup:3', 'Local pickup', '50', 4 ),
|
||||
} );
|
||||
} );
|
||||
it( 'returns the lowest and highest rates when flat rate shipping method is used', () => {
|
||||
expect( getShippingPrices( testPackage.shipping_rates ) ).toEqual( {
|
||||
min: generateRate( 'flat_rate:1', 'Flat rate', '10', 1 ),
|
||||
max: generateRate( 'flat_rate:2', 'Flat rate', '50', 5 ),
|
||||
} );
|
||||
} );
|
||||
it( 'returns undefined as lowest and highest rates when shipping rates are not available', () => {
|
||||
const testEmptyShippingRates: CartShippingPackageShippingRate[] = [];
|
||||
expect( getLocalPickupPrices( testEmptyShippingRates ) ).toEqual( {
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
} );
|
||||
expect( getShippingPrices( testEmptyShippingRates ) ).toEqual( {
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'isPackageRateCollectable', () => {
|
||||
it( 'correctly identifies if a package rate is collectable or not', () => {
|
||||
expect(
|
||||
isPackageRateCollectable( testPackage.shipping_rates[ 0 ] )
|
||||
).toBe( false );
|
||||
expect(
|
||||
isPackageRateCollectable( testPackage.shipping_rates[ 1 ] )
|
||||
).toBe( true );
|
||||
} );
|
||||
describe( 'hasCollectableRate', () => {
|
||||
it( 'correctly identifies if an array contains a collectable rate', () => {
|
||||
const ratesToTest = [ 'flat_rate', 'local_pickup' ];
|
||||
expect( hasCollectableRate( ratesToTest ) ).toBe( true );
|
||||
const ratesToTest2 = [ 'flat_rate', 'free_shipping' ];
|
||||
expect( hasCollectableRate( ratesToTest2 ) ).toBe( false );
|
||||
} );
|
||||
it( 'returns false for all rates if local pickup is disabled', () => {
|
||||
// Attempt to assign to const or readonly variable error on next line is OK because it is mocked by jest
|
||||
blockSettings.LOCAL_PICKUP_ENABLED = false;
|
||||
const ratesToTest = [ 'flat_rate', 'local_pickup' ];
|
||||
expect( hasCollectableRate( ratesToTest ) ).toBe( false );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
Reference in New Issue
Block a user