diff --git a/CHANGELOG.md b/CHANGELOG.md index 58267bdc8..c965326bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Implement `StatePingAnimation` core component - Implement `addressUtils` and `ensUtils` module utilities - Implement `useDebouncedValue` core hook and `clipboardUtils` core utility +- Support `withSign` option on formatter ### Changed @@ -26,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Library build process to avoid bundling dependencies and peer-dependencies when using subfolders import (e.g. `wagmi/chains`) +- Formatter utility to support negative numbers ## [1.0.20] - 2024-03-13 diff --git a/src/core/utils/formatterUtils/formatterUtils.mdx b/src/core/utils/formatterUtils/formatterUtils.mdx index 860a87069..41b922260 100644 --- a/src/core/utils/formatterUtils/formatterUtils.mdx +++ b/src/core/utils/formatterUtils/formatterUtils.mdx @@ -54,7 +54,7 @@ Generic quantities represent anything countable, such as members, proposals, vot **Examples**: @@ -67,8 +67,8 @@ Fiat totals represent any summed monetary value, such as treasury amounts or fia diff --git a/src/core/utils/formatterUtils/formatterUtils.test.ts b/src/core/utils/formatterUtils/formatterUtils.test.ts index ec5322bdd..f9fd7c18e 100644 --- a/src/core/utils/formatterUtils/formatterUtils.test.ts +++ b/src/core/utils/formatterUtils/formatterUtils.test.ts @@ -8,82 +8,116 @@ describe('formatter utils', () => { describe('number formatting', () => { describe('generic amounts', () => { test.each([ + { value: -1234, result: '-1,234' }, + { value: -123, result: '-123' }, { value: 0, result: '0' }, { value: 123, result: '123' }, + { value: 123, result: '+123', withSign: true }, { value: 1234, result: '1,234' }, + { value: 1234, result: '+1,234', withSign: true }, { value: 1234567, result: '1,234,567' }, { value: 1234567890, result: '1,234,567,890' }, { value: 1234567890123, result: '1,234,567,890,123' }, { value: 1234567890123456, result: '1,234,567,890,123,456' }, - ])('correctly apply the long generic formatting for value $value', ({ value, result }) => { - expect(formatterUtils.formatNumber(value, { format: NumberFormat.GENERIC_LONG })).toEqual(result); + ])('formats $value as $result using long format', ({ value, result, ...options }) => { + expect(formatterUtils.formatNumber(value, { format: NumberFormat.GENERIC_LONG, ...options })).toEqual( + result, + ); }); test.each([ + { value: -1234, result: '-1.23K' }, + { value: -123, result: '-123' }, { value: 0, result: '0' }, { value: 123, result: '123' }, + { value: 123, result: '+123', withSign: true }, { value: 1234, result: '1.23K' }, + { value: 1234, result: '+1.23K', withSign: true }, { value: 1234567, result: '1.23M' }, { value: 1234567890, result: '1.23B' }, { value: 1234567890123, result: '1.23T' }, { value: 1234567890123456, result: '1.23 x 10^15' }, - ])('correctly apply the short generic formatting for value $value', ({ value, result }) => { - expect(formatterUtils.formatNumber(value, { format: NumberFormat.GENERIC_SHORT })).toEqual(result); + ])('formats $value as $result using short format', ({ value, result, ...options }) => { + expect(formatterUtils.formatNumber(value, { format: NumberFormat.GENERIC_SHORT, ...options })).toEqual( + result, + ); }); }); describe('fiat total amounts', () => { test.each([ + { value: -1234.56789, result: '-$1,234.57' }, + { value: -0.012345678, result: '-$0.01' }, + { value: -0.0012345678, result: '-$0.001' }, { value: 0, result: '$0.00' }, - { value: 0.0012345678, result: '<$0.01' }, + { value: 0.0012345678, result: '$0.001' }, + { value: 0.0012345678, result: '+$0.001', withSign: true }, { value: 0.012345678, result: '$0.01' }, + { value: 0.012345678, result: '+$0.01', withSign: true }, { value: 0.12345678, result: '$0.12' }, { value: 123.45678, result: '$123.46' }, { value: 1234.56789, result: '$1,234.57' }, + { value: 1234.56789, result: '+$1,234.57', withSign: true }, { value: 1234567.89012, result: '$1,234,567.89' }, { value: 1234567890.12345, result: '$1,234,567,890.12' }, { value: 1234567890123.45678, result: '$1,234,567,890,123.46' }, { value: 1234567890123456.78901, result: '$1,234,567,890,123,456.80' }, - ])('correctly apply the long fiat-total formatting for value $value', ({ value, result }) => { - expect(formatterUtils.formatNumber(value, { format: NumberFormat.FIAT_TOTAL_LONG })).toEqual(result); + ])('formats $value as $result using long format', ({ value, result, ...options }) => { + expect( + formatterUtils.formatNumber(value, { format: NumberFormat.FIAT_TOTAL_LONG, ...options }), + ).toEqual(result); }); test.each([ + { value: -1234.56789, result: '-$1.23K' }, + { value: -0.0012345678, result: '-$0.001' }, { value: 0, result: '$0.00' }, - { value: 0.0012345678, result: '<$0.01' }, + { value: 0.0012345678, result: '$0.001' }, + { value: 0.0012345678, result: '+$0.001', withSign: true }, { value: 0.012345678, result: '$0.01' }, { value: 0.12345678, result: '$0.12' }, { value: 123.45678, result: '$123.46' }, { value: 1234.56789, result: '$1.23K' }, + { value: 1234.56789, result: '+$1.23K', withSign: true }, { value: 1234567.89012, result: '$1.23M' }, { value: 1234567890.12345, result: '$1.23B' }, { value: 1234567890123.45678, result: '$1.23T' }, { value: 1234567890123456.78901, result: '$1.23 x 10^15' }, - ])('correctly apply the short fiat-total formatting for value $value', ({ value, result }) => { - expect(formatterUtils.formatNumber(value, { format: NumberFormat.FIAT_TOTAL_SHORT })).toEqual(result); + ])('formats $value as $result using short format', ({ value, result, ...options }) => { + expect( + formatterUtils.formatNumber(value, { format: NumberFormat.FIAT_TOTAL_SHORT, ...options }), + ).toEqual(result); }); }); describe('token amounts', () => { test.each([ + { value: -1234.5678, result: '-1,234.5678' }, + { value: -0.0123456789012345678, result: '-0.012345678901234568' }, { value: 0, result: '0' }, { value: 0.0012, result: '0.0012' }, + { value: 0.0012, result: '+0.0012', withSign: true }, { value: 0.0123456789012345678, result: '0.012345678901234568' }, { value: 0.12345678901234567, result: '0.12345678901234566' }, { value: 123.4567, result: '123.4567' }, { value: 1234, result: '1,234' }, + { value: 1234, result: '+1,234', withSign: true }, { value: 1234.5678, result: '1,234.5678' }, { value: 1234567.8901, result: '1,234,567.8901' }, { value: 1234567890.1234, result: '1,234,567,890.1234' }, { value: 1234567890123.4567, result: '1,234,567,890,123.4568' }, { value: 1234567890123456.789, result: '1,234,567,890,123,456.8' }, - ])('correctly apply the long token formatting for value $value', ({ value, result }) => { - expect(formatterUtils.formatNumber(value, { format: NumberFormat.TOKEN_AMOUNT_LONG })).toEqual(result); + ])('formats $value as $result using long format', ({ value, result, ...options }) => { + expect( + formatterUtils.formatNumber(value, { format: NumberFormat.TOKEN_AMOUNT_LONG, ...options }), + ).toEqual(result); }); test.each([ + { value: -1234, result: '-1.23K' }, + { value: -0.0012, result: '-0.001' }, { value: 0, result: '0' }, - { value: 0.0012, result: '<0.01' }, + { value: 0.0012, result: '0.001' }, { value: 0.0123456789012345678, result: '0.01' }, { value: 0.12345678901234567, result: '0.12' }, { value: 123.4567, result: '123.46' }, @@ -93,55 +127,76 @@ describe('formatter utils', () => { { value: 1234567890.1234, result: '1.23B' }, { value: 1234567890123.4567, result: '1.23T' }, { value: 1234567890123456.789, result: '1.23 x 10^15' }, - ])('correctly apply the short token formatting for value $value', ({ value, result }) => { + ])('formats $value as $result using short format', ({ value, result }) => { expect(formatterUtils.formatNumber(value, { format: NumberFormat.TOKEN_AMOUNT_SHORT })).toEqual(result); }); }); describe('token prices', () => { test.each([ + { value: -1234.56789, result: '-$1,234.57' }, + { value: -0.0012345678, result: '-$0.001235' }, { value: 0, result: 'Unknown' }, { value: 0.0012345678, result: '$0.001235' }, + { value: 0.0012345678, result: '+$0.001235', withSign: true }, { value: 0.012345678, result: '$0.01235' }, { value: 0.12345678, result: '$0.1235' }, { value: 123.45678, result: '$123.46' }, { value: 1234.56789, result: '$1,234.57' }, + { value: 1234.56789, result: '+$1,234.57', withSign: true }, { value: 1234567.89012, result: '$1,234,567.89' }, { value: 1234567890.12345, result: '$1,234,567,890.12' }, { value: 1234567890123.45678, result: '$1,234,567,890,123.46' }, { value: 1234567890123456.78901, result: '$1,234,567,890,123,456.80' }, - ])('correctly apply the token-price formatting for value $value', ({ value, result }) => { - expect(formatterUtils.formatNumber(value, { format: NumberFormat.TOKEN_PRICE })).toEqual(result); + ])('formats $value as $result using token format', ({ value, result, ...options }) => { + expect(formatterUtils.formatNumber(value, { format: NumberFormat.TOKEN_PRICE, ...options })).toEqual( + result, + ); }); }); describe('percentages', () => { test.each([ + { value: -1, result: '-100.00%' }, + { value: -0.999001, result: '-99.90%' }, + { value: -0.00012345, result: '-0.01%' }, { value: 0, result: '0.00%' }, + { value: 0, result: '0.00%', withSign: true }, { value: 0.00012345, result: '0.01%' }, { value: 0.0012345, result: '0.12%' }, { value: 0.012345, result: '1.23%' }, { value: 0.12345, result: '12.35%' }, + { value: 0.12345, result: '+12.35%', withSign: true }, { value: 0.510001, result: '51.00%' }, { value: 0.9985, result: '99.85%' }, { value: 0.999001, result: '99.90%' }, { value: 1, result: '100.00%' }, - ])('correctly apply the long percentage formatting for value $value', ({ value, result }) => { - expect(formatterUtils.formatNumber(value, { format: NumberFormat.PERCENTAGE_LONG })).toEqual(result); + ])('formats $value as $result using long format', ({ value, result, ...options }) => { + expect( + formatterUtils.formatNumber(value, { format: NumberFormat.PERCENTAGE_LONG, ...options }), + ).toEqual(result); }); test.each([ + { value: -0.999001, result: '-99.9%' }, + { value: -0.12345, result: '-12.3%' }, + { value: -0.00012345, result: '-0.01%' }, { value: 0, result: '0%' }, - { value: 0.00012345, result: '<0.1%' }, + { value: 0.00012345, result: '0.01%' }, + { value: 0.00012345, result: '+0.01%', withSign: true }, { value: 0.0012345, result: '0.1%' }, { value: 0.012345, result: '1.2%' }, { value: 0.12345, result: '12.3%' }, + { value: 0.12345, result: '+12.3%', withSign: true }, { value: 0.510001, result: '51%' }, { value: 0.9985, result: '99.9%' }, - { value: 0.999001, result: '>99.9%' }, + { value: 0.999001, result: '99.9%' }, + { value: 0.999001, result: '+99.9%', withSign: true }, { value: 1, result: '100%' }, - ])('correctly apply the short percentage formatting for value $value', ({ value, result }) => { - expect(formatterUtils.formatNumber(value, { format: NumberFormat.PERCENTAGE_SHORT })).toEqual(result); + ])('formats $value as $result using short format', ({ value, result, ...options }) => { + expect( + formatterUtils.formatNumber(value, { format: NumberFormat.PERCENTAGE_SHORT, ...options }), + ).toEqual(result); }); }); }); diff --git a/src/core/utils/formatterUtils/formatterUtils.ts b/src/core/utils/formatterUtils/formatterUtils.ts index 9d78e0837..021830398 100644 --- a/src/core/utils/formatterUtils/formatterUtils.ts +++ b/src/core/utils/formatterUtils/formatterUtils.ts @@ -1,16 +1,11 @@ -import { NumberFormat, numberFormats, type DynamicOption } from './formatterUtilsDefinitions'; +import { NumberFormat, numberFormats, type DynamicOption, type INumberFormat } from './formatterUtilsDefinitions'; -export interface IFormatNumberOptions { +export interface IFormatNumberOptions extends INumberFormat { /** * Number format to use. * @default GENERIC_LONG */ format?: NumberFormat; - /** - * Fallback value returned when input value is not set or not valid. - * @default null - */ - fallback?: string; } const cache: Record = {}; @@ -28,33 +23,36 @@ class FormatterUtils { ]; formatNumber = (value: number | string | null | undefined, options: IFormatNumberOptions = {}): string | null => { - const { format = NumberFormat.GENERIC_LONG, fallback = null } = options; + const { format = NumberFormat.GENERIC_LONG, ...otherOptions } = options; + const mergedOptions = { ...numberFormats[format], ...otherOptions }; const { fixedFractionDigits, maxFractionDigits, minFractionDigits, maxSignificantDigits, useBaseSymbol, - minDisplayValue, - maxDisplayValue, isCurrency, isPercentage, - fallback: fallbackFormat, + withSign, + fallback = null, displayFallback, - } = numberFormats[format]; + } = mergedOptions; const parsedValue = typeof value === 'number' ? value : parseFloat(value ?? ''); if (Boolean(displayFallback?.(parsedValue)) || isNaN(parsedValue)) { - return fallbackFormat ?? fallback; + return fallback; } - const fixedFractionDigitsOption = this.getDynamicOption(parsedValue, fixedFractionDigits); - const maxSignificantDigitsOption = this.getDynamicOption(parsedValue, maxSignificantDigits); + let processedValue = isPercentage ? parsedValue * 100 : parsedValue; + + const fixedFractionDigitsOption = this.getDynamicOption(processedValue, fixedFractionDigits); + const maxSignificantDigitsOption = this.getDynamicOption(processedValue, maxSignificantDigits); + const maxDigitsFallback = fixedFractionDigitsOption ?? maxFractionDigits; const maxDigits = maxSignificantDigitsOption - ? this.significantDigitsToFractionDigits(parsedValue, maxSignificantDigitsOption) - : fixedFractionDigitsOption ?? maxFractionDigits; + ? this.significantDigitsToFractionDigits(processedValue, maxSignificantDigitsOption, maxDigitsFallback) + : maxDigitsFallback; const minDigits = fixedFractionDigitsOption ?? minFractionDigits; @@ -71,33 +69,22 @@ class FormatterUtils { }); } - const baseRange = this.baseSymbolRanges.find((range) => Math.abs(parsedValue) >= range.value); + const baseRange = this.baseSymbolRanges.find((range) => Math.abs(processedValue) >= range.value); const baseRangeDenominator = - parsedValue > 1e15 ? 10 ** (this.getDecimalPlaces(parsedValue) - 1) : baseRange?.value ?? 1; + processedValue > 1e15 ? 10 ** (this.getDecimalPlaces(processedValue) - 1) : baseRange?.value ?? 1; - let processedValue = isPercentage ? parsedValue * 100 : parsedValue; - - // Set the processedValue to the minDisplayValue (e.g. 0.0012 to 0.01) or maxDisplayValue (e.g. 99.99 to 99.9) - // when the value is not zero / 100 and smaller / higher than the minDisplayValue / maxDisplayValue - const useMinDisplayValue = minDisplayValue != null && processedValue > 0 && processedValue < minDisplayValue; - const useMaxDisplayValue = maxDisplayValue != null && processedValue < 100 && processedValue > maxDisplayValue; - - if (useMinDisplayValue) { - processedValue = minDisplayValue; - } else if (useMaxDisplayValue) { - processedValue = maxDisplayValue; - } else if (useBaseSymbol) { - processedValue = parsedValue / baseRangeDenominator; + if (useBaseSymbol) { + processedValue = processedValue / baseRangeDenominator; } let formattedValue = cache[cacheKey]!.format(processedValue); + if (withSign && processedValue > 0) { + formattedValue = `+${formattedValue}`; + } + if (useBaseSymbol && baseRange != null) { formattedValue = `${formattedValue}${baseRange.symbol(parsedValue)}`; - } else if (useMinDisplayValue) { - formattedValue = `<${formattedValue}`; - } else if (useMaxDisplayValue) { - formattedValue = `>${formattedValue}`; } if (isPercentage) { @@ -114,8 +101,8 @@ class FormatterUtils { private getDecimalPlaces = (value: number) => value.toString().split('.')[0].length; - private significantDigitsToFractionDigits = (value: number, digits: number) => - value === 0 ? 0 : Math.floor(digits - Math.log10(value)); + private significantDigitsToFractionDigits = (value: number, digits: number, fallback?: number) => + value === 0 ? fallback : Math.floor(digits - Math.log10(Math.abs(value))); } export const formatterUtils = new FormatterUtils(); diff --git a/src/core/utils/formatterUtils/formatterUtilsDefinitions.ts b/src/core/utils/formatterUtils/formatterUtilsDefinitions.ts index e7cbe2abe..f9554e598 100644 --- a/src/core/utils/formatterUtils/formatterUtilsDefinitions.ts +++ b/src/core/utils/formatterUtils/formatterUtilsDefinitions.ts @@ -24,14 +24,6 @@ export interface INumberFormat { * Uses the base symbol (K, M, B, T) when set to true. */ useBaseSymbol?: boolean; - /** - * Formats the number as "< $value" when the value is lower than the one specified. - */ - minDisplayValue?: number; - /** - * Formats the number as "> $value" when the value is higher than the one specified. - */ - maxDisplayValue?: number; /** * Format the number with the default currency when set to true. */ @@ -40,6 +32,10 @@ export interface INumberFormat { * Format the number as a percentage when set to true. */ isPercentage?: boolean; + /** + * Always displays the number sign on the formatted number when set to true. + */ + withSign?: boolean; /** * Fallback to display in case the value is null. */ @@ -73,34 +69,33 @@ export const numberFormats: Record = { }, [NumberFormat.FIAT_TOTAL_SHORT]: { fixedFractionDigits: 2, - minDisplayValue: 0.01, + maxSignificantDigits: (value) => (Math.abs(value) < 0.01 ? 1 : undefined), useBaseSymbol: true, isCurrency: true, }, [NumberFormat.FIAT_TOTAL_LONG]: { fixedFractionDigits: 2, - minDisplayValue: 0.01, + maxSignificantDigits: (value) => (Math.abs(value) < 0.01 ? 1 : undefined), isCurrency: true, }, [NumberFormat.TOKEN_AMOUNT_SHORT]: { maxFractionDigits: 2, useBaseSymbol: true, - minDisplayValue: 0.01, + maxSignificantDigits: (value) => (Math.abs(value) < 0.01 ? 1 : undefined), }, [NumberFormat.TOKEN_AMOUNT_LONG]: { maxFractionDigits: 18, }, [NumberFormat.TOKEN_PRICE]: { - fixedFractionDigits: (value) => (value >= 1 ? 2 : undefined), - maxSignificantDigits: (value) => (value < 1 ? 4 : undefined), + fixedFractionDigits: (value) => (Math.abs(value) >= 1 ? 2 : undefined), + maxSignificantDigits: (value) => (Math.abs(value) < 1 ? 4 : undefined), isCurrency: true, fallback: 'Unknown', displayFallback: (value) => isNaN(value) || value === 0, }, [NumberFormat.PERCENTAGE_SHORT]: { maxFractionDigits: 1, - minDisplayValue: 0.1, - maxDisplayValue: 99.9, + maxSignificantDigits: (value) => (Math.abs(value) < 0.1 ? 1 : undefined), isPercentage: true, }, [NumberFormat.PERCENTAGE_LONG]: { diff --git a/src/modules/components/asset/assetDataListItem/assetDataListItemStructure/assetDataListItemStructure.tsx b/src/modules/components/asset/assetDataListItem/assetDataListItemStructure/assetDataListItemStructure.tsx index 216ed69db..b893eef33 100644 --- a/src/modules/components/asset/assetDataListItem/assetDataListItemStructure/assetDataListItemStructure.tsx +++ b/src/modules/components/asset/assetDataListItem/assetDataListItemStructure/assetDataListItemStructure.tsx @@ -25,7 +25,7 @@ export interface IAssetDataListItemStructureProps extends IDataListItemProps { */ fiatPrice?: number | string; /** - * the price change in percentage of the asset (E.g. in last 24h). + * The price change in percentage of the asset (E.g. in last 24h). * @default 0 */ priceChange?: number; @@ -34,43 +34,44 @@ export interface IAssetDataListItemStructureProps extends IDataListItemProps { export const AssetDataListItemStructure: React.FC = (props) => { const { logoSrc, name, amount, symbol, fiatPrice, priceChange = 0, ...otherProps } = props; - const usdAmountChanged = useMemo(() => { + const fiatAmount = Number(amount ?? 0) * Number(fiatPrice ?? 0); + + const fiatAmountChanged = useMemo(() => { if (!fiatPrice || !priceChange) { return 0; } - const usdAmount = (amount ? Number(amount) : 0) * (fiatPrice ? Number(fiatPrice) : 0); - const oldUsdAmount = (100 / (priceChange + 100)) * usdAmount; - return usdAmount - oldUsdAmount; - }, [amount, fiatPrice, priceChange]); - const sign = (value: number) => (value > 0 ? '+' : value < 0 ? '-' : ''); + const oldFiatAmount = (100 / (priceChange + 100)) * fiatAmount; + return fiatAmount - oldFiatAmount; + }, [fiatAmount, fiatPrice, priceChange]); const changedAmountClasses = classNames( 'text-sm font-normal leading-tight md:text-base', - { 'text-success-800': usdAmountChanged > 0 }, - { 'text-neutral-500': usdAmountChanged === 0 }, - { 'text-critical-800': usdAmountChanged < 0 }, + { 'text-success-800': fiatAmountChanged > 0 }, + { 'text-neutral-500': fiatAmountChanged === 0 }, + { 'text-critical-800': fiatAmountChanged < 0 }, ); + const tagVariant = priceChange > 0 ? 'success' : priceChange < 0 ? 'critical' : 'neutral'; + const formattedAmount = formatterUtils.formatNumber(amount, { format: NumberFormat.TOKEN_AMOUNT_SHORT, fallback: '', }); - const formattedPrice = formatterUtils.formatNumber( - (amount ? Number(amount) : 0) * (fiatPrice ? Number(fiatPrice) : 0), - { - format: NumberFormat.FIAT_TOTAL_SHORT, - fallback: '-', - }, - ); + const formattedPrice = formatterUtils.formatNumber(fiatAmount, { + format: NumberFormat.FIAT_TOTAL_SHORT, + fallback: '-', + }); - const formattedPriceChanged = formatterUtils.formatNumber(Math.abs(usdAmountChanged), { + const formattedPriceChanged = formatterUtils.formatNumber(fiatAmountChanged, { format: NumberFormat.FIAT_TOTAL_SHORT, + withSign: true, }); - const formattedPriceChangedPercentage = formatterUtils.formatNumber(Math.abs(priceChange / 100), { + const formattedPriceChangedPercentage = formatterUtils.formatNumber(priceChange / 100, { format: NumberFormat.PERCENTAGE_SHORT, + withSign: true, }); return ( @@ -83,7 +84,7 @@ export const AssetDataListItemStructure: React.FC {name}

- {`${formattedAmount}`} + {formattedAmount} {symbol}

@@ -94,14 +95,8 @@ export const AssetDataListItemStructure: React.FC
- - {sign(usdAmountChanged)} - {formattedPriceChanged} - - 0 ? 'success' : priceChange < 0 ? 'critical' : 'neutral'} - /> + {formattedPriceChanged} +
) : (