diff --git a/package-lock.json b/package-lock.json index 7ce06053d..9ea23f2ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "libphonenumber-js": "^1.13.14", "lodash.clonedeep": "^4.5.0", "lodash.debounce": "^4.0.8", + "lodash.get": "^4.4.2", "mat-tel-input": "^21.1.3", "ngx-currency": "^19.0.0", "rxjs": "^7.8.2", @@ -15209,6 +15210,13 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/package.json b/package.json index 42bc84a5c..147f7e2c8 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "libphonenumber-js": "^1.13.14", "lodash.clonedeep": "^4.5.0", "lodash.debounce": "^4.0.8", + "lodash.get": "^4.4.2", "mat-tel-input": "^21.1.3", "ngx-currency": "^19.0.0", "rxjs": "^7.8.2", diff --git a/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.html b/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.html index 41c621d49..e68c181b5 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.html +++ b/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.html @@ -14,6 +14,7 @@ currencyMask [options]="{ prefix: currencySymbol, + suffix: currencySuffix, thousands: thousandSeparator, decimal: decimalSeparator, align: 'left', diff --git a/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.ts b/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.ts index 1eda96f33..fee526040 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/currency/currency.component.ts @@ -8,16 +8,27 @@ import { NgxCurrencyDirective, NgxCurrencyInputMode } from 'ngx-currency'; import { FieldBase } from '../field.base'; import { FieldWarningDirective } from '../../../_directives/field-warning.directive'; import { ComponentMapperComponent } from '../../../_bridge/component-mapper/component-mapper.component'; -import { handleEvent } from '../../../_helpers/event-util'; -import { getCurrencyCharacters, getCurrencyOptions } from '../../../_helpers/currency-utils'; -import { format } from '../../../_helpers/formatters'; +import { + formatNumberFieldForDisplay, + getCurrencyInputFormat, + NumberInputFormat, + parseFormattedNumber, + saveNumberOnBlur +} from '../../../_helpers/currency-utils'; import { PConnFieldProps } from '../../../_types/PConnProps.interface'; -interface CurrrencyProps extends PConnFieldProps { +interface CurrrencyProps extends Omit { // If any, enter additional props that only exist on Currency here + value?: number | string; currencyISOCode?: string; + alwaysShowISOCode?: boolean; allowDecimals: boolean; formatter?: string; + currencyDisplay?: 'symbol' | 'code' | 'name'; + negative?: 'minus-sign' | 'parentheses'; + notation?: 'standard' | 'compact'; + currencyDecimalPrecision?: string; + decimalPrecision?: number | string; } @Component({ @@ -38,14 +49,14 @@ export class CurrencyComponent extends FieldBase { configProps$: CurrrencyProps; override fieldControl = new FormControl(null, { updateOn: 'blur' }); - currencyOptions: object = {}; currencySymbol: string; + currencySuffix: string; thousandSeparator: string; decimalSeparator: string; decimalPrecision: number | undefined; formattedValue: string; - formatter; inputMode = NgxCurrencyInputMode.Natural; + private inputFormat: NumberInputFormat; /** * Updates the component when there are changes in the state. @@ -59,56 +70,38 @@ export class CurrencyComponent extends FieldBase { // Extract and normalize the value property const { value } = this.configProps$; - if (value) { - this.value$ = typeof value === 'string' ? parseFloat(value) : value; - this.fieldControl.setValue(this.value$); - } + this.value$ = value === undefined || value === null || value === '' ? '' : Number(value); + this.fieldControl.setValue(this.value$ === '' ? null : this.value$); // update currency properties this.updateCurrencyProperties(this.configProps$); } /** - * Updates the currency properties - * - * @param {Object} configProps - Configuration properties. - * @param {boolean} configProps.allowDecimals - Whether to allow decimal values. - * @param {string} configProps.currencyISOCode - The ISO code of the currency. - * @param {string} configProps.formatter - The formatter type (e.g., 'currency'). + * Updates the input mask and read-only value, matching the Constellation DX Currency component. */ - protected updateCurrencyProperties(configProps): void { - const { allowDecimals, currencyISOCode = 'USD', formatter } = configProps; + protected updateCurrencyProperties(configProps: CurrrencyProps): void { + const { allowDecimals = true, currencyISOCode = 'USD', alwaysShowISOCode = false } = configProps; - const theSymbols = getCurrencyCharacters(currencyISOCode); - this.currencySymbol = theSymbols.theCurrencySymbol; - this.thousandSeparator = theSymbols.theDigitGroupSeparator; - this.decimalSeparator = theSymbols.theDecimalIndicator; - this.decimalPrecision = allowDecimals ? 2 : 0; + const inputFormat = getCurrencyInputFormat( + currencyISOCode, + this.utils.getBooleanValue(alwaysShowISOCode), + this.utils.getBooleanValue(allowDecimals) + ); + this.inputFormat = inputFormat; + this.currencySymbol = inputFormat.prefix; + this.currencySuffix = inputFormat.suffix; + this.thousandSeparator = inputFormat.thousandSeparator; + this.decimalSeparator = inputFormat.decimalSeparator; + this.decimalPrecision = inputFormat.precision; if (['DISPLAY_ONLY', 'STACKED_LARGE_VAL'].includes(this.displayMode$)) { - this.formattedValue = format(this.value$, formatter ? formatter.toLowerCase() : 'currency', getCurrencyOptions(currencyISOCode)); + this.formattedValue = formatNumberFieldForDisplay('Currency', this.value$, { ...configProps, currencyISOCode }); } } fieldOnBlur(event: any) { - const oldVal = this.value$ ?? ''; - const isValueChanged = event.target.value.toString() !== oldVal.toString(); - - if (isValueChanged) { - const actionsApi = this.pConn$?.getActionsApi(); - const propName = this.pConn$?.getStateProps().value; - let value = event?.target?.value; - value = value?.substring(1); - // replacing thousand separator with empty string as not required in api call - const thousandSep = this.thousandSeparator === '.' ? '\\.' : this.thousandSeparator; - let regExp = new RegExp(String.raw`${thousandSep}`, 'g'); - value = value?.replace(regExp, ''); - // replacing decimal separator with '.' - if (this.decimalSeparator !== '.') { - regExp = new RegExp(String.raw`${this.decimalSeparator}`, 'g'); - value = value.replace(regExp, '.'); - } - handleEvent(actionsApi, 'changeNblur', propName, value); - } + const parsed = parseFormattedNumber(event?.target?.value, this.inputFormat); + saveNumberOnBlur(this.pConn$, parsed !== '' ? Number(parsed) : '', this.configProps$?.value); } } diff --git a/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.html b/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.html index 3dc41cbd4..c5bc925b0 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.html +++ b/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.html @@ -11,8 +11,8 @@ matInput currencyMask [options]="{ - prefix: currencySymbol, - suffix: suffix, + prefix: '', + suffix: '', thousands: thousandSeparator, decimal: decimalSeparator, align: 'left', diff --git a/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.ts b/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.ts index c3cfa4fa6..ffe2928f0 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/decimal/decimal.component.ts @@ -8,17 +8,27 @@ import { NgxCurrencyDirective, NgxCurrencyInputMode } from 'ngx-currency'; import { FieldBase } from '../field.base'; import { FieldWarningDirective } from '../../../_directives/field-warning.directive'; import { ComponentMapperComponent } from '../../../_bridge/component-mapper/component-mapper.component'; -import { handleEvent } from '../../../_helpers/event-util'; -import { getCurrencyCharacters, getCurrencyOptions } from '../../../_helpers/currency-utils'; -import { format } from '../../../_helpers/formatters'; +import { + formatNumberFieldForDisplay, + getNumberInputFormat, + NumberInputFormat, + parseDecimalPrecision, + parseFormattedNumber, + saveNumberOnBlur +} from '../../../_helpers/currency-utils'; import { PConnFieldProps } from '../../../_types/PConnProps.interface'; -interface DecimalProps extends PConnFieldProps { +interface DecimalProps extends Omit { // If any, enter additional props that only exist on Decimal here + value?: number | string; currencyISOCode?: string; - decimalPrecision?: number; - showGroupSeparators?: string; + decimalPrecision?: number | string; + showGroupSeparators?: boolean | string; formatter?: string; + currencyDisplay?: 'symbol' | 'code' | 'name'; + negative?: 'minus-sign' | 'parentheses'; + notation?: 'standard' | 'compact'; + currencyDecimalPrecision?: string; } @Component({ @@ -42,12 +52,10 @@ export class DecimalComponent extends FieldBase { decimalSeparator: string; thousandSeparator: string; - currencySymbol = ''; decimalPrecision: number | undefined; - formatter; formattedValue: any; inputMode: any = NgxCurrencyInputMode.Natural; - suffix = ''; + private inputFormat: NumberInputFormat; /** * Updates the component when there are changes in the state. @@ -61,66 +69,34 @@ export class DecimalComponent extends FieldBase { // Extract and normalize the value property const { value } = this.configProps$; - if (value) { - this.value$ = typeof value === 'string' ? parseFloat(value) : value; - this.fieldControl.setValue(this.value$); - } + this.value$ = value === undefined || value === null || value === '' ? '' : Number(value); + this.fieldControl.setValue(this.value$ === '' ? null : this.value$); // updates decimal properties this.updateDecimalProperties(this.configProps$); } /** - * Updates decimal properties based on the provided configuration. - * - * @param {Object} configProps - Configuration properties. - * @param {string} configProps.currencyISOCode - ISO code of the currency. - * @param {string} configProps.formatter - Formatter type (e.g., 'decimal', 'currency'). - * @param {boolean} configProps.showGroupSeparators - Whether to show group separators. + * Updates the input mask and read-only value, matching the Constellation DX Decimal component. */ - protected updateDecimalProperties(configProps): void { - const { currencyISOCode = '', formatter, showGroupSeparators } = configProps; - - // Extract currency symbols and options - const theSymbols = getCurrencyCharacters(currencyISOCode); - this.decimalSeparator = theSymbols.theDecimalIndicator; - this.thousandSeparator = showGroupSeparators ? theSymbols.theDigitGroupSeparator : ''; - - const theCurrencyOptions = getCurrencyOptions(currencyISOCode); - const formatterLower = formatter?.toLowerCase() || 'decimal'; - this.formattedValue = format(this.value$, formatterLower, theCurrencyOptions); - - if (this.bReadonly$ && formatter === 'Currency') { - this.currencySymbol = theSymbols.theCurrencySymbol; + protected updateDecimalProperties(configProps: DecimalProps): void { + const { decimalPrecision, showGroupSeparators = true } = configProps; + const numberOfDecimals = parseDecimalPrecision(decimalPrecision, 2); + const groupSeparators = this.utils.getBooleanValue(showGroupSeparators); + + this.inputFormat = getNumberInputFormat(); + this.decimalSeparator = this.inputFormat.decimalSeparator; + this.thousandSeparator = groupSeparators ? this.inputFormat.thousandSeparator : ''; + // ngx-currency needs a fixed precision; Constellation allows any number of decimals when none is configured + this.decimalPrecision = numberOfDecimals ?? 2; + + if (['DISPLAY_ONLY', 'STACKED_LARGE_VAL'].includes(this.displayMode$)) { + this.formattedValue = formatNumberFieldForDisplay('Decimal', this.value$, configProps); } - - if (this.bReadonly$ && formatter === 'Percentage') { - this.suffix = '%'; - } - - this.decimalPrecision = this.configProps$?.decimalPrecision ?? 2; } fieldOnBlur(event: any) { - const oldVal = this.value$ ?? ''; - const isValueChanged = event.target.value.toString() !== oldVal.toString(); - - if (isValueChanged) { - const actionsApi = this.pConn$?.getActionsApi(); - const propName = this.pConn$?.getStateProps().value; - let value = event?.target?.value; - // replacing thousand separator with empty string as not required in api call - if (this.configProps$.showGroupSeparators) { - const thousandSep = this.thousandSeparator === '.' ? '\\.' : this.thousandSeparator; - const regExp = new RegExp(String.raw`${thousandSep}`, 'g'); - value = value?.replace(regExp, ''); - } - // replacing decimal separator with '.' - if (this.decimalSeparator !== '.') { - const regExp = new RegExp(String.raw`${this.decimalSeparator}`, 'g'); - value = value.replace(regExp, '.'); - } - handleEvent(actionsApi, 'changeNblur', propName, value); - } + const parsed = parseFormattedNumber(event?.target?.value, this.inputFormat); + saveNumberOnBlur(this.pConn$, parsed !== '' ? Number(parsed) : '', this.configProps$?.value); } } diff --git a/packages/angular-sdk-components/src/lib/_components/field/integer/integer.component.html b/packages/angular-sdk-components/src/lib/_components/field/integer/integer.component.html index 832881366..b0e51c7f1 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/integer/integer.component.html +++ b/packages/angular-sdk-components/src/lib/_components/field/integer/integer.component.html @@ -1,5 +1,5 @@
- +
@@ -8,10 +8,19 @@ {{ label$ }} { // If any, enter additional props that only exist on Integer here + value?: number | string; + showGroupSeparators?: boolean | string; + formatter?: string; + decimalPrecision?: number | string; + currencyISOCode?: string; + currencyDisplay?: 'symbol' | 'code' | 'name'; + negative?: 'minus-sign' | 'parentheses'; + notation?: 'standard' | 'compact'; + currencyDecimalPrecision?: string; } @Component({ selector: 'app-integer', templateUrl: './integer.component.html', styleUrls: ['./integer.component.scss'], - imports: [CommonModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, FieldWarningDirective, forwardRef(() => ComponentMapperComponent)] + imports: [ + CommonModule, + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + FieldWarningDirective, + NgxCurrencyDirective, + forwardRef(() => ComponentMapperComponent) + ] }) export class IntegerComponent extends FieldBase { configProps$: IntegerProps; override fieldControl = new FormControl(null, null); + decimalSeparator: string; + thousandSeparator: string; + formattedValue: string; + inputMode: any = NgxCurrencyInputMode.Natural; + private inputFormat: NumberInputFormat; + /** * Updates the component when there are changes in the state. */ @@ -36,8 +66,25 @@ export class IntegerComponent extends FieldBase { // Extract and normalize the value property const { value } = this.configProps$; - if (value) { - this.value$ = typeof value === 'string' ? parseInt(value, 10) : value; + this.value$ = value === undefined || value === null || value === '' ? '' : Number(value); + this.fieldControl.setValue(this.value$ === '' ? null : this.value$); + + this.updateIntegerProperties(this.configProps$); + } + + /** + * Updates the input mask and read-only value, matching the Constellation DX Integer component. + */ + protected updateIntegerProperties(configProps: IntegerProps): void { + const { showGroupSeparators = true } = configProps; + const groupSeparators = this.utils.getBooleanValue(showGroupSeparators); + + this.inputFormat = getNumberInputFormat(); + this.decimalSeparator = this.inputFormat.decimalSeparator; + this.thousandSeparator = groupSeparators ? this.inputFormat.thousandSeparator : ''; + + if (['DISPLAY_ONLY', 'STACKED_LARGE_VAL'].includes(this.displayMode$)) { + this.formattedValue = formatNumberFieldForDisplay('Integer', this.value$, configProps); } } @@ -53,13 +100,7 @@ export class IntegerComponent extends FieldBase { } fieldOnBlur(event: any) { - const oldVal = this.value$ ?? ''; - const isValueChanged = event.target.value.toString() !== oldVal.toString(); - - if (isValueChanged) { - const value = event?.target?.value; - const normalizedValue = value === '' ? '' : Number(value); - handleEvent(this.actionsApi, 'changeNblur', this.propName, normalizedValue); - } + const parsed = parseFormattedNumber(event?.target?.value, this.inputFormat); + saveNumberOnBlur(this.pConn$, parsed !== '' ? Number(parsed) : '', this.configProps$?.value); } } diff --git a/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.html b/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.html index fe2a664ac..914c445d1 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.html +++ b/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.html @@ -11,8 +11,8 @@ matInput currencyMask [options]="{ - prefix: '', - suffix: '%', + prefix: prefix, + suffix: suffix, thousands: thousandSeparator, decimal: decimalSeparator, align: 'left', diff --git a/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.ts b/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.ts index 4628e2894..b6d014285 100644 --- a/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.ts +++ b/packages/angular-sdk-components/src/lib/_components/field/percentage/percentage.component.ts @@ -8,16 +8,24 @@ import { NgxCurrencyDirective, NgxCurrencyInputMode } from 'ngx-currency'; import { FieldBase } from '../field.base'; import { FieldWarningDirective } from '../../../_directives/field-warning.directive'; import { ComponentMapperComponent } from '../../../_bridge/component-mapper/component-mapper.component'; -import { handleEvent } from '../../../_helpers/event-util'; -import { getCurrencyCharacters } from '../../../_helpers/currency-utils'; -import { format } from '../../../_helpers/formatters'; +import { + decimalsToPercentage, + formatNumberFieldForDisplay, + getNumberInputFormat, + NumberInputFormat, + parseDecimalPrecision, + parseFormattedNumber, + percentageToDecimals, + saveNumberOnBlur +} from '../../../_helpers/currency-utils'; import { PConnFieldProps } from '../../../_types/PConnProps.interface'; interface PercentageProps extends Omit { - value?: number; - showGroupSeparators?: string; - decimalPrecision?: number; + value?: number | string; + showGroupSeparators?: boolean | string; + decimalPrecision?: number | string; currencyISOCode?: string; + storePercentageAsDecimal?: boolean | string; // If any, enter additional props that only exist on Percentage here } @@ -39,11 +47,16 @@ export class PercentageComponent extends FieldBase { configProps$: PercentageProps; override fieldControl = new FormControl(null, null); + prefix: string; + suffix: string; decimalSeparator: string; thousandSeparator: string; inputMode: any = NgxCurrencyInputMode.Natural; decimalPrecision: number | undefined; formattedValue: string; + private inputFormat: NumberInputFormat; + private numberOfDecimals: number | undefined; + private storeAsDecimal = false; /** * Updates the component when there are changes in the state. @@ -55,34 +68,39 @@ export class PercentageComponent extends FieldBase { // Update component common properties this.updateComponentCommonProperties(this.configProps$); - // Set component specific properties - const { value } = this.configProps$; - if (value) { - this.value$ = value; - this.fieldControl.setValue(value); + const { value, decimalPrecision, storePercentageAsDecimal } = this.configProps$; + this.numberOfDecimals = parseDecimalPrecision(decimalPrecision, 2); + this.storeAsDecimal = this.utils.getBooleanValue(storePercentageAsDecimal); + + // The stored value may be a fraction (0.25); the field always shows/edits the percentage (25) + if (value === undefined || value === null || value === '') { + this.value$ = ''; + } else { + this.value$ = this.storeAsDecimal && Number(value) ? decimalsToPercentage(Number(value), this.numberOfDecimals) : Number(value); } + this.fieldControl.setValue(this.value$ === '' ? null : this.value$); // update percentage properties this.updatePercentageProperties(this.configProps$); } /** - * Updates the percentage properties - * - * @param {Object} configProps - Configuration properties. - * @param {boolean} configProps.showGroupSeparators - Whether to show group separators. - * @param {number} configProps.decimalPrecision - The number of decimal places to display. + * Updates the input mask and read-only value, matching the Constellation DX Percentage component. */ - updatePercentageProperties(configProps): void { - const { showGroupSeparators, decimalPrecision } = configProps; + updatePercentageProperties(configProps: PercentageProps): void { + const { showGroupSeparators = false } = configProps; + const groupSeparators = this.utils.getBooleanValue(showGroupSeparators); - const theSymbols = getCurrencyCharacters(''); - this.decimalSeparator = theSymbols.theDecimalIndicator; - this.thousandSeparator = showGroupSeparators ? theSymbols.theDigitGroupSeparator : ''; - this.decimalPrecision = decimalPrecision ?? 2; + this.inputFormat = getNumberInputFormat('percent'); + this.prefix = this.inputFormat.prefix; + this.suffix = this.inputFormat.suffix; + this.decimalSeparator = this.inputFormat.decimalSeparator; + this.thousandSeparator = groupSeparators ? this.inputFormat.thousandSeparator : ''; + // ngx-currency needs a fixed precision; Constellation allows any number of decimals when none is configured + this.decimalPrecision = this.numberOfDecimals ?? 2; if (['DISPLAY_ONLY', 'STACKED_LARGE_VAL'].includes(this.displayMode$)) { - this.formattedValue = this.value$ ? format(this.value$, 'percentage') : ''; + this.formattedValue = formatNumberFieldForDisplay('Percentage', this.value$, configProps); } } @@ -98,24 +116,11 @@ export class PercentageComponent extends FieldBase { } fieldOnBlur(event: any) { - const oldVal = this.value$ ?? ''; - const isValueChanged = event.target.value.toString() !== oldVal.toString(); - - if (isValueChanged) { - let value = event?.target?.value; - value = value ? value.replace(/%/g, '') : ''; - // replacing thousand separator with empty string as not required in api call - if (this.configProps$.showGroupSeparators) { - const thousandSep = this.thousandSeparator === '.' ? '\\.' : this.thousandSeparator; - const regExp = new RegExp(String.raw`${thousandSep}`, 'g'); - value = value?.replace(regExp, ''); - } - // replacing decimal separator with '.' - if (this.decimalSeparator !== '.') { - const regExp = new RegExp(String.raw`${this.decimalSeparator}`, 'g'); - value = value.replace(regExp, '.'); - } - handleEvent(this.actionsApi, 'changeNblur', this.propName, value); + const parsed = parseFormattedNumber(event?.target?.value, this.inputFormat); + let newValue: number | '' = parsed !== '' ? Number(parsed) : ''; + if (this.storeAsDecimal && newValue !== '') { + newValue = percentageToDecimals(newValue, this.numberOfDecimals); } + saveNumberOnBlur(this.pConn$, newValue, this.configProps$?.value); } } diff --git a/packages/angular-sdk-components/src/lib/_helpers/currency-utils.ts b/packages/angular-sdk-components/src/lib/_helpers/currency-utils.ts index acfbd3ec7..8b85fe047 100644 --- a/packages/angular-sdk-components/src/lib/_helpers/currency-utils.ts +++ b/packages/angular-sdk-components/src/lib/_helpers/currency-utils.ts @@ -1,5 +1,7 @@ -import { formatters } from './formatters/format-utils'; import { currencyMap } from './formatters/currency-map'; +import { getLocale } from './formatters/common'; +import { isValidCurrency } from './formatters/format-utils'; +import { handleEvent } from './event-util'; export const getCurrencyOptions = (inISOCode: string) => { const operatorLocale = PCore.getEnvironmentInfo().getUseLocale() || PCore.getEnvironmentInfo().getLocale() || 'en-US'; @@ -45,22 +47,233 @@ export const getCurrencyCharacters = (inISOCode: string) => { }; const theCurrencyOptions = getCurrencyOptions(inISOCode); + const parts = new Intl.NumberFormat(theCurrencyOptions.locale, { + style: 'currency', + currency: theCurrencyOptions.currency + }).formatToParts(1234.56); + const integerIndex = parts.findIndex(part => part.type === 'integer'); + const currencyPrefix = parts + .slice(0, Math.max(integerIndex, 0)) + .map(part => part.value) + .join(''); + const currencyPart = parts.find(part => part.type === 'currency'); - const testValue = 1234.56; - const formattedString = formatters.Currency(testValue, theCurrencyOptions); + theCurrencyChars.theCurrencySymbol = currencyPrefix || currencyPart?.value || theCurrencyChars.theCurrencySymbol; + theCurrencyChars.theDigitGroupSeparator = parts.find(part => part.type === 'group')?.value || theCurrencyChars.theDigitGroupSeparator; + theCurrencyChars.theDecimalIndicator = parts.find(part => part.type === 'decimal')?.value || theCurrencyChars.theDecimalIndicator; - // console.log(`formattedString: ${formattedString}`); + return theCurrencyChars; +}; - // Here, we have the formatted string (ex: $1,234.56) where: - // Currency symbol = formattedString[0] - // Separator = formattedString[2] - // DecimalIndicator = formattedString[6]; +/* + * Helpers below mirror the Constellation DX number fields (Currency, Decimal, Integer, Percentage) + * and the Cosmos NumberInput / CurrencyInput / NumberDisplay / CurrencyDisplay they render. + */ - theCurrencyChars.theCurrencySymbol = formattedString[0]; - theCurrencyChars.theDigitGroupSeparator = formattedString[2]; - theCurrencyChars.theDecimalIndicator = formattedString[6]; +export interface NumberInputFormat { + prefix: string; + suffix: string; + thousandSeparator: string; + decimalSeparator: string; +} - // console.log(`theCurrencyChars: symbol: ${theCurrencyChars.theCurrencySymbol} | theDigitGroupSeparator: ${theCurrencyChars.theDigitGroupSeparator} | theDecimalIndicator: ${theCurrencyChars.theDecimalIndicator}`); +export interface CurrencyDisplayOptions { + currencyDisplay?: 'symbol' | 'code' | 'name'; + negative?: 'minus-sign' | 'parentheses'; + notation?: 'standard' | 'compact'; + fractionDigits?: number; +} - return theCurrencyChars; +interface NumberDisplayOptions { + fractionDigits?: number; + groupSeparators?: boolean; + notation?: 'standard' | 'compact'; + unit?: string; +} + +const isEmptyValue = (value: any) => value === null || value === undefined || value === ''; + +const getInputFormat = (options: Intl.NumberFormatOptions): NumberInputFormat => { + const parts = new Intl.NumberFormat(getLocale(), { + ...options, + numberingSystem: 'latn', + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).formatToParts(1234.5); + const types = parts.map(part => part.type); + const firstNumberIndex = types.indexOf('integer'); + const lastNumberIndex = Math.max(types.lastIndexOf('integer'), types.lastIndexOf('fraction')); + const join = (list: Intl.NumberFormatPart[]) => list.map(part => part.value).join(''); + + return { + prefix: join(parts.slice(0, firstNumberIndex)), + suffix: join(parts.slice(lastNumberIndex + 1)), + thousandSeparator: parts.find(part => part.type === 'group')?.value ?? ',', + decimalSeparator: parts.find(part => part.type === 'decimal')?.value ?? '.' + }; +}; + +// Cosmos CurrencyInput: operator locale + configured ISO code, symbol placed where the locale puts it +export const getCurrencyInputFormat = (currencyISOCode: string | undefined, alwaysShowISOCode = false, allowDecimals = true) => { + const currency = currencyISOCode && isValidCurrency(currencyISOCode) ? currencyISOCode : undefined; + const options: Intl.NumberFormatOptions = currency ? { style: 'currency', currency, currencyDisplay: alwaysShowISOCode ? 'code' : 'symbol' } : {}; + const currencyFractionDigits = currency ? (new Intl.NumberFormat(getLocale(), options).resolvedOptions().minimumFractionDigits ?? 0) : 2; + + return { ...getInputFormat(options), precision: allowDecimals ? currencyFractionDigits : 0 }; +}; + +// Cosmos NumberInput: operator locale separators; unit 'percent' places the % sign where the locale puts it +export const getNumberInputFormat = (unit?: string): NumberInputFormat => getInputFormat(unit ? { style: 'unit', unit } : {}); + +// Converts masked input text (e.g. "-1 234,50 kr") into a plain number string ("-1234.50") +export const parseFormattedNumber = (text: string | null | undefined, inputFormat: NumberInputFormat): string => { + const { prefix, suffix, thousandSeparator, decimalSeparator } = inputFormat; + let value = text ?? ''; + if (prefix) value = value.replace(prefix, ''); + if (suffix) value = value.replace(suffix, ''); + if (thousandSeparator) value = value.split(thousandSeparator).join(''); + if (decimalSeparator !== '.') value = value.split(decimalSeparator).join('.'); + return value.replace(/[^\d.-]/g, ''); +}; + +// DX number fields save a number (or '' when cleared) and only when the value actually changed +export const saveNumberOnBlur = (pConn: typeof PConnect, newValue: number | '', currentValue: any) => { + const previous = isEmptyValue(currentValue) ? '' : Number(currentValue); + if (newValue === previous) { + if (newValue === '') pConn.getValidationApi().validate(''); + return; + } + handleEvent(pConn.getActionsApi(), 'changeNblur', pConn.getStateProps().value, newValue); +}; + +// DX Decimal/Percentage pass a fallback of 2 for non-blank invalid values; Integer/Currency pass none +export const parseDecimalPrecision = (decimalPrecision: any, invalidFallback?: number): number | undefined => { + const parsed = parseInt(decimalPrecision, 10); + if (!Number.isNaN(parsed)) return parsed; + return isEmptyValue(decimalPrecision) ? undefined : invalidFallback; +}; + +const getCurrencyDisplayFractionDigits = (currencyDecimalPrecision: any = 'auto'): number | undefined => { + if (currencyDecimalPrecision === 'auto') return undefined; + const parsed = parseInt(currencyDecimalPrecision, 10); + return Number.isNaN(parsed) ? undefined : parsed; +}; + +const isPercentageStoredAsDecimal = (): boolean => !!PCore.getFieldDefaultUtils?.()?.fieldDefaults?.['Percentage']?.['storePercentageAsDecimal']; + +export const percentageToDecimals = (percentage: number, precision = 2) => Number((percentage / 100).toFixed(precision + 2)); + +export const decimalsToPercentage = (decimal: number, precision = 2) => Number((decimal * 100).toFixed(precision)); + +// Cosmos NumberDisplay +const formatNumberForDisplay = ( + value: any, + { fractionDigits, groupSeparators = true, notation = 'standard', unit }: NumberDisplayOptions = {} +): string => { + if (isEmptyValue(value)) return ''; + const digits = fractionDigits ?? String(value).split('.')[1]?.length ?? 0; + + return new Intl.NumberFormat(getLocale(), { + notation, + useGrouping: groupSeparators, + minimumFractionDigits: digits, + maximumFractionDigits: digits, + numberingSystem: 'latn', + ...(unit ? { style: 'unit', unit } : {}) + }).format(Number(value)); +}; + +// Cosmos CurrencyDisplay (accounting negatives always use standard notation, as in the DX components) +const formatCurrencyForDisplay = ( + value: any, + currencyISOCode: string | undefined, + { currencyDisplay = 'symbol', negative = 'minus-sign', notation = 'standard', fractionDigits }: CurrencyDisplayOptions = {} +): string => { + if (isEmptyValue(value)) return ''; + + const options: Intl.NumberFormatOptions | undefined = + currencyISOCode && isValidCurrency(currencyISOCode) + ? { + style: 'currency', + currency: currencyISOCode, + currencyDisplay, + useGrouping: true, + notation: negative === 'parentheses' ? 'standard' : notation, + currencySign: negative === 'parentheses' ? 'accounting' : 'standard', + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits + } + : undefined; + + return new Intl.NumberFormat(getLocale(), options).format(Number(value)); +}; + +export type NumberFieldType = 'Currency' | 'Decimal' | 'Integer' | 'Percentage'; + +export interface NumberFieldDisplayProps { + formatter?: string; + currencyISOCode?: string; + decimalPrecision?: number | string; + showGroupSeparators?: boolean | string; + currencyDisplay?: CurrencyDisplayOptions['currencyDisplay']; + negative?: CurrencyDisplayOptions['negative']; + notation?: CurrencyDisplayOptions['notation']; + currencyDecimalPrecision?: string; +} + +const toBoolean = (value: any, defaultValue: boolean): boolean => { + if (value === undefined || value === null) return defaultValue; + return typeof value === 'string' ? value.toLowerCase() === 'true' : !!value; +}; + +// Read-only value of the DX Currency/Decimal/Integer/Percentage fields, per the field's "Format" setting. +// For Percentage, pass the value already converted to a percentage (see decimalsToPercentage). +export const formatNumberFieldForDisplay = (fieldType: NumberFieldType, value: any, configProps: NumberFieldDisplayProps): string => { + const { formatter, currencyISOCode, decimalPrecision, showGroupSeparators, notation, currencyDisplay, negative, currencyDecimalPrecision } = + configProps; + const numberOfDecimals = parseDecimalPrecision(decimalPrecision, fieldType === 'Decimal' || fieldType === 'Percentage' ? 2 : undefined); + // DX Currency ignores showGroupSeparators; DX Percentage defaults it to off + const groupSeparators = fieldType === 'Currency' || toBoolean(showGroupSeparators, fieldType !== 'Percentage'); + const currencyOptions: CurrencyDisplayOptions = { + currencyDisplay, + negative, + notation, + fractionDigits: getCurrencyDisplayFractionDigits(currencyDecimalPrecision) + }; + + if (fieldType === 'Percentage') { + return formatNumberForDisplay(value, { fractionDigits: numberOfDecimals, groupSeparators, unit: 'percent' }); + } + + switch (formatter) { + case 'Currency': + case 'Currency-Code': + case 'Currency-Compact': + if (fieldType === 'Integer') { + currencyOptions.currencyDisplay = formatter === 'Currency-Code' ? 'code' : currencyDisplay; + currencyOptions.notation = formatter === 'Currency-Compact' ? 'compact' : notation; + } + return formatCurrencyForDisplay(value, currencyISOCode, currencyOptions); + case 'Integer': + return formatNumberForDisplay(value, { fractionDigits: 0, groupSeparators, notation }); + case 'Decimal': + return formatNumberForDisplay(value, { fractionDigits: numberOfDecimals, groupSeparators, notation }); + case 'Decimal-Auto': + return formatNumberForDisplay(value, { fractionDigits: Number.isInteger(Number(value)) ? 0 : 2, groupSeparators, notation }); + case 'Percentage': { + const percentValue = fieldType === 'Decimal' && isPercentageStoredAsDecimal() && value ? Number(value) * 100 : value; + // DX Currency keeps group separators here; DX Decimal and Integer turn them off + return formatNumberForDisplay(percentValue, { + fractionDigits: numberOfDecimals, + groupSeparators: fieldType === 'Currency', + notation, + unit: 'percent' + }); + } + default: + if (fieldType === 'Currency') { + return formatCurrencyForDisplay(value, currencyISOCode, currencyOptions); + } + return formatNumberForDisplay(value, { fractionDigits: fieldType === 'Integer' ? 0 : numberOfDecimals, groupSeparators, notation }); + } }; diff --git a/packages/angular-sdk-components/src/lib/_helpers/formatters/format-utils.ts b/packages/angular-sdk-components/src/lib/_helpers/formatters/format-utils.ts index ba0149679..5862a4cb0 100644 --- a/packages/angular-sdk-components/src/lib/_helpers/formatters/format-utils.ts +++ b/packages/angular-sdk-components/src/lib/_helpers/formatters/format-utils.ts @@ -1,8 +1,22 @@ import { getLocale } from '../common'; -import { currencyMap } from './currency-map'; +import get from 'lodash.get'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function NumberFormatter(value, { locale = 'en-US', decPlaces = 2, style = '', currency = 'USD' } = {}) { +interface CurrencyFormatOptions { + locale?: string; + options?: Intl.NumberFormatOptions; +} + +interface CurrencyFormatterOptions { + symbol?: boolean; + locale?: string; + decPlaces?: number; + currencyISOCode?: string; + isoCodeSelection?: string; + rowData?: unknown; + notation?: Intl.NumberFormatOptions['notation']; +} + +function NumberFormatter(value, { locale = 'en-US', decPlaces = 2 } = {}) { const currentLocale = getLocale(locale); if (value !== null && value !== undefined) { return Number(value).toLocaleString(currentLocale, { @@ -13,52 +27,62 @@ function NumberFormatter(value, { locale = 'en-US', decPlaces = 2, style = '', c return value; } +export const isValidCurrency = (currency: string) => { + try { + new Intl.NumberFormat(undefined, { style: 'currency', currency }); + return true; + } catch { + return false; + } +}; + +function getCodeForCurrencyCodeFormatter(currencyISOCode, rowData, isoCodeSelection = 'constant') { + let code; + currencyISOCode = currencyISOCode.replace('@P ', ''); + currencyISOCode = currencyISOCode.replace('@L ', ''); + if (isoCodeSelection === 'constant') { + code = isValidCurrency(currencyISOCode) ? currencyISOCode : undefined; + } else { + // For ISOCodeSelection === 'propertyRef' + currencyISOCode = currencyISOCode.indexOf('.') === 0 ? currencyISOCode.substring(1) : currencyISOCode; + const resolvedCurrencyCode = get(rowData, currencyISOCode); + code = typeof resolvedCurrencyCode === 'string' && isValidCurrency(resolvedCurrencyCode) ? resolvedCurrencyCode : undefined; + } + return code; +} + +function formatCurrency(value: number | bigint, { locale, options }: CurrencyFormatOptions) { + const formatter = new Intl.NumberFormat(locale, options); + + return formatter.format(value); +} + function CurrencyFormatter( - value, - { symbol = true, position = 'before', locale = 'en-US', decPlaces = 2, style = 'currency', currency = 'USD' } = {} + value: number | bigint | string | null | undefined, + { symbol = true, locale, decPlaces = 2, currencyISOCode, isoCodeSelection, rowData, notation }: CurrencyFormatterOptions = {} ) { + // locale is used here only to format the values and not for deciding the symbol. + // Currency symbol should be decided always from the configured ISO Code. + // - If no code is configured, fallback to App-level setting or USD const currentLocale = getLocale(locale); - let formattedValue = value; if (value !== null && value !== undefined && value !== '') { - formattedValue = NumberFormatter(value, { - locale: currentLocale, - decPlaces, - style, - currency - }); + // For All formats of currency, Cosmos API "formatCurrency" required Currency ISO code + const code = currencyISOCode + ? getCodeForCurrencyCodeFormatter(currencyISOCode, rowData, isoCodeSelection) + : window.PCore?.getFieldDefaultUtils?.()?.fieldDefaults?.['Currency']?.['currencyISOCode'] || 'USD'; - // For currency other than EUR, we need to determine the country code from currency code - // If currency is EUR, we use the locale to determine the country code - let countryCode: string | undefined; - if (currency !== 'EUR') { - countryCode = currency.substring(0, 2); - } else { - countryCode = currentLocale?.split('-')[1].toUpperCase(); - } + const options: Intl.NumberFormatOptions | undefined = code + ? { + style: 'currency', + currency: code, + currencyDisplay: symbol ? 'symbol' : 'code', + ...(notation === 'compact' ? { notation } : { minimumFractionDigits: decPlaces, maximumFractionDigits: decPlaces }) + } + : undefined; - // If countryCode is still undefined, setting it as US - if (!countryCode) { - countryCode = 'US'; - } - - let code; - if (symbol) { - code = currencyMap[countryCode]?.symbolFormat; - } else { - code = currencyMap[countryCode]?.currencyCode; - } - - // if position is provided, change placeholder accordingly. - if (position && code) { - if (position.toLowerCase() === 'before' && code.startsWith('{#}')) { - code = code.slice(3) + code.slice(0, 3); - } else if (position.toLowerCase() === 'after' && code.endsWith('{#}')) { - code = code.slice(-3) + code.slice(0, -3); - } - } - return code?.replace('{#}', formattedValue) || formattedValue; + return formatCurrency(typeof value === 'string' ? Number(value) : value, { locale: currentLocale, options }); } - return formattedValue; + return value; } function SymbolFormatter(value, { symbol = '$', suffix = true, locale = 'en-US' } = {}) {