rebase on oct-10-2023

This commit is contained in:
Rachit Bhargava
2023-10-10 17:23:21 -04:00
parent d37566ffb6
commit d096058d7d
4789 changed files with 254611 additions and 307223 deletions

View File

@@ -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;
}
}
}

View File

@@ -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 }
/>
);
} ) }

View File

@@ -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;

View File

@@ -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 )

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 } />

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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 ) => {

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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();
} );
} );

View File

@@ -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 ||

View File

@@ -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';

View File

@@ -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;

View File

@@ -1,3 +0,0 @@
img.wc-block-components-product-image[alt=""] {
border: 1px solid $image-placeholder-border-color;
}

View File

@@ -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 }

View File

@@ -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 ) }
/>

View File

@@ -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;
}
}

View File

@@ -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 }

View File

@@ -128,6 +128,7 @@ export const TotalsCoupon = ( {
setCouponValue( newCouponValue );
} }
focusOnMount={ true }
validateOnMount={ false }
showError={ false }
/>
<Button

View File

@@ -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();
} );
} );

View File

@@ -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 }

View File

@@ -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 }
) }
</>
);
};

View File

@@ -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>

View File

@@ -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;
};

View File

@@ -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 => {

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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 */
}

View File

@@ -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,

View File

@@ -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 + '%',

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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

View File

@@ -76,6 +76,7 @@ export interface ProductSortSelectProps {
export interface ProductListContainerProps {
attributes: Attributes;
urlParameterSuffix: string | undefined;
}
export interface NoMatchingProductsProps {

View File

@@ -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,
} ) }

View File

@@ -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;

View File

@@ -7,6 +7,7 @@ import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import './style.scss';
const Rating = ( {
className,

View File

@@ -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;
}
}

View File

@@ -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 = ( {
&#65293;
</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;

View File

@@ -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>

View File

@@ -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 }` }

View File

@@ -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;
}
}
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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>

View File

@@ -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);

View File

@@ -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 ]
);
/**

View File

@@ -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 = '&hellip;' ) => {
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 = '&hellip;'
) => {
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 ) );
};

View File

@@ -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'
);
};

View File

@@ -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.

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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,
] );
};

View File

@@ -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 ) {

View File

@@ -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 };

View File

@@ -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>
);
};

View File

@@ -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 );
} );
} );
} );

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 } );
};

View File

@@ -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 } );
};

View File

@@ -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

View File

@@ -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,
} );
};

View File

@@ -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,
},
};
};

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 ) );

View File

@@ -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;
};

View File

@@ -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,
};
}

View File

@@ -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';

View File

@@ -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 > );
};

View File

@@ -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,
} ),
{}
);

View File

@@ -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 {};
};

View File

@@ -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 );
};

View File

@@ -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 ) );

View File

@@ -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 );
} );
} );
} );