Merged in feature/117-dev-dev01 (pull request #8)

auto-patch  117-dev-dev01-2023-12-15T16_09_06

* auto-patch  117-dev-dev01-2023-12-15T16_09_06
This commit is contained in:
Tony Volpe
2023-12-15 16:10:57 +00:00
parent 0825f6bd5f
commit 3dc9eca989
1424 changed files with 28118 additions and 10097 deletions

View File

@@ -0,0 +1,49 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
/**
* Internal dependencies
*/
import Button, { ButtonProps } from '..';
const availableTypes = [ 'button', 'input', 'submit' ];
export default {
title: 'Base Components/Button',
argTypes: {
children: {
control: 'text',
},
type: {
control: 'radio',
options: availableTypes,
},
},
component: Button,
} as Meta< ButtonProps >;
const Template: Story< ButtonProps > = ( args ) => {
return <Button { ...args } />;
};
export const Default = Template.bind( {} );
Default.args = {
children: 'Buy Now',
disabled: false,
showSpinner: false,
type: 'button',
};
export const Disabled = Template.bind( {} );
Disabled.args = {
...Default.args,
disabled: true,
};
export const Loading = Template.bind( {} );
Loading.args = {
...Default.args,
disabled: true,
showSpinner: true,
};

View File

@@ -1,11 +1,11 @@
/**
* External dependencies
*/
import { isPostcode } from '@woocommerce/blocks-checkout';
import {
ValidatedTextInput,
isPostcode,
type ValidatedTextInputHandle,
} from '@woocommerce/blocks-checkout';
} from '@woocommerce/blocks-components';
import {
BillingCountryInput,
ShippingCountryInput,
@@ -180,6 +180,7 @@ const AddressForm = ( {
( fieldsRef.current[ field.key ] = el )
}
{ ...fieldProps }
type={ field.type }
value={ values[ field.key ] }
onChange={ ( newValue: string ) =>
onChange( {

View File

@@ -34,7 +34,8 @@ table.wc-block-cart-items {
}
.wc-block-cart-item__quantity {
.wc-block-cart-item__remove-link {
@include link-button;
@include link-button();
@include hover-effect();
@include font-size( smaller );
text-transform: none;
@@ -75,7 +76,7 @@ table.wc-block-cart-items {
.wc-block-cart-item__remove-link {
display: none;
}
&:not(.wc-block-mini-cart-items) {
&:not(.wc-block-mini-cart-items):not(:last-child) {
.wc-block-cart-items__row {
@include with-translucent-border( 0 0 1px );
}

View File

@@ -3,7 +3,7 @@
*/
import { __ } from '@wordpress/i18n';
import { useContainerWidthContext } from '@woocommerce/base-context';
import { Panel } from '@woocommerce/blocks-checkout';
import { Panel } from '@woocommerce/blocks-components';
import type { CartItem } from '@woocommerce/types';
/**

View File

@@ -5,7 +5,7 @@ import type { ShippingAddress } from '@woocommerce/settings';
import { useCustomerData } from '@woocommerce/base-context/hooks';
import { dispatch } from '@wordpress/data';
import { CART_STORE_KEY, processErrorResponse } from '@woocommerce/block-data';
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
import { StoreNoticesContainer } from '@woocommerce/blocks-components';
import { removeNoticesWithContext } from '@woocommerce/base-utils';
/**

View File

@@ -4,8 +4,7 @@
import classNames from 'classnames';
import { _n, sprintf } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import { Panel } from '@woocommerce/blocks-checkout';
import { Label } from '@woocommerce/blocks-components';
import { Label, Panel } from '@woocommerce/blocks-components';
import { useCallback } from '@wordpress/element';
import { useShippingData } from '@woocommerce/base-context/hooks';
import { sanitizeHTML } from '@woocommerce/utils';

View File

@@ -9,11 +9,11 @@ import { withInstanceId } from '@wordpress/compose';
import {
ValidatedTextInput,
ValidationInputError,
} from '@woocommerce/blocks-checkout';
} from '@woocommerce/blocks-components';
import { useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import classnames from 'classnames';
import type { MouseEvent } from 'react';
import type { MouseEvent, MouseEventHandler } from 'react';
/**
* Internal dependencies
@@ -62,18 +62,18 @@ export const TotalsCoupon = ( {
validationErrorId: store.getValidationErrorId( textInputId ),
};
} );
const handleCouponAnchorClick = (
e: MouseEvent< HTMLAnchorElement, MouseEvent >
const handleCouponAnchorClick: MouseEventHandler< HTMLAnchorElement > = (
e: MouseEvent< HTMLAnchorElement >
) => {
e.preventDefault();
setIsCouponFormHidden( false );
};
const handleCouponSubmit = (
e: MouseEvent< HTMLButtonElement, MouseEvent >
const handleCouponSubmit: MouseEventHandler< HTMLButtonElement > = (
e: MouseEvent< HTMLButtonElement >
) => {
e.preventDefault();
if ( onSubmit !== undefined ) {
onSubmit( couponValue ).then( ( result ) => {
if ( typeof onSubmit !== 'undefined' ) {
onSubmit( couponValue )?.then( ( result ) => {
if ( result ) {
setCouponValue( '' );
setIsCouponFormHidden( true );

View File

@@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { useArgs } from '@storybook/client-api';
import type { Story, Meta } from '@storybook/react';
import { INTERACTION_TIMEOUT } from '@woocommerce/storybook-controls';
import { useDispatch } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { TotalsCoupon, TotalsCouponProps } from '..';
export default {
title: 'Base Components/Totals/Coupon',
component: TotalsCoupon,
args: {
initialOpen: true,
},
} as Meta< TotalsCouponProps >;
const INVALID_COUPON_ERROR = {
hidden: false,
message: 'Invalid coupon code',
};
const Template: Story< TotalsCouponProps > = ( args ) => {
const [ {}, setArgs ] = useArgs();
const onSubmit = ( code: string ) => {
args.onSubmit?.( code );
setArgs( { isLoading: true } );
return new Promise( ( resolve ) => {
setTimeout( () => {
setArgs( { isLoading: false } );
resolve( true );
}, INTERACTION_TIMEOUT );
} );
};
return <TotalsCoupon { ...args } onSubmit={ onSubmit } />;
};
export const Default = Template.bind( {} );
Default.args = {};
export const LoadingState = Template.bind( {} );
LoadingState.args = {
isLoading: true,
};
export const ErrorState: Story< TotalsCouponProps > = ( args ) => {
const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY );
setValidationErrors( { coupon: INVALID_COUPON_ERROR } );
return <TotalsCoupon { ...args } />;
};
ErrorState.decorators = [
( StoryComponent ) => {
return <StoryComponent />;
},
];

View File

@@ -3,8 +3,8 @@
*/
import { __, sprintf } from '@wordpress/i18n';
import LoadingMask from '@woocommerce/base-components/loading-mask';
import { RemovableChip } from '@woocommerce/blocks-components';
import { applyCheckoutFilter, TotalsItem } from '@woocommerce/blocks-checkout';
import { RemovableChip, TotalsItem } from '@woocommerce/blocks-components';
import { applyCheckoutFilter } from '@woocommerce/blocks-checkout';
import { getSetting } from '@woocommerce/settings';
import {
CartResponseCouponItemWithLabel,

View File

@@ -0,0 +1,111 @@
/**
* External dependencies
*/
import { useArgs } from '@storybook/client-api';
import type { Story, Meta } from '@storybook/react';
import {
currenciesAPIShape as currencies,
currencyControl,
INTERACTION_TIMEOUT,
} from '@woocommerce/storybook-controls';
import {
CartResponseCouponItemWithLabel,
CartTotalsItem,
LooselyMustHave,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import Discount, { TotalsDiscountProps } from '..';
const EXAMPLE_COUPONS: CartResponseCouponItemWithLabel[] = [
{
code: 'AWSMSB',
discount_type: '',
label: 'Awesome Storybook coupon',
totals: {
...currencies.EUR,
total_discount: '5000',
total_discount_tax: '250',
},
},
{
code: 'STONKS',
discount_type: '',
label: 'Most valuable coupon',
totals: {
...currencies.EUR,
total_discount: '10000',
total_discount_tax: '1000',
},
},
];
function extractValuesFromCoupons(
coupons: LooselyMustHave< CartResponseCouponItemWithLabel, 'totals' >[]
) {
return coupons.reduce(
( acc, curr ) => {
const totalDiscount =
Number( acc.total_discount ) +
Number( curr.totals.total_discount );
const totalDiscountTax =
Number( acc.total_discount_tax ) +
Number( curr.totals.total_discount_tax );
return {
total_discount: String( totalDiscount ),
total_discount_tax: String( totalDiscountTax ),
};
},
{ total_discount: '0', total_discount_tax: '0' } as LooselyMustHave<
CartTotalsItem,
'total_discount' | 'total_discount_tax'
>
);
}
export default {
title: 'Base Components/Totals/Discount',
component: Discount,
argTypes: {
currency: currencyControl,
removeCoupon: { action: 'Removing coupon with code' },
},
args: {
cartCoupons: EXAMPLE_COUPONS,
isRemovingCoupon: false,
values: extractValuesFromCoupons( EXAMPLE_COUPONS ),
},
} as Meta< TotalsDiscountProps >;
const Template: Story< TotalsDiscountProps > = ( args ) => {
const [ {}, setArgs ] = useArgs();
const removeCoupon = ( code: string ) => {
args.removeCoupon( code );
setArgs( { isRemovingCoupon: true } );
const cartCoupons = args.cartCoupons.filter(
( coupon ) => coupon.code !== code
);
const values = extractValuesFromCoupons( cartCoupons );
setTimeout(
() => setArgs( { cartCoupons, values, isRemovingCoupon: false } ),
INTERACTION_TIMEOUT
);
};
return <Discount { ...args } removeCoupon={ removeCoupon } />;
};
export const Default = Template.bind( {} );
Default.args = {};
export const RemovingCoupon = Template.bind( {} );
RemovingCoupon.args = {
isRemovingCoupon: true,
};

View File

@@ -4,8 +4,11 @@
import { __, sprintf } from '@wordpress/i18n';
import classNames from 'classnames';
import { createInterpolateElement } from '@wordpress/element';
import { FormattedMonetaryAmount } from '@woocommerce/blocks-components';
import { applyCheckoutFilter, TotalsItem } from '@woocommerce/blocks-checkout';
import {
FormattedMonetaryAmount,
TotalsItem,
} from '@woocommerce/blocks-components';
import { applyCheckoutFilter } from '@woocommerce/blocks-checkout';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { getSetting } from '@woocommerce/settings';
import {

View File

@@ -0,0 +1,87 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
import { allSettings } from '@woocommerce/settings';
import { Currency } from '@woocommerce/types';
/**
* Internal dependencies
*/
import FooterItem, { TotalsFooterItemProps } from '..';
const NZD: Currency = {
code: 'NZD',
symbol: '$',
thousandSeparator: ' ',
decimalSeparator: '.',
minorUnit: 2,
prefix: '$',
suffix: '',
};
export default {
title: 'Base Components/Totals/FooterItem',
component: FooterItem,
args: {
currency: NZD,
values: { total_price: '2500', total_tax: '550' },
},
} as Meta< TotalsFooterItemProps >;
const Template: Story< TotalsFooterItemProps > = ( args ) => (
<FooterItem { ...args } />
);
export const Default = Template.bind( {} );
Default.decorators = [
( StoryComponent ) => {
allSettings.displayCartPricesIncludingTax = false;
return <StoryComponent />;
},
];
export const NoTaxLabel = Template.bind( {} );
NoTaxLabel.decorators = [
( StoryComponent ) => {
allSettings.displayCartPricesIncludingTax = true;
return <StoryComponent />;
},
];
export const SingleTaxLabel = Template.bind( {} );
SingleTaxLabel.args = {
values: {
total_price: '2500',
total_tax: '550',
tax_lines: [ { name: '10% VAT', price: '550', rate: '10.00' } ],
},
};
SingleTaxLabel.decorators = [
( StoryComponent ) => {
allSettings.displayCartPricesIncludingTax = true;
return <StoryComponent />;
},
];
export const MultipleTaxLabels = Template.bind( {} );
MultipleTaxLabels.args = {
values: {
total_price: '2500',
total_tax: '550',
tax_lines: [
{ name: '10% VAT', price: '300', rate: '10.00' },
{ name: '5% VAT', price: '250', rate: '5.00' },
],
},
};
MultipleTaxLabels.decorators = [
( StoryComponent ) => {
allSettings.displayCartPricesIncludingTax = true;
return <StoryComponent />;
},
];

View File

@@ -5,8 +5,8 @@ import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { TotalsItem } from '@woocommerce/blocks-checkout';
import type { Currency } from '@woocommerce/price-format';
import { TotalsItem } from '@woocommerce/blocks-components';
import type { Currency } from '@woocommerce/types';
import { ShippingVia } from '@woocommerce/base-components/cart-checkout/totals/shipping/shipping-via';
import {
isAddressComplete,

View File

@@ -2,10 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
formatShippingAddress,
isAddressComplete,
} from '@woocommerce/base-utils';
import { formatShippingAddress } from '@woocommerce/base-utils';
import { useEditorContext } from '@woocommerce/base-context';
import { ShippingAddress as ShippingAddressType } from '@woocommerce/settings';
import PickupLocation from '@woocommerce/base-components/cart-checkout/pickup-location';
@@ -31,15 +28,19 @@ export const ShippingAddress = ( {
setIsShippingCalculatorOpen,
shippingAddress,
}: ShippingAddressProps ): JSX.Element | null => {
const addressComplete = isAddressComplete( shippingAddress );
const { isEditor } = useEditorContext();
const prefersCollection = useSelect( ( select ) =>
select( CHECKOUT_STORE_KEY ).prefersCollection()
);
// If the address is incomplete, and we're not in the editor, don't show anything.
if ( ! addressComplete && ! isEditor ) {
const hasFormattedAddress = !! formatShippingAddress( shippingAddress );
// If there is no default customer location set in the store, the customer hasn't provided their address,
// but a default shipping method is available for all locations,
// then the shipping calculator will be hidden to avoid confusion.
if ( ! hasFormattedAddress && ! isEditor ) {
return null;
}
const formattedLocation = formatShippingAddress( shippingAddress );
return (
<>

View File

@@ -229,8 +229,11 @@ describe( 'TotalsShipping', () => {
currency={ {
code: 'USD',
symbol: '$',
position: 'left',
precision: 2,
minorUnit: 2,
decimalSeparator: '.',
prefix: '',
suffix: '',
thousandSeparator: ', ',
} }
values={ {
total_shipping: '0',
@@ -274,8 +277,11 @@ describe( 'TotalsShipping', () => {
currency={ {
code: 'USD',
symbol: '$',
position: 'left',
precision: 2,
minorUnit: 2,
decimalSeparator: '.',
prefix: '',
suffix: '',
thousandSeparator: ', ',
} }
values={ {
total_shipping: '0',
@@ -295,4 +301,51 @@ describe( 'TotalsShipping', () => {
screen.queryByText( 'Add an address for shipping options' )
).not.toBeInTheDocument();
} );
it( 'does show the calculator button when default rates are available and has formatted address', () => {
baseContextHooks.useStoreCart.mockReturnValue( {
cartItems: mockPreviewCart.items,
cartTotals: [ mockPreviewCart.totals ],
cartCoupons: mockPreviewCart.coupons,
cartFees: mockPreviewCart.fees,
cartNeedsShipping: mockPreviewCart.needs_shipping,
shippingRates: mockPreviewCart.shipping_rates,
shippingAddress: {
...shippingAddress,
city: '',
state: 'California',
country: 'US',
postcode: '',
},
billingAddress: mockPreviewCart.billing_address,
cartHasCalculatedShipping: mockPreviewCart.has_calculated_shipping,
isLoadingRates: false,
} );
render(
<SlotFillProvider>
<TotalsShipping
currency={ {
code: 'USD',
symbol: '$',
minorUnit: 2,
decimalSeparator: '.',
prefix: '',
suffix: '',
thousandSeparator: ', ',
} }
values={ {
total_shipping: '0',
total_shipping_tax: '0',
} }
showCalculator={ true }
showRateSelector={ true }
isCheckout={ false }
className={ '' }
/>
</SlotFillProvider>
);
expect( screen.queryByText( 'Change address' ) ).toBeInTheDocument();
expect(
screen.queryByText( 'Add an address for shipping options' )
).not.toBeInTheDocument();
} );
} );

View File

@@ -6,7 +6,7 @@ import { __ } from '@wordpress/i18n';
import { useEffect, useRef } from '@wordpress/element';
import { withInstanceId } from '@wordpress/compose';
import { ComboboxControl } from 'wordpress-components';
import { ValidationInputError } from '@woocommerce/blocks-checkout';
import { ValidationInputError } from '@woocommerce/blocks-components';
import { isObject } from '@woocommerce/types';
import { useDispatch, useSelect } from '@wordpress/data';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
@@ -121,14 +121,26 @@ const Combobox = ( {
// Try to match.
const normalizedFilterValue =
filterValue.toLocaleUpperCase();
const foundOption = options.find(
// Try to find an exact match first using values.
const foundValue = options.find(
( option ) =>
option.label
.toLocaleUpperCase()
.startsWith( normalizedFilterValue ) ||
option.value.toLocaleUpperCase() ===
normalizedFilterValue
normalizedFilterValue
);
if ( foundValue ) {
onChange( foundValue.value );
return;
}
// Fallback to a label match.
const foundOption = options.find( ( option ) =>
option.label
.toLocaleUpperCase()
.startsWith( normalizedFilterValue )
);
if ( foundOption ) {
onChange( foundOption.value );
}

View File

@@ -0,0 +1,67 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
import { useDispatch } from '@wordpress/data';
import { useState, useEffect } from '@wordpress/element';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { CountryInput, CountryInputWithCountriesProps } from '..';
import { countries } from './countries-filler';
type CountryCode = keyof typeof countries;
export default {
title: 'Base Components/CountryInput',
component: CountryInput,
args: {
countries,
autoComplete: 'off',
id: 'country',
label: 'Countries: ',
required: false,
},
argTypes: {
countries: { control: false },
options: { table: { disable: true } },
value: { control: false },
},
decorators: [ ( StoryComponent ) => <StoryComponent /> ],
} as Meta< CountryInputWithCountriesProps >;
const Template: Story< CountryInputWithCountriesProps > = ( args ) => {
const [ selectedCountry, selectCountry ] = useState< CountryCode | '' >(
''
);
const { clearValidationError, showValidationError } =
useDispatch( VALIDATION_STORE_KEY );
useEffect( () => {
showValidationError( 'country' );
}, [ showValidationError ] );
function updateCountry( country: CountryCode ) {
clearValidationError( 'country' );
selectCountry( country );
}
return (
<CountryInput
{ ...args }
onChange={ ( value ) => updateCountry( value as CountryCode ) }
value={ selectedCountry }
/>
);
};
export const Default = Template.bind( {} );
export const WithError = Template.bind( {} );
WithError.args = {
errorId: 'country',
errorMessage: 'Please select a country',
required: true,
};

View File

@@ -0,0 +1,41 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import FormTokenField, { Props } from '..';
export default {
title: 'Base Components/FormTokenField',
argTypes: {},
component: FormTokenField,
} as Meta< Props >;
const Template: Story< Props > = ( args ) => {
const [ selected, setSelected ] = useState< string[] >( [] );
return (
<FormTokenField
{ ...args }
value={ selected }
onChange={ ( tokens ) => setSelected( tokens ) }
/>
);
};
const suggestions = [ 'foo', 'bar', 'baz' ];
export const Default = Template.bind( {} );
Default.args = {
suggestions,
};
export const Disabled = Template.bind( {} );
Disabled.args = {
...Default.args,
disabled: true,
};

View File

@@ -10,7 +10,6 @@ export * from './filter-reset-button';
export * from './filter-submit-button';
export * from './form';
export * from './form-token-field';
export * from './label';
export * from './load-more-button';
export * from './loading-mask';
export * from './noninteractive';
@@ -26,9 +25,6 @@ export * from './read-more';
export * from './reviews';
export * from './sidebar-layout';
export * from './snackbar-list';
export * from './sort-select';
export * from './state-input';
export * from './summary';
export * from './tabs';
export * from './textarea';
export * from './title';

View File

@@ -0,0 +1,112 @@
import { Canvas, Meta, ArgTypes, Primary, Source } from '@storybook/blocks';
import * as NoticeBannerStories from '../stories/index.stories.tsx';
<Meta name="Docs" of={ NoticeBannerStories } />
# NoticeBanner
An informational UI displayed near the top of the store pages.
<Primary />
## Design Guidelines
`NoticeBanner` is an informational UI element displayed near the top of store pages used to indicate the result of an action, or to draw the user's attention to necessary information.
Notices are color-coded to indicate the type of message being communicated, and also show an icon to reinforce the meaning of the message. The color and icon used for a notice are determined by the `status` prop.
### Default Notices
By default, noices are grey and used for less important messaging.
<Canvas of={ NoticeBannerStories.Default } />
### Informational Notices
Blue notices with an info icon are used for general information for buyers, but do not require them to take an action.
<Canvas of={ NoticeBannerStories.Info } />
### Error Notices
Red notices with an alert icon are used to show that an error has occurred and that the user needs to take action.
<Canvas of={ NoticeBannerStories.Error } />
### Success Notices
Green notices with a success icon are used to show an action was successful.
<Canvas of={ NoticeBannerStories.Success } />
### Warning Notices
Yellow notices with an alert icon are used to show that the user may need to take action, or needs to be aware of something important.
<Canvas of={ NoticeBannerStories.Warning } />
### Error Summary
If you provide a `summary` it will be displayed above the notice content. This can be useful for displaying a summary of errors in a list format.
<Canvas of={ NoticeBannerStories.ErrorSummary } />
## Development Guidelines
### Props
<ArgTypes />
### Usage examples
#### Example: string based notices
To display a basic notice, pass the notice message as a string:
```jsx
import { NoticeBanner } from '@woocommerce/base-components';
<NoticeBanner status="info">Your message here</NoticeBanner>;
```
#### Example: components within notices
For more complex markup, you can wrap any JSX element:
```jsx
import { NoticeBanner } from '@woocommerce/base-components';
<NoticeBanner status="error">
<p>
An error occurred: <code>{ errorDetails }</code>.
</p>
</NoticeBanner>;
```
#### Example: list of notices
In this example, the summary prop is used to indicate to the user that there are errors in the form submission.
```typescript
import { NoticeBanner } from '@woocommerce/base-components';
const errorMessages = [
'First error message',
'Second error message',
'Third error message',
];
<NoticeBanner
status="error"
summary="There are errors in your form submission:"
>
<ul>
{ errorMessages.map( ( message ) => (
<li key={ message }>{ message }</li>
) ) }
</ul>
</NoticeBanner>;
```
The list of error messages is rendered within the NoticeBanner component using an unordered list (`<ul>`) and list items (`<li>`). The `status` prop is set to `error` to indicate that the notice represents an error message.

View File

@@ -14,29 +14,20 @@ import Button from '../button';
import { useSpokenMessage } from '../../hooks';
export interface NoticeBannerProps {
// The displayed message of a notice. Also used as the spoken message for assistive technology, unless `spokenMessage` is provided as an alternative message.
children: React.ReactNode;
// Additional class name to give to the notice.
className?: string | undefined;
// Determines whether the notice can be dismissed by the user.
isDismissible?: boolean | undefined;
// Function called when dismissing the notice.
onRemove?: ( () => void ) | undefined;
// Determines the level of politeness for the notice for assistive technology.
politeness?: 'polite' | 'assertive' | undefined;
// Optionally provided to change the spoken message for assistive technology.
spokenMessage?: string | React.ReactNode | undefined;
// Status determines the color of the notice and the icon.
className?: string;
isDismissible?: boolean;
onRemove?: () => void;
politeness?: 'polite' | 'assertive';
spokenMessage?: string | React.ReactNode;
status: 'success' | 'error' | 'info' | 'warning' | 'default';
// Optional summary text shown above notice content, used when several notices are listed together.
summary?: string | undefined;
summary?: string;
}
/**
* NoticeBanner: An informational UI displayed near the top of the store pages.
* NoticeBanner component.
*
* Notices are informational UI displayed near the top of store pages. WooCommerce blocks, themes, and plugins all use
* notices to indicate the result of an action, or to draw the users attention to necessary information.
* An informational UI displayed near the top of the store pages.
*/
const NoticeBanner = ( {
className,

View File

@@ -0,0 +1,109 @@
/**
* External dependencies
*/
import type { StoryFn, Meta } from '@storybook/react';
/**
* Internal dependencies
*/
import NoticeBanner, { NoticeBannerProps } from '../';
const availableStatus = [ 'default', 'success', 'error', 'warning', 'info' ];
export default {
title: 'Base Components/NoticeBanner',
argTypes: {
status: {
control: 'radio',
options: availableStatus,
description:
'Status determines the color of the notice and the icon.',
},
isDismissible: {
control: 'boolean',
description:
'Determines whether the notice can be dismissed by the user. When set to true, a close icon will be displayed on the banner.',
},
summary: {
description:
'Optional summary text shown above notice content, used when several notices are listed together.',
control: 'text',
},
className: {
description: 'Additional class name to give to the notice.',
control: 'text',
},
spokenMessage: {
description:
'Optionally provided to change the spoken message for assistive technology. If not provided, the `children` prop will be used as the spoken message.',
control: 'text',
},
politeness: {
control: 'radio',
options: [ 'polite', 'assertive' ],
description:
'Determines the level of politeness for the notice for assistive technology.',
},
children: {
description:
'The displayed message of a notice. Also used as the spoken message for assistive technology, unless `spokenMessage` is provided as an alternative message.',
disable: true,
},
onRemove: {
description:
'Function called when dismissing the notice. When the close icon is clicked or the Escape key is pressed, this function will be called.',
disable: true,
},
},
component: NoticeBanner,
} as Meta< NoticeBannerProps >;
const Template: StoryFn< NoticeBannerProps > = ( args ) => {
return <NoticeBanner { ...args } />;
};
export const Default = Template.bind( {} );
Default.args = {
children: 'This is a default notice',
status: 'default',
isDismissible: true,
summary: undefined,
className: undefined,
spokenMessage: undefined,
politeness: undefined,
};
export const Error = Template.bind( {} );
Error.args = {
children: 'This is an error notice',
status: 'error',
};
export const Warning = Template.bind( {} );
Warning.args = {
children: 'This is a warning notice',
status: 'warning',
};
export const Info = Template.bind( {} );
Info.args = {
children: 'This is an informational notice',
status: 'info',
};
export const Success = Template.bind( {} );
Success.args = {
children: 'This is a success notice',
status: 'success',
};
export const ErrorSummary = Template.bind( {} );
ErrorSummary.args = {
summary: 'Please fix the following errors',
children: (
<ul>
<li>This is an error notice</li>
<li>This is another error notice</li>
</ul>
),
status: 'error',
};

View File

@@ -15,6 +15,10 @@
padding: 0.3em 0.6em;
min-width: 2.2em;
&:not([disabled]) {
cursor: pointer;
}
@include breakpoint("<782px") {
padding: 0.1em 0.2em;
min-width: 1.6em;

View File

@@ -0,0 +1,60 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
import { useState } from '@wordpress/element';
import { currencies, currencyControl } from '@woocommerce/storybook-controls';
/**
* Internal dependencies
*/
import PriceSlider, { PriceSliderProps } from '..';
export default {
title: 'Base Components/PriceSlider',
component: PriceSlider,
args: {
currency: currencies.USD,
maxPrice: 5000,
maxConstraint: 5000,
minConstraint: 1000,
minPrice: 1000,
step: 250,
},
argTypes: {
currency: currencyControl,
maxPrice: { control: { disable: true } },
minPrice: { control: { disable: true } },
},
} as Meta< PriceSliderProps >;
const Template: Story< PriceSliderProps > = ( args ) => {
const { maxPrice, minPrice, ...props } = args;
// PriceSlider expects client to update min & max price, i.e. is a controlled component
const [ min, setMin ] = useState( minPrice );
const [ max, setMax ] = useState( maxPrice );
return (
<PriceSlider
{ ...props }
maxPrice={ max }
minPrice={ min }
onChange={ ( [ newMin, newMax ] ) => {
setMin( newMin );
setMax( newMax );
} }
/>
);
};
export const Default = Template.bind( {} );
export const WithoutInputs = Template.bind( {} );
WithoutInputs.args = {
showInputFields: false,
};
export const WithButton = Template.bind( {} );
WithButton.args = {
showFilterButton: true,
};

View File

@@ -2,7 +2,8 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import SortSelect from '@woocommerce/base-components/sort-select';
import { SortSelect } from '@woocommerce/blocks-components';
/**
* Internal dependencies
*/

View File

@@ -0,0 +1,32 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
/**
* Internal dependencies
*/
import ProductName, { ProductNameProps } from '..';
export default {
title: 'Base Components/ProductName',
component: ProductName,
args: {
name: 'Test product',
permalink: '#',
},
} as Meta< ProductNameProps >;
const Template: Story< ProductNameProps > = ( args ) => (
<ProductName { ...args } />
);
export const Default = Template.bind( {} );
Default.args = {
disabled: false,
};
export const DisabledProduct = Template.bind( {} );
DisabledProduct.args = {
disabled: true,
};

View File

@@ -280,7 +280,7 @@ const ProductPrice = ( {
console.error( 'Price formats need to include the `<price/>` tag.' );
}
const isDiscounted = regularPrice && price !== regularPrice;
const isDiscounted = regularPrice && price && price < regularPrice;
let priceComponent = (
<span
className={ classNames(

View File

@@ -0,0 +1,56 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
import { currencyControl } from '@woocommerce/storybook-controls';
/**
* Internal dependencies
*/
import ProductPrice, { ProductPriceProps } from '..';
const ALLOWED_ALIGN_VALUES = [ 'left', 'center', 'right' ];
export default {
title: 'Base Components/ProductPrice',
component: ProductPrice,
argTypes: {
align: {
control: { type: 'radio' },
options: ALLOWED_ALIGN_VALUES,
},
currency: currencyControl,
},
args: {
align: 'left',
format: '<price/>',
price: 3000,
currency: {
code: 'USD',
symbol: '$',
thousandSeparator: ' ',
decimalSeparator: '.',
minorUnit: 2,
prefix: '$',
suffix: '',
},
},
} as Meta< ProductPriceProps >;
const Template: Story< ProductPriceProps > = ( args ) => (
<ProductPrice { ...args } />
);
export const Default = Template.bind( {} );
Default.args = {};
export const Sale = Template.bind( {} );
Sale.args = {
regularPrice: 4500,
};
export const Range = Template.bind( {} );
Range.args = {
maxPrice: 5000,
minPrice: 3000,
};

View File

@@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { useArgs } from '@storybook/client-api';
import type { Story, Meta } from '@storybook/react';
/**
* Internal dependencies
*/
import QuantitySelector, { QuantitySelectorProps } from '..';
export default {
title: 'Base Components/QuantitySelector',
component: QuantitySelector,
args: {
itemName: 'widgets',
quantity: 1,
},
} as Meta< QuantitySelectorProps >;
const Template: Story< QuantitySelectorProps > = ( args ) => {
const [ {}, setArgs ] = useArgs();
const onChange = ( newVal: number ) => {
args.onChange?.( newVal );
setArgs( { quantity: newVal } );
};
return <QuantitySelector { ...args } onChange={ onChange } />;
};
export const Default = Template.bind( {} );
Default.args = {};
export const Disabled = Template.bind( {} );
Disabled.args = {
disabled: true,
};

View File

@@ -0,0 +1,70 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
/**
* Internal dependencies
*/
import ReadMore, { defaultProps, ReadMoreProps } from '..';
export default {
title: 'Base Components/ReadMore',
component: ReadMore,
args: defaultProps,
argTypes: {
children: { control: { disable: true } },
},
} as Meta< ReadMoreProps >;
const LongText = (
<>
<h1>
No! Alderaan is peaceful. We have no weapons. You can&apos;t
possibly
</h1>
<p>
As you wish. But with the blast shield down, I can&apos;t even see!
How am I supposed to fight? Look, I ain&apos;t in this for your
revolution, and I&apos;m not in it for you, Princess. I expect to be
well paid. I&apos;m in it for the money.
</p>
<p>
You mean it controls your actions?
<strong>
{ ' ' }
She must have hidden the plans in the escape pod.
</strong>{ ' ' }
<em>
Send a detachment down to retrieve them, and see to it
personally, Commander.
</em>
There&apos;ll be no one to stop us this time!
</p>
<h2>Escape is not his plan. I must face him, alone.</h2>
<ol>
<li>Partially, but it also obeys your commands.</li>
<li>
Leave that to me. Send a distress signal, and inform the Senate
that all on board were killed.
</li>
<li>
A tremor in the Force. The last time I felt it was in the
presence of my old master.
</li>
</ol>
<aside>
<a href="http://fillerama.io">
Content from http://fillerama.io &quot;Star Wars&quot;
</a>
</aside>
</>
);
const Template: Story< ReadMoreProps > = ( args ) => <ReadMore { ...args } />;
export const Default = Template.bind( {} );
Default.args = {
children: LongText,
maxLines: 6,
};

View File

@@ -2,7 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import SortSelect from '@woocommerce/base-components/sort-select';
import { SortSelect } from '@woocommerce/blocks-components';
import type { ChangeEventHandler } from 'react';
/**
@@ -12,7 +12,7 @@ import './style.scss';
interface ReviewSortSelectProps {
onChange: ChangeEventHandler;
readOnly: boolean;
readOnly?: boolean;
value: 'most-recent' | 'highest-rating' | 'lowest-rating';
}

View File

@@ -0,0 +1,90 @@
import { Canvas, Meta, ArgTypes } from '@storybook/blocks';
import * as SnackbarListStories from '../stories/index.stories.tsx';
import * as SnackbarStories from '../stories/snackbar.stories.tsx';
<Meta name="Docs" of={ SnackbarListStories } />
# SnackbarList
A temporary informational UI element displayed at the bottom of store pages.
<Canvas
of={ SnackbarListStories.Default }
layout="padded"
className={ 'force-canvas-height' }
/>
## Design Guidelines
The Snackbar is a temporary informational UI element displayed at the bottom of store pages. WooCommerce blocks, themes, and plugins all use snackbar notices to indicate the result of a successful action. For example, adding something to the cart.
Snackbar notices work in the same way as the NoticeBanner component, and support the same statuses and styles.
### Default Snackbars
By default, notices are grey and used for less important messaging.
<Canvas of={ SnackbarStories.Default } />
### Informational Snackbars
Blue notices with an info icon are used for general information for buyers, but do not require action.
<Canvas of={ SnackbarStories.Info } />
### Error Snackbars
Red notices with an alert icon are used to show that an error has occurred and that the user needs to take action.
<Canvas of={ SnackbarStories.Error } />
### Success Snackbars
Green notices with a success icon are used to show an action was successful.
<Canvas of={ SnackbarStories.Success } />
### Warning Snackbars
Yellow notices with an alert icon are used to show that the user may need to take action, or needs to be aware of something important.
<Canvas of={ SnackbarStories.Warning } />
## Development Guidelines
The component consuming `SnackbarList` is responsible for managing the notices state. The `SnackbarList` component will automatically remove notices from the list when they are dismissed by the user using the provided `onRemove` callback, and also when the notice times out after 10000ms.
### Props
<ArgTypes />
### NoticeType
Each notice can be provided with the following args.
- The `id` (required) prop is used to identify the notice and should be unique.
- The `content` (required) prop is the content to display in the notice.
- The `status` prop is used to determine the color of the notice and the icon. Acceptable values are 'success', 'error', 'info', 'warning', and 'default'.
- The `isDismissible` prop determines whether the notice can be dismissed by the user.
- The `spokenMessage` prop is used to change the spoken message for assistive technology. If not provided, the `content` prop will be used as the spoken message.
### Usage example
To display snackbar notices, pass an array of `notices` to the `SnackbarList` component:
```jsx
import { SnackbarList } from '@woocommerce/base-components';
const notices = [
{
id: '1',
content: 'This is a snackbar notice.',
status: 'default',
isDismissible: true,
spokenMessage: "Hello snackbar!"
}
];
<SnackbarList notices={ notices }>;
```

View File

@@ -2,6 +2,7 @@
* External dependencies
*/
import { useEffect } from '@wordpress/element';
import classNames from 'classnames';
/**
* Internal dependencies
@@ -9,15 +10,18 @@ import { useEffect } from '@wordpress/element';
import NoticeBanner, { NoticeBannerProps } from '../notice-banner';
import { SNACKBAR_TIMEOUT } from './constants';
const Snackbar = ( {
export interface SnackbarProps extends NoticeBannerProps {
// A ref to the list that contains the snackbar.
listRef?: React.MutableRefObject< HTMLDivElement | null >;
}
export const Snackbar = ( {
onRemove = () => void 0,
children,
listRef,
className,
...notice
}: {
// A ref to the list that contains the snackbar.
listRef?: React.MutableRefObject< HTMLDivElement | null >;
} & NoticeBannerProps ) => {
}: SnackbarProps ) => {
// Only set up the timeout dismiss if we're not explicitly dismissing.
useEffect( () => {
const timeoutHandle = setTimeout( () => {
@@ -29,6 +33,10 @@ const Snackbar = ( {
return (
<NoticeBanner
className={ classNames(
className,
'wc-block-components-notice-snackbar'
) }
{ ...notice }
onRemove={ () => {
// Prevent focus loss by moving it to the list element.

View File

@@ -0,0 +1,84 @@
/**
* External dependencies
*/
import type { StoryFn, Meta } from '@storybook/react';
/**
* Internal dependencies
*/
import SnackbarList, { SnackbarListProps } from '../';
export default {
title: 'Base Components/SnackbarList',
args: {
notices: [
{
id: '1',
content: 'This is a snackbar notice.',
status: 'success',
isDismissible: true,
},
],
className: undefined,
onRemove: () => void 0,
},
argTypes: {
className: {
description: 'Additional class name to give to the notice.',
control: 'text',
},
notices: {
description:
'A list of notices to display as snackbars. Each notice must have an `id` and `content` prop.',
disable: true,
},
onRemove: {
description:
'Function called when dismissing the notice. When the close icon is clicked or the Escape key is pressed, this function will be called. This is also called when the notice times out after 10000ms.',
disable: true,
},
},
component: SnackbarList,
} as Meta< SnackbarListProps >;
const Template: StoryFn< SnackbarListProps > = ( args ) => {
return <SnackbarList { ...args } />;
};
export const Default = Template.bind( {} );
Default.args = {
notices: [
{
id: '1',
content: 'This is a snackbar notice.',
status: 'default',
isDismissible: true,
},
{
id: '2',
content: 'This is an informational snackbar notice.',
status: 'info',
isDismissible: true,
},
{
id: '3',
content: 'This is a snackbar error notice.',
status: 'error',
isDismissible: true,
},
{
id: '4',
content: 'This is a snackbar warning notice.',
status: 'warning',
isDismissible: true,
},
{
id: '5',
content: 'This is a snackbar success notice.',
status: 'success',
isDismissible: true,
},
],
className: undefined,
onRemove: () => void 0,
};

View File

@@ -0,0 +1,97 @@
/**
* External dependencies
*/
import type { StoryFn, Meta } from '@storybook/react';
/**
* Internal dependencies
*/
import Snackbar, { SnackbarProps } from '../snackbar';
const availableStatus = [ 'default', 'success', 'error', 'warning', 'info' ];
export default {
title: 'Base Components/SnackbarList/Snackbar',
argTypes: {
status: {
control: 'radio',
options: availableStatus,
description:
'Status determines the color of the notice and the icon.',
},
isDismissible: {
control: 'boolean',
description:
'Determines whether the notice can be dismissed by the user. When set to true, a close icon will be displayed on the banner.',
},
summary: {
description:
'Optional summary text shown above notice content, used when several notices are listed together.',
control: 'text',
},
className: {
description: 'Additional class name to give to the notice.',
control: 'text',
},
spokenMessage: {
description:
'Optionally provided to change the spoken message for assistive technology. If not provided, the `children` prop will be used as the spoken message.',
control: 'text',
},
politeness: {
control: 'radio',
options: [ 'polite', 'assertive' ],
description:
'Determines the level of politeness for the notice for assistive technology.',
},
children: {
description:
'The displayed message of a notice. Also used as the spoken message for assistive technology, unless `spokenMessage` is provided as an alternative message.',
disable: true,
},
onRemove: {
description:
'Function called when dismissing the notice. When the close icon is clicked or the Escape key is pressed, this function will be called.',
disable: true,
},
},
component: Snackbar,
} as Meta< SnackbarProps >;
const Template: StoryFn< SnackbarProps > = ( args ) => {
return <Snackbar { ...args } />;
};
export const Default = Template.bind( {} );
Default.args = {
children: 'This is a default snackbar notice',
status: 'default',
isDismissible: true,
summary: undefined,
className: undefined,
spokenMessage: undefined,
politeness: undefined,
};
export const Error = Template.bind( {} );
Error.args = {
children: 'This is an error snackbar notice',
status: 'error',
};
export const Warning = Template.bind( {} );
Warning.args = {
children: 'This is a warning snackbar notice',
status: 'warning',
};
export const Info = Template.bind( {} );
Info.args = {
children: 'This is an informational snackbar notice',
status: 'info',
};
export const Success = Template.bind( {} );
Success.args = {
children: 'This is a success snackbar notice',
status: 'success',
};

View File

@@ -9,25 +9,32 @@
bottom: $gap-large;
left: $gap-large;
right: $gap-large;
}
.wc-block-components-notice-banner {
display: inline-flex;
width: auto;
max-width: 600px;
pointer-events: all;
border: 1px solid transparent;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
position: relative;
margin: $gap-large 0 0;
.wc-block-components-notice-snackbar-list .wc-block-components-notice-banner,
.wc-block-components-notice-banner.wc-block-components-notice-snackbar {
display: inline-flex;
width: auto;
max-width: 600px;
pointer-events: all;
border: 1px solid transparent;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
position: relative;
margin: $gap-large $gap 0 0;
&.is-default {
border-color: $gray-800;
}
&.is-error,
&.is-info,
&.is-success {
border-color: transparent;
}
@include breakpoint("<782px") {
width: 100%;
max-width: none;
}
&.is-default {
border-color: $gray-800;
}
@include breakpoint("<782px") {
width: 100%;
max-width: none;
}
}

View File

@@ -5,7 +5,7 @@ import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import { useCallback, useMemo, useEffect, useRef } from '@wordpress/element';
import classnames from 'classnames';
import { ValidatedTextInput } from '@woocommerce/blocks-checkout';
import { ValidatedTextInput } from '@woocommerce/blocks-components';
/**
* Internal dependencies

View File

@@ -0,0 +1,54 @@
/**
* External dependencies
*/
import type { Story, Meta } from '@storybook/react';
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { __TabsWithoutInstanceId as Tabs, TabsProps } from '..';
export default {
title: 'Base Components/Tabs',
component: Tabs,
args: {
tabs: [
{
name: 'firstTab',
title: 'First Tab',
content: <div>Content of the first tab</div>,
},
{
name: 'secondTab',
title: 'Second Tab',
content: <div>Content of the second tab</div>,
},
],
initialTabName: 'firstTab',
},
argTypes: {
initialTabName: {
control: {
type: 'select',
options: [ 'firstTab', 'secondTab' ],
},
},
},
} as Meta< TabsProps >;
const Template: Story< TabsProps > = ( args ) => {
const [ initialTab, setInitialTab ] = useState( args.initialTabName );
return (
<Tabs
initialTabName={ initialTab }
onSelect={ ( newTabName ) => {
setInitialTab( newTabName );
} }
{ ...args }
/>
);
};
export const Default = Template.bind( {} );

View File

@@ -16,7 +16,7 @@ import {
PAYMENT_STORE_KEY,
CART_STORE_KEY,
} from '@woocommerce/block-data';
import { ValidationInputError } from '@woocommerce/blocks-checkout';
import { ValidationInputError } from '@woocommerce/blocks-components';
/**
* Internal dependencies

View File

@@ -24,8 +24,6 @@ interface CheckoutAddress {
setShippingAddress: ( data: Partial< ShippingAddress > ) => void;
setBillingAddress: ( data: Partial< BillingAddress > ) => void;
setEmail: ( value: string ) => void;
setBillingPhone: ( value: string ) => void;
setShippingPhone: ( value: string ) => void;
useShippingAsBilling: boolean;
setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void;
defaultAddressFields: AddressFields;
@@ -59,28 +57,13 @@ export const useCheckoutAddress = (): CheckoutAddress => {
} = useCustomerData();
const setEmail = useCallback(
( value ) =>
( value: string ) =>
void setBillingAddress( {
email: value,
} ),
[ setBillingAddress ]
);
const setBillingPhone = useCallback(
( value ) =>
void setBillingAddress( {
phone: value,
} ),
[ setBillingAddress ]
);
const setShippingPhone = useCallback(
( value ) =>
void setShippingAddress( {
phone: value,
} ),
[ setShippingAddress ]
);
const forcedBillingAddress: boolean = getSetting(
'forcedBillingAddress',
false
@@ -91,8 +74,6 @@ export const useCheckoutAddress = (): CheckoutAddress => {
setShippingAddress,
setBillingAddress,
setEmail,
setBillingPhone,
setShippingPhone,
defaultAddressFields,
useShippingAsBilling,
setUseShippingAsBilling: __internalSetUseShippingAsBilling,
@@ -101,8 +82,8 @@ export const useCheckoutAddress = (): CheckoutAddress => {
! forcedBillingAddress && needsShipping && ! prefersCollection,
showShippingMethods: needsShipping && ! prefersCollection,
showBillingFields:
! needsShipping || ! useShippingAsBilling || prefersCollection,
! needsShipping || ! useShippingAsBilling || !! prefersCollection,
forcedBillingAddress,
useBillingAsShipping: forcedBillingAddress || prefersCollection,
useBillingAsShipping: forcedBillingAddress || !! prefersCollection,
};
};

View File

@@ -35,12 +35,10 @@ describe( 'formatError', () => {
const mockResponse = new Response( mockMalformedJson, { status: 400 } );
const error = await formatError( mockResponse );
const expectedError = {
message:
'invalid json response body at reason: Unexpected end of JSON input',
type: 'general',
};
expect( error ).toEqual( expectedError );
expect( error.message ).toContain(
'invalid json response body at reason:'
);
expect( error.type ).toEqual( 'general' );
} );
} );