Skip to content

Commit 82dd5e0

Browse files
committed
Address CodeRabbit feedback
1 parent 24b0a32 commit 82dd5e0

2 files changed

Lines changed: 85 additions & 12 deletions

File tree

packages/react-core/src/helpers/__tests__/util.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,17 +114,51 @@ test('formatBreakpointMods', () => {
114114
expect(formatBreakpointMods({ default: 'column', lg: 'row' }, styles)).toEqual('pf-m-column pf-m-row-on-lg');
115115
});
116116

117-
test('parseLocalizedDecimal accepts dot and comma decimal separators', () => {
117+
test('parseLocalizedDecimal accepts locale-formatted decimals', () => {
118118
const enFormatter = new Intl.NumberFormat('en');
119119
const esFormatter = new Intl.NumberFormat('es');
120+
const deFormatter = new Intl.NumberFormat('de-DE');
120121

121122
expect(parseLocalizedDecimal('50.2', enFormatter)).toBe(50.2);
122123
expect(parseLocalizedDecimal('50,2', esFormatter)).toBe(50.2);
123124
expect(parseLocalizedDecimal('1.234,56', esFormatter)).toBe(1234.56);
124125
expect(parseLocalizedDecimal('1,234.56', enFormatter)).toBe(1234.56);
126+
expect(parseLocalizedDecimal('1.234,56', deFormatter)).toBe(1234.56);
125127
expect(parseLocalizedDecimal('', enFormatter)).toBeNaN();
126128
});
127129

130+
test('parseLocalizedDecimal rejects mismatched separators', () => {
131+
const enFormatter = new Intl.NumberFormat('en-US');
132+
const deFormatter = new Intl.NumberFormat('de-DE');
133+
const deNoGrouping = new Intl.NumberFormat('de-DE', { useGrouping: false });
134+
135+
// Comma used as decimal in en-US would otherwise silently merge to 502
136+
expect(parseLocalizedDecimal('50,2', enFormatter)).toBeNaN();
137+
// Dot used as decimal in de-DE would otherwise silently merge to 502
138+
expect(parseLocalizedDecimal('50.2', deFormatter)).toBeNaN();
139+
// Mixed separators for de-DE
140+
expect(parseLocalizedDecimal('1,234.56', deFormatter)).toBeNaN();
141+
// Without a reported group separator, alternate '.' must not be stripped/merged
142+
expect(parseLocalizedDecimal('1.234,56', deNoGrouping)).toBeNaN();
143+
expect(parseLocalizedDecimal('50,2', deNoGrouping)).toBe(50.2);
144+
});
145+
146+
test('parseLocalizedDecimal rejects malformed numeric tokens', () => {
147+
const enFormatter = new Intl.NumberFormat('en-US');
148+
149+
expect(parseLocalizedDecimal('12abc', enFormatter)).toBeNaN();
150+
expect(parseLocalizedDecimal('1,2abc', enFormatter)).toBeNaN();
151+
expect(parseLocalizedDecimal('12-3', enFormatter)).toBeNaN();
152+
expect(parseLocalizedDecimal('--12', enFormatter)).toBeNaN();
153+
expect(parseLocalizedDecimal('+-12', enFormatter)).toBeNaN();
154+
expect(parseLocalizedDecimal('12.3.4', enFormatter)).toBeNaN();
155+
expect(parseLocalizedDecimal('abc', enFormatter)).toBeNaN();
156+
157+
expect(parseLocalizedDecimal('-50.2', enFormatter)).toBe(-50.2);
158+
expect(parseLocalizedDecimal('+12', enFormatter)).toBe(12);
159+
expect(parseLocalizedDecimal('1,234.56', enFormatter)).toBe(1234.56);
160+
});
161+
128162
test('formatLocalizedDecimal uses locale decimal separator', () => {
129163
expect(formatLocalizedDecimal(50.2, new Intl.NumberFormat('en-US'))).toBe('50.2');
130164
expect(formatLocalizedDecimal(50.2, new Intl.NumberFormat('de-DE'))).toBe('50,2');

packages/react-core/src/helpers/util.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -592,8 +592,8 @@ export const getInlineStartProperty = (
592592
};
593593

594594
/**
595-
* Parses a decimal input string, accepting both comma and dot as the decimal separator.
596-
* Given the locale, it discovers the locale's separators and integers using a reference value.
595+
* Parses a decimal input string using the given formatter's locale separators.
596+
* Grouping is removed only when formatToParts reports a group separator.
597597
*
598598
* @param {string} value - The input string to parse
599599
* @param {Intl.NumberFormat} formatter - The Intl.NumberFormat instance to use for formatting
@@ -605,7 +605,9 @@ export const parseLocalizedDecimal = (value: string, formatter: Intl.NumberForma
605605
}
606606

607607
const parts = formatter.formatToParts(12345.6);
608-
const groupSymbol = parts.find((p) => p.type === 'group')?.value || ',';
608+
// Only strip grouping when the formatter actually reports a group separator.
609+
// Never fall back to ',' — that can match comma-decimal locales and eat the decimal.
610+
const groupSymbol = parts.find((p) => p.type === 'group')?.value;
609611
const decimalSymbol = parts.find((p) => p.type === 'decimal')?.value || '.';
610612

611613
// in case of non-Arabic numerals
@@ -614,22 +616,59 @@ export const parseLocalizedDecimal = (value: string, formatter: Intl.NumberForma
614616
.filter((p) => p.type === 'integer')
615617
.map((p) => p.value)
616618
.join('');
617-
let normalizedString = value;
619+
let normalizedString = value.trim();
618620
if (localDigits.length === 10 && localDigits !== '1234567890') {
619621
const standardDigits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
620622
const digitMap = new Map();
621623
[...localDigits].forEach((char, idx) => digitMap.set(char, standardDigits[idx]));
622-
normalizedString = [...value].map((char) => digitMap.get(char) || char).join('');
624+
normalizedString = [...normalizedString].map((char) => digitMap.get(char) || char).join('');
623625
}
624626

625-
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
627+
// Reject '.' / ',' when they are neither this locale's decimal nor its reported group separator
628+
// (e.g. mixed-separator de-DE input like "1,234.56").
629+
for (const sep of ['.', ','] as const) {
630+
if (sep !== decimalSymbol && sep !== groupSymbol && normalizedString.includes(sep)) {
631+
return NaN;
632+
}
633+
}
634+
635+
const decimalIndex = normalizedString.indexOf(decimalSymbol);
636+
if (decimalIndex !== -1 && normalizedString.indexOf(decimalSymbol, decimalIndex + decimalSymbol.length) !== -1) {
637+
return NaN;
638+
}
626639

627-
const cleaned = normalizedString
628-
.replace(new RegExp(escapeRegex(groupSymbol), 'g'), '')
629-
.replace(new RegExp(escapeRegex(decimalSymbol), 'g'), '.')
630-
.replace(/[^0-9.-]/g, '');
640+
let intPart = decimalIndex === -1 ? normalizedString : normalizedString.slice(0, decimalIndex);
641+
const fracPart = decimalIndex === -1 ? undefined : normalizedString.slice(decimalIndex + decimalSymbol.length);
642+
643+
let sign = '';
644+
if (intPart.startsWith('-') || intPart.startsWith('+')) {
645+
sign = intPart[0] === '-' ? '-' : '';
646+
intPart = intPart.slice(1);
647+
}
648+
649+
if (fracPart !== undefined && groupSymbol && fracPart.includes(groupSymbol)) {
650+
return NaN;
651+
}
652+
653+
// If grouping appears, require valid thousands groups so values like en-US "50,2" are invalid
654+
// instead of silently merging into 502.
655+
if (groupSymbol && intPart.includes(groupSymbol)) {
656+
const groups = intPart.split(groupSymbol);
657+
if (groups.length < 2 || !/^\d{1,3}$/.test(groups[0]) || groups.slice(1).some((group) => !/^\d{3}$/.test(group))) {
658+
return NaN;
659+
}
660+
intPart = groups.join('');
661+
}
662+
663+
if (!/^\d*$/.test(intPart) || (fracPart !== undefined && !/^\d*$/.test(fracPart))) {
664+
return NaN;
665+
}
666+
667+
if (intPart === '' && (fracPart === undefined || fracPart === '')) {
668+
return NaN;
669+
}
631670

632-
return parseFloat(cleaned);
671+
return parseFloat(`${sign}${intPart || '0'}${fracPart !== undefined ? `.${fracPart}` : ''}`);
633672
};
634673

635674
/**

0 commit comments

Comments
 (0)