* Add Typescript to Panel and Icon

* Fix Icon component import

* Convert packages/checkout/utils/validation/index to TypeScript

* Convert checkout registry to TypeScript

* Add return type to mustContain

* Add TypeScript to Totals components from @woocommerce/blocks-checkout

* Add TypeScript to @woocommerce/price-format

* Use types from @woocommerce/type-defs when possible

* Allow empty objects when loading

* Fix formatting in payment-method-data-context.js

* Add missing return types

* Fix up price warnings

* Fix more warnings in FormattedMonetaryAmount

Co-authored-by: Raluca Stan <ralucastn@gmail.com>
Co-authored-by: Thomas Roberts <thomas.roberts@automattic.com>
Co-authored-by: Mike Jolley <mike.jolley@me.com>
This commit is contained in:
Albert Juhé Lluveras 2021-03-05 15:03:48 +01:00 committed by GitHub
parent ba16cf9b74
commit 0e1b1e3579
19 changed files with 299 additions and 194 deletions

View File

@ -33,20 +33,22 @@ const currencyToNumberFormat = ( currency ) => {
* Takes a price and returns a formatted price using the NumberFormat component.
*
* @param {Object} props Component props.
* @param {string} props.className CSS class used.
* @param {string=} props.className CSS class used.
* @param {number} props.value Value of money amount.
* @param {Object} props.currency Currency configuration object.
* @param {function():any} props.onValueChange Function to call when value changes.
* @param {Object} props.props Rest of props passed into component.
* @param {function():any=} props.onValueChange Function to call when value changes.
* @param {string=} props.displayType Display type.
* @param {Object=} props.props Rest of props passed into component.
*/
const FormattedMonetaryAmount = ( {
className,
className = '',
value,
currency,
onValueChange,
onValueChange = () => {},
displayType = 'text',
...props
} ) => {
if ( value === '-' ) {
if ( ! Number.isFinite( value ) ) {
return null;
}
@ -62,7 +64,7 @@ const FormattedMonetaryAmount = ( {
className
);
const numberFormatProps = {
displayType: 'text',
displayType,
...props,
...currencyToNumberFormat( currency ),
value: undefined,

View File

@ -3,7 +3,7 @@
*/
import { Fragment } from '@wordpress/element';
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import type { ReactElement, HTMLAttributes } from 'react';
interface LabelProps {
label?: string;
@ -23,7 +23,7 @@ const Label = ( {
screenReaderLabel,
wrapperElement,
wrapperProps = {},
}: LabelProps ): JSX.Element => {
}: LabelProps ): ReactElement => {
let Wrapper;
const hasLabel = typeof label !== 'undefined' && label !== null;

View File

@ -1,23 +0,0 @@
/**
* External dependencies
*/
import { cloneElement, isValidElement } from 'wordpress-element';
import PropTypes from 'prop-types';
function Icon( { srcElement, size = 24, ...props } ) {
return (
isValidElement( srcElement ) &&
cloneElement( srcElement, {
width: size,
height: size,
...props,
} )
);
}
Icon.propTypes = {
srcElement: PropTypes.element,
size: PropTypes.number,
};
export default Icon;

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { cloneElement, isValidElement } from 'wordpress-element';
import type { ReactElement } from 'react';
interface IconProps {
srcElement?: ReactElement;
size?: number;
className?: string;
}
function Icon( {
srcElement,
size = 24,
...props
}: IconProps ): ReactElement | null {
if ( ! isValidElement( srcElement ) ) {
return null;
}
return cloneElement( srcElement, {
width: size,
height: size,
...props,
} );
}
export default Icon;

View File

@ -0,0 +1,6 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {},
"include": [ "." ],
"exclude": [ "**/test/**" ]
}

View File

@ -2,8 +2,8 @@
* External dependencies
*/
import { useState } from '@wordpress/element';
import type { ReactChildren, ReactNode, ReactElement } from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { Icon, chevronUp, chevronDown } from '@woocommerce/icons';
/**
@ -11,6 +11,15 @@ import { Icon, chevronUp, chevronDown } from '@woocommerce/icons';
*/
import './style.scss';
interface PanelProps {
children?: ReactChildren;
className?: string;
initialOpen?: boolean;
hasBorder?: boolean;
title?: ReactNode;
titleTag?: keyof JSX.IntrinsicElements;
}
const Panel = ( {
children,
className,
@ -18,8 +27,8 @@ const Panel = ( {
hasBorder = false,
title,
titleTag: TitleTag = 'div',
} ) => {
const [ isOpen, setIsOpen ] = useState( initialOpen );
}: PanelProps ): ReactElement => {
const [ isOpen, setIsOpen ] = useState< boolean >( initialOpen );
return (
<div
@ -51,12 +60,4 @@ const Panel = ( {
);
};
Panel.propTypes = {
className: PropTypes.string,
hasBorder: PropTypes.bool,
initialOpen: PropTypes.bool,
title: PropTypes.node,
titleTag: PropTypes.string,
};
export default Panel;

View File

@ -9,16 +9,30 @@ import { CURRENT_USER_IS_ADMIN } from '@woocommerce/block-settings';
*/
import { returnTrue } from '../';
let checkoutFilters = {};
type CheckoutFilterFunction = < T >(
label: T,
extensions: Record< string, unknown >,
args?: CheckoutFilterArguments
) => T;
type CheckoutFilterArguments =
| ( Record< string, unknown > & {
context?: string;
} )
| null;
let checkoutFilters: Record<
string,
Record< string, CheckoutFilterFunction >
> = {};
/**
* Register filters for a specific extension.
*
* @param {string} namespace Name of the extension namespace.
* @param {Object} filters Object of filters for that namespace. Each key of
* the object is the name of a filter.
*/
export const __experimentalRegisterCheckoutFilters = ( namespace, filters ) => {
export const __experimentalRegisterCheckoutFilters = (
namespace: string,
filters: Record< string, CheckoutFilterFunction >
): void => {
checkoutFilters = {
...checkoutFilters,
[ namespace ]: filters,
@ -32,7 +46,7 @@ export const __experimentalRegisterCheckoutFilters = ( namespace, filters ) => {
* @return {Function[]} Array of functions that are registered for that filter
* name.
*/
const getCheckoutFilters = ( filterName ) => {
const getCheckoutFilters = ( filterName: string ): CheckoutFilterFunction[] => {
const namespaces = Object.keys( checkoutFilters );
const filters = namespaces
.map( ( namespace ) => checkoutFilters[ namespace ][ filterName ] )
@ -42,18 +56,6 @@ const getCheckoutFilters = ( filterName ) => {
/**
* Apply a filter.
*
* @param {Object} o Object of arguments.
* @param {string} o.filterName Name of the filter to apply.
* @param {any} o.defaultValue Default value to filter.
* @param {Object} [o.extensions] Values extend to REST API response.
* @param {any} [o.arg] Argument to pass to registered functions.
* If several arguments need to be passed, use
* an object.
* @param {Function} [o.validation] Function that needs to return true when
* the filtered value is passed in order for
* the filter to be applied.
* @return {any} Filtered value.
*/
export const __experimentalApplyCheckoutFilter = ( {
filterName,
@ -61,7 +63,18 @@ export const __experimentalApplyCheckoutFilter = ( {
extensions,
arg = null,
validation = returnTrue,
} ) => {
}: {
/** Name of the filter to apply. */
filterName: string;
/** Default value to filter. */
defaultValue: unknown;
/** Values extend to REST API response. */
extensions: Record< string, unknown >;
/** Object containing arguments for the filter function. */
arg: CheckoutFilterArguments;
/** Function that needs to return true when the filtered value is passed in order for the filter to be applied. */
validation: ( value: unknown ) => boolean;
} ): unknown => {
return useMemo( () => {
const filters = getCheckoutFilters( filterName );

View File

@ -4,14 +4,26 @@
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { DISPLAY_CART_PRICES_INCLUDING_TAX } from '@woocommerce/block-settings';
import PropTypes from 'prop-types';
import type { Currency } from '@woocommerce/price-format';
import type { CartFeeItem } from '@woocommerce/type-defs/cart';
import type { ReactElement } from 'react';
/**
* Internal dependencies
*/
import TotalsItem from '../item';
const TotalsFees = ( { currency, cartFees, className } ) => {
interface TotalsFeesProps {
currency: Currency;
cartFees: CartFeeItem[];
className?: string;
}
const TotalsFees = ( {
currency,
cartFees,
className,
}: TotalsFeesProps ): ReactElement | null => {
return (
<>
{ cartFees.map( ( { id, name, totals } ) => {
@ -46,10 +58,4 @@ const TotalsFees = ( { currency, cartFees, className } ) => {
);
};
TotalsFees.propTypes = {
currency: PropTypes.object.isRequired,
cartFees: PropTypes.array.isRequired,
className: PropTypes.string,
};
export default TotalsFees;

View File

@ -1,52 +0,0 @@
/**
* External dependencies
*/
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { isValidElement } from '@wordpress/element';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
/**
* Internal dependencies
*/
import './style.scss';
const TotalsItem = ( { className, currency, label, value, description } ) => {
return (
<div
className={ classnames(
'wc-block-components-totals-item',
className
) }
>
<span className="wc-block-components-totals-item__label">
{ label }
</span>
{ isValidElement( value ) ? (
<div className="wc-block-components-totals-item__value">
{ value }
</div>
) : (
<FormattedMonetaryAmount
className="wc-block-components-totals-item__value"
currency={ currency }
displayType="text"
value={ value }
/>
) }
<div className="wc-block-components-totals-item__description">
{ description }
</div>
</div>
);
};
TotalsItem.propTypes = {
currency: PropTypes.object,
label: PropTypes.string.isRequired,
value: PropTypes.oneOfType( [ PropTypes.number, PropTypes.node ] ),
className: PropTypes.string,
description: PropTypes.node,
};
export default TotalsItem;

View File

@ -0,0 +1,71 @@
/**
* External dependencies
*/
import classnames from 'classnames';
import { isValidElement } from '@wordpress/element';
import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-monetary-amount';
import type { ReactElement, ReactNode } from 'react';
import type { Currency } from '@woocommerce/price-format';
/**
* Internal dependencies
*/
import './style.scss';
interface TotalsItemProps {
className?: string;
currency: Currency;
label: string;
// Value may be a number, or react node. Numbers are passed to FormattedMonetaryAmount.
value: number | ReactNode;
description?: ReactNode;
}
const TotalsItemValue = ( {
value,
currency,
}: Partial< TotalsItemProps > ): ReactElement | null => {
if ( isValidElement( value ) ) {
return (
<div className="wc-block-components-totals-item__value">
{ value }
</div>
);
}
return Number.isFinite( value ) ? (
<FormattedMonetaryAmount
className="wc-block-components-totals-item__value"
currency={ currency || {} }
displayType="text"
value={ value as number }
/>
) : null;
};
const TotalsItem = ( {
className,
currency,
label,
value,
description,
}: TotalsItemProps ): ReactElement => {
return (
<div
className={ classnames(
'wc-block-components-totals-item',
className
) }
>
<span className="wc-block-components-totals-item__label">
{ label }
</span>
<TotalsItemValue value={ value } currency={ currency } />
<div className="wc-block-components-totals-item__description">
{ description }
</div>
</div>
);
};
export default TotalsItem;

View File

@ -3,14 +3,32 @@
*/
import { __ } from '@wordpress/i18n';
import { DISPLAY_CART_PRICES_INCLUDING_TAX } from '@woocommerce/block-settings';
import PropTypes from 'prop-types';
import type { Currency } from '@woocommerce/price-format';
import type { ReactElement } from 'react';
/**
* Internal dependencies
*/
import TotalsItem from '../item';
const Subtotal = ( { currency, values, className } ) => {
interface Values {
// eslint-disable-next-line camelcase
total_items: string;
// eslint-disable-next-line camelcase
total_items_tax: string;
}
interface SubtotalProps {
className?: string;
currency: Currency;
values: Values | Record< string, never >;
}
const Subtotal = ( {
currency,
values,
className,
}: SubtotalProps ): ReactElement => {
const { total_items: totalItems, total_items_tax: totalItemsTax } = values;
const itemsValue = parseInt( totalItems, 10 );
const itemsTaxValue = parseInt( totalItemsTax, 10 );
@ -29,13 +47,4 @@ const Subtotal = ( { currency, values, className } ) => {
);
};
Subtotal.propTypes = {
currency: PropTypes.object.isRequired,
values: PropTypes.shape( {
total_items: PropTypes.string,
total_items_tax: PropTypes.string,
} ).isRequired,
className: PropTypes.string,
};
export default Subtotal;

View File

@ -3,18 +3,37 @@
*/
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import PropTypes from 'prop-types';
import {
TAXES_ENABLED,
DISPLAY_ITEMIZED_TAXES,
} from '@woocommerce/block-settings';
import type { Currency } from '@woocommerce/price-format';
import type { CartTotalsTaxLineItem } from '@woocommerce/type-defs/cart';
import { ReactElement } from 'react';
/**
* Internal dependencies
*/
import TotalsItem from '../item';
const TotalsTaxes = ( { currency, values, className } ) => {
interface Values {
// eslint-disable-next-line camelcase
tax_lines: CartTotalsTaxLineItem[];
// eslint-disable-next-line camelcase
total_tax: string;
}
interface TotalsTaxesProps {
className?: string;
currency: Currency;
values: Values | Record< string, never >;
}
const TotalsTaxes = ( {
currency,
values,
className,
}: TotalsTaxesProps ): ReactElement | null => {
const { total_tax: totalTax, tax_lines: taxLines } = values;
if ( ! TAXES_ENABLED ) {
@ -50,12 +69,4 @@ const TotalsTaxes = ( { currency, values, className } ) => {
);
};
TotalsTaxes.propTypes = {
currency: PropTypes.object.isRequired,
values: PropTypes.shape( {
total_tax: PropTypes.string,
} ).isRequired,
className: PropTypes.string,
};
export default TotalsTaxes;

View File

@ -5,12 +5,8 @@ import { __, sprintf } from '@wordpress/i18n';
/**
* Checks if value passed is a string, throws an error if not.
*
* @param {string} value Value to be validated.
*
* @return {Error|true} Error if value is not string, true otherwise.
*/
export const mustBeString = ( value ) => {
export const mustBeString = ( value: unknown ): true | Error => {
if ( typeof value !== 'string' ) {
throw Error(
sprintf(
@ -27,14 +23,9 @@ export const mustBeString = ( value ) => {
};
/**
* Checks if value passed contain passed label
*
* @param {string} value Value to be validated.
* @param {string} label Label to be searched for.
*
* @return {Error|true} Error if value contains label, true otherwise.
* Checks if value passed contain passed label.
*/
export const mustContain = ( value, label ) => {
export const mustContain = ( value: string, label: string ): true | Error => {
if ( ! value.includes( label ) ) {
throw Error(
sprintf(
@ -55,8 +46,5 @@ export const mustContain = ( value, label ) => {
* A function that always return true.
* We need to have a single instance of this function so it doesn't
* invalidate our memo comparison.
*
*
* @return {true} Returns true.
*/
export const returnTrue = () => true;
export const returnTrue = (): true => true;

View File

@ -1 +1,2 @@
export * from './utils';
export * from './types';

View File

@ -0,0 +1,9 @@
export interface Currency {
code: string;
decimalSeparator: string;
minorUnit: number;
prefix: string;
suffix: string;
symbol: string;
thousandSeparator: string;
}

View File

@ -2,14 +2,24 @@
* External dependencies
*/
import { CURRENCY } from '@woocommerce/settings';
import type { CurrencyResponseInfo } from '@woocommerce/type-defs/cart-response';
/**
* Internal dependencies
*/
import type { Currency } from '../types';
type SymbolPosition = 'left' | 'left_space' | 'right' | 'right_space';
/**
* Get currency prefix.
*
* @param {string} symbol Currency symbol.
* @param {string} symbolPosition Position of currency symbol from settings.
*/
const getPrefix = ( symbol, symbolPosition ) => {
const getPrefix = (
// Currency symbol.
symbol: string,
// Position of currency symbol from settings.
symbolPosition: SymbolPosition
): string => {
const prefixes = {
left: symbol,
left_space: ' ' + symbol,
@ -21,11 +31,13 @@ const getPrefix = ( symbol, symbolPosition ) => {
/**
* Get currency suffix.
*
* @param {string} symbol Currency symbol.
* @param {string} symbolPosition Position of currency symbol from settings.
*/
const getSuffix = ( symbol, symbolPosition ) => {
const getSuffix = (
// Currency symbol.
symbol: string,
// Position of currency symbol from settings.
symbolPosition: SymbolPosition
): string => {
const suffixes = {
left: '',
left_space: '',
@ -38,23 +50,29 @@ const getSuffix = ( symbol, symbolPosition ) => {
/**
* Currency information in normalized format from server settings.
*/
const siteCurrencySettings = {
const siteCurrencySettings: Currency = {
code: CURRENCY.code,
symbol: CURRENCY.symbol,
thousandSeparator: CURRENCY.thousandSeparator,
decimalSeparator: CURRENCY.decimalSeparator,
minorUnit: CURRENCY.precision,
prefix: getPrefix( CURRENCY.symbol, CURRENCY.symbolPosition ),
suffix: getSuffix( CURRENCY.symbol, CURRENCY.symbolPosition ),
prefix: getPrefix(
CURRENCY.symbol,
CURRENCY.symbolPosition as SymbolPosition
),
suffix: getSuffix(
CURRENCY.symbol,
CURRENCY.symbolPosition as SymbolPosition
),
};
/**
* Gets currency information in normalized format from an API response or the server.
*
* @param {Object} currencyData Currency data object, for example an API response containing currency formatting data.
* @return {Object} Normalized currency info.
*/
export const getCurrencyFromPriceResponse = ( currencyData ) => {
export const getCurrencyFromPriceResponse = (
// Currency data object, for example an API response containing currency formatting data.
currencyData: CurrencyResponseInfo | Record< string, never >
): Currency => {
if ( ! currencyData || typeof currencyData !== 'object' ) {
return siteCurrencySettings;
}
@ -84,11 +102,10 @@ export const getCurrencyFromPriceResponse = ( currencyData ) => {
/**
* Gets currency information in normalized format, allowing overrides.
*
* @param {Object} currencyData Currency data object.
* @return {Object} Normalized currency info.
*/
export const getCurrency = ( currencyData = {} ) => {
export const getCurrency = (
currencyData: Partial< Currency > = {}
): Currency => {
return {
...siteCurrencySettings,
...currencyData,
@ -98,24 +115,27 @@ export const getCurrency = ( currencyData = {} ) => {
/**
* Format a price, provided using the smallest unit of the currency, as a
* decimal complete with currency symbols using current store settings.
*
* @param {number|string} price Price in minor unit, e.g. cents.
* @param {Object} currencyData Currency data object.
*/
export const formatPrice = ( price, currencyData ) => {
export const formatPrice = (
// Price in minor unit, e.g. cents.
price: number | string,
currencyData: Currency
): string => {
if ( price === '' || price === undefined ) {
return '';
}
const priceInt = parseInt( price, 10 );
const priceInt: number =
typeof price === 'number' ? price : parseInt( price, 10 );
if ( ! Number.isFinite( priceInt ) ) {
return '';
}
const currency = getCurrency( currencyData );
const formattedPrice = priceInt / 10 ** currency.minorUnit;
const formattedValue = currency.prefix + formattedPrice + currency.suffix;
const currency: Currency = getCurrency( currencyData );
const formattedPrice: number = priceInt / 10 ** currency.minorUnit;
const formattedValue: string =
currency.prefix + formattedPrice + currency.suffix;
// This uses a textarea to magically decode HTML currency symbols.
const txt = document.createElement( 'textarea' );

View File

@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {},
"include": [
".",
"../assets/js/icons",
"../assets/js/settings",
"../assets/js/type-defs",
"../assets/js/base/components"
],
"exclude": [ "**/test/**" ]
}

View File

@ -46,6 +46,7 @@
"@woocommerce/icons": [ "assets/js/icons" ],
"@woocommerce/resource-previews": [ "assets/js/previews" ],
"@woocommerce/knobs": [ "storybook/knobs" ],
"@woocommerce/price-format": [ "packages/prices" ],
"@woocommerce/settings": [ "assets/js/settings/shared" ],
"@woocommerce/shared-context": [ "assets/js/shared/context" ],
"@woocommerce/type-defs/*": [ "assets/js/type-defs/*" ],

View File

@ -1,9 +1,11 @@
{
"extends": "./tsconfig.base.json",
"include": [ "./assets/js/**/*" ],
"exclude": [ "./assets/js/data" ],
"references": [
{ "path": "./assets/js/data" },
{ "path": "/assets/js/base/components" }
]
"extends": "./tsconfig.base.json",
"include": [ "./assets/js/**/*" ],
"exclude": [ "./assets/js/data" ],
"references": [
{ "path": "./assets/js/data" },
{ "path": "./assets/js/icons" },
{ "path": "./assets/js/base/components" },
{ "path": "./packages" }
]
}