rebase on oct-10-2023
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
.wc-block-components-button:not(.is-link) {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
min-height: 3em;
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
@@ -57,12 +57,7 @@
|
||||
}
|
||||
|
||||
body:not(.woocommerce-block-theme-has-button-styles) .wc-block-components-button:not(.is-link) {
|
||||
@include reset-typography();
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
padding: 0 em($gap);
|
||||
text-decoration: none;
|
||||
text-transform: none;
|
||||
min-height: 3em;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px $studio-blue;
|
||||
@@ -77,21 +72,4 @@ body:not(.woocommerce-block-theme-has-button-styles) .wc-block-components-button
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
&.contained {
|
||||
background-color: $gray-900;
|
||||
color: $white;
|
||||
|
||||
&:disabled,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: $gray-900;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { ValidatedTextInput, isPostcode } from '@woocommerce/blocks-checkout';
|
||||
import {
|
||||
ValidatedTextInput,
|
||||
isPostcode,
|
||||
type ValidatedTextInputHandle,
|
||||
} from '@woocommerce/blocks-checkout';
|
||||
import {
|
||||
BillingCountryInput,
|
||||
ShippingCountryInput,
|
||||
@@ -10,178 +14,110 @@ import {
|
||||
BillingStateInput,
|
||||
ShippingStateInput,
|
||||
} from '@woocommerce/base-components/state-input';
|
||||
import { useEffect, useMemo } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useEffect, useMemo, useRef } from '@wordpress/element';
|
||||
import { withInstanceId } from '@wordpress/compose';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import {
|
||||
AddressField,
|
||||
AddressFields,
|
||||
AddressType,
|
||||
defaultAddressFields,
|
||||
ShippingAddress,
|
||||
} from '@woocommerce/settings';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { FieldValidationStatus } from '@woocommerce/types';
|
||||
import { defaultAddressFields } from '@woocommerce/settings';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
AddressFormProps,
|
||||
FieldType,
|
||||
FieldConfig,
|
||||
AddressFormFields,
|
||||
} from './types';
|
||||
import prepareAddressFields from './prepare-address-fields';
|
||||
import validateShippingCountry from './validate-shipping-country';
|
||||
import customValidationHandler from './custom-validation-handler';
|
||||
|
||||
// If it's the shipping address form and the user starts entering address
|
||||
// values without having set the country first, show an error.
|
||||
const validateShippingCountry = (
|
||||
values: ShippingAddress,
|
||||
setValidationErrors: (
|
||||
errors: Record< string, FieldValidationStatus >
|
||||
) => void,
|
||||
clearValidationError: ( error: string ) => void,
|
||||
hasValidationError: boolean
|
||||
): void => {
|
||||
const validationErrorId = 'shipping_country';
|
||||
if (
|
||||
! hasValidationError &&
|
||||
! values.country &&
|
||||
( values.city || values.state || values.postcode )
|
||||
) {
|
||||
setValidationErrors( {
|
||||
[ validationErrorId ]: {
|
||||
message: __(
|
||||
'Please select a country to calculate rates.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
hidden: false,
|
||||
},
|
||||
} );
|
||||
}
|
||||
if ( hasValidationError && values.country ) {
|
||||
clearValidationError( validationErrorId );
|
||||
}
|
||||
};
|
||||
|
||||
interface AddressFormProps {
|
||||
// Id for component.
|
||||
id?: string;
|
||||
// Unique id for form.
|
||||
instanceId: string;
|
||||
// Array of fields in form.
|
||||
fields: ( keyof AddressFields )[];
|
||||
// Field configuration for fields in form.
|
||||
fieldConfig?: Record< keyof AddressFields, Partial< AddressField > >;
|
||||
// Function to all for an form onChange event.
|
||||
onChange: ( newValue: ShippingAddress ) => void;
|
||||
// Type of form.
|
||||
type?: AddressType;
|
||||
// Values for fields.
|
||||
values: ShippingAddress;
|
||||
}
|
||||
const defaultFields = Object.keys(
|
||||
defaultAddressFields
|
||||
) as unknown as FieldType[];
|
||||
|
||||
/**
|
||||
* Checkout address form.
|
||||
*/
|
||||
const AddressForm = ( {
|
||||
id = '',
|
||||
fields = Object.keys(
|
||||
defaultAddressFields
|
||||
) as unknown as ( keyof AddressFields )[],
|
||||
fieldConfig = {} as Record< keyof AddressFields, Partial< AddressField > >,
|
||||
fields = defaultFields,
|
||||
fieldConfig = {} as FieldConfig,
|
||||
instanceId,
|
||||
onChange,
|
||||
type = 'shipping',
|
||||
values,
|
||||
}: AddressFormProps ): JSX.Element => {
|
||||
const validationErrorId = 'shipping_country';
|
||||
const { setValidationErrors, clearValidationError } =
|
||||
useDispatch( VALIDATION_STORE_KEY );
|
||||
|
||||
const countryValidationError = useSelect( ( select ) => {
|
||||
const store = select( VALIDATION_STORE_KEY );
|
||||
return store.getValidationError( validationErrorId );
|
||||
} );
|
||||
|
||||
// Track incoming props.
|
||||
const currentFields = useShallowEqual( fields );
|
||||
const currentFieldConfig = useShallowEqual( fieldConfig );
|
||||
const currentCountry = useShallowEqual( values.country );
|
||||
|
||||
const addressFormFields = useMemo( () => {
|
||||
return prepareAddressFields(
|
||||
// Memoize the address form fields passed in from the parent component.
|
||||
const addressFormFields = useMemo( (): AddressFormFields => {
|
||||
const preparedFields = prepareAddressFields(
|
||||
currentFields,
|
||||
fieldConfig,
|
||||
values.country
|
||||
currentFieldConfig,
|
||||
currentCountry
|
||||
);
|
||||
}, [ currentFields, fieldConfig, values.country ] );
|
||||
return {
|
||||
fields: preparedFields,
|
||||
type,
|
||||
required: preparedFields.filter( ( field ) => field.required ),
|
||||
hidden: preparedFields.filter( ( field ) => field.hidden ),
|
||||
};
|
||||
}, [ currentFields, currentFieldConfig, currentCountry, type ] );
|
||||
|
||||
// Stores refs for rendered fields so we can access them later.
|
||||
const fieldsRef = useRef<
|
||||
Record< string, ValidatedTextInputHandle | null >
|
||||
>( {} );
|
||||
|
||||
// Clear values for hidden fields.
|
||||
useEffect( () => {
|
||||
addressFormFields.forEach( ( field ) => {
|
||||
if ( field.hidden && values[ field.key ] ) {
|
||||
onChange( {
|
||||
...values,
|
||||
[ field.key ]: '',
|
||||
} );
|
||||
}
|
||||
} );
|
||||
}, [ addressFormFields, onChange, values ] );
|
||||
const newValues = {
|
||||
...values,
|
||||
...Object.fromEntries(
|
||||
addressFormFields.hidden.map( ( field ) => [ field.key, '' ] )
|
||||
),
|
||||
};
|
||||
if ( ! isShallowEqual( values, newValues ) ) {
|
||||
onChange( newValues );
|
||||
}
|
||||
}, [ onChange, addressFormFields, values ] );
|
||||
|
||||
// Maybe validate country when other fields change so user is notified that it's required.
|
||||
useEffect( () => {
|
||||
if ( type === 'shipping' ) {
|
||||
validateShippingCountry(
|
||||
values,
|
||||
setValidationErrors,
|
||||
clearValidationError,
|
||||
!! countryValidationError?.message &&
|
||||
! countryValidationError?.hidden
|
||||
);
|
||||
validateShippingCountry( values );
|
||||
}
|
||||
}, [
|
||||
values,
|
||||
countryValidationError?.message,
|
||||
countryValidationError?.hidden,
|
||||
setValidationErrors,
|
||||
clearValidationError,
|
||||
type,
|
||||
] );
|
||||
}, [ values, type ] );
|
||||
|
||||
// Changing country may change format for postcodes.
|
||||
useEffect( () => {
|
||||
fieldsRef.current?.postcode?.revalidate();
|
||||
}, [ currentCountry ] );
|
||||
|
||||
id = id || instanceId;
|
||||
|
||||
/**
|
||||
* Custom validation handler for fields with field specific handling.
|
||||
*/
|
||||
const customValidationHandler = (
|
||||
inputObject: HTMLInputElement,
|
||||
field: string,
|
||||
customValues: {
|
||||
country: string;
|
||||
}
|
||||
): boolean => {
|
||||
if (
|
||||
field === 'postcode' &&
|
||||
customValues.country &&
|
||||
! isPostcode( {
|
||||
postcode: inputObject.value,
|
||||
country: customValues.country,
|
||||
} )
|
||||
) {
|
||||
inputObject.setCustomValidity(
|
||||
__(
|
||||
'Please enter a valid postcode',
|
||||
'woo-gutenberg-products-block'
|
||||
)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={ id } className="wc-block-components-address-form">
|
||||
{ addressFormFields.map( ( field ) => {
|
||||
{ addressFormFields.fields.map( ( field ) => {
|
||||
if ( field.hidden ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a consistent error ID based on the field key and type
|
||||
const errorId = `${ type }_${ field.key }`;
|
||||
const fieldProps = {
|
||||
id: `${ id }-${ field.key }`,
|
||||
errorId: `${ type }_${ field.key }`,
|
||||
label: field.required ? field.label : field.optionalLabel,
|
||||
autoCapitalize: field.autocapitalize,
|
||||
autoComplete: field.autocomplete,
|
||||
errorMessage: field.errorMessage,
|
||||
required: field.required,
|
||||
className: `wc-block-components-address-form__${ field.key }`,
|
||||
};
|
||||
|
||||
if ( field.key === 'country' ) {
|
||||
const Tag =
|
||||
@@ -191,24 +127,26 @@ const AddressForm = ( {
|
||||
return (
|
||||
<Tag
|
||||
key={ field.key }
|
||||
id={ `${ id }-${ field.key }` }
|
||||
errorId={ errorId }
|
||||
label={
|
||||
field.required
|
||||
? field.label
|
||||
: field.optionalLabel
|
||||
}
|
||||
{ ...fieldProps }
|
||||
value={ values.country }
|
||||
autoComplete={ field.autocomplete }
|
||||
onChange={ ( newValue ) =>
|
||||
onChange( {
|
||||
onChange={ ( newCountry ) => {
|
||||
const newValues = {
|
||||
...values,
|
||||
country: newValue,
|
||||
country: newCountry,
|
||||
state: '',
|
||||
} )
|
||||
}
|
||||
errorMessage={ field.errorMessage }
|
||||
required={ field.required }
|
||||
};
|
||||
// Country will impact postcode too. Do we need to clear it?
|
||||
if (
|
||||
values.postcode &&
|
||||
! isPostcode( {
|
||||
postcode: values.postcode,
|
||||
country: newCountry,
|
||||
} )
|
||||
) {
|
||||
newValues.postcode = '';
|
||||
}
|
||||
onChange( newValues );
|
||||
} }
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -221,24 +159,15 @@ const AddressForm = ( {
|
||||
return (
|
||||
<Tag
|
||||
key={ field.key }
|
||||
id={ `${ id }-${ field.key }` }
|
||||
errorId={ errorId }
|
||||
{ ...fieldProps }
|
||||
country={ values.country }
|
||||
label={
|
||||
field.required
|
||||
? field.label
|
||||
: field.optionalLabel
|
||||
}
|
||||
value={ values.state }
|
||||
autoComplete={ field.autocomplete }
|
||||
onChange={ ( newValue ) =>
|
||||
onChange( {
|
||||
...values,
|
||||
state: newValue,
|
||||
} )
|
||||
}
|
||||
errorMessage={ field.errorMessage }
|
||||
required={ field.required }
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -246,24 +175,23 @@ const AddressForm = ( {
|
||||
return (
|
||||
<ValidatedTextInput
|
||||
key={ field.key }
|
||||
id={ `${ id }-${ field.key }` }
|
||||
errorId={ errorId }
|
||||
className={ `wc-block-components-address-form__${ field.key }` }
|
||||
label={
|
||||
field.required ? field.label : field.optionalLabel
|
||||
ref={ ( el ) =>
|
||||
( fieldsRef.current[ field.key ] = el )
|
||||
}
|
||||
{ ...fieldProps }
|
||||
value={ values[ field.key ] }
|
||||
autoCapitalize={ field.autocapitalize }
|
||||
autoComplete={ field.autocomplete }
|
||||
onChange={ ( newValue: string ) =>
|
||||
onChange( {
|
||||
...values,
|
||||
[ field.key ]:
|
||||
field.key === 'postcode'
|
||||
? newValue.trimStart().toUpperCase()
|
||||
: newValue,
|
||||
[ field.key ]: newValue,
|
||||
} )
|
||||
}
|
||||
customFormatter={ ( value: string ) => {
|
||||
if ( field.key === 'postcode' ) {
|
||||
return value.trimStart().toUpperCase();
|
||||
}
|
||||
return value;
|
||||
} }
|
||||
customValidation={ ( inputObject: HTMLInputElement ) =>
|
||||
customValidationHandler(
|
||||
inputObject,
|
||||
@@ -271,8 +199,6 @@ const AddressForm = ( {
|
||||
values
|
||||
)
|
||||
}
|
||||
errorMessage={ field.errorMessage }
|
||||
required={ field.required }
|
||||
/>
|
||||
);
|
||||
} ) }
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { isPostcode } from '@woocommerce/blocks-checkout';
|
||||
|
||||
/**
|
||||
* Custom validation handler for fields with field specific handling.
|
||||
*/
|
||||
const customValidationHandler = (
|
||||
inputObject: HTMLInputElement,
|
||||
field: string,
|
||||
customValues: {
|
||||
country: string;
|
||||
}
|
||||
): boolean => {
|
||||
// Pass validation if the field is not required and is empty.
|
||||
if ( ! inputObject.required && ! inputObject.value ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
field === 'postcode' &&
|
||||
customValues.country &&
|
||||
! isPostcode( {
|
||||
postcode: inputObject.value,
|
||||
country: customValues.country,
|
||||
} )
|
||||
) {
|
||||
inputObject.setCustomValidity(
|
||||
__(
|
||||
'Please enter a valid postcode',
|
||||
'woo-gutenberg-products-block'
|
||||
)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default customValidationHandler;
|
||||
@@ -8,20 +8,12 @@ import {
|
||||
AddressFields,
|
||||
CountryAddressFields,
|
||||
defaultAddressFields,
|
||||
getSetting,
|
||||
KeyedAddressField,
|
||||
LocaleSpecificAddressField,
|
||||
} from '@woocommerce/settings';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { isNumber, isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* This is locale data from WooCommerce countries class. This doesn't match the shape of the new field data blocks uses,
|
||||
* but we can import part of it to set which fields are required.
|
||||
*
|
||||
* This supports new properties such as optionalLabel which are not used by core (yet).
|
||||
*/
|
||||
const coreLocale = getSetting< CountryAddressFields >( 'countryLocale', {} );
|
||||
import { COUNTRY_LOCALE } from '@woocommerce/block-settings';
|
||||
|
||||
/**
|
||||
* Gets props from the core locale, then maps them to the shape we require in the client.
|
||||
@@ -72,7 +64,15 @@ const getSupportedCoreLocaleProps = (
|
||||
return fields;
|
||||
};
|
||||
|
||||
const countryAddressFields: CountryAddressFields = Object.entries( coreLocale )
|
||||
/**
|
||||
* COUNTRY_LOCALE is locale data from WooCommerce countries class. This doesn't match the shape of the new field data blocks uses,
|
||||
* but we can import part of it to set which fields are required.
|
||||
*
|
||||
* This supports new properties such as optionalLabel which are not used by core (yet).
|
||||
*/
|
||||
const countryAddressFields: CountryAddressFields = Object.entries(
|
||||
COUNTRY_LOCALE
|
||||
)
|
||||
.map( ( [ country, countryLocale ] ) => [
|
||||
country,
|
||||
Object.entries( countryLocale )
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type {
|
||||
AddressField,
|
||||
AddressFields,
|
||||
AddressType,
|
||||
ShippingAddress,
|
||||
KeyedAddressField,
|
||||
} from '@woocommerce/settings';
|
||||
|
||||
export type FieldConfig = Record<
|
||||
keyof AddressFields,
|
||||
Partial< AddressField >
|
||||
>;
|
||||
|
||||
export type FieldType = keyof AddressFields;
|
||||
|
||||
export type AddressFormFields = {
|
||||
fields: KeyedAddressField[];
|
||||
type: AddressType;
|
||||
required: KeyedAddressField[];
|
||||
hidden: KeyedAddressField[];
|
||||
};
|
||||
|
||||
export interface AddressFormProps {
|
||||
// Id for component.
|
||||
id?: string;
|
||||
// Unique id for form.
|
||||
instanceId: string;
|
||||
// Type of form (billing or shipping).
|
||||
type?: AddressType;
|
||||
// Array of fields in form.
|
||||
fields: FieldType[];
|
||||
// Field configuration for fields in form.
|
||||
fieldConfig?: FieldConfig;
|
||||
// Called with the new address data when the address form changes. This is only called when all required fields are filled and there are no validation errors.
|
||||
onChange: ( newValue: ShippingAddress ) => void;
|
||||
// Values for fields.
|
||||
values: ShippingAddress;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { type ShippingAddress } from '@woocommerce/settings';
|
||||
import { select, dispatch } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
// If it's the shipping address form and the user starts entering address
|
||||
// values without having set the country first, show an error.
|
||||
const validateShippingCountry = ( values: ShippingAddress ): void => {
|
||||
const validationErrorId = 'shipping_country';
|
||||
const hasValidationError =
|
||||
select( VALIDATION_STORE_KEY ).getValidationError( validationErrorId );
|
||||
if (
|
||||
! values.country &&
|
||||
( values.city || values.state || values.postcode )
|
||||
) {
|
||||
if ( hasValidationError ) {
|
||||
dispatch( VALIDATION_STORE_KEY ).showValidationError(
|
||||
validationErrorId
|
||||
);
|
||||
} else {
|
||||
dispatch( VALIDATION_STORE_KEY ).setValidationErrors( {
|
||||
[ validationErrorId ]: {
|
||||
message: __(
|
||||
'Please select your country',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
hidden: false,
|
||||
},
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
if ( hasValidationError && values.country ) {
|
||||
dispatch( VALIDATION_STORE_KEY ).clearValidationError(
|
||||
validationErrorId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default validateShippingCountry;
|
||||
@@ -11,6 +11,7 @@ import type { RefObject } from 'react';
|
||||
* Internal dependencies
|
||||
*/
|
||||
import CartLineItemRow from './cart-line-item-row';
|
||||
import './style.scss';
|
||||
|
||||
const placeholderRows = [ ...Array( 3 ) ].map( ( _x, i ) => (
|
||||
<CartLineItemRow lineItem={ {} } key={ i } />
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
table.wc-block-cart-items,
|
||||
table.wc-block-cart-items th,
|
||||
table.wc-block-cart-items td {
|
||||
// Override Storefront theme gray table background.
|
||||
background: none !important;
|
||||
// Remove borders on default themes.
|
||||
border: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-styles-wrapper table.wc-block-cart-items,
|
||||
table.wc-block-cart-items {
|
||||
width: 100%;
|
||||
|
||||
.wc-block-cart-items__header {
|
||||
@include font-size( smaller );
|
||||
text-transform: uppercase;
|
||||
|
||||
.wc-block-cart-items__header-image {
|
||||
width: 100px;
|
||||
}
|
||||
.wc-block-cart-items__header-product {
|
||||
visibility: hidden;
|
||||
}
|
||||
.wc-block-cart-items__header-total {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
.wc-block-cart-items__row {
|
||||
.wc-block-cart-item__image img {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.wc-block-cart-item__quantity {
|
||||
.wc-block-cart-item__remove-link {
|
||||
@include link-button;
|
||||
@include font-size( smaller );
|
||||
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.wc-block-components-product-name {
|
||||
display: block;
|
||||
max-width: max-content;
|
||||
}
|
||||
.wc-block-cart-item__total {
|
||||
@include font-size( regular );
|
||||
text-align: right;
|
||||
line-height: inherit;
|
||||
}
|
||||
.wc-block-components-product-metadata {
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-medium,
|
||||
.is-small,
|
||||
.is-mobile {
|
||||
table.wc-block-cart-items {
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
.wc-block-cart-items__header {
|
||||
display: none;
|
||||
}
|
||||
.wc-block-cart-item__remove-link {
|
||||
display: none;
|
||||
}
|
||||
&:not(.wc-block-mini-cart-items) {
|
||||
.wc-block-cart-items__row {
|
||||
@include with-translucent-border( 0 0 1px );
|
||||
}
|
||||
}
|
||||
.wc-block-cart-items__row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 132px;
|
||||
padding: $gap 0;
|
||||
|
||||
.wc-block-cart-item__image {
|
||||
grid-column-start: 1;
|
||||
grid-row-start: 1;
|
||||
padding-right: $gap;
|
||||
}
|
||||
.wc-block-cart-item__product {
|
||||
grid-column-start: 2;
|
||||
grid-column-end: 4;
|
||||
grid-row-start: 1;
|
||||
justify-self: stretch;
|
||||
padding: 0 $gap $gap 0;
|
||||
}
|
||||
.wc-block-cart-item__quantity {
|
||||
grid-column-start: 1;
|
||||
grid-row-start: 2;
|
||||
vertical-align: bottom;
|
||||
padding-right: $gap;
|
||||
align-self: end;
|
||||
padding-top: $gap;
|
||||
}
|
||||
.wc-block-cart-item__total {
|
||||
grid-row-start: 1;
|
||||
|
||||
.wc-block-components-formatted-money-amount {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-large.wc-block-cart {
|
||||
margin-bottom: 3em;
|
||||
|
||||
.wc-block-cart-items {
|
||||
@include with-translucent-border( 0 0 1px );
|
||||
|
||||
th {
|
||||
padding: 0.25rem $gap 0.25rem 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
@include with-translucent-border( 1px 0 0 );
|
||||
padding: $gap 0 $gap $gap;
|
||||
vertical-align: top;
|
||||
}
|
||||
th:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
td:last-child {
|
||||
padding-right: $gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
.wc-block-components-form .wc-block-components-checkout-step {
|
||||
position: relative;
|
||||
border: none;
|
||||
padding: 0 0 0 $gap-large;
|
||||
padding: 0 0 0 $gap-larger;
|
||||
background: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
|
||||
.wc-block-components-checkout-step__container {
|
||||
position: relative;
|
||||
|
||||
textarea {
|
||||
font-style: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-checkout-step__content > * {
|
||||
@@ -32,14 +37,8 @@
|
||||
}
|
||||
|
||||
.wc-block-components-checkout-step__heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin: em($gap-small) 0 em($gap);
|
||||
position: relative;
|
||||
align-items: center;
|
||||
gap: em($gap);
|
||||
|
||||
.wc-block-components-express-payment-continue-rule + .wc-block-components-checkout-step & {
|
||||
margin-top: 0;
|
||||
@@ -77,7 +76,6 @@
|
||||
content: "\00a0" counter(checkout-step) ".";
|
||||
content: "\00a0" counter(checkout-step) "." / "";
|
||||
position: absolute;
|
||||
width: $gap-large;
|
||||
left: -$gap-large;
|
||||
top: 0;
|
||||
text-align: center;
|
||||
|
||||
@@ -35,7 +35,6 @@ const OrderSummary = ( {
|
||||
{ __( 'Order summary', 'woo-gutenberg-products-block' ) }
|
||||
</span>
|
||||
}
|
||||
titleTag="h2"
|
||||
>
|
||||
<div className="wc-block-components-order-summary__content">
|
||||
{ cartItems.map( ( cartItem ) => {
|
||||
|
||||
@@ -8,97 +8,97 @@
|
||||
.wc-block-components-panel__content {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary__content {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item {
|
||||
@include with-translucent-border(0 0 1px);
|
||||
@include font-size(small);
|
||||
display: flex;
|
||||
padding-bottom: 1px;
|
||||
padding-top: $gap;
|
||||
width: 100%;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
.wc-block-components-order-summary__content {
|
||||
display: table;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
> div {
|
||||
padding-bottom: 0;
|
||||
.wc-block-components-order-summary-item {
|
||||
@include with-translucent-border(0 0 1px);
|
||||
@include font-size(small);
|
||||
display: flex;
|
||||
padding-bottom: 1px;
|
||||
padding-top: $gap;
|
||||
width: 100%;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
&:last-child {
|
||||
> div {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-metadata {
|
||||
@include font-size(regular);
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-metadata {
|
||||
@include font-size(regular);
|
||||
.wc-block-components-order-summary-item__image,
|
||||
.wc-block-components-order-summary-item__description {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__image,
|
||||
.wc-block-components-order-summary-item__description {
|
||||
display: table-cell;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__image {
|
||||
width: #{$gap-large * 2};
|
||||
padding-bottom: $gap;
|
||||
position: relative;
|
||||
|
||||
> img {
|
||||
.wc-block-components-order-summary-item__image {
|
||||
width: #{$gap-large * 2};
|
||||
max-width: #{$gap-large * 2};
|
||||
padding-bottom: $gap;
|
||||
position: relative;
|
||||
|
||||
> img {
|
||||
width: #{$gap-large * 2};
|
||||
max-width: #{$gap-large * 2};
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__quantity {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 2px solid;
|
||||
border-radius: 1em;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
color: #000;
|
||||
display: flex;
|
||||
line-height: 1;
|
||||
min-height: 20px;
|
||||
padding: 0 0.4em;
|
||||
position: absolute;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__description {
|
||||
padding-left: $gap-large;
|
||||
padding-right: $gap-small;
|
||||
padding-bottom: $gap;
|
||||
|
||||
p,
|
||||
.wc-block-components-product-metadata {
|
||||
line-height: 1.375;
|
||||
margin-top: #{ ($gap-large - $gap) * 0.5 };
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__total-price {
|
||||
font-weight: bold;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
.wc-block-components-order-summary-item__individual-prices {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__quantity {
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border: 2px solid;
|
||||
border-radius: 1em;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
color: #000;
|
||||
display: flex;
|
||||
line-height: 1;
|
||||
min-height: 20px;
|
||||
padding: 0 0.4em;
|
||||
position: absolute;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__description {
|
||||
padding-left: $gap-large;
|
||||
padding-right: $gap-small;
|
||||
padding-bottom: $gap;
|
||||
|
||||
p,
|
||||
.wc-block-components-product-metadata {
|
||||
line-height: 1.375;
|
||||
margin-top: #{ ($gap-large - $gap) * 0.5 };
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-order-summary-item__total-price {
|
||||
font-weight: bold;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
|
||||
.wc-block-components-order-summary-item__individual-prices {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isPackageRateCollectable } from '@woocommerce/base-utils';
|
||||
* Shows a formatted pickup location.
|
||||
*/
|
||||
const PickupLocation = (): JSX.Element | null => {
|
||||
const { pickupAddress, pickupMethod } = useSelect( ( select ) => {
|
||||
const { pickupAddress } = useSelect( ( select ) => {
|
||||
const cartShippingRates = select( 'wc/store/cart' ).getShippingRates();
|
||||
|
||||
const flattenedRates = cartShippingRates.flatMap(
|
||||
@@ -36,7 +36,6 @@ const PickupLocation = (): JSX.Element | null => {
|
||||
const selectedRatePickupAddress = selectedRateMetaData.value;
|
||||
return {
|
||||
pickupAddress: selectedRatePickupAddress,
|
||||
pickupMethod: selectedCollectableRate.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -44,20 +43,15 @@ const PickupLocation = (): JSX.Element | null => {
|
||||
if ( isObject( selectedCollectableRate ) ) {
|
||||
return {
|
||||
pickupAddress: undefined,
|
||||
pickupMethod: selectedCollectableRate.name,
|
||||
};
|
||||
}
|
||||
return {
|
||||
pickupAddress: undefined,
|
||||
pickupMethod: undefined,
|
||||
};
|
||||
} );
|
||||
|
||||
// If the method does not contain an address, or the method supporting collection was not found, return early.
|
||||
if (
|
||||
typeof pickupAddress === 'undefined' &&
|
||||
typeof pickupMethod === 'undefined'
|
||||
) {
|
||||
if ( typeof pickupAddress === 'undefined' ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -67,9 +61,7 @@ const PickupLocation = (): JSX.Element | null => {
|
||||
{ sprintf(
|
||||
/* translators: %s: shipping method name, e.g. "Amazon Locker" */
|
||||
__( 'Collection from %s', 'woo-gutenberg-products-block' ),
|
||||
typeof pickupAddress === 'undefined'
|
||||
? pickupMethod
|
||||
: pickupAddress
|
||||
pickupAddress
|
||||
) + ' ' }
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ jest.mock( '@woocommerce/settings', () => {
|
||||
};
|
||||
} );
|
||||
describe( 'PickupLocation', () => {
|
||||
it( `renders an address if one is set in the method's metadata`, async () => {
|
||||
it( `renders an address if one is set in the methods metadata`, async () => {
|
||||
dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true );
|
||||
|
||||
// Deselect the default selected rate and select pickup_location:1 rate.
|
||||
@@ -54,7 +54,7 @@ describe( 'PickupLocation', () => {
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
} );
|
||||
it( 'renders the method name if address is not in metadata', async () => {
|
||||
it( 'renders no address if one is not set in the methods metadata', async () => {
|
||||
dispatch( CHECKOUT_STORE_KEY ).setPrefersCollection( true );
|
||||
|
||||
// Deselect the default selected rate and select pickup_location:1 rate.
|
||||
@@ -87,7 +87,7 @@ describe( 'PickupLocation', () => {
|
||||
|
||||
render( <PickupLocation /> );
|
||||
expect(
|
||||
screen.getByText( /Collection from Local pickup/ )
|
||||
).toBeInTheDocument();
|
||||
screen.queryByText( /Collection from / )
|
||||
).not.toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { useCheckoutSubmit } from '@woocommerce/base-context/hooks';
|
||||
import { Icon, check } from '@wordpress/icons';
|
||||
import Button from '@woocommerce/base-components/button';
|
||||
|
||||
interface PlaceOrderButton {
|
||||
label: string;
|
||||
fullWidth?: boolean | undefined;
|
||||
}
|
||||
|
||||
const PlaceOrderButton = ( { label }: PlaceOrderButton ): JSX.Element => {
|
||||
const PlaceOrderButton = ( {
|
||||
label,
|
||||
fullWidth = false,
|
||||
}: PlaceOrderButton ): JSX.Element => {
|
||||
const {
|
||||
onSubmit,
|
||||
isCalculating,
|
||||
@@ -20,7 +25,13 @@ const PlaceOrderButton = ( { label }: PlaceOrderButton ): JSX.Element => {
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="wc-block-components-checkout-place-order-button"
|
||||
className={ classnames(
|
||||
'wc-block-components-checkout-place-order-button',
|
||||
{
|
||||
'wc-block-components-checkout-place-order-button--full-width':
|
||||
fullWidth,
|
||||
}
|
||||
) }
|
||||
onClick={ onSubmit }
|
||||
disabled={
|
||||
isCalculating ||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { kebabCase } from 'lodash';
|
||||
import { paramCase as kebabCase } from 'change-case';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import type { ProductResponseItemData } from '@woocommerce/types';
|
||||
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
interface ProductImageProps {
|
||||
image: { alt?: string; thumbnail?: string };
|
||||
fallbackAlt: string;
|
||||
@@ -37,13 +32,7 @@ const ProductImage = ( {
|
||||
alt: '',
|
||||
};
|
||||
|
||||
return (
|
||||
<img
|
||||
className="wc-block-components-product-image"
|
||||
{ ...imageProps }
|
||||
alt={ imageProps.alt }
|
||||
/>
|
||||
);
|
||||
return <img { ...imageProps } alt={ imageProps.alt } />;
|
||||
};
|
||||
|
||||
export default ProductImage;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
img.wc-block-components-product-image[alt=""] {
|
||||
border: 1px solid $image-placeholder-border-color;
|
||||
}
|
||||
@@ -7,12 +7,8 @@ import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { Panel } from '@woocommerce/blocks-checkout';
|
||||
import Label from '@woocommerce/base-components/label';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
import {
|
||||
useShippingData,
|
||||
useStoreEvents,
|
||||
} from '@woocommerce/base-context/hooks';
|
||||
import { useShippingData } from '@woocommerce/base-context/hooks';
|
||||
import { sanitizeHTML } from '@woocommerce/utils';
|
||||
import { debounce } from 'lodash';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
/**
|
||||
@@ -31,8 +27,7 @@ export const ShippingRatesControlPackage = ( {
|
||||
collapsible,
|
||||
showItems,
|
||||
}: PackageProps ): ReactElement => {
|
||||
const { selectShippingRate } = useShippingData();
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
const { selectShippingRate, isSelectingRate } = useShippingData();
|
||||
const multiplePackages =
|
||||
document.querySelectorAll(
|
||||
'.wc-block-components-shipping-rates-control__package'
|
||||
@@ -91,17 +86,12 @@ export const ShippingRatesControlPackage = ( {
|
||||
) }
|
||||
</>
|
||||
);
|
||||
const onSelectRate = debounce(
|
||||
useCallback(
|
||||
( newShippingRateId: string ) => {
|
||||
selectShippingRate( newShippingRateId, packageId );
|
||||
dispatchCheckoutEvent( 'set-selected-shipping-rate', {
|
||||
shippingRateId: newShippingRateId,
|
||||
} );
|
||||
},
|
||||
[ dispatchCheckoutEvent, packageId, selectShippingRate ]
|
||||
),
|
||||
1000
|
||||
|
||||
const onSelectRate = useCallback(
|
||||
( newShippingRateId: string ) => {
|
||||
selectShippingRate( newShippingRateId, packageId );
|
||||
},
|
||||
[ packageId, selectShippingRate ]
|
||||
);
|
||||
const packageRatesProps = {
|
||||
className,
|
||||
@@ -112,12 +102,20 @@ export const ShippingRatesControlPackage = ( {
|
||||
( rate ) => rate.selected
|
||||
),
|
||||
renderOption,
|
||||
disabled: isSelectingRate,
|
||||
};
|
||||
|
||||
if ( shouldBeCollapsible ) {
|
||||
return (
|
||||
<Panel
|
||||
className="wc-block-components-shipping-rates-control__package"
|
||||
className={ classNames(
|
||||
'wc-block-components-shipping-rates-control__package',
|
||||
className,
|
||||
{
|
||||
'wc-block-components-shipping-rates-control__package--disabled':
|
||||
isSelectingRate,
|
||||
}
|
||||
) }
|
||||
// initialOpen remembers only the first value provided to it, so by the
|
||||
// time we know we have several packages, initialOpen would be hardcoded to true.
|
||||
// If we're rendering a panel, we're more likely rendering several
|
||||
@@ -134,7 +132,11 @@ export const ShippingRatesControlPackage = ( {
|
||||
<div
|
||||
className={ classNames(
|
||||
'wc-block-components-shipping-rates-control__package',
|
||||
className
|
||||
className,
|
||||
{
|
||||
'wc-block-components-shipping-rates-control__package--disabled':
|
||||
isSelectingRate,
|
||||
}
|
||||
) }
|
||||
>
|
||||
{ header }
|
||||
|
||||
@@ -6,6 +6,7 @@ import RadioControl, {
|
||||
RadioControlOptionLayout,
|
||||
} from '@woocommerce/base-components/radio-control';
|
||||
import type { CartShippingPackageShippingRate } from '@woocommerce/types';
|
||||
import { usePrevious } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
@@ -20,6 +21,7 @@ interface PackageRates {
|
||||
className?: string;
|
||||
noResultsMessage: JSX.Element;
|
||||
selectedRate: CartShippingPackageShippingRate | undefined;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const PackageRates = ( {
|
||||
@@ -29,26 +31,37 @@ const PackageRates = ( {
|
||||
rates,
|
||||
renderOption = renderPackageRateOption,
|
||||
selectedRate,
|
||||
disabled = false,
|
||||
}: PackageRates ): JSX.Element => {
|
||||
const selectedRateId = selectedRate?.rate_id || '';
|
||||
const previousSelectedRateId = usePrevious( selectedRateId );
|
||||
|
||||
// Store selected rate ID in local state so shipping rates changes are shown in the UI instantly.
|
||||
const [ selectedOption, setSelectedOption ] = useState( selectedRateId );
|
||||
|
||||
// Update the selected option if cart state changes in the data stores.
|
||||
useEffect( () => {
|
||||
const [ selectedOption, setSelectedOption ] = useState( () => {
|
||||
if ( selectedRateId ) {
|
||||
return selectedRateId;
|
||||
}
|
||||
// Default to first rate if no rate is selected.
|
||||
return rates[ 0 ]?.rate_id;
|
||||
} );
|
||||
|
||||
// Update the selected option if cart state changes in the data store.
|
||||
useEffect( () => {
|
||||
if (
|
||||
selectedRateId &&
|
||||
selectedRateId !== previousSelectedRateId &&
|
||||
selectedRateId !== selectedOption
|
||||
) {
|
||||
setSelectedOption( selectedRateId );
|
||||
}
|
||||
}, [ selectedRateId ] );
|
||||
}, [ selectedRateId, selectedOption, previousSelectedRateId ] );
|
||||
|
||||
// Update the selected option if there is no rate selected on mount.
|
||||
// Update the data store when the local selected rate changes.
|
||||
useEffect( () => {
|
||||
if ( ! selectedOption && rates[ 0 ] ) {
|
||||
setSelectedOption( rates[ 0 ].rate_id );
|
||||
onSelectRate( rates[ 0 ].rate_id );
|
||||
if ( selectedOption ) {
|
||||
onSelectRate( selectedOption );
|
||||
}
|
||||
}, [ onSelectRate, rates, selectedOption ] );
|
||||
}, [ onSelectRate, selectedOption ] );
|
||||
|
||||
if ( rates.length === 0 ) {
|
||||
return noResultsMessage;
|
||||
@@ -62,6 +75,7 @@ const PackageRates = ( {
|
||||
setSelectedOption( value );
|
||||
onSelectRate( value );
|
||||
} }
|
||||
disabled={ disabled }
|
||||
selected={ selectedOption }
|
||||
options={ rates.map( renderOption ) }
|
||||
/>
|
||||
|
||||
@@ -43,7 +43,11 @@
|
||||
}
|
||||
.wc-block-components-radio-control__description-group {
|
||||
@include font-size(smaller);
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useShippingData,
|
||||
} from '@woocommerce/base-context';
|
||||
import NoticeBanner from '@woocommerce/base-components/notice-banner';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
@@ -95,7 +96,16 @@ const ShippingRatesControl = ( {
|
||||
context,
|
||||
};
|
||||
const { isEditor } = useEditorContext();
|
||||
const { hasSelectedLocalPickup } = useShippingData();
|
||||
const { hasSelectedLocalPickup, selectedRates } = useShippingData();
|
||||
|
||||
// Check if all rates selected are the same.
|
||||
const selectedRateIds = isObject( selectedRates )
|
||||
? ( Object.values( selectedRates ) as string[] )
|
||||
: [];
|
||||
const allPackagesHaveSameRate = selectedRateIds.every( ( rate: string ) => {
|
||||
return rate === selectedRateIds[ 0 ];
|
||||
} );
|
||||
|
||||
return (
|
||||
<LoadingMask
|
||||
isLoading={ isLoadingRates }
|
||||
@@ -105,9 +115,10 @@ const ShippingRatesControl = ( {
|
||||
) }
|
||||
showSpinner={ true }
|
||||
>
|
||||
<ExperimentalOrderShippingPackages.Slot { ...slotFillProps } />
|
||||
{ hasSelectedLocalPickup &&
|
||||
context === 'woocommerce/cart' &&
|
||||
shippingRates.length > 1 &&
|
||||
! allPackagesHaveSameRate &&
|
||||
! isEditor && (
|
||||
<NoticeBanner
|
||||
className="wc-block-components-notice"
|
||||
@@ -120,6 +131,7 @@ const ShippingRatesControl = ( {
|
||||
) }
|
||||
</NoticeBanner>
|
||||
) }
|
||||
<ExperimentalOrderShippingPackages.Slot { ...slotFillProps } />
|
||||
<ExperimentalOrderShippingPackages>
|
||||
<Packages
|
||||
packages={ shippingRates }
|
||||
|
||||
@@ -128,6 +128,7 @@ export const TotalsCoupon = ( {
|
||||
setCouponValue( newCouponValue );
|
||||
} }
|
||||
focusOnMount={ true }
|
||||
validateOnMount={ false }
|
||||
showError={ false }
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { TotalsCoupon } from '..';
|
||||
|
||||
describe( 'TotalsCoupon', () => {
|
||||
it( "Shows a validation error when one is in the wc/store/validation data store and doesn't show one when there isn't", () => {
|
||||
const { rerender } = render( <TotalsCoupon instanceId={ 'coupon' } /> );
|
||||
const openCouponFormButton = screen.getByText( 'Add a coupon' );
|
||||
expect( openCouponFormButton ).toBeInTheDocument();
|
||||
userEvent.click( openCouponFormButton );
|
||||
expect(
|
||||
screen.queryByText( 'Invalid coupon code' )
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const { setValidationErrors } = dispatch( VALIDATION_STORE_KEY );
|
||||
act( () => {
|
||||
setValidationErrors( {
|
||||
coupon: {
|
||||
hidden: false,
|
||||
message: 'Invalid coupon code',
|
||||
},
|
||||
} );
|
||||
} );
|
||||
rerender( <TotalsCoupon instanceId={ 'coupon' } /> );
|
||||
expect( screen.getByText( 'Invalid coupon code' ) ).toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
@@ -19,7 +19,11 @@ import { useSelect } from '@wordpress/data';
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ShippingCalculator from '../../shipping-calculator';
|
||||
import { hasShippingRate, getTotalShippingValue } from './utils';
|
||||
import {
|
||||
hasShippingRate,
|
||||
getTotalShippingValue,
|
||||
areShippingMethodsMissing,
|
||||
} from './utils';
|
||||
import ShippingPlaceholder from './shipping-placeholder';
|
||||
import ShippingAddress from './shipping-address';
|
||||
import ShippingRateSelector from './shipping-rate-selector';
|
||||
@@ -74,8 +78,12 @@ export const TotalsShipping = ( {
|
||||
.flatMap( ( rate ) => rate.name );
|
||||
}
|
||||
);
|
||||
|
||||
const addressComplete = isAddressComplete( shippingAddress );
|
||||
const shippingMethodsMissing = areShippingMethodsMissing(
|
||||
hasRates,
|
||||
prefersCollection,
|
||||
shippingRates
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -87,10 +95,10 @@ export const TotalsShipping = ( {
|
||||
<TotalsItem
|
||||
label={ __( 'Shipping', 'woo-gutenberg-products-block' ) }
|
||||
value={
|
||||
hasRates && cartHasCalculatedShipping
|
||||
? totalShippingValue
|
||||
: // if address is not complete, display the link to add an address.
|
||||
! addressComplete && (
|
||||
! shippingMethodsMissing && cartHasCalculatedShipping
|
||||
? // if address is not complete, display the link to add an address.
|
||||
totalShippingValue
|
||||
: ( ! addressComplete || isCheckout ) && (
|
||||
<ShippingPlaceholder
|
||||
showCalculator={ showCalculator }
|
||||
isCheckout={ isCheckout }
|
||||
@@ -104,9 +112,9 @@ export const TotalsShipping = ( {
|
||||
)
|
||||
}
|
||||
description={
|
||||
( ! shippingMethodsMissing && cartHasCalculatedShipping ) ||
|
||||
// If address is complete, display the shipping address.
|
||||
( hasRates && cartHasCalculatedShipping ) ||
|
||||
addressComplete ? (
|
||||
( addressComplete && ! isCheckout ) ? (
|
||||
<>
|
||||
<ShippingVia
|
||||
selectedShippingRates={ selectedShippingRates }
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ShippingAddress = ( {
|
||||
) : (
|
||||
<ShippingLocation formattedLocation={ formattedLocation } />
|
||||
) }
|
||||
{ showCalculator && ! prefersCollection ? (
|
||||
{ showCalculator && (
|
||||
<CalculatorButton
|
||||
label={ __(
|
||||
'Change address',
|
||||
@@ -57,7 +57,7 @@ export const ShippingAddress = ( {
|
||||
isShippingCalculatorOpen={ isShippingCalculatorOpen }
|
||||
setIsShippingCalculatorOpen={ setIsShippingCalculatorOpen }
|
||||
/>
|
||||
) : null }
|
||||
) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,6 +61,7 @@ jest.mock( '@woocommerce/base-context/hooks', () => {
|
||||
} );
|
||||
baseContextHooks.useShippingData.mockReturnValue( {
|
||||
needsShipping: true,
|
||||
selectShippingRate: jest.fn(),
|
||||
shippingRates: [
|
||||
{
|
||||
package_id: 0,
|
||||
@@ -192,7 +193,7 @@ describe( 'TotalsShipping', () => {
|
||||
} }
|
||||
showCalculator={ true }
|
||||
showRateSelector={ true }
|
||||
isCheckout={ true }
|
||||
isCheckout={ false }
|
||||
className={ '' }
|
||||
/>
|
||||
</SlotFillProvider>
|
||||
@@ -237,7 +238,7 @@ describe( 'TotalsShipping', () => {
|
||||
} }
|
||||
showCalculator={ true }
|
||||
showRateSelector={ true }
|
||||
isCheckout={ true }
|
||||
isCheckout={ false }
|
||||
className={ '' }
|
||||
/>
|
||||
</SlotFillProvider>
|
||||
@@ -282,7 +283,7 @@ describe( 'TotalsShipping', () => {
|
||||
} }
|
||||
showCalculator={ true }
|
||||
showRateSelector={ true }
|
||||
isCheckout={ true }
|
||||
isCheckout={ false }
|
||||
className={ '' }
|
||||
/>
|
||||
</SlotFillProvider>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import type { CartResponseShippingRate } from '@woocommerce/type-defs/cart-response';
|
||||
import { hasCollectableRate } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Searches an array of packages/rates to see if there are actually any rates
|
||||
@@ -20,7 +21,7 @@ export const hasShippingRate = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the total shippin value based on store settings.
|
||||
* Calculates the total shipping value based on store settings.
|
||||
*/
|
||||
export const getTotalShippingValue = ( values: {
|
||||
total_shipping: string;
|
||||
@@ -31,3 +32,31 @@ export const getTotalShippingValue = ( values: {
|
||||
parseInt( values.total_shipping_tax, 10 )
|
||||
: parseInt( values.total_shipping, 10 );
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if no shipping methods are available or if all available shipping methods are local pickup
|
||||
* only.
|
||||
*/
|
||||
export const areShippingMethodsMissing = (
|
||||
hasRates: boolean,
|
||||
prefersCollection: boolean | undefined,
|
||||
shippingRates: CartResponseShippingRate[]
|
||||
) => {
|
||||
if ( ! hasRates ) {
|
||||
// No shipping methods available
|
||||
return true;
|
||||
}
|
||||
|
||||
// We check for the availability of shipping options if the shopper selected "Shipping"
|
||||
if ( ! prefersCollection ) {
|
||||
return shippingRates.some(
|
||||
( shippingRatePackage ) =>
|
||||
! shippingRatePackage.shipping_rates.some(
|
||||
( shippingRate ) =>
|
||||
! hasCollectableRate( shippingRate.method_id )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CountryInput = ( {
|
||||
required = false,
|
||||
errorId,
|
||||
errorMessage = __(
|
||||
'Please select a country.',
|
||||
'Please select a country',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
}: CountryInputWithCountriesProps ): JSX.Element => {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
@import "node_modules/@wordpress/base-styles/breakpoints";
|
||||
@import "node_modules/@wordpress/base-styles/mixins";
|
||||
@import "node_modules/wordpress-components/src/combobox-control/style";
|
||||
|
||||
.wc-block-components-country-input {
|
||||
margin-top: em($gap-large);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const DrawerCloseButton = () => {
|
||||
// The Drawer component will use a portal to render the close button inside
|
||||
// this div.
|
||||
return <div className="wc-block-components-drawer__close-wrapper"></div>;
|
||||
};
|
||||
|
||||
export default DrawerCloseButton;
|
||||
@@ -1,14 +1,33 @@
|
||||
/**
|
||||
* Some code of the Drawer component is based on the Modal component from Gutenberg:
|
||||
* https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/modal/index.tsx
|
||||
*/
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Modal } from 'wordpress-components';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import classNames from 'classnames';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import type { ForwardedRef, KeyboardEvent, RefObject } from 'react';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
createPortal,
|
||||
useEffect,
|
||||
useRef,
|
||||
forwardRef,
|
||||
} from '@wordpress/element';
|
||||
import { close } from '@wordpress/icons';
|
||||
import {
|
||||
useFocusReturn,
|
||||
useFocusOnMount,
|
||||
useConstrainedTabbing,
|
||||
useMergeRefs,
|
||||
} from '@wordpress/compose';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Button from '../button';
|
||||
import * as ariaHelper from './utils/aria-helper';
|
||||
import './style.scss';
|
||||
|
||||
interface DrawerProps {
|
||||
@@ -18,32 +37,103 @@ interface DrawerProps {
|
||||
onClose: () => void;
|
||||
slideIn?: boolean;
|
||||
slideOut?: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const Drawer = ( {
|
||||
children,
|
||||
className,
|
||||
isOpen,
|
||||
onClose,
|
||||
slideIn = true,
|
||||
slideOut = true,
|
||||
title,
|
||||
}: DrawerProps ): JSX.Element | null => {
|
||||
interface CloseButtonPortalProps {
|
||||
onClick: () => void;
|
||||
contentRef: RefObject< HTMLDivElement >;
|
||||
}
|
||||
|
||||
const CloseButtonPortal = ( {
|
||||
onClick,
|
||||
contentRef,
|
||||
}: CloseButtonPortalProps ) => {
|
||||
const closeButtonWrapper = contentRef?.current?.querySelector(
|
||||
'.wc-block-components-drawer__close-wrapper'
|
||||
);
|
||||
|
||||
return closeButtonWrapper
|
||||
? createPortal(
|
||||
<Button
|
||||
className="wc-block-components-drawer__close"
|
||||
icon={ close }
|
||||
onClick={ onClick }
|
||||
label={ __( 'Close', 'woo-gutenberg-products-block' ) }
|
||||
showTooltip={ false }
|
||||
/>,
|
||||
closeButtonWrapper
|
||||
)
|
||||
: null;
|
||||
};
|
||||
|
||||
const UnforwardedDrawer = (
|
||||
{
|
||||
children,
|
||||
className,
|
||||
isOpen,
|
||||
onClose,
|
||||
slideIn = true,
|
||||
slideOut = true,
|
||||
}: DrawerProps,
|
||||
forwardedRef: ForwardedRef< HTMLDivElement >
|
||||
): JSX.Element | null => {
|
||||
const [ debouncedIsOpen ] = useDebounce< boolean >( isOpen, 300 );
|
||||
const isClosing = ! isOpen && debouncedIsOpen;
|
||||
const bodyOpenClassName = 'drawer-open';
|
||||
|
||||
const onRequestClose = () => {
|
||||
document.body.classList.remove( bodyOpenClassName );
|
||||
ariaHelper.showApp();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const ref = useRef< HTMLDivElement >();
|
||||
const focusOnMountRef = useFocusOnMount();
|
||||
const constrainedTabbingRef = useConstrainedTabbing();
|
||||
const focusReturnRef = useFocusReturn();
|
||||
const contentRef = useRef< HTMLDivElement >( null );
|
||||
|
||||
useEffect( () => {
|
||||
if ( isOpen ) {
|
||||
ariaHelper.hideApp( ref.current );
|
||||
document.body.classList.add( bodyOpenClassName );
|
||||
}
|
||||
}, [ isOpen, bodyOpenClassName ] );
|
||||
|
||||
const overlayRef = useMergeRefs( [ ref, forwardedRef ] );
|
||||
const drawerRef = useMergeRefs( [
|
||||
constrainedTabbingRef,
|
||||
focusReturnRef,
|
||||
focusOnMountRef,
|
||||
] );
|
||||
|
||||
if ( ! isOpen && ! isClosing ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={ title }
|
||||
focusOnMount={ true }
|
||||
onRequestClose={ onClose }
|
||||
className={ classNames( className, 'wc-block-components-drawer' ) }
|
||||
overlayClassName={ classNames(
|
||||
function handleEscapeKeyDown( event: KeyboardEvent< HTMLDivElement > ) {
|
||||
if (
|
||||
// Ignore keydowns from IMEs
|
||||
event.nativeEvent.isComposing ||
|
||||
// Workaround for Mac Safari where the final Enter/Backspace of an IME composition
|
||||
// is `isComposing=false`, even though it's technically still part of the composition.
|
||||
// These can only be detected by keyCode.
|
||||
event.keyCode === 229
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( event.code === 'Escape' && ! event.defaultPrevented ) {
|
||||
event.preventDefault();
|
||||
onRequestClose();
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
ref={ overlayRef }
|
||||
className={ classNames(
|
||||
'wc-block-components-drawer__screen-overlay',
|
||||
{
|
||||
'wc-block-components-drawer__screen-overlay--is-hidden':
|
||||
@@ -54,11 +144,42 @@ const Drawer = ( {
|
||||
slideOut,
|
||||
}
|
||||
) }
|
||||
closeButtonLabel={ __( 'Close', 'woo-gutenberg-products-block' ) }
|
||||
onKeyDown={ handleEscapeKeyDown }
|
||||
onClick={ ( e ) => {
|
||||
// If click was done directly in the overlay element and not one
|
||||
// of its descendants, close the drawer.
|
||||
if ( e.target === ref.current ) {
|
||||
onRequestClose();
|
||||
}
|
||||
} }
|
||||
>
|
||||
{ children }
|
||||
</Modal>
|
||||
<div
|
||||
className={ classNames(
|
||||
className,
|
||||
'wc-block-components-drawer'
|
||||
) }
|
||||
ref={ drawerRef }
|
||||
role="dialog"
|
||||
tabIndex={ -1 }
|
||||
>
|
||||
<div
|
||||
className="wc-block-components-drawer__content"
|
||||
role="document"
|
||||
ref={ contentRef }
|
||||
>
|
||||
<CloseButtonPortal
|
||||
contentRef={ contentRef }
|
||||
onClick={ onRequestClose }
|
||||
/>
|
||||
{ children }
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
const Drawer = forwardRef( UnforwardedDrawer );
|
||||
|
||||
export default Drawer;
|
||||
export { default as DrawerCloseButton } from './close-button';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
:root {
|
||||
/* This value might be overridden in PHP based on the attribute set by the user. */
|
||||
--drawer-width: 480px;
|
||||
--neg-drawer-width: calc(var(--drawer-width) * -1);
|
||||
}
|
||||
|
||||
$drawer-animation-duration: 0.3s;
|
||||
$drawer-width: 480px;
|
||||
$drawer-width-mobile: 100vw;
|
||||
|
||||
@keyframes fadein {
|
||||
from {
|
||||
@@ -18,19 +22,17 @@ $drawer-width-mobile: 100vw;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-$drawer-width);
|
||||
transform: translateX(max(-100%, var(--neg-drawer-width)));
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
@keyframes slidein {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
@keyframes rtlslidein {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-$drawer-width-mobile);
|
||||
}
|
||||
to {
|
||||
transform: translateX(min(100%, var(--drawer-width)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,13 +74,13 @@ $drawer-width-mobile: 100vw;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translateX(-$drawer-width);
|
||||
width: $drawer-width;
|
||||
transform: translateX(max(-100%, var(--neg-drawer-width)));
|
||||
width: var(--drawer-width);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
transform: translateX(-$drawer-width-mobile);
|
||||
width: $drawer-width-mobile;
|
||||
}
|
||||
.rtl .wc-block-components-drawer {
|
||||
transform: translateX(min(100%, var(--drawer-width)));
|
||||
}
|
||||
|
||||
.wc-block-components-drawer__screen-overlay--with-slide-out .wc-block-components-drawer {
|
||||
@@ -90,6 +92,10 @@ $drawer-width-mobile: 100vw;
|
||||
animation-name: slidein;
|
||||
}
|
||||
|
||||
.rtl .wc-block-components-drawer__screen-overlay--with-slide-in .wc-block-components-drawer {
|
||||
animation-name: rtlslidein;
|
||||
}
|
||||
|
||||
.wc-block-components-drawer__screen-overlay--is-hidden .wc-block-components-drawer {
|
||||
transform: translateX(0);
|
||||
}
|
||||
@@ -105,44 +111,57 @@ $drawer-width-mobile: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-drawer .components-modal__content {
|
||||
padding: $gap-largest $gap;
|
||||
}
|
||||
// Important rules are needed to reset button styles.
|
||||
.wc-block-components-drawer__close {
|
||||
@include reset-box();
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
position: absolute !important;
|
||||
top: $gap-small;
|
||||
right: $gap-small;
|
||||
opacity: 0.6;
|
||||
z-index: 2;
|
||||
// Increase clickable area.
|
||||
padding: 1em !important;
|
||||
margin: -1em;
|
||||
|
||||
.wc-block-components-drawer .components-modal__header {
|
||||
position: relative;
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Close button.
|
||||
.components-button {
|
||||
@include reset-box();
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
position: absolute;
|
||||
opacity: 0.6;
|
||||
z-index: 2;
|
||||
// The SVG has some white spacing around, thus we have to set this magic number.
|
||||
right: 8px;
|
||||
top: 0;
|
||||
// Increase clickable area.
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
// Don't show focus styles if the close button hasn't been focused by the
|
||||
// user directly. This is done to prevent focus styles to appear when
|
||||
// opening the drawer with the mouse, as the focus is moved inside
|
||||
// programmatically.
|
||||
&:focus:not(:focus-visible) {
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
> span {
|
||||
@include visually-hidden();
|
||||
}
|
||||
> span {
|
||||
@include visually-hidden();
|
||||
}
|
||||
svg {
|
||||
fill: currentColor;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Same styles as `Title` component.
|
||||
.wc-block-components-drawer .components-modal__header-heading {
|
||||
@include reset-box();
|
||||
// We need the font size to be in rem so it doesn't change depending on the parent element.
|
||||
@include font-size(large, 1rem);
|
||||
word-break: break-word;
|
||||
.wc-block-components-drawer__content {
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-bar .wc-block-components-drawer__content {
|
||||
margin-top: 46px;
|
||||
height: calc(100dvh - 46px);
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 783px) {
|
||||
.admin-bar .wc-block-components-drawer__content {
|
||||
margin-top: 32px;
|
||||
height: calc(100dvh - 32px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copied from https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/modal/aria-helper.ts
|
||||
*/
|
||||
const LIVE_REGION_ARIA_ROLES = new Set( [
|
||||
'alert',
|
||||
'status',
|
||||
'log',
|
||||
'marquee',
|
||||
'timer',
|
||||
] );
|
||||
|
||||
let hiddenElements: Element[] = [],
|
||||
isHidden = false;
|
||||
|
||||
/**
|
||||
* Determines if the passed element should not be hidden from screen readers.
|
||||
*
|
||||
* @param {HTMLElement} element The element that should be checked.
|
||||
*
|
||||
* @return {boolean} Whether the element should not be hidden from screen-readers.
|
||||
*/
|
||||
export function elementShouldBeHidden( element: Element ) {
|
||||
const role = element.getAttribute( 'role' );
|
||||
return ! (
|
||||
element.tagName === 'SCRIPT' ||
|
||||
element.hasAttribute( 'aria-hidden' ) ||
|
||||
element.hasAttribute( 'aria-live' ) ||
|
||||
( role && LIVE_REGION_ARIA_ROLES.has( role ) )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides all elements in the body element from screen-readers except
|
||||
* the provided element and elements that should not be hidden from
|
||||
* screen-readers.
|
||||
*
|
||||
* The reason we do this is because `aria-modal="true"` currently is bugged
|
||||
* in Safari, and support is spotty in other browsers overall. In the future
|
||||
* we should consider removing these helper functions in favor of
|
||||
* `aria-modal="true"`.
|
||||
*
|
||||
* @param {HTMLDivElement} unhiddenElement The element that should not be hidden.
|
||||
*/
|
||||
export function hideApp( unhiddenElement?: HTMLDivElement ) {
|
||||
if ( isHidden ) {
|
||||
return;
|
||||
}
|
||||
const elements = Array.from( document.body.children );
|
||||
elements.forEach( ( element ) => {
|
||||
if ( element === unhiddenElement ) {
|
||||
return;
|
||||
}
|
||||
if ( elementShouldBeHidden( element ) ) {
|
||||
element.setAttribute( 'aria-hidden', 'true' );
|
||||
hiddenElements.push( element );
|
||||
}
|
||||
} );
|
||||
isHidden = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes all elements in the body that have been hidden by `hideApp`
|
||||
* visible again to screen-readers.
|
||||
*/
|
||||
export function showApp() {
|
||||
if ( ! isHidden ) {
|
||||
return;
|
||||
}
|
||||
hiddenElements.forEach( ( element ) => {
|
||||
element.removeAttribute( 'aria-hidden' );
|
||||
} );
|
||||
hiddenElements = [];
|
||||
isHidden = false;
|
||||
}
|
||||
@@ -8,5 +8,6 @@
|
||||
@import "node_modules/@wordpress/base-styles/mixins";
|
||||
@import "node_modules/wordpress-components/src/popover/style";
|
||||
@import "node_modules/wordpress-components/src/tooltip/style";
|
||||
@import "node_modules/wordpress-components/src/form-token-field/style";
|
||||
/* stylelint-enable no-invalid-position-at-import-rule */
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
}
|
||||
|
||||
// Legacy notice compatibility.
|
||||
.wc-forward.wp-element-button {
|
||||
.wc-forward {
|
||||
float: right;
|
||||
color: $gray-800 !important;
|
||||
background: transparent;
|
||||
@@ -52,6 +52,8 @@
|
||||
border: 0;
|
||||
appearance: none;
|
||||
opacity: 0.6;
|
||||
text-decoration-line: underline;
|
||||
text-underline-position: under;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
|
||||
@@ -148,17 +148,14 @@ const PriceSlider = ( {
|
||||
}
|
||||
|
||||
const low =
|
||||
Math.round(
|
||||
100 *
|
||||
( ( minPrice - minConstraint ) /
|
||||
( maxConstraint - minConstraint ) )
|
||||
) - 0.5;
|
||||
100 *
|
||||
( ( minPrice - minConstraint ) /
|
||||
( maxConstraint - minConstraint ) );
|
||||
|
||||
const high =
|
||||
Math.round(
|
||||
100 *
|
||||
( ( maxPrice - minConstraint ) /
|
||||
( maxConstraint - minConstraint ) )
|
||||
) + 0.5;
|
||||
100 *
|
||||
( ( maxPrice - minConstraint ) /
|
||||
( maxConstraint - minConstraint ) );
|
||||
|
||||
return {
|
||||
'--low': low + '%',
|
||||
|
||||
@@ -169,7 +169,6 @@
|
||||
width: 100%;
|
||||
height: 0;
|
||||
display: block;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
outline: none !important;
|
||||
position: absolute;
|
||||
@@ -366,7 +365,6 @@
|
||||
@include ie11() {
|
||||
.wc-block-components-price-slider__range-input-wrapper {
|
||||
border: 0;
|
||||
height: auto;
|
||||
position: relative;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, useEffect } from '@wordpress/element';
|
||||
import PropTypes from 'prop-types';
|
||||
import type { HTMLElementEvent } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
@@ -40,9 +39,4 @@ const ProductListContainer = ( {
|
||||
);
|
||||
};
|
||||
|
||||
ProductListContainer.propTypes = {
|
||||
attributes: PropTypes.object.isRequired,
|
||||
hideOutOfStockItems: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ProductListContainer;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, _n, sprintf } from '@wordpress/i18n';
|
||||
import { isEqual } from 'lodash';
|
||||
import fastDeepEqual from 'fast-deep-equal/es6';
|
||||
import classnames from 'classnames';
|
||||
import Pagination from '@woocommerce/base-components/pagination';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
@@ -113,7 +113,9 @@ const announceLoadingCompletion = ( totalProducts: number ): void => {
|
||||
const areQueryTotalsDifferent: AreQueryTotalsDifferent = (
|
||||
{ totalQuery: nextQuery, totalProducts: nextProducts },
|
||||
{ totalQuery: currentQuery } = {}
|
||||
) => ! isEqual( nextQuery, currentQuery ) && Number.isFinite( nextProducts );
|
||||
) =>
|
||||
! fastDeepEqual( nextQuery, currentQuery ) &&
|
||||
Number.isFinite( nextProducts );
|
||||
|
||||
const ProductList = ( {
|
||||
attributes,
|
||||
@@ -169,7 +171,7 @@ const ProductList = ( {
|
||||
|
||||
// If query state (excluding pagination/sorting attributes) changed, reset pagination to the first page.
|
||||
useEffect( () => {
|
||||
if ( isEqual( totalQuery, previousQueryTotals?.totalQuery ) ) {
|
||||
if ( fastDeepEqual( totalQuery, previousQueryTotals?.totalQuery ) ) {
|
||||
return;
|
||||
}
|
||||
onPageChange( 1 );
|
||||
@@ -210,7 +212,7 @@ const ProductList = ( {
|
||||
const totalPages =
|
||||
! Number.isFinite( totalProducts ) &&
|
||||
Number.isFinite( previousQueryTotals?.totalProducts ) &&
|
||||
isEqual( totalQuery, previousQueryTotals?.totalQuery )
|
||||
fastDeepEqual( totalQuery, previousQueryTotals?.totalQuery )
|
||||
? Math.ceil( ( previousQueryTotals?.totalProducts || 0 ) / perPage )
|
||||
: Math.ceil( totalProducts / perPage );
|
||||
const listProducts = products.length
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface ProductSortSelectProps {
|
||||
|
||||
export interface ProductListContainerProps {
|
||||
attributes: Attributes;
|
||||
urlParameterSuffix: string | undefined;
|
||||
}
|
||||
|
||||
export interface NoMatchingProductsProps {
|
||||
|
||||
@@ -242,7 +242,7 @@ export interface ProductPriceProps {
|
||||
/**
|
||||
* Custom margin to apply to the price wrapper.
|
||||
*/
|
||||
spacingStyle?:
|
||||
style?:
|
||||
| Pick<
|
||||
React.CSSProperties,
|
||||
'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft'
|
||||
@@ -263,7 +263,7 @@ const ProductPrice = ( {
|
||||
regularPrice,
|
||||
regularPriceClassName,
|
||||
regularPriceStyle,
|
||||
spacingStyle,
|
||||
style,
|
||||
}: ProductPriceProps ): JSX.Element => {
|
||||
const wrapperClassName = classNames(
|
||||
className,
|
||||
@@ -327,7 +327,7 @@ const ProductPrice = ( {
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={ wrapperClassName } style={ spacingStyle }>
|
||||
<span className={ wrapperClassName } style={ style }>
|
||||
{ createInterpolateElement( format, {
|
||||
price: priceComponent,
|
||||
} ) }
|
||||
|
||||
@@ -13,6 +13,18 @@
|
||||
}
|
||||
/*rtl:end:ignore*/
|
||||
|
||||
.wc-block-components-product-price {
|
||||
display: block;
|
||||
|
||||
.wc-block-all-products .wc-block-components-product-price {
|
||||
margin-bottom: $gap-small;
|
||||
}
|
||||
|
||||
ins {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-price__value {
|
||||
&.is-discounted {
|
||||
margin-left: 0.5em;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { __, sprintf } from '@wordpress/i18n';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
const Rating = ( {
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
.wc-block-components-product-rating {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
|
||||
&__stars {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 5.3em;
|
||||
height: 1.618em;
|
||||
line-height: 1.618;
|
||||
font-size: 1em;
|
||||
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
|
||||
font-family: star;
|
||||
font-weight: 400;
|
||||
|
||||
&.wc-block-grid__product-rating__stars {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "\53\53\53\53\53";
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
span {
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
color: inherit;
|
||||
padding-top: 1.5em;
|
||||
}
|
||||
span::before {
|
||||
content: "\53\53\53\53\53";
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-all-products & {
|
||||
margin-top: 0;
|
||||
margin-bottom: $gap-small;
|
||||
}
|
||||
|
||||
&__container {
|
||||
> * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
&__stars + &__reviews_count {
|
||||
margin-left: $gap-smaller;
|
||||
}
|
||||
|
||||
&__norating-container {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: $gap-smaller;
|
||||
}
|
||||
|
||||
&__norating {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 1.5em;
|
||||
height: 1.618em;
|
||||
line-height: 1.618;
|
||||
font-size: 1em;
|
||||
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
|
||||
font-family: star;
|
||||
font-weight: 400;
|
||||
-webkit-text-stroke: 2px var(--wp--preset--color--black, #000);
|
||||
&::before {
|
||||
content: "\53";
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: absolute;
|
||||
color: transparent;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-all-products,
|
||||
.wp-block-query {
|
||||
.is-loading {
|
||||
.wc-block-components-product-rating {
|
||||
@include placeholder();
|
||||
width: 7em;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-rating__container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wc-block-components-product-rating__stars.wc-block-grid__product-rating__stars {
|
||||
margin: inherit;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { speak } from '@wordpress/a11y';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useLayoutEffect } from '@wordpress/element';
|
||||
import { useCallback, useLayoutEffect, useRef } from '@wordpress/element';
|
||||
import { DOWN, UP } from '@wordpress/keycodes';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
@@ -67,9 +67,13 @@ const QuantitySelector = ( {
|
||||
className
|
||||
);
|
||||
|
||||
const inputRef = useRef< HTMLInputElement | null >( null );
|
||||
const decreaseButtonRef = useRef< HTMLButtonElement | null >( null );
|
||||
const increaseButtonRef = useRef< HTMLButtonElement | null >( null );
|
||||
const hasMaximum = typeof maximum !== 'undefined';
|
||||
const canDecrease = quantity - step >= minimum;
|
||||
const canIncrease = ! hasMaximum || quantity + step <= maximum;
|
||||
const canDecrease = ! disabled && quantity - step >= minimum;
|
||||
const canIncrease =
|
||||
! disabled && ( ! hasMaximum || quantity + step <= maximum );
|
||||
|
||||
/**
|
||||
* The goal of this function is to normalize what was inserted,
|
||||
@@ -154,6 +158,7 @@ const QuantitySelector = ( {
|
||||
return (
|
||||
<div className={ classes }>
|
||||
<input
|
||||
ref={ inputRef }
|
||||
className="wc-block-components-quantity-selector__input"
|
||||
disabled={ disabled }
|
||||
type="number"
|
||||
@@ -186,6 +191,7 @@ const QuantitySelector = ( {
|
||||
) }
|
||||
/>
|
||||
<button
|
||||
ref={ decreaseButtonRef }
|
||||
aria-label={ sprintf(
|
||||
/* translators: %s refers to the item name in the cart. */
|
||||
__(
|
||||
@@ -195,7 +201,7 @@ const QuantitySelector = ( {
|
||||
itemName
|
||||
) }
|
||||
className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--minus"
|
||||
disabled={ disabled || ! canDecrease }
|
||||
disabled={ ! canDecrease }
|
||||
onClick={ () => {
|
||||
const newQuantity = quantity - step;
|
||||
onChange( newQuantity );
|
||||
@@ -215,6 +221,7 @@ const QuantitySelector = ( {
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
ref={ increaseButtonRef }
|
||||
aria-label={ sprintf(
|
||||
/* translators: %s refers to the item's name in the cart. */
|
||||
__(
|
||||
@@ -223,7 +230,7 @@ const QuantitySelector = ( {
|
||||
),
|
||||
itemName
|
||||
) }
|
||||
disabled={ disabled || ! canIncrease }
|
||||
disabled={ ! canIncrease }
|
||||
className="wc-block-components-quantity-selector__button wc-block-components-quantity-selector__button--plus"
|
||||
onClick={ () => {
|
||||
const newQuantity = quantity + step;
|
||||
|
||||
@@ -16,6 +16,7 @@ const RadioControl = ( {
|
||||
selected = '',
|
||||
onChange,
|
||||
options = [],
|
||||
disabled = false,
|
||||
}: RadioControlProps ): JSX.Element | null => {
|
||||
const instanceId = useInstanceId( RadioControl );
|
||||
const radioControlId = id || instanceId;
|
||||
@@ -43,6 +44,7 @@ const RadioControl = ( {
|
||||
option.onChange( value );
|
||||
}
|
||||
} }
|
||||
disabled={ disabled }
|
||||
/>
|
||||
) ) }
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ const Option = ( {
|
||||
name,
|
||||
onChange,
|
||||
option,
|
||||
disabled = false,
|
||||
}: RadioControlOptionProps ): JSX.Element => {
|
||||
const { value, label, description, secondaryLabel, secondaryDescription } =
|
||||
option;
|
||||
@@ -46,6 +47,7 @@ const Option = ( {
|
||||
[ `${ name }-${ value }__secondary-description` ]:
|
||||
secondaryDescription,
|
||||
} ) }
|
||||
disabled={ disabled }
|
||||
/>
|
||||
<OptionLayout
|
||||
id={ `${ name }-${ value }` }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
.wc-block-components-radio-control__option {
|
||||
@include reset-color();
|
||||
@include reset-typography();
|
||||
display: block;
|
||||
margin: em($gap) 0;
|
||||
margin-top: 0;
|
||||
padding: 0 0 0 em($gap-larger);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -69,7 +69,9 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
transform: translate(0, -45%);
|
||||
margin: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:checked::before {
|
||||
background: #000;
|
||||
@@ -95,6 +97,12 @@
|
||||
background: $input-text-dark;
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface RadioControlProps {
|
||||
onChange: ( value: string ) => void;
|
||||
// List of radio control options.
|
||||
options: RadioControlOption[];
|
||||
// Is the control disabled.
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface RadioControlOptionProps {
|
||||
@@ -21,6 +23,7 @@ export interface RadioControlOptionProps {
|
||||
name?: string;
|
||||
onChange: ( value: string ) => void;
|
||||
option: RadioControlOption;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface RadioControlOptionContent {
|
||||
|
||||
@@ -49,12 +49,11 @@
|
||||
.is-large {
|
||||
.wc-block-components-sidebar {
|
||||
.wc-block-components-totals-item,
|
||||
.wc-block-components-totals-coupon-link,
|
||||
.wc-block-components-panel {
|
||||
.wc-block-components-panel,
|
||||
.wc-block-components-totals-coupon {
|
||||
padding-left: $gap;
|
||||
padding-right: $gap;
|
||||
}
|
||||
|
||||
.wc-block-components-panel {
|
||||
.wc-block-components-totals-item {
|
||||
padding: 0;
|
||||
|
||||
@@ -10,11 +10,15 @@ export interface SkeletonProps {
|
||||
export const Skeleton = ( {
|
||||
numberOfLines = 1,
|
||||
}: SkeletonProps ): JSX.Element => {
|
||||
const skeletonLines = Array( numberOfLines ).fill(
|
||||
<span
|
||||
className="wc-block-components-skeleton-text-line"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
const skeletonLines = Array.from(
|
||||
{ length: numberOfLines },
|
||||
( _: undefined, index ) => (
|
||||
<span
|
||||
className="wc-block-components-skeleton-text-line"
|
||||
aria-hidden="true"
|
||||
key={ index }
|
||||
/>
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div className="wc-block-components-skeleton">{ skeletonLines }</div>
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
max-width: 600px;
|
||||
margin: 0;
|
||||
pointer-events: all;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@@ -55,13 +55,15 @@ const StateInput = ( {
|
||||
*/
|
||||
const onChangeState = useCallback(
|
||||
( stateValue: string ) => {
|
||||
onChange(
|
||||
const newValue =
|
||||
options.length > 0
|
||||
? optionMatcher( stateValue, options )
|
||||
: stateValue
|
||||
);
|
||||
: stateValue;
|
||||
if ( newValue !== value ) {
|
||||
onChange( newValue );
|
||||
}
|
||||
},
|
||||
[ onChange, options ]
|
||||
[ onChange, options, value ]
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { count } from '@wordpress/wordcount';
|
||||
import { autop } from '@wordpress/autop';
|
||||
|
||||
/**
|
||||
* Generates the summary text from a string of text.
|
||||
*
|
||||
* @param {string} source Source text.
|
||||
* @param {number} maxLength Limit number of countType returned if text has multiple paragraphs.
|
||||
* @param {string} countType What is being counted. One of words, characters_excluding_spaces, or characters_including_spaces.
|
||||
* @return {string} Generated summary.
|
||||
*/
|
||||
export const generateSummary = (
|
||||
source,
|
||||
maxLength = 15,
|
||||
countType = 'words'
|
||||
) => {
|
||||
const sourceWithParagraphs = autop( source );
|
||||
const sourceWordCount = count( sourceWithParagraphs, countType );
|
||||
|
||||
if ( sourceWordCount <= maxLength ) {
|
||||
return sourceWithParagraphs;
|
||||
}
|
||||
|
||||
const firstParagraph = getFirstParagraph( sourceWithParagraphs );
|
||||
const firstParagraphWordCount = count( firstParagraph, countType );
|
||||
|
||||
if ( firstParagraphWordCount <= maxLength ) {
|
||||
return firstParagraph;
|
||||
}
|
||||
|
||||
if ( countType === 'words' ) {
|
||||
return trimWords( firstParagraph, maxLength );
|
||||
}
|
||||
|
||||
return trimCharacters(
|
||||
firstParagraph,
|
||||
maxLength,
|
||||
countType === 'characters_including_spaces'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get first paragraph from some HTML text, or return whole string.
|
||||
*
|
||||
* @param {string} source Source text.
|
||||
* @return {string} First paragraph found in string.
|
||||
*/
|
||||
const getFirstParagraph = ( source ) => {
|
||||
const pIndex = source.indexOf( '</p>' );
|
||||
|
||||
if ( pIndex === -1 ) {
|
||||
return source;
|
||||
}
|
||||
|
||||
return source.substr( 0, pIndex + 4 );
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove HTML tags from a string.
|
||||
*
|
||||
* @param {string} htmlString String to remove tags from.
|
||||
* @return {string} Plain text string.
|
||||
*/
|
||||
const removeTags = ( htmlString ) => {
|
||||
const tagsRegExp = /<\/?[a-z][^>]*?>/gi;
|
||||
return htmlString.replace( tagsRegExp, '' );
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove trailing punctuation and append some characters to a string.
|
||||
*
|
||||
* @param {string} text Text to append to.
|
||||
* @param {string} moreText Text to append.
|
||||
* @return {string} String with appended characters.
|
||||
*/
|
||||
const appendMoreText = ( text, moreText ) => {
|
||||
return text.replace( /[\s|\.\,]+$/i, '' ) + moreText;
|
||||
};
|
||||
|
||||
/**
|
||||
* Limit words in string and returned trimmed version.
|
||||
*
|
||||
* @param {string} text Text to trim.
|
||||
* @param {number} maxLength Number of countType to limit to.
|
||||
* @param {string} moreText Appended to the trimmed string.
|
||||
* @return {string} Trimmed string.
|
||||
*/
|
||||
const trimWords = ( text, maxLength, moreText = '…' ) => {
|
||||
const textToTrim = removeTags( text );
|
||||
const trimmedText = textToTrim
|
||||
.split( ' ' )
|
||||
.splice( 0, maxLength )
|
||||
.join( ' ' );
|
||||
|
||||
return autop( appendMoreText( trimmedText, moreText ) );
|
||||
};
|
||||
|
||||
/**
|
||||
* Limit characters in string and returned trimmed version.
|
||||
*
|
||||
* @param {string} text Text to trim.
|
||||
* @param {number} maxLength Number of countType to limit to.
|
||||
* @param {boolean} includeSpaces Should spaces be included in the count.
|
||||
* @param {string} moreText Appended to the trimmed string.
|
||||
* @return {string} Trimmed string.
|
||||
*/
|
||||
const trimCharacters = (
|
||||
text,
|
||||
maxLength,
|
||||
includeSpaces = true,
|
||||
moreText = '…'
|
||||
) => {
|
||||
const textToTrim = removeTags( text );
|
||||
const trimmedText = textToTrim.slice( 0, maxLength );
|
||||
|
||||
if ( includeSpaces ) {
|
||||
return autop( appendMoreText( trimmedText, moreText ) );
|
||||
}
|
||||
|
||||
const matchSpaces = trimmedText.match( /([\s]+)/g );
|
||||
const spaceCount = matchSpaces ? matchSpaces.length : 0;
|
||||
const trimmedTextExcludingSpaces = textToTrim.slice(
|
||||
0,
|
||||
maxLength + spaceCount
|
||||
);
|
||||
|
||||
return autop( appendMoreText( trimmedTextExcludingSpaces, moreText ) );
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { autop } from '@wordpress/autop';
|
||||
import { trimCharacters, trimWords } from '@woocommerce/utils';
|
||||
import { count, CountType } from '@wordpress/wordcount';
|
||||
|
||||
/**
|
||||
* Get first paragraph from some HTML text, or return whole string.
|
||||
*
|
||||
* @param {string} source Source text.
|
||||
* @return {string} First paragraph found in string.
|
||||
*/
|
||||
const getFirstParagraph = ( source: string ) => {
|
||||
const pIndex = source.indexOf( '</p>' );
|
||||
|
||||
if ( pIndex === -1 ) {
|
||||
return source;
|
||||
}
|
||||
|
||||
return source.substr( 0, pIndex + 4 );
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates the summary text from a string of text.
|
||||
*
|
||||
* @param {string} source Source text.
|
||||
* @param {number} maxLength Limit number of countType returned if text has multiple paragraphs.
|
||||
* @param {string} countType What is being counted. One of words, characters_excluding_spaces, or characters_including_spaces.
|
||||
* @return {string} Generated summary.
|
||||
*/
|
||||
export const generateSummary = (
|
||||
source: string,
|
||||
maxLength = 15,
|
||||
countType: CountType = 'words'
|
||||
) => {
|
||||
const sourceWithParagraphs = autop( source );
|
||||
const sourceWordCount = count( sourceWithParagraphs, countType );
|
||||
|
||||
if ( sourceWordCount <= maxLength ) {
|
||||
return sourceWithParagraphs;
|
||||
}
|
||||
|
||||
const firstParagraph = getFirstParagraph( sourceWithParagraphs );
|
||||
const firstParagraphWordCount = count( firstParagraph, countType );
|
||||
|
||||
if ( firstParagraphWordCount <= maxLength ) {
|
||||
return firstParagraph;
|
||||
}
|
||||
|
||||
if ( countType === 'words' ) {
|
||||
return trimWords( firstParagraph, maxLength );
|
||||
}
|
||||
|
||||
return trimCharacters(
|
||||
firstParagraph,
|
||||
maxLength,
|
||||
countType === 'characters_including_spaces'
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,13 @@
|
||||
@include reset-box();
|
||||
@include font-size(large);
|
||||
word-break: break-word;
|
||||
|
||||
textarea {
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
font-weight: inherit;
|
||||
font-style: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// For Twenty Twenty we need to increase specificity a bit more.
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { uniqueId } from 'lodash';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
@@ -13,6 +8,10 @@ import {
|
||||
EventObserversType,
|
||||
} from './types';
|
||||
|
||||
export function generateUniqueId() {
|
||||
return Math.floor( Math.random() * Date.now() ).toString();
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
addEventCallback: (
|
||||
eventType: string,
|
||||
@@ -20,7 +19,7 @@ export const actions = {
|
||||
priority = 10
|
||||
): ActionType => {
|
||||
return {
|
||||
id: uniqueId(),
|
||||
id: generateUniqueId(),
|
||||
type: ACTION.ADD_EVENT_CALLBACK,
|
||||
eventType,
|
||||
callback,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isEqual } from 'lodash';
|
||||
import fastDeepEqual from 'fast-deep-equal/es6';
|
||||
import { useRef } from '@wordpress/element';
|
||||
import {
|
||||
CART_STORE_KEY as storeKey,
|
||||
@@ -247,7 +247,7 @@ export const useStoreCart = (
|
||||
|
||||
if (
|
||||
! currentResults.current ||
|
||||
! isEqual( currentResults.current, results )
|
||||
! fastDeepEqual( currentResults.current, results )
|
||||
) {
|
||||
currentResults.current = results;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
*/
|
||||
import { useState, useEffect, useMemo } from '@wordpress/element';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { isEmpty, sortBy } from 'lodash';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import { objectHasProp } from '@woocommerce/types';
|
||||
import { sort } from 'fast-sort';
|
||||
import { useShallowEqual } from '@woocommerce/base-hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
@@ -22,7 +22,7 @@ const buildCollectionDataQuery = (
|
||||
if (
|
||||
Array.isArray( collectionDataQueryState.calculate_attribute_counts )
|
||||
) {
|
||||
query.calculate_attribute_counts = sortBy(
|
||||
query.calculate_attribute_counts = sort(
|
||||
collectionDataQueryState.calculate_attribute_counts.map(
|
||||
( { taxonomy, queryType } ) => {
|
||||
return {
|
||||
@@ -30,9 +30,8 @@ const buildCollectionDataQuery = (
|
||||
query_type: queryType,
|
||||
};
|
||||
}
|
||||
),
|
||||
[ 'taxonomy', 'query_type' ]
|
||||
);
|
||||
)
|
||||
).asc( [ 'taxonomy', 'query_type' ] );
|
||||
}
|
||||
|
||||
return query;
|
||||
@@ -47,7 +46,6 @@ interface UseCollectionDataProps {
|
||||
queryStock?: boolean;
|
||||
queryRating?: boolean;
|
||||
queryState: Record< string, unknown >;
|
||||
productIds?: number[];
|
||||
isEditor?: boolean;
|
||||
}
|
||||
|
||||
@@ -57,7 +55,6 @@ export const useCollectionData = ( {
|
||||
queryStock,
|
||||
queryRating,
|
||||
queryState,
|
||||
productIds,
|
||||
isEditor = false,
|
||||
}: UseCollectionDataProps ) => {
|
||||
let context = useQueryStateContext();
|
||||
@@ -167,7 +164,6 @@ export const useCollectionData = ( {
|
||||
per_page: undefined,
|
||||
orderby: undefined,
|
||||
order: undefined,
|
||||
...( ! isEmpty( productIds ) && { include: productIds } ),
|
||||
...collectionDataQueryVars,
|
||||
},
|
||||
shouldSelect: debouncedShouldSelect,
|
||||
|
||||
@@ -10,4 +10,5 @@ export * from './use-customer-data';
|
||||
export * from './use-checkout-address';
|
||||
export * from './use-checkout-submit';
|
||||
export * from './use-checkout-extension-data';
|
||||
export * from './use-show-shipping-total-warning';
|
||||
export * from './use-validation';
|
||||
|
||||
@@ -76,7 +76,7 @@ export const useShippingData = (): ShippingData => {
|
||||
} as {
|
||||
selectShippingRate: (
|
||||
newShippingRateId: string,
|
||||
packageId?: string | number | undefined
|
||||
packageId?: string | number | null
|
||||
) => Promise< unknown >;
|
||||
};
|
||||
|
||||
@@ -94,16 +94,20 @@ export const useShippingData = (): ShippingData => {
|
||||
): void => {
|
||||
let selectPromise;
|
||||
|
||||
if ( typeof newShippingRateId === 'undefined' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking location handling
|
||||
*
|
||||
* Forces pickup location to be selected for all packages since we don't allow a mix of shipping and pickup.
|
||||
*/
|
||||
if (
|
||||
hasCollectableRate( newShippingRateId.split( ':' )[ 0 ] ) ||
|
||||
hasSelectedLocalPickup
|
||||
) {
|
||||
selectPromise = dispatchSelectShippingRate( newShippingRateId );
|
||||
if ( hasCollectableRate( newShippingRateId.split( ':' )[ 0 ] ) ) {
|
||||
selectPromise = dispatchSelectShippingRate(
|
||||
newShippingRateId,
|
||||
null
|
||||
);
|
||||
} else {
|
||||
selectPromise = dispatchSelectShippingRate(
|
||||
newShippingRateId,
|
||||
@@ -120,11 +124,7 @@ export const useShippingData = (): ShippingData => {
|
||||
processErrorResponse( error );
|
||||
} );
|
||||
},
|
||||
[
|
||||
hasSelectedLocalPickup,
|
||||
dispatchSelectShippingRate,
|
||||
dispatchCheckoutEvent,
|
||||
]
|
||||
[ dispatchSelectShippingRate, dispatchCheckoutEvent ]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { CART_STORE_KEY, CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { hasShippingRate } from '@woocommerce/base-components/cart-checkout/totals/shipping/utils';
|
||||
import { hasCollectableRate } from '@woocommerce/base-utils';
|
||||
import { isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useShippingData } from './shipping/use-shipping-data';
|
||||
|
||||
export const useShowShippingTotalWarning = () => {
|
||||
const context = 'woocommerce/checkout-totals-block';
|
||||
const errorNoticeId = 'wc-blocks-totals-shipping-warning';
|
||||
|
||||
const { shippingRates } = useShippingData();
|
||||
const hasRates = hasShippingRate( shippingRates );
|
||||
const {
|
||||
prefersCollection,
|
||||
isRateBeingSelected,
|
||||
shippingNotices,
|
||||
cartData,
|
||||
} = useSelect( ( select ) => {
|
||||
return {
|
||||
cartData: select( CART_STORE_KEY ).getCartData(),
|
||||
prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(),
|
||||
isRateBeingSelected:
|
||||
select( CART_STORE_KEY ).isShippingRateBeingSelected(),
|
||||
shippingNotices: select( 'core/notices' ).getNotices( context ),
|
||||
};
|
||||
} );
|
||||
const { createInfoNotice, removeNotice } = useDispatch( 'core/notices' );
|
||||
|
||||
useEffect( () => {
|
||||
if ( ! hasRates || isRateBeingSelected ) {
|
||||
// Early return because shipping rates were not yet loaded from the cart data store, or the user is changing
|
||||
// rate, no need to alter the notice until we know what the actual rate is.
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedRates = cartData?.shippingRates?.reduce(
|
||||
( acc: string[], rate ) => {
|
||||
const selectedRateForPackage = rate.shipping_rates.find(
|
||||
( shippingRate ) => {
|
||||
return shippingRate.selected;
|
||||
}
|
||||
);
|
||||
if (
|
||||
typeof selectedRateForPackage?.method_id !== 'undefined'
|
||||
) {
|
||||
acc.push( selectedRateForPackage?.method_id );
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const isPickupRateSelected = Object.values( selectedRates ).some(
|
||||
( rate: unknown ) => {
|
||||
if ( isString( rate ) ) {
|
||||
return hasCollectableRate( rate );
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// There is a mismatch between the method the user chose (pickup or shipping) and the currently selected rate.
|
||||
if (
|
||||
hasRates &&
|
||||
! prefersCollection &&
|
||||
! isRateBeingSelected &&
|
||||
isPickupRateSelected &&
|
||||
shippingNotices.length === 0
|
||||
) {
|
||||
createInfoNotice(
|
||||
__(
|
||||
'Totals will be recalculated when a valid shipping method is selected.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
{
|
||||
id: 'wc-blocks-totals-shipping-warning',
|
||||
isDismissible: false,
|
||||
context,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show the notice if they have selected local pickup, or if they have selected a valid regular shipping rate.
|
||||
if (
|
||||
( prefersCollection || ! isPickupRateSelected ) &&
|
||||
shippingNotices.length > 0
|
||||
) {
|
||||
removeNotice( errorNoticeId, context );
|
||||
}
|
||||
}, [
|
||||
cartData?.shippingRates,
|
||||
createInfoNotice,
|
||||
hasRates,
|
||||
isRateBeingSelected,
|
||||
prefersCollection,
|
||||
removeNotice,
|
||||
shippingNotices,
|
||||
shippingRates,
|
||||
] );
|
||||
};
|
||||
@@ -2,12 +2,8 @@
|
||||
* External dependencies
|
||||
*/
|
||||
import { doAction } from '@wordpress/hooks';
|
||||
import { useCallback, useRef, useEffect } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useStoreCart } from './cart/use-store-cart';
|
||||
import { select } from '@wordpress/data';
|
||||
import { useCallback } from '@wordpress/element';
|
||||
|
||||
type StoreEvent = (
|
||||
eventName: string,
|
||||
@@ -21,15 +17,6 @@ export const useStoreEvents = (): {
|
||||
dispatchStoreEvent: StoreEvent;
|
||||
dispatchCheckoutEvent: StoreEvent;
|
||||
} => {
|
||||
const storeCart = useStoreCart();
|
||||
const currentStoreCart = useRef( storeCart );
|
||||
|
||||
// Track the latest version of the cart so we can use the current value in our callback function below without triggering
|
||||
// other useEffect hooks using dispatchCheckoutEvent as a dependency.
|
||||
useEffect( () => {
|
||||
currentStoreCart.current = storeCart;
|
||||
}, [ storeCart ] );
|
||||
|
||||
const dispatchStoreEvent = useCallback( ( eventName, eventParams = {} ) => {
|
||||
try {
|
||||
doAction(
|
||||
@@ -50,7 +37,7 @@ export const useStoreEvents = (): {
|
||||
`experimental__woocommerce_blocks-checkout-${ eventName }`,
|
||||
{
|
||||
...eventParams,
|
||||
storeCart: currentStoreCart.current,
|
||||
storeCart: select( 'wc/store/cart' ).getCartData(),
|
||||
}
|
||||
);
|
||||
} catch ( e ) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
emitterCallback,
|
||||
reducer,
|
||||
emitEvent,
|
||||
emitEventWithAbort,
|
||||
ActionType,
|
||||
} from '../../../event-emit';
|
||||
|
||||
// These events are emitted when the Cart status is BEFORE_PROCESSING and AFTER_PROCESSING
|
||||
// to enable third parties to hook into the cart process
|
||||
const EVENTS = {
|
||||
PROCEED_TO_CHECKOUT: 'cart_proceed_to_checkout',
|
||||
};
|
||||
|
||||
type EventEmittersType = Record< string, ReturnType< typeof emitterCallback > >;
|
||||
|
||||
/**
|
||||
* Receives a reducer dispatcher and returns an object with the
|
||||
* various event emitters for the payment processing events.
|
||||
*
|
||||
* Calling the event registration function with the callback will register it
|
||||
* for the event emitter and will return a dispatcher for removing the
|
||||
* registered callback (useful for implementation in `useEffect`).
|
||||
*
|
||||
* @param {Function} observerDispatch The emitter reducer dispatcher.
|
||||
* @return {Object} An object with the various payment event emitter registration functions
|
||||
*/
|
||||
const useEventEmitters = (
|
||||
observerDispatch: React.Dispatch< ActionType >
|
||||
): EventEmittersType => {
|
||||
const eventEmitters = useMemo(
|
||||
() => ( {
|
||||
onProceedToCheckout: emitterCallback(
|
||||
EVENTS.PROCEED_TO_CHECKOUT,
|
||||
observerDispatch
|
||||
),
|
||||
} ),
|
||||
[ observerDispatch ]
|
||||
);
|
||||
return eventEmitters;
|
||||
};
|
||||
|
||||
export { EVENTS, useEventEmitters, reducer, emitEvent, emitEventWithAbort };
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
useEventEmitters,
|
||||
reducer as emitReducer,
|
||||
emitEventWithAbort,
|
||||
EVENTS,
|
||||
} from './event-emit';
|
||||
import type { emitterCallback } from '../../../event-emit';
|
||||
|
||||
type CartEventsContextType = {
|
||||
// Used to register a callback that will fire when the cart has been processed and has an error.
|
||||
onProceedToCheckout: ReturnType< typeof emitterCallback >;
|
||||
// Used to register a callback that will fire when the cart has been processed and has an error.
|
||||
dispatchOnProceedToCheckout: () => Promise< unknown[] >;
|
||||
};
|
||||
|
||||
const CartEventsContext = createContext< CartEventsContextType >( {
|
||||
onProceedToCheckout: () => () => void null,
|
||||
dispatchOnProceedToCheckout: () => new Promise( () => void null ),
|
||||
} );
|
||||
|
||||
export const useCartEventsContext = () => {
|
||||
return useContext( CartEventsContext );
|
||||
};
|
||||
|
||||
/**
|
||||
* Checkout Events provider
|
||||
* Emit Checkout events and provide access to Checkout event handlers
|
||||
*
|
||||
* @param {Object} props Incoming props for the provider.
|
||||
* @param {Object} props.children The children being wrapped.
|
||||
*/
|
||||
export const CartEventsProvider = ( {
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
} ): JSX.Element => {
|
||||
const [ observers, observerDispatch ] = useReducer( emitReducer, {} );
|
||||
const currentObservers = useRef( observers );
|
||||
const { onProceedToCheckout } = useEventEmitters( observerDispatch );
|
||||
|
||||
// set observers on ref so it's always current.
|
||||
useEffect( () => {
|
||||
currentObservers.current = observers;
|
||||
}, [ observers ] );
|
||||
|
||||
const dispatchOnProceedToCheckout = async () => {
|
||||
return await emitEventWithAbort(
|
||||
currentObservers.current,
|
||||
EVENTS.PROCEED_TO_CHECKOUT,
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const cartEvents = {
|
||||
onProceedToCheckout,
|
||||
dispatchOnProceedToCheckout,
|
||||
};
|
||||
return (
|
||||
<CartEventsContext.Provider value={ cartEvents }>
|
||||
{ children }
|
||||
</CartEventsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useCartEventsContext } from '@woocommerce/base-context';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CartEventsProvider } from '../index';
|
||||
import Block from '../../../../../../blocks/cart/inner-blocks/proceed-to-checkout-block/block';
|
||||
|
||||
describe( 'CartEventsProvider', () => {
|
||||
it( 'allows observers to unsubscribe', async () => {
|
||||
const mockObserver = jest.fn().mockReturnValue( { type: 'error' } );
|
||||
const MockObserverComponent = () => {
|
||||
const { onProceedToCheckout } = useCartEventsContext();
|
||||
useEffect( () => {
|
||||
const unsubscribe = onProceedToCheckout( () => {
|
||||
mockObserver();
|
||||
unsubscribe();
|
||||
} );
|
||||
}, [ onProceedToCheckout ] );
|
||||
return <div>Mock observer</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<CartEventsProvider>
|
||||
<div>
|
||||
<MockObserverComponent />
|
||||
<Block checkoutPageId={ 0 } className="test-block" />
|
||||
</div>
|
||||
</CartEventsProvider>
|
||||
);
|
||||
expect( screen.getByText( 'Mock observer' ) ).toBeInTheDocument();
|
||||
const button = screen.getByText( 'Proceed to Checkout' );
|
||||
|
||||
// Forcibly set the button URL to # to prevent JSDOM error: `["Error: Not implemented: navigation (except hash changes)`
|
||||
button.parentElement?.removeAttribute( 'href' );
|
||||
|
||||
// Click twice. The observer should unsubscribe after the first click.
|
||||
button.click();
|
||||
button.click();
|
||||
await waitFor( () => {
|
||||
expect( mockObserver ).toHaveBeenCalledTimes( 1 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './payment-events';
|
||||
export * from './shipping';
|
||||
export * from './checkout-events';
|
||||
export * from './cart-events';
|
||||
export * from './cart';
|
||||
export * from './checkout-processor';
|
||||
export * from './checkout-provider';
|
||||
|
||||
@@ -4,9 +4,7 @@ export * from './use-position-relative-to-viewport';
|
||||
export * from './use-previous';
|
||||
export * from './use-shallow-equal';
|
||||
export * from './use-throw-error';
|
||||
export * from './use-spacing-props';
|
||||
export * from './use-typography-props';
|
||||
export * from './use-color-props';
|
||||
export * from './use-border-props';
|
||||
export * from './use-is-mounted';
|
||||
export * from './use-spoken-message';
|
||||
export * from './use-style-props';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/* eslint-disable @wordpress/no-unsafe-wp-apis */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __experimentalUseBorderProps } from '@wordpress/block-editor';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
import { parseStyle } from '@woocommerce/base-utils';
|
||||
|
||||
type WithClass = {
|
||||
className: string;
|
||||
};
|
||||
|
||||
type WithStyle = {
|
||||
style: Record< string, unknown >;
|
||||
};
|
||||
|
||||
// @todo The @wordpress/block-editor dependency should never be used on the frontend of the store due to excessive side and its dependency on @wordpress/components
|
||||
// @see https://github.com/woocommerce/woocommerce-blocks/issues/8071
|
||||
export const useBorderProps = (
|
||||
attributes: unknown
|
||||
): WithStyle & WithClass => {
|
||||
const attributesObject = isObject( attributes ) ? attributes : {};
|
||||
const style = parseStyle( attributesObject.style );
|
||||
|
||||
return __experimentalUseBorderProps( { ...attributesObject, style } );
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
/* eslint-disable @wordpress/no-unsafe-wp-apis */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __experimentalUseColorProps } from '@wordpress/block-editor';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
import { parseStyle } from '@woocommerce/base-utils';
|
||||
|
||||
type WithClass = {
|
||||
className: string;
|
||||
};
|
||||
|
||||
type WithStyle = {
|
||||
style: Record< string, unknown >;
|
||||
};
|
||||
|
||||
// @todo The @wordpress/block-editor dependency should never be used on the frontend of the store due to excessive side and its dependency on @wordpress/components
|
||||
// @see https://github.com/woocommerce/woocommerce-blocks/issues/8071
|
||||
export const useColorProps = ( attributes: unknown ): WithStyle & WithClass => {
|
||||
const attributesObject = isObject( attributes ) ? attributes : {};
|
||||
const style = parseStyle( attributesObject.style );
|
||||
|
||||
return __experimentalUseColorProps( { ...attributesObject, style } );
|
||||
};
|
||||
@@ -7,7 +7,7 @@ interface Validation< T > {
|
||||
( value: T, previousValue: T | undefined ): boolean;
|
||||
}
|
||||
/**
|
||||
* Use Previous based on https://usehooks.com/usePrevious/.
|
||||
* Use Previous based on https://usehooks.com/useprevious/.
|
||||
*
|
||||
* @param {*} value
|
||||
* @param {Function} [validation] Function that needs to validate for the value
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/* eslint-disable @wordpress/no-unsafe-wp-apis */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';
|
||||
import { isObject } from '@woocommerce/types';
|
||||
import { parseStyle } from '@woocommerce/base-utils';
|
||||
|
||||
type WithStyle = {
|
||||
style: Record< string, unknown >;
|
||||
};
|
||||
|
||||
// @todo The @wordpress/block-editor dependency should never be used on the frontend of the store due to excessive side and its dependency on @wordpress/components
|
||||
// @see https://github.com/woocommerce/woocommerce-blocks/issues/8071
|
||||
export const useSpacingProps = ( attributes: unknown ): WithStyle => {
|
||||
if ( typeof __experimentalGetSpacingClassesAndStyles !== 'function' ) {
|
||||
return {
|
||||
style: {},
|
||||
};
|
||||
}
|
||||
|
||||
const attributesObject = isObject( attributes ) ? attributes : {};
|
||||
const style = parseStyle( attributesObject.style );
|
||||
|
||||
return __experimentalGetSpacingClassesAndStyles( {
|
||||
...attributesObject,
|
||||
style,
|
||||
} );
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { isString, isObject } from '@woocommerce/types';
|
||||
import type { Style as StyleEngineProperties } from '@wordpress/style-engine/src/types';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { useTypographyProps } from './use-typography-props';
|
||||
import {
|
||||
getColorClassesAndStyles,
|
||||
getBorderClassesAndStyles,
|
||||
getSpacingClassesAndStyles,
|
||||
} from '../utils';
|
||||
|
||||
export type StyleProps = {
|
||||
className: string;
|
||||
style: CSSProperties;
|
||||
};
|
||||
|
||||
type BlockAttributes = Record< string, unknown > & {
|
||||
style?: StyleEngineProperties | string | undefined;
|
||||
};
|
||||
|
||||
type StyleAttributes = Record< string, unknown > & {
|
||||
style: StyleEngineProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses incoming props.
|
||||
*
|
||||
* This may include style properties at the top level, or may include a nested `style` object. This ensures the expected
|
||||
* values are present and converts any string based values to objects as required.
|
||||
*/
|
||||
const parseStyleAttributes = ( rawProps: BlockAttributes ): StyleAttributes => {
|
||||
const props = isObject( rawProps )
|
||||
? rawProps
|
||||
: {
|
||||
style: {},
|
||||
};
|
||||
|
||||
let style = props.style;
|
||||
|
||||
if ( isString( style ) ) {
|
||||
style = JSON.parse( style ) || {};
|
||||
}
|
||||
|
||||
if ( ! isObject( style ) ) {
|
||||
style = {};
|
||||
}
|
||||
|
||||
return {
|
||||
...props,
|
||||
style,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the CSS class names and inline styles for a block when provided with its props/attributes.
|
||||
*
|
||||
* This hook (and its utilities) borrow functionality from the Gutenberg Block Editor package--something we don't want
|
||||
* to import on the frontend.
|
||||
*/
|
||||
export const useStyleProps = ( props: BlockAttributes ): StyleProps => {
|
||||
const styleAttributes = parseStyleAttributes( props );
|
||||
const colorProps = getColorClassesAndStyles( styleAttributes );
|
||||
const borderProps = getBorderClassesAndStyles( styleAttributes );
|
||||
const spacingProps = getSpacingClassesAndStyles( styleAttributes );
|
||||
const typographyProps = useTypographyProps( styleAttributes );
|
||||
|
||||
return {
|
||||
className: classnames(
|
||||
typographyProps.className,
|
||||
colorProps.className,
|
||||
borderProps.className,
|
||||
spacingProps.className
|
||||
),
|
||||
style: {
|
||||
...typographyProps.style,
|
||||
...colorProps.style,
|
||||
...borderProps.style,
|
||||
...spacingProps.style,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,39 +1,38 @@
|
||||
/* eslint-disable @wordpress/no-unsafe-wp-apis */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isObject, isString } from '@woocommerce/types';
|
||||
import { parseStyle } from '@woocommerce/base-utils';
|
||||
import type { Style as StyleEngineProperties } from '@wordpress/style-engine/src/types';
|
||||
|
||||
type WithClass = {
|
||||
className: string;
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { StyleProps } from './use-style-props';
|
||||
|
||||
type blockAttributes = {
|
||||
style: StyleEngineProperties;
|
||||
// String identifier for the font size preset--not an absolute value.
|
||||
fontSize?: string | undefined;
|
||||
// String identifier for the font family preset, not the actual font family.
|
||||
fontFamily?: string | undefined;
|
||||
};
|
||||
|
||||
type WithStyle = {
|
||||
style: Record< string, unknown >;
|
||||
};
|
||||
|
||||
export const useTypographyProps = (
|
||||
attributes: unknown
|
||||
): WithStyle & WithClass => {
|
||||
const attributesObject = isObject( attributes ) ? attributes : {};
|
||||
const style = parseStyle( attributesObject.style );
|
||||
const typography = isObject( style.typography )
|
||||
? ( style.typography as Record< string, string > )
|
||||
export const useTypographyProps = ( props: blockAttributes ): StyleProps => {
|
||||
const typography = isObject( props.style.typography )
|
||||
? props.style.typography
|
||||
: {};
|
||||
|
||||
const classNameFallback = isString( typography.fontFamily )
|
||||
? typography.fontFamily
|
||||
: '';
|
||||
const className = attributesObject.fontFamily
|
||||
? `has-${ attributesObject.fontFamily }-font-family`
|
||||
const className = props.fontFamily
|
||||
? `has-${ props.fontFamily }-font-family`
|
||||
: classNameFallback;
|
||||
|
||||
return {
|
||||
className,
|
||||
style: {
|
||||
fontSize: attributesObject.fontSize
|
||||
? `var(--wp--preset--font-size--${ attributesObject.fontSize })`
|
||||
fontSize: props.fontSize
|
||||
? `var(--wp--preset--font-size--${ props.fontSize })`
|
||||
: typography.fontSize,
|
||||
fontStyle: typography.fontStyle,
|
||||
fontWeight: typography.fontWeight,
|
||||
|
||||
@@ -11,9 +11,12 @@ import {
|
||||
defaultAddressFields,
|
||||
ShippingAddress,
|
||||
BillingAddress,
|
||||
getSetting,
|
||||
} 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.
|
||||
@@ -116,24 +119,16 @@ export const formatShippingAddress = (
|
||||
if ( Object.values( address ).length === 0 ) {
|
||||
return null;
|
||||
}
|
||||
const shippingCountries = getSetting< Record< string, string > >(
|
||||
'shippingCountries',
|
||||
{}
|
||||
);
|
||||
const shippingStates = getSetting< Record< string, string > >(
|
||||
'shippingStates',
|
||||
{}
|
||||
);
|
||||
const formattedCountry =
|
||||
typeof shippingCountries[ address.country ] === 'string'
|
||||
? decodeEntities( shippingCountries[ address.country ] )
|
||||
typeof SHIPPING_COUNTRIES[ address.country ] === 'string'
|
||||
? decodeEntities( SHIPPING_COUNTRIES[ address.country ] )
|
||||
: '';
|
||||
|
||||
const formattedState =
|
||||
typeof shippingStates[ address.country ] === 'object' &&
|
||||
typeof shippingStates[ address.country ][ address.state ] === 'string'
|
||||
typeof SHIPPING_STATES[ address.country ] === 'object' &&
|
||||
typeof SHIPPING_STATES[ address.country ][ address.state ] === 'string'
|
||||
? decodeEntities(
|
||||
shippingStates[ address.country ][ address.state ]
|
||||
SHIPPING_STATES[ address.country ][ address.state ]
|
||||
)
|
||||
: address.state;
|
||||
|
||||
|
||||
@@ -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,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,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,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,12 @@ 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 './parse-style';
|
||||
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,11 @@
|
||||
export const mapKeys = (
|
||||
obj: object,
|
||||
mapper: ( value: unknown, key: string ) => string
|
||||
) =>
|
||||
Object.entries( obj ).reduce(
|
||||
( acc, [ key, value ] ) => ( {
|
||||
...acc,
|
||||
[ mapper( value, key ) ]: value,
|
||||
} ),
|
||||
{}
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { isString, isObject } from '@woocommerce/types';
|
||||
|
||||
export const parseStyle = ( style: unknown ): Record< string, unknown > => {
|
||||
if ( isString( style ) ) {
|
||||
return JSON.parse( style ) || {};
|
||||
}
|
||||
|
||||
if ( isObject( style ) ) {
|
||||
return style;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -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,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 ) );
|
||||
@@ -5,9 +5,20 @@ import {
|
||||
hasCollectableRate,
|
||||
isPackageRateCollectable,
|
||||
} from '@woocommerce/base-utils';
|
||||
import { CartShippingRate } from '@woocommerce/type-defs/cart';
|
||||
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,
|
||||
@@ -27,76 +38,86 @@ jest.mock( '@woocommerce/block-settings', () => ( {
|
||||
...jest.requireActual( '@woocommerce/block-settings' ),
|
||||
LOCAL_PICKUP_ENABLED: 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 );
|
||||
|
||||
// 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 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 );
|
||||
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', () => {
|
||||
const testPackage: CartShippingRate = {
|
||||
package_id: 0,
|
||||
name: 'Shipping',
|
||||
destination: {
|
||||
address_1: '',
|
||||
address_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
},
|
||||
items: [],
|
||||
shipping_rates: [
|
||||
{
|
||||
rate_id: 'flat_rate:1',
|
||||
name: 'Flat rate',
|
||||
description: '',
|
||||
delivery_time: '',
|
||||
price: '10',
|
||||
taxes: '0',
|
||||
instance_id: 1,
|
||||
method_id: 'flat_rate',
|
||||
meta_data: [],
|
||||
selected: true,
|
||||
currency_code: 'USD',
|
||||
currency_symbol: '$',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
},
|
||||
{
|
||||
rate_id: 'local_pickup:2',
|
||||
name: 'Local pickup',
|
||||
description: '',
|
||||
delivery_time: '',
|
||||
price: '0',
|
||||
taxes: '0',
|
||||
instance_id: 2,
|
||||
method_id: 'local_pickup',
|
||||
meta_data: [],
|
||||
selected: false,
|
||||
currency_code: 'USD',
|
||||
currency_symbol: '$',
|
||||
currency_minor_unit: 2,
|
||||
currency_decimal_separator: '.',
|
||||
currency_thousand_separator: ',',
|
||||
currency_prefix: '$',
|
||||
currency_suffix: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
isPackageRateCollectable( testPackage.shipping_rates[ 0 ] )
|
||||
).toBe( false );
|
||||
@@ -104,4 +125,18 @@ describe( 'isPackageRateCollectable', () => {
|
||||
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