Skip to content

Commit

Permalink
Add support for custom decimal separator for numeric types (#1981)
Browse files Browse the repository at this point in the history
  • Loading branch information
mvorisek committed Jan 29, 2023
1 parent c98604a commit d86b13b
Show file tree
Hide file tree
Showing 6 changed files with 120 additions and 37 deletions.
2 changes: 1 addition & 1 deletion docs/paginator.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ to display current page BEFORE the paginator on your page::
Remember that values of 'page' and 'total' are integers, so you may need to do type-casting::

$label->set($p->page); // will not work
$label->set((string)$p->page); // works fine
$label->set((string) $p->page); // works fine

Range and Logic
===============
Expand Down
6 changes: 3 additions & 3 deletions src/Form/Control/Dropdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ protected function _renderItemsForModel(): void
{
foreach ($this->model as $key => $row) {
$title = $row->getTitle();
$this->_tItem->set('value', (string) $key);
$this->_tItem->set('value', $key);
$this->_tItem->set('title', $title || is_numeric($title) ? (string) $title : '');
// add item to template
$this->template->dangerouslyAppendHtml('Item', $this->_tItem->renderToHtml());
Expand All @@ -277,7 +277,7 @@ protected function _renderItemsForModel(): void
protected function _renderItemsForValues(): void
{
foreach ($this->values as $key => $val) {
$this->_tItem->set('value', (string) $key);
$this->_tItem->set('value', $key);
if (is_array($val)) {
if (array_key_exists('icon', $val)) {
$this->_tIcon->set('iconClass', $val['icon'] . ' icon');
Expand Down Expand Up @@ -305,7 +305,7 @@ protected function _renderItemsForValues(): void
protected function _addCallBackRow($row, $key = null): void
{
$res = ($this->renderRowFunction)($row, $key);
$this->_tItem->set('value', (string) $res['value']);
$this->_tItem->set('value', $res['value']);
$this->_tItem->set('title', $res['title']);

// Icon
Expand Down
4 changes: 2 additions & 2 deletions src/Form/Control/ScopeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ public static function queryToScope(array $query): Scope\AbstractScope
public static function queryToCondition(array $query): Scope\Condition
{
$key = $query['rule'] ?? null;
$operator = (string) ($query['operator'] ?? null);
$operator = $query['operator'] ?? null;
$value = $query['value'] ?? null;

switch ($operator) {
Expand All @@ -573,7 +573,7 @@ public static function queryToCondition(array $query): Scope\Condition
break;
case self::OPERATOR_IN:
case self::OPERATOR_NOT_IN:
$value = explode(static::detectDelimiter($value), (string) $value);
$value = explode(static::detectDelimiter($value), $value);

break;
}
Expand Down
7 changes: 6 additions & 1 deletion src/HtmlTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Atk4\Core\AppScopeTrait;
use Atk4\Core\WarnDynamicPropertyTrait;
use Atk4\Data\Field;
use Atk4\Data\Model;
use Atk4\Ui\HtmlTemplate\TagTree;
use Atk4\Ui\HtmlTemplate\Value as HtmlValue;
Expand Down Expand Up @@ -174,7 +175,11 @@ protected function _setOrAppend($tag, $value = null, bool $encodeHtml = true, bo
}

// TODO remove later in favor of strong string type
$value = (string) $value;
if ($value === null) {
$value = '';
} elseif (is_int($value)) { // @phpstan-ignore-line
$value = $this->getApp()->uiPersistence->typecastSaveField(new Field(['type' => 'integer']), $value);
}

$htmlValue = new HtmlValue();
if ($encodeHtml) {
Expand Down
39 changes: 25 additions & 14 deletions src/Persistence/Ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Atk4\Data\Field\PasswordField;
use Atk4\Data\Model;
use Atk4\Data\Persistence;
use Atk4\Data\Persistence\Sql\Expression;
use Atk4\Ui\Exception;

/**
Expand All @@ -25,14 +26,15 @@ class Ui extends Persistence
/** @var string */
public $locale = 'en';

/** @var string Thousands separator for numeric types. */
public $thousandsSeparator = ' ';
/** @var string Decimal point separator for numeric (non-integer) types. */
public $decimalSeparator = '.';

/** @var string Currency symbol for 'atk4_money' type. */
public $currency = '€';
/** @var int Number of decimal digits for 'atk4_money' type. */
public $currencyDecimals = 2;
/** @var string Decimal point separator for 'atk4_money' type. */
public $currencyDecimalSeparator = '.';
/** @var string Thousands separator for 'atk4_money' type. */
public $currencyThousandsSeparator = ' ';

/** @var string */
public $timezone;
Expand Down Expand Up @@ -92,12 +94,26 @@ protected function _typecastSaveField(Field $field, $value): string
$value = parent::_typecastLoadField($field, $value);
$value = $value ? $this->yes : $this->no;

break;
case 'integer':
case 'float':
$value = parent::_typecastLoadField($field, $value);
$value = is_int($value)
? (string) $value
: Expression::castFloatToString($value);
$value = preg_replace_callback('~\.?\d+~', function ($matches) {
return substr($matches[0], 0, 1) === '.'
? $this->decimalSeparator . preg_replace('~\d{3}\K(?!$)~', /* ' ' */ '', substr($matches[0], 1))
: preg_replace('~(?<!^)(?=(?:\d{3})+$)~', $this->thousandsSeparator, $matches[0]);
}, $value);
$value = str_replace(' ', "\u{00a0}" /* Unicode NBSP */, $value);

break;
case 'atk4_money':
$value = parent::_typecastLoadField($field, $value);
$valueDecimals = strlen(preg_replace('~^[^.]$|^.+\.|0+$~s', '', number_format($value, max(0, 11 - (int) log10($value)), '.', '')));
$value = ($this->currency ? $this->currency . ' ' : '')
. number_format($value, max($this->currencyDecimals, $valueDecimals), $this->currencyDecimalSeparator, $this->currencyThousandsSeparator);
. number_format($value, max($this->currencyDecimals, $valueDecimals), $this->decimalSeparator, $this->thousandsSeparator);
$value = str_replace(' ', "\u{00a0}" /* Unicode NBSP */, $value);

break;
Expand Down Expand Up @@ -142,12 +158,14 @@ protected function _typecastLoadField(Field $field, $value)
}

break;
case 'integer':
case 'float':
case 'atk4_money':
if (is_string($value)) {
$value = str_replace([' ', "\u{00a0}" /* Unicode NBSP */, '_', $this->currency, '$', '€'], '', $value);
$dSep = $this->currencyDecimalSeparator;
$dSep = $this->decimalSeparator;
$tSeps = array_filter(
array_unique([$dSep, $this->currencyThousandsSeparator, '.', ',']),
array_unique([$dSep, $this->thousandsSeparator, '.', ',']),
fn ($sep) => strpos($value, $sep) !== false
);
usort($tSeps, fn ($sepA, $sepB) => strrpos($value, $sepB) <=> strrpos($value, $sepA));
Expand Down Expand Up @@ -228,13 +246,6 @@ public function typecastSaveRow(Model $model, array $row): array
{
$result = [];
foreach ($row as $key => $value) {
// no knowledge of the field, it wasn't defined, leave it as-is
if (!$model->hasField($key)) {
$result[$key] = $value;

continue;
}

$result[$key] = $this->typecastSaveField($model->getField($key), $value);
}

Expand Down
99 changes: 83 additions & 16 deletions tests/PersistenceUiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ class PersistenceUiTest extends TestCase
{
/**
* @param mixed $phpValue
* @param mixed $expectedUiValue
* @param mixed $uiValue
*
* @dataProvider providerTypecast
* @dataProvider providerTypecastBidirectional
* @dataProvider providerTypecastLoadOnly
*/
public function testTypecast(array $persistenceSeed, array $fieldSeed, $phpValue, $expectedUiValue): void
public function testTypecast(array $persistenceSeed, array $fieldSeed, $phpValue, $uiValue, bool $isUiValueNormalized = true): void
{
$p = (new UiPersistence())->setDefaults($persistenceSeed);
$field = (new Field())->setDefaults($fieldSeed);
Expand All @@ -25,35 +26,50 @@ public function testTypecast(array $persistenceSeed, array $fieldSeed, $phpValue
$phpValue = new \DateTime($matches[1]);
}

$uiValue = $p->typecastSaveField($field, $phpValue);
static::assertSame($expectedUiValue, $uiValue);
if ($isUiValueNormalized) {
$savedUiValue = $p->typecastSaveField($field, $phpValue);
static::assertSame($uiValue, $savedUiValue);
}

$readPhpValue = $p->typecastLoadField($field, $uiValue);
if ($readPhpValue instanceof \DateTimeInterface) {
$this->{'assertEquals'}($phpValue, $readPhpValue);
} else {
static::assertSame($phpValue, $readPhpValue);
}
$uiValue = $p->typecastSaveField($field, $readPhpValue);
static::assertSame($expectedUiValue, $uiValue);

$savedUiValue = $p->typecastSaveField($field, $readPhpValue);
if ($isUiValueNormalized) {
static::assertSame($uiValue, $savedUiValue);
} else {
$this->testTypecast($persistenceSeed, $fieldSeed, $phpValue, $savedUiValue);
}
}

public function providerTypecast(): iterable
public function providerTypecastBidirectional(): iterable
{
$fixSpaceToNbspFx = fn (string $v) => str_replace(' ', "\u{00a0}", $v);

yield [[], [], '1', '1'];
yield [[], [], '0', '0'];
yield [[], ['type' => 'string'], '1', '1'];
yield [[], ['type' => 'string'], '0', '0'];
yield [[], ['type' => 'text'], "\n0\n\n0", "\n0\n\n0"];
yield [[], ['type' => 'integer'], 1, '1'];
yield [[], ['type' => 'integer'], 0, '0'];
yield [[], ['type' => 'integer'], -1_100_230_000_456_345_678, '-1100230000456345678'];
yield [[], ['type' => 'float'], 1.0, '1'];
yield [[], ['type' => 'float'], 0.0, '0'];
yield [[], ['type' => 'float'], -1_100_230_000.4567, '-1100230000.4567'];
yield [[], ['type' => 'integer'], -1_100_230_000_456_345_678, $fixSpaceToNbspFx('-1 100 230 000 456 345 678')];
yield [['thousandsSeparator' => ','], ['type' => 'integer'], 12345678, '12,345,678'];
yield [[], ['type' => 'float'], 1.0, '1.0'];
yield [[], ['type' => 'float'], 0.0, '0.0'];
yield [[], ['type' => 'float'], -1_100_230_000.4567, $fixSpaceToNbspFx('-1 100 230 000.4567')];
yield [[], ['type' => 'float'], 1.100123, '1.100123'];
yield [[], ['type' => 'float'], 1.100123E-6, '1.100123E-6'];
yield [[], ['type' => 'float'], 1.100123E+221, '1.100123E+221'];
yield [[], ['type' => 'float'], -1.100123E-221, '-1.100123E-221'];
yield [[], ['type' => 'float'], 12345678.3579, $fixSpaceToNbspFx('12 345 678.3579')];
yield [['decimalSeparator' => ','], ['type' => 'float'], 12345678.3579, $fixSpaceToNbspFx('12 345 678,3579')];
yield [['thousandsSeparator' => ','], ['type' => 'float'], 12345678.3579, '12,345,678.3579'];
yield [['decimalSeparator' => ',', 'thousandsSeparator' => '.'], ['type' => 'float'], 12345678.3579, '12.345.678,3579'];
yield [[], ['type' => 'boolean'], false, 'No'];
yield [[], ['type' => 'boolean'], true, 'Yes'];

Expand All @@ -70,7 +86,6 @@ public function providerTypecast(): iterable
yield [['timezone' => $tz, 'datetimeFormat' => 'j.n.Y g:i:s A'], ['type' => 'datetime'], $evalDatetime, '2.1.2022 10:20:30 AM'];
}

$fixSpaceToNbspFx = fn (string $v) => str_replace(' ', "\u{00a0}", $v);
yield [[], ['type' => 'atk4_money'], 1.0, $fixSpaceToNbspFx('€ 1.00')];
yield [[], ['type' => 'atk4_money'], 0.0, $fixSpaceToNbspFx('€ 0.00')];
yield [['currency' => ''], ['type' => 'atk4_money'], 1.0, $fixSpaceToNbspFx('1.00')];
Expand All @@ -79,13 +94,65 @@ public function providerTypecast(): iterable
yield [['currencyDecimals' => 4], ['type' => 'atk4_money'], 1.102, $fixSpaceToNbspFx('€ 1.1020')];
yield [[], ['type' => 'atk4_money'], 1_234_056_789.1, $fixSpaceToNbspFx('€ 1 234 056 789.10')];
yield [[], ['type' => 'atk4_money'], 234_056_789.101, $fixSpaceToNbspFx('€ 234 056 789.101')];
yield [['currencyDecimalSeparator' => ','], ['type' => 'atk4_money'], 1.0, $fixSpaceToNbspFx('€ 1,00')];
yield [['decimalSeparator' => ','], ['type' => 'atk4_money'], 1.0, $fixSpaceToNbspFx('€ 1,00')];
yield [[], ['type' => 'atk4_money'], 1000.0, $fixSpaceToNbspFx('€ 1 000.00')];
yield [['currencyThousandsSeparator' => ','], ['type' => 'atk4_money'], 1000.0, $fixSpaceToNbspFx('€ 1,000.00')];
yield [['currencyDecimalSeparator' => ',', 'currencyThousandsSeparator' => '.'], ['type' => 'atk4_money'], 1000.0, $fixSpaceToNbspFx('€ 1.000,00')];
yield [['thousandsSeparator' => ','], ['type' => 'atk4_money'], 1000.0, $fixSpaceToNbspFx('€ 1,000.00')];
yield [['decimalSeparator' => ',', 'thousandsSeparator' => '.'], ['type' => 'atk4_money'], 1000.0, $fixSpaceToNbspFx('€ 1.000,00')];

foreach (['string', 'text', 'integer', 'float', 'boolean', 'date', 'time', 'datetime', 'atk4_money'] as $type) {
yield [[], ['type' => $type], null, null];
}
}

public function providerTypecastLoadOnly(): iterable
{
foreach (['integer', 'float', 'boolean', 'date', 'time', 'datetime', 'atk4_money'] as $type) {
yield [[], ['type' => $type], null, '', false];
}

yield [[], ['type' => 'string'], '', '', false];
yield [[], ['type' => 'text'], '', '', false];
yield [[], ['type' => 'string'], '', ' ', false];
yield [[], ['type' => 'string'], '', " \r\r\n ", false];
yield [[], ['type' => 'string', 'nullable' => false], '', '', false];
yield [[], ['type' => 'string', 'nullable' => false], '', ' ', false];
yield [[], ['type' => 'string', 'nullable' => false], '', " \n ", false];
yield [[], ['type' => 'text', 'required' => true], '', '', false];
yield [[], ['type' => 'text'], "\n0", "\n0", false];
yield [[], ['type' => 'text'], "\n0", "\r0", false];
yield [[], ['type' => 'text'], "\n0", "\r\n0", false];
yield [[], ['type' => 'text', 'nullable' => false], '', '', false];

yield [[], ['type' => 'boolean'], false, '0', false];
yield [[], ['type' => 'boolean'], true, '1', false];

yield [[], ['type' => 'integer'], 0, '0.4', false];
yield [[], ['type' => 'integer'], 1, '1.49', false];
// yield [[], ['type' => 'integer'], 2, '1.5', false];
yield [[], ['type' => 'integer'], -1, '-1.49', false];
// yield [[], ['type' => 'integer'], -2, '-1.5', false];
yield [[], ['type' => 'integer'], -1_100_230_000_456_345_678, '-1_100_230_000_456_345_6_7_8', false];

yield [[], ['type' => 'float'], 1.0, '1', false];
yield [[], ['type' => 'float'], 0.0, '0', false];
yield [[], ['type' => 'float'], 0.3, '.3', false];
yield [[], ['type' => 'float'], -0.3, '-.3', false];
yield [[], ['type' => 'float'], 0.3, '+00.3', false];
yield [[], ['type' => 'float'], -0.3, '-00.300', false];
yield [[], ['type' => 'float'], 1234567.23456789, '1234567.23456789', false];
yield [[], ['type' => 'float'], 1234567.23456789, '1234_5_6_7.234 567 89', false];

yield [[], ['type' => 'atk4_money'], 2.0, '€2', false];
yield [[], ['type' => 'atk4_money'], 2.0, '$2', false];
yield [[], ['type' => 'atk4_money'], 2.0, '2€', false];
yield [[], ['type' => 'atk4_money'], 2.0, '2$', false];
yield [[], ['type' => 'atk4_money'], -1.3, '€-1.3', false];
yield [[], ['type' => 'atk4_money'], -1.3, '-1.3$', false];
yield [[], ['type' => 'atk4_money'], 0.3, '€.3', false];
yield [[], ['type' => 'atk4_money'], 0.3, '.3$', false];
yield [[], ['type' => 'atk4_money'], -0.3, '€-.3', false];
yield [[], ['type' => 'atk4_money'], -0.3, '-.3$', false];
// yield [[], ['type' => 'atk4_money'], 4.2, '4€2', false];
// yield [[], ['type' => 'atk4_money'], -4.2, '-4$2', false];
}
}

0 comments on commit d86b13b

Please sign in to comment.