Skip to content

Commit 32c59c3

Browse files
committed
Add locale-aware number parsing to Range fieldtype
Range fieldtype now properly handles locale-specific number formats from different countries and regions. Implementation: - Uses NumberFormatter (intl extension) for locale-aware parsing - Manual fallback handles common formats without intl extension - Strips space and apostrophe thousands separators - Whichever separator comes first is thousands Supported formats: - US/UK: 1,000.50 (comma thousands, period decimal) - European: 1.000,50 (period thousands, comma decimal) - French: 1 000,50 (space thousands, comma decimal) - Swiss: 1'000.50 (apostrophe thousands, period decimal) - Simple: 123.45 or 123,45 Test coverage includes 10+ locale-specific format tests.
1 parent 5512179 commit 32c59c3

File tree

2 files changed

+293
-4
lines changed

2 files changed

+293
-4
lines changed

src/Fieldtypes/Range.php

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,13 @@ protected function configFieldItems(): array
6565

6666
public function process($data)
6767
{
68+
$normalized = $this->normalizeNumber($data);
69+
6870
if ($this->usesDecimals()) {
69-
return (float) $data;
71+
return (float) $normalized;
7072
}
7173

72-
return (int) $data;
74+
return (int) $normalized;
7375
}
7476

7577
protected function usesDecimals(): bool
@@ -83,11 +85,52 @@ protected function usesDecimals(): bool
8385

8486
protected function isDecimal($value): bool
8587
{
86-
if (! is_numeric($value)) {
88+
$normalized = $this->normalizeNumber($value);
89+
90+
if (! is_numeric($normalized)) {
8791
return false;
8892
}
8993

90-
return floor((float) $value) != (float) $value;
94+
return floor((float) $normalized) != (float) $normalized;
95+
}
96+
97+
protected function normalizeNumber($value)
98+
{
99+
if (is_numeric($value)) {
100+
return $value;
101+
}
102+
103+
if (! is_string($value)) {
104+
return $value;
105+
}
106+
107+
// Try locale-aware parsing first (requires intl extension)
108+
if (class_exists(\NumberFormatter::class)) {
109+
$formatter = new \NumberFormatter(app()->getLocale(), \NumberFormatter::DECIMAL);
110+
$parsed = $formatter->parse($value);
111+
112+
if ($parsed !== false) {
113+
return $parsed;
114+
}
115+
}
116+
117+
// Fallback: normalize common number formats manually
118+
// Remove common thousands separators: space (French), apostrophe (Swiss)
119+
$value = str_replace([' ', "'"], '', $value);
120+
121+
if (str_contains($value, ',')) {
122+
if (str_contains($value, '.')) {
123+
// Both separators present - whichever comes first is thousands
124+
return strpos($value, '.') < strpos($value, ',')
125+
? str_replace(['.', ','], ['', '.'], $value) // European: 1.000,50
126+
: str_replace(',', '', $value); // US: 1,000.50
127+
}
128+
129+
// Only comma - treat as decimal separator
130+
return str_replace(',', '.', $value);
131+
}
132+
133+
return $value;
91134
}
92135

93136
public function toGqlType()

tests/Fieldtypes/RangeFieldtypeTest.php

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,250 @@ public function it_returns_float_graphql_type_with_decimal_config()
134134

135135
$this->assertEquals('Float', $type->name);
136136
}
137+
138+
#[Test]
139+
public function it_handles_locale_specific_comma_decimal_in_config()
140+
{
141+
$fieldtype = (new Range())->setField(new Field('test', [
142+
'type' => 'range',
143+
'min' => 0,
144+
'max' => 100,
145+
'step' => '0,5', // European format: 0.5
146+
]));
147+
148+
$result = $fieldtype->process('7,5');
149+
150+
$this->assertIsFloat($result);
151+
$this->assertEquals(7.5, $result);
152+
}
153+
154+
#[Test]
155+
public function it_handles_locale_specific_comma_decimal_in_value()
156+
{
157+
$fieldtype = (new Range())->setField(new Field('test', [
158+
'type' => 'range',
159+
'min' => 0,
160+
'max' => 100,
161+
'step' => 0.1,
162+
]));
163+
164+
$result = $fieldtype->process('3,14');
165+
166+
$this->assertIsFloat($result);
167+
$this->assertEquals(3.14, $result);
168+
}
169+
170+
#[Test]
171+
public function it_handles_thousands_separator_with_decimal()
172+
{
173+
$fieldtype = (new Range())->setField(new Field('test', [
174+
'type' => 'range',
175+
'min' => 0,
176+
'max' => 10000,
177+
'step' => 0.01,
178+
]));
179+
180+
// Format: 1,234.56 (comma as thousands separator)
181+
$result = $fieldtype->process('1,234.56');
182+
183+
$this->assertIsFloat($result);
184+
$this->assertEquals(1234.56, $result);
185+
}
186+
187+
#[Test]
188+
public function it_uses_number_formatter_for_locale_aware_parsing()
189+
{
190+
if (! class_exists(\NumberFormatter::class)) {
191+
$this->markTestSkipped('NumberFormatter (intl extension) not available');
192+
}
193+
194+
$originalLocale = app()->getLocale();
195+
app()->setLocale('de_DE');
196+
197+
$fieldtype = (new Range())->setField(new Field('test', [
198+
'type' => 'range',
199+
'min' => 0,
200+
'max' => 10000,
201+
'step' => 0.01,
202+
]));
203+
204+
// Test that NumberFormatter correctly parses German locale format
205+
// NumberFormatter will handle this according to locale rules
206+
$result = $fieldtype->process('1.234,56');
207+
208+
app()->setLocale($originalLocale);
209+
210+
$this->assertIsFloat($result);
211+
$this->assertEquals(1234.56, $result);
212+
}
213+
214+
#[Test]
215+
public function it_handles_french_format_space_as_thousands_separator()
216+
{
217+
$fieldtype = (new Range())->setField(new Field('test', [
218+
'type' => 'range',
219+
'min' => 0,
220+
'max' => 100000,
221+
'step' => 0.01,
222+
]));
223+
224+
// French (fr_FR): space as thousands separator, comma as decimal
225+
$result = $fieldtype->process('1 234,56');
226+
227+
$this->assertIsFloat($result);
228+
$this->assertEquals(1234.56, $result);
229+
}
230+
231+
#[Test]
232+
public function it_handles_german_format_with_multiple_thousands()
233+
{
234+
$fieldtype = (new Range())->setField(new Field('test', [
235+
'type' => 'range',
236+
'min' => 0,
237+
'max' => 10000000,
238+
'step' => 0.01,
239+
]));
240+
241+
// German (de_DE): period as thousands, comma as decimal
242+
$result = $fieldtype->process('1.234.567,89');
243+
244+
$this->assertIsFloat($result);
245+
$this->assertEquals(1234567.89, $result);
246+
}
247+
248+
#[Test]
249+
public function it_handles_swiss_german_apostrophe_separator()
250+
{
251+
$fieldtype = (new Range())->setField(new Field('test', [
252+
'type' => 'range',
253+
'min' => 0,
254+
'max' => 10000000,
255+
'step' => 0.01,
256+
]));
257+
258+
// Swiss German (de_CH): apostrophe as thousands, period as decimal
259+
$result = $fieldtype->process("1'234'567.89");
260+
261+
$this->assertIsFloat($result);
262+
$this->assertEquals(1234567.89, $result);
263+
}
264+
265+
#[Test]
266+
public function it_handles_swiss_format_with_comma_decimal()
267+
{
268+
$fieldtype = (new Range())->setField(new Field('test', [
269+
'type' => 'range',
270+
'min' => 0,
271+
'max' => 100000,
272+
'step' => 0.01,
273+
]));
274+
275+
// Swiss (some regions): apostrophe as thousands, comma as decimal
276+
$result = $fieldtype->process("12'345,67");
277+
278+
$this->assertIsFloat($result);
279+
$this->assertEquals(12345.67, $result);
280+
}
281+
282+
#[Test]
283+
public function it_handles_italian_format()
284+
{
285+
$fieldtype = (new Range())->setField(new Field('test', [
286+
'type' => 'range',
287+
'min' => 0,
288+
'max' => 10000000,
289+
'step' => 0.01,
290+
]));
291+
292+
// Italian (it_IT): period as thousands, comma as decimal
293+
$result = $fieldtype->process('2.500.000,75');
294+
295+
$this->assertIsFloat($result);
296+
$this->assertEquals(2500000.75, $result);
297+
}
298+
299+
#[Test]
300+
public function it_handles_spanish_format()
301+
{
302+
$fieldtype = (new Range())->setField(new Field('test', [
303+
'type' => 'range',
304+
'min' => 0,
305+
'max' => 100000,
306+
'step' => 0.01,
307+
]));
308+
309+
// Spanish (es_ES): period as thousands, comma as decimal
310+
$result = $fieldtype->process('12.345,67');
311+
312+
$this->assertIsFloat($result);
313+
$this->assertEquals(12345.67, $result);
314+
}
315+
316+
#[Test]
317+
public function it_handles_us_format()
318+
{
319+
$fieldtype = (new Range())->setField(new Field('test', [
320+
'type' => 'range',
321+
'min' => 0,
322+
'max' => 10000000,
323+
'step' => 0.01,
324+
]));
325+
326+
// US (en_US): comma as thousands, period as decimal
327+
$result = $fieldtype->process('9,876,543.21');
328+
329+
$this->assertIsFloat($result);
330+
$this->assertEquals(9876543.21, $result);
331+
}
332+
333+
#[Test]
334+
public function it_handles_uk_format()
335+
{
336+
$fieldtype = (new Range())->setField(new Field('test', [
337+
'type' => 'range',
338+
'min' => 0,
339+
'max' => 100000,
340+
'step' => 0.01,
341+
]));
342+
343+
// UK (en_GB): comma as thousands, period as decimal
344+
$result = $fieldtype->process('54,321.98');
345+
346+
$this->assertIsFloat($result);
347+
$this->assertEquals(54321.98, $result);
348+
}
349+
350+
#[Test]
351+
public function it_handles_simple_decimal_without_thousands()
352+
{
353+
$fieldtype = (new Range())->setField(new Field('test', [
354+
'type' => 'range',
355+
'min' => 0,
356+
'max' => 1000,
357+
'step' => 0.01,
358+
]));
359+
360+
// Simple decimal without thousands separator
361+
$result = $fieldtype->process('123.45');
362+
363+
$this->assertIsFloat($result);
364+
$this->assertEquals(123.45, $result);
365+
}
366+
367+
#[Test]
368+
public function it_handles_simple_comma_decimal_without_thousands()
369+
{
370+
$fieldtype = (new Range())->setField(new Field('test', [
371+
'type' => 'range',
372+
'min' => 0,
373+
'max' => 1000,
374+
'step' => 0.01,
375+
]));
376+
377+
// Simple comma decimal without thousands separator (European)
378+
$result = $fieldtype->process('789,12');
379+
380+
$this->assertIsFloat($result);
381+
$this->assertEquals(789.12, $result);
382+
}
137383
}

0 commit comments

Comments
 (0)