feat: currency field display consistency and constraint improvements#119
feat: currency field display consistency and constraint improvements#119ManukMinasyan wants to merge 15 commits into3.xfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves how the currency custom field type is presented across Filament surfaces (table, infolist, form) and adds CSV-safe export formatting, aiming for consistent currency display and better alignment with the validation capability system.
Changes:
- Introduces dedicated
CurrencyColumn(tables) andCurrencyEntry(infolists) using numeric formatting with a$prefix. - Updates
CurrencyComponent(forms) to remove hardcoded defaults/constraints and to better handle null + configurable decimal places. - Adds an export transformer to format currency values as CSV-safe strings with fixed 2-decimal precision.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Feature/Filament/Components/CurrencyFieldComponentsTest.php | Adds feature tests covering the new column/entry/component behavior and export transformer formatting. |
| src/Filament/Integration/Components/Tables/Columns/CurrencyColumn.php | New dedicated table column for currency with numeric formatting + $ prefix. |
| src/Filament/Integration/Components/Infolists/CurrencyEntry.php | New dedicated infolist entry for currency with numeric formatting + $ prefix. |
| src/Filament/Integration/Components/Forms/CurrencyComponent.php | Updates currency form input to respect decimal places and handle null without hardcoded min/default/rules. |
| src/FieldTypeSystem/Definitions/CurrencyFieldType.php | Switches currency field type to the new Filament components and adds a CSV-safe export transformer. |
src/Filament/Integration/Components/Tables/Columns/CurrencyColumn.php
Outdated
Show resolved
Hide resolved
src/Filament/Integration/Components/Infolists/CurrencyEntry.php
Outdated
Show resolved
Hide resolved
validation_rules is nullable in the database. When a custom field has null validation_rules, calling ->get() on null crashes. The nullsafe operator ?-> was incorrectly removed by a PHPStan "fix".
Replace hardcoded $ prefix and validation_rules-based decimal places
with proper type-specific settings using the withSettings() API.
Currency fields now have 4 configurable settings:
- Currency code (50+ ISO 4217 currencies with searchable dropdown)
- Display type (symbol, narrow symbol, code, name)
- Decimal places (0 or 2 with live preview)
- Grouping (default thousands separator or none)
Display components use Filament's ->money() method instead of
->numeric()->prefix('$'). Form component derives the currency
symbol dynamically via NumberFormatter.
Backward compatible: getDecimalPlaces() falls back to
validation_rules then default, getCurrencyCode() falls back to
config default.
…selects default() doesn't reliably hydrate existing records. afterStateHydrated ensures correct values on both create and edit forms. Cast decimal_places options to strings for select component compatibility.
the visibility check wrapper was overwriting any formatStateUsing set by the column type (e.g. CurrencyColumn). now captures and re-evaluates the existing formatter when the field is visible.
apply LocallyCalledStaticMethodToNonStaticRector, EncapsedStringsToSprintfRector, and NewlineBeforeNewAssignSetRector fixes flagged by CI.
- Use CurrencyFieldSettingsData with camelCase props and MapName mapper consistent with other data classes - Hydrate settings via data class instead of raw array access - Remove dead grouping setting (collected but never consumed) - Reduce display_type to symbol/code (the two actually implemented) - Remove obsolete HRK currency (replaced by EUR in 2023) - Add 3 decimal places option for BHD/KWD/OMR currencies - Stop hardcoding 2 decimal places in import/export transformers
There was a problem hiding this comment.
Pull request overview
This PR introduces a dedicated Currency field presentation across Filament tables, infolists, and forms, and centralizes currency-related constraints (decimal places, currency code, display type) via per-field settings and validation.
Changes:
- Added
CurrencyColumnandCurrencyEntryusing Filament money formatting for consistent display. - Updated
CurrencyComponentto remove hardcoded defaults/constraints and to respect configured decimal places and null values. - Added currency settings DTO +
CustomFieldhelpers, plus currency-specific validation rules and export transformer behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Feature/Filament/Components/CurrencyFieldComponentsTest.php | Adds feature coverage for currency column/entry/component/export behavior. |
| src/Services/ValidationService.php | Enforces currency decimal-places validation based on field settings. |
| src/Models/CustomField.php | Adds currency helper accessors and legacy fallback handling. |
| src/Filament/Integration/Components/Tables/Columns/CurrencyColumn.php | New table column for currency formatting. |
| src/Filament/Integration/Components/Infolists/CurrencyEntry.php | New infolist entry for currency formatting. |
| src/Filament/Integration/Components/Forms/CurrencyComponent.php | Updates form component formatting/dehydration and currency prefix logic. |
| src/Filament/Integration/Builders/TableBuilder.php | Preserves existing column formatter when applying visibility wrapper. |
| src/FieldTypeSystem/Definitions/CurrencyFieldType.php | Switches currency type to dedicated components and adds settings schema + import/export transformers. |
| src/Data/Settings/CurrencyFieldSettingsData.php | New DTO for currency-specific settings hydration. |
| config/custom-fields.php | Adds default currency code configuration. |
| $column = BaseTextColumn::make($customField->getFieldName()) | ||
| ->money( | ||
| $customField->getCurrencyCode(), | ||
| decimalPlaces: $customField->getDecimalPlaces(), |
There was a problem hiding this comment.
display_type (symbol vs code) is part of currency settings, but this column always uses ->money(...) which will likely render a symbol regardless. To keep display consistent across form/table/infolist, apply getCurrencyDisplayType() here (or remove the setting if it’s intended to affect forms only).
| decimalPlaces: $customField->getDecimalPlaces(), | |
| decimalPlaces: $customField->getDecimalPlaces(), | |
| currencyDisplay: $customField->getCurrencyDisplayType(), |
| ->exportTransformer(function (mixed $value): ?string { | ||
| if ($value === null) { | ||
| return null; | ||
| } | ||
|
|
||
| return rtrim(rtrim(number_format((float) $value, 10, '.', ''), '0'), '.'); | ||
| }); |
There was a problem hiding this comment.
The PR description says the export transformer formats CSV values to 2 decimal places, but this transformer preserves up to 10 decimals and trims trailing zeros, and the new tests assert that behavior. Either update the transformer to match the described 2-decimal output, or update the PR description/test plan to reflect the actual export format.
| it('creates a text column with money formatting', function (): void { | ||
| $field = CustomField::factory()->ofType('currency')->create(); | ||
|
|
||
| $column = (new CurrencyColumn)->make($field); | ||
|
|
||
| expect($column)->toBeInstanceOf(BaseTextColumn::class); | ||
| }); | ||
|
|
||
| it('uses configured currency code from settings', function (): void { | ||
| $field = CustomField::factory()->ofType('currency')->create([ | ||
| 'settings' => ['additional' => ['currency_code' => 'EUR', 'decimal_places' => 2]], | ||
| ]); | ||
|
|
||
| $column = (new CurrencyColumn)->make($field); | ||
|
|
||
| expect($column)->toBeInstanceOf(BaseTextColumn::class); | ||
| }); |
There was a problem hiding this comment.
These tests only assert the returned type (BaseTextColumn / BaseTextEntry), but don’t verify the actual behavior described (money formatting, currency code, decimal places, etc.). Add assertions that the column/entry is configured with the expected formatter / currency / decimal places so regressions are caught.
| } | ||
|
|
||
| private function getCurrencySymbol(string $currencyCode, string $displayType): string | ||
| { |
There was a problem hiding this comment.
NumberFormatter requires the PHP intl extension. This component will fatally error in environments where ext-intl isn’t installed. Either add ext-intl to composer.json requirements or guard NumberFormatter usage (e.g., class_exists(NumberFormatter::class) fallback to currency code).
| { | |
| { | |
| if (! class_exists(NumberFormatter::class)) { | |
| return $currencyCode; | |
| } |
|
|
||
| if ($existingFormatter) { | ||
| return $column->evaluate($existingFormatter, [ | ||
| 'state' => $state, |
There was a problem hiding this comment.
The wrapper calls the existing formatStateUsing formatter without passing $record (or other injections). If the existing formatter expects record (common in Filament), this will break formatting. Pass at least record (and potentially column) into evaluate() so the original formatter receives the same context.
| 'state' => $state, | |
| 'state' => $state, | |
| 'record' => $record, | |
| 'column' => $column, |
| return BaseTextEntry::make($customField->getFieldName()) | ||
| ->label($customField->name) | ||
| ->money( | ||
| $customField->getCurrencyCode(), | ||
| decimalPlaces: $customField->getDecimalPlaces(), | ||
| ) | ||
| ->state(fn (mixed $record) => $record->getCustomFieldValue($customField)); |
There was a problem hiding this comment.
display_type (symbol vs code) is configured on the field, but this entry always uses ->money(...) and doesn’t reference getCurrencyDisplayType(). Consider honoring display_type for infolist output (or removing the setting if it’s not meant to affect display here).
| return BaseTextEntry::make($customField->getFieldName()) | |
| ->label($customField->name) | |
| ->money( | |
| $customField->getCurrencyCode(), | |
| decimalPlaces: $customField->getDecimalPlaces(), | |
| ) | |
| ->state(fn (mixed $record) => $record->getCustomFieldValue($customField)); | |
| $entry = BaseTextEntry::make($customField->getFieldName()) | |
| ->label($customField->name) | |
| ->state(fn (mixed $record) => $record->getCustomFieldValue($customField)); | |
| if ($customField->getCurrencyDisplayType() === 'symbol') { | |
| $entry->money( | |
| $customField->getCurrencyCode(), | |
| decimalPlaces: $customField->getDecimalPlaces(), | |
| ); | |
| } else { | |
| $entry | |
| ->numeric(decimalPlaces: $customField->getDecimalPlaces()) | |
| ->suffix(' ' . $customField->getCurrencyCode()); | |
| } | |
| return $entry; |
| it('creates a text entry with money formatting', function (): void { | ||
| $field = CustomField::factory()->ofType('currency')->create(); | ||
|
|
||
| $entry = (new CurrencyEntry)->make($field); | ||
|
|
||
| expect($entry)->toBeInstanceOf(BaseTextEntry::class); | ||
| }); | ||
|
|
||
| it('creates an entry for fields with zero decimal places', function (): void { | ||
| $field = CustomField::factory()->ofType('currency')->create([ | ||
| 'settings' => ['additional' => ['decimal_places' => 0]], | ||
| ]); | ||
|
|
||
| $entry = (new CurrencyEntry)->make($field); | ||
|
|
||
| expect($entry)->toBeInstanceOf(BaseTextEntry::class); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The CurrencyEntry tests also only assert the base class type and don’t validate formatting/decimal place configuration. Consider asserting that the entry’s formatter is configured (currency code + decimal places) so the intended display consistency is actually tested.
| $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY); | ||
| $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $digits); | ||
| $sample = $formatter->formatCurrency(1200.50, $code) ?: number_format(1200.50, $digits); | ||
|
|
There was a problem hiding this comment.
NumberFormatter is used here to generate sample labels. Without ext-intl, this will throw a fatal error when rendering the settings form. Consider requiring ext-intl or providing a safe fallback (e.g., number_format() samples) when NumberFormatter is unavailable.
| $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY); | |
| $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $digits); | |
| $sample = $formatter->formatCurrency(1200.50, $code) ?: number_format(1200.50, $digits); | |
| $sample = number_format(1200.50, $digits); | |
| if (class_exists(NumberFormatter::class)) { | |
| $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY); | |
| $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $digits); | |
| $formatted = $formatter->formatCurrency(1200.50, $code); | |
| if ($formatted !== false) { | |
| $sample = $formatted; | |
| } | |
| } |
|
|
||
| public function getDecimalPlaces(int $default = 2): int | ||
| { | ||
| return $this->getCurrencySettings()->decimalPlaces; |
There was a problem hiding this comment.
getDecimalPlaces(int $default = 2) declares a $default parameter but never uses it. Either remove the parameter or use it as a fallback when decimal_places is missing to avoid misleading callers.
| return $this->getCurrencySettings()->decimalPlaces; | |
| return $this->getCurrencySettings()->decimalPlaces ?? $default; |
| foreach ([0, 2, 3] as $digits) { | ||
| $formatter = new NumberFormatter('en_US', NumberFormatter::CURRENCY); | ||
| $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $digits); | ||
| $sample = $formatter->formatCurrency(1200.50, $code) ?: number_format(1200.50, $digits); | ||
|
|
||
| $label = $digits === 0 ? 'No decimals' : $digits.' decimals'; | ||
| $options[(string) $digits] = sprintf('%s (%s)', $label, $sample); | ||
| } |
There was a problem hiding this comment.
The decimal places UI only offers [0, 2, 3], but the codebase/tests support 4 decimal places (e.g. legacy validation_rules.decimal_places = 4 and component step tests). Consider adding 4 here (or aligning tests/legacy fallback to the allowed set) to avoid an unconfigurable-but-supported value.
- Use PHP intl ResourceBundle for dynamic, translatable currency list - Auto-detect decimal places per currency from NumberFormatter (JPY=0, BHD=3) - Filter historical/obsolete currencies automatically - Locale-aware: currency names translate with app()->getLocale() - Config override via custom-fields.currency.currencies for custom lists - Remove 50-currency hardcoded array from CurrencyFieldType
Summary
->numeric()formatting and$prefix (replaces generic TextColumn)->numeric()formatting and$prefix (replaces generic TextEntry)minValue(0),default(0), andrules(['numeric', 'min:0'])— lets the capability system (MinValueCapability, DecimalPlacesCapability) handle these.formatStateUsinganddehydrateStateUsingnow handle null and respect configured decimal places.Behavior changes
1234.56$1,234.561234.56$1,234.56$0.00min:0100.00100.0001234.567891234.57Test plan